diff --git a/docs/development/l2-hardware-checklist.md b/docs/development/l2-hardware-checklist.md index 0367fa3c..9c6f2b1c 100644 --- a/docs/development/l2-hardware-checklist.md +++ b/docs/development/l2-hardware-checklist.md @@ -301,3 +301,15 @@ The speaker plays through the ES8311 codec (I2S MCLK 10 / BCK 11 / WS 12 / DOUT - [ ] Nearby with many nodes: up to 64 rows; contact list "All" shows beyond 64 contacts - [ ] Home tile and screen title read "Nodes" (was "Nearby") - [ ] Nodes > advert: "Zero hop" / "Flood" (were "Nearby only" / "Everyone"), toasts "Zero-hop advert sent" / "Flood advert sent" + +## Roadmap stage 3: message history on the SD card (2026-09-26) +- [ ] Receive and send a few channel posts and DMs, reboot: they're all back (Messages list and inside), relay marks / delivery ticks as before the reboot +- [ ] A busy channel: "Older messages (n)" at the top pages back 50 at a time, "Newer messages" at the bottom returns; older than the ~256 the memory holds are still there +- [ ] Settings > System > Storage: SD used / free bar, Maps / Messages / Trails / Other fill in while "Counting files..." runs (a big map takes a while), internal flash bar +- [ ] Kept per chat 500 → 100: conversations with more keep their newest 100; back to 500 keeps working (new messages add up again) +- [ ] Delete message history: first tap asks, second deletes; Messages is empty, the card's Messages size drops to ~0 +- [ ] Without an SD card: messages work as before (memory only), Storage says "No SD card" +- [ ] A channel deleted and added again (same name / key) gets its history back +- [ ] Map tools > Save with a card: toast "Saved trails/trail-....trl"; Save again later: a second file +- [ ] Map tools > Load: list newest first ("Saved on the device" too if there is one); a trail: distance / time / points, Load frames it on the map, GPX writes trails/.gpx, Delete asks then deletes +- [ ] Diagnostics > Live: GPS row (no data / no fix, n sats / fix, n sats), Last start, Last crash (after a crash) diff --git a/docs/development/l2-roadmap.md b/docs/development/l2-roadmap.md index 01319ce2..a0135acc 100644 --- a/docs/development/l2-roadmap.md +++ b/docs/development/l2-roadmap.md @@ -54,11 +54,26 @@ Decisions already made: ## 3. Data on the SD card -Postponed (2026-09-26): the user may extend this stage first. - -- [ ] Message history on SD, kept across reboots (~100 per conversation; - the newest ones cached in PSRAM). -- [ ] Trail saved to SD (it holds 4096 points in PSRAM since stage 2). +- [x] Message history on SD (`HistoryStore.h`, `-D HIST_ARCHIVE`): one + file per conversation in `/sdcard/meshcore/history` (channels keyed by a + hash of name + secret, contacts / rooms by key prefix), a ring of the + history entries themselves, every new message and later change (relay + echo, delivery) written at once. After a reboot the newest entries go + back into the RAM ring (Messages list, previews); a conversation shows + 50 at a time with "Older messages (n)" / "Newer messages" and reads the + card, so it reaches back as far as the card keeps. Kept per chat: 100 / + 250 / 500 (default) / 1000 / 2000, ~20 KB per 100 messages. +- [x] Settings > Storage: SD card used / free with what takes it (maps, + messages, GPX trails, other; files counted in the background), the + internal flash, "Kept per chat", "Delete message history" (tap twice). +- [x] Saved trails on the SD card: Map tools > Save writes a new + `/sdcard/trails/trail-YYYYMMDD-HHMM.trl` each time (the internal slot + without a card); Load lists them newest first (plus the internal slot, + where the low-battery auto-save goes) -- each with distance, time and + points, Load (the map frames it), GPX, Delete. +- [x] Diagnostics > Live: GPS (off / no data / fix or no fix with satellites), + why the device last started (crash, watchdog, low voltage...), and the + last crash from the core dump partition (task and address). ## 4. UI layout diff --git a/examples/companion_radio/ui-core/MessageHistory.h b/examples/companion_radio/ui-core/MessageHistory.h index 37f27535..d8197be2 100644 --- a/examples/companion_radio/ui-core/MessageHistory.h +++ b/examples/companion_radio/ui-core/MessageHistory.h @@ -57,6 +57,9 @@ struct ChHistEntry { // path_len packs (hash_size-1)<<6|hop_count, same as mesh::Packet::path_len. uint8_t path_len; uint8_t path[MAX_HIST_PATH_BYTES]; +#ifdef HIST_ARCHIVE + uint32_t arc_id; // record id in the conversation's archive file (HistArchive) +#endif }; struct DmHistEntry { @@ -78,8 +81,25 @@ struct DmHistEntry { // repeater-echo concept), so this stays unset (0) for outgoing entries. uint8_t path_len; uint8_t path[MAX_HIST_PATH_BYTES]; +#ifdef HIST_ARCHIVE + uint32_t arc_id; +#endif }; +#ifdef HIST_ARCHIVE +// A lasting copy of the history (the L2 keeps one file per conversation on +// the SD card, ui-lvgl/HistoryStore.h). Told of every new entry -- it sets +// arc_id -- and of every later change to one (relay echoes, delivery), so the +// copy shows the same marks the ring does. +class HistArchive { +public: + virtual void chAppend(ChHistEntry& e) = 0; + virtual void chUpdate(const ChHistEntry& e) = 0; + virtual void dmAppend(DmHistEntry& e) = 0; + virtual void dmUpdate(const DmHistEntry& e) = 0; +}; +#endif + // Boards with PSRAM raise these (the L2: -D HIST_CH_MAX=256 -D HIST_DM_MAX=128, // ~80 KB in PSRAM with UiCore); the defaults are sized for the nRF52's heap. #ifndef HIST_CH_MAX @@ -158,6 +178,9 @@ public: else _hist[pos].path_len = 0; if (!viewing && !own_message && _ch_unread[ch_idx] < 99) _ch_unread[ch_idx]++; +#ifdef HIST_ARCHIVE + if (_arc) _arc->chAppend(_hist[pos]); +#endif return pos; } @@ -212,6 +235,9 @@ public: } } } +#ifdef HIST_ARCHIVE + if (_arc) _arc->chUpdate(e); +#endif return; } } @@ -226,6 +252,9 @@ public: _hist[pos].relay_status = ACK_PENDING; _hist[pos].relay_seq = seq; _hist[pos].path_len = 0; +#ifdef HIST_ARCHIVE + if (_arc) _arc->chUpdate(_hist[pos]); +#endif } ChHistEntry& chAtPos(int pos) { return _hist[pos]; } @@ -334,6 +363,9 @@ public: _dm_hist[pos].resends_left = (outgoing && ack_tag) ? resends : 0; if (path && path_len_packed) capturePath(_dm_hist[pos].path_len, _dm_hist[pos].path, path, path_len_packed); else _dm_hist[pos].path_len = 0; +#ifdef HIST_ARCHIVE + if (_arc) _arc->dmAppend(_dm_hist[pos]); +#endif } // ack_tag/ack_deadline_ms/resends let an outgoing DM (e.g. one the phone app @@ -396,6 +428,9 @@ public: DmHistEntry& e = _dm_hist[(_dm_hist_head + i) % DM_HIST_MAX]; if (e.outgoing && e.ack_status == ACK_PENDING && e.ack_tag == ack_crc) { e.ack_status = ACK_OK; +#ifdef HIST_ARCHIVE + if (_arc) _arc->dmUpdate(e); +#endif return; } } @@ -410,9 +445,9 @@ public: DmHistEntry& e = _dm_hist[(_dm_hist_head + i) % DM_HIST_MAX]; if (!e.outgoing || e.ack_status != ACK_PENDING) continue; if ((int32_t)(now - e.ack_deadline_ms) < 0) continue; // still waiting - if (e.resends_left == 0) { e.ack_status = ACK_FAIL; continue; } + if (e.resends_left == 0) { failDm(e); continue; } ContactInfo c; - if (!contactByPrefix(e.prefix, c)) { e.ack_status = ACK_FAIL; continue; } + if (!contactByPrefix(e.prefix, c)) { failDm(e); continue; } uint32_t expected_ack = 0, est_timeout = 0; uint8_t next_attempt = e.attempt + 1; if (the_mesh.sendMessage(c, e.msg_ts, next_attempt, e.text, @@ -422,7 +457,7 @@ public: e.ack_deadline_ms = now + est_timeout + 4000; e.resends_left--; } else { - e.ack_status = ACK_FAIL; // couldn't compose/send — give up + failDm(e); // couldn't compose/send — give up } } } @@ -451,7 +486,48 @@ public: return false; } +#ifdef HIST_ARCHIVE + void setArchive(HistArchive* a) { _arc = a; } + + // Put back an entry kept in the archive (after a reboot), oldest first: no + // unread count, no archive write; a delivery still waiting is settled as + // plain "sent" (its ACK timer didn't survive the reboot). + void restoreCh(const ChHistEntry& in) { + int pos; + if (_hist_count < CH_HIST_MAX) { pos = (_hist_head + _hist_count) % CH_HIST_MAX; _hist_count++; } + else { pos = _hist_head; _hist_head = (_hist_head + 1) % CH_HIST_MAX; } + _hist[pos] = in; + if (_hist[pos].relay_status == ACK_PENDING) _hist[pos].relay_status = ACK_NONE; + _hist[pos].relay_seq = 0; + } + void restoreDm(const DmHistEntry& in) { + int pos; + if (_dm_hist_count < DM_HIST_MAX) { pos = (_dm_hist_head + _dm_hist_count) % DM_HIST_MAX; _dm_hist_count++; } + else { pos = _dm_hist_head; _dm_hist_head = (_dm_hist_head + 1) % DM_HIST_MAX; } + _dm_hist[pos] = in; + if (_dm_hist[pos].ack_status == ACK_PENDING) _dm_hist[pos].ack_status = ACK_NONE; + _dm_hist[pos].resends_left = 0; + _dm_hist[pos].ack_tag = 0; + } +#endif + + // Forget every message (L2: Settings > Storage > Delete message history). + void clearAll() { + _hist_head = _hist_count = _dm_hist_head = _dm_hist_count = 0; + clearAllChannelUnread(); + } + private: + void failDm(DmHistEntry& e) { + e.ack_status = ACK_FAIL; +#ifdef HIST_ARCHIVE + if (_arc) _arc->dmUpdate(e); +#endif + } + +#ifdef HIST_ARCHIVE + HistArchive* _arc = nullptr; +#endif ChHistEntry _hist[CH_HIST_MAX]; int _hist_head, _hist_count; uint8_t _ch_unread[MAX_GROUP_CHANNELS]; diff --git a/examples/companion_radio/ui-lvgl/ConversationScreen.h b/examples/companion_radio/ui-lvgl/ConversationScreen.h index 86d7ebea..b3d130b0 100644 --- a/examples/companion_radio/ui-lvgl/ConversationScreen.h +++ b/examples/companion_radio/ui-lvgl/ConversationScreen.h @@ -269,12 +269,12 @@ void UITask::messageMenu(int idx) { AckState dm_st = ACK_NONE; if (m.pos >= 0) { if (m.channel) { - const ChHistEntry& e = h.chAtPos(m.pos); + const ChHistEntry& e = s_th_ch[m.pos]; packed = e.path_len; path = e.path; relay = m.own; ts = e.timestamp; const char* sep = strstr(e.text, ": "); body = sep ? sep + 2 : e.text; } else { - const DmHistEntry& e = h.dmAtPos(m.pos); + const DmHistEntry& e = s_th_dm[m.pos]; packed = e.path_len; path = e.path; ts = e.timestamp; body = e.text; if (e.outgoing) dm_st = h.dmEffectiveStatus(e); else { @@ -304,7 +304,11 @@ void UITask::messageMenu(int idx) { struct tm ti = *gmtime(&t); char clk[12]; fmtClock(clk, sizeof(clk), ti, _prefs, true); - snprintf(when, sizeof(when), "%s - %s ago", clk, age); + if (clockBehind(now, ts)) { // the clock isn't set yet: the date instead of an age + char date[32]; + fmtDate(date, sizeof(date), ti, nullptr); + snprintf(when, sizeof(when), "%s %s", date, clk); + } else snprintf(when, sizeof(when), "%s - %s ago", clk, age); } else { snprintf(when, sizeof(when), "%s ago", age); } diff --git a/examples/companion_radio/ui-lvgl/DiagScreen.h b/examples/companion_radio/ui-lvgl/DiagScreen.h index bd81a6ba..7edd0b41 100644 --- a/examples/companion_radio/ui-lvgl/DiagScreen.h +++ b/examples/companion_radio/ui-lvgl/DiagScreen.h @@ -10,9 +10,39 @@ namespace diagview { enum : uint8_t { TAB_LIVE, TAB_SYSTEM, TAB_FONT, TAB_COUNT }; static uint8_t s_tab = TAB_LIVE; // kept across visits static lv_obj_t* s_list = nullptr; -static lv_obj_t* s_vals[diag::MAX_ROWS]; +static const int EXTRA = 3; // GPS, last reset, last crash (L2 only) +static lv_obj_t* s_vals[diag::MAX_ROWS + EXTRA]; static int s_rows = 0; +static void extraRow(diag::Row* rows, int& n, const char* lbl, const char* fmt, ...) { + rows[n].label = lbl; + va_list ap; va_start(ap, fmt); + vsnprintf(rows[n].value, sizeof(rows[n].value), fmt, ap); + va_end(ap); + n++; +} + +// ui-core's live rows plus the receiver's state and why the device last +// started -- what to look at when GPS gets no fix or the device restarted. +static int allRows(diag::Row* rows, bool gps_on) { + int n = diag::liveRows(rows); +#if defined(SEEED_WIO_TRACKER_L2) + static uint32_t s_chars = 0, s_moved_ms = 0; + uint32_t c = gps.rxChars(); + if (c != s_chars) { s_chars = c; s_moved_ms = millis(); } + bool data = c > 0 && millis() - s_moved_ms < 3000; + if (!gps_on) extraRow(rows, n, "GPS", "off"); + else if (!data) extraRow(rows, n, "GPS", "no data (%lu B)", (unsigned long)c); + else extraRow(rows, n, "GPS", "%s, %ld sats", gps.isValid() ? "fix" : "no fix", gps.satellitesCount()); +#else + (void)gps_on; +#endif + extraRow(rows, n, "Last start", "%s", lvport::resetReason()); + char crash[32]; + if (lvport::crashSummary(crash, sizeof(crash))) extraRow(rows, n, "Last crash", "%s", crash); + return n; +} + } // namespace diagview static void onOpenDiag(lv_event_t* e) { (void)e; s_ui->showDiag(); } @@ -56,8 +86,8 @@ void UITask::buildDiag() { s_rows = 0; if (s_tab == TAB_LIVE) { - diag::Row rows[diag::MAX_ROWS]; - s_rows = diag::liveRows(rows); + diag::Row rows[diag::MAX_ROWS + EXTRA]; + s_rows = allRows(rows, _core->gpsEnabled()); for (int i = 0; i < s_rows; i++) { lv_obj_t* r = lv_obj_create(s_list); lv_obj_remove_style_all(r); @@ -82,8 +112,8 @@ void UITask::buildDiag() { void UITask::refreshDiag() { using namespace diagview; if (_screen != SCR_DIAG || s_tab != TAB_LIVE || _nav_overlay) return; - diag::Row rows[diag::MAX_ROWS]; - int n = diag::liveRows(rows); + diag::Row rows[diag::MAX_ROWS + EXTRA]; + int n = allRows(rows, _core->gpsEnabled()); if (n != s_rows) { buildDiag(); return; } for (int i = 0; i < n; i++) if (strcmp(lv_label_get_text(s_vals[i]), rows[i].value) != 0) lv_label_set_text(s_vals[i], rows[i].value); diff --git a/examples/companion_radio/ui-lvgl/HistoryStore.h b/examples/companion_radio/ui-lvgl/HistoryStore.h new file mode 100644 index 00000000..47659810 --- /dev/null +++ b/examples/companion_radio/ui-lvgl/HistoryStore.h @@ -0,0 +1,350 @@ +#pragma once +// Message history on the SD card (roadmap stage 3): one file per conversation +// under /sdcard/meshcore/history -- c_.bin for a channel (the key hashes +// its name and secret, so a channel deleted and added again finds its history, +// and a new channel in a reused slot doesn't), d_.bin for a contact or +// room (the 4-byte public key prefix the history ring keys DMs by). +// +// A file is a ring of fixed-size records -- the history entries themselves +// (ChHistEntry / DmHistEntry) -- holding the newest "kept per conversation" +// (Settings > Storage) of them. Record ids run on without gaps, so the record +// with id X sits at slot (head + X - first id) % capacity: a later change to +// an entry (a relay echo, a delivery) finds its record by the entry's arc_id. +// Every change is written straight away (a message is a few hundred bytes). +// +// The RAM ring (MessageHistory) stays what everything else reads -- unread +// counts, the Messages list, resends; this copy survives a reboot (restore() +// puts the newest entries back) and holds far more than the ring, which the +// conversation view pages through (UITask::refreshThread). +// +// Plain stdio on /sdcard: the same code runs in the sim (Emscripten's MEMFS). + +#include +#include +#include +#include +#include +#include +#include + +namespace histstore { + +static const char* const ROOT = "/sdcard/meshcore"; +static const char* const DIR_PATH = "/sdcard/meshcore/history"; +static const uint32_t MAGIC = 0x5453484D; // "MHST" +static const uint16_t VERSION = 1; + +// Settings > Storage > Kept per conversation. +static const uint16_t KEEP[] = { 100, 250, 500, 1000, 2000 }; +static const int KEEP_COUNT = sizeof(KEEP) / sizeof(KEEP[0]); +static const int KEEP_DEFAULT = 2; // 500 +static const char* const KEEP_OPTS = "100\n250\n500\n1000\n2000"; + +struct Hdr { + uint32_t magic; + uint16_t ver, rec_size; + uint32_t cap, head, count, next_id; +}; + +// FNV-1a over the channel's name and secret. +static uint32_t chKey(const ChannelDetails& ch) { + uint32_t h = 2166136261u; + for (const char* p = ch.name; *p; p++) { h ^= (uint8_t)*p; h *= 16777619u; } + for (size_t i = 0; i < sizeof(ch.channel.secret); i++) { h ^= ch.channel.secret[i]; h *= 16777619u; } + return h; +} + +class SdArchive : public HistArchive { +public: + // SD mounted and the folder there. Checked again at most every 30 s while + // it isn't (a card put in later is picked up). + bool ready() { + if (_ok) return true; + uint32_t now = millis(); + if (_tried && now - _last_try < 30000) return false; + _tried = true; + _last_try = now; + if (!lvport::mountStorage()) return false; + mkdir("/sdcard", 0777); // the sim's MEMFS has it only once maps are loaded (the card: fails, harmless) + mkdir(ROOT, 0777); + mkdir(DIR_PATH, 0777); + struct stat st; + _ok = stat(DIR_PATH, &st) == 0 && S_ISDIR(st.st_mode); + return _ok; + } + + void setKeep(uint32_t n) { _keep = n; } + uint32_t keep() const { return _keep; } + + bool chPath(int ch_idx, char* out, size_t n) { + ChannelDetails ch; + if (ch_idx < 0 || !the_mesh.getChannel(ch_idx, ch) || !ch.name[0]) return false; + snprintf(out, n, "%s/c_%08lx.bin", DIR_PATH, (unsigned long)chKey(ch)); + return true; + } + static void dmPath(const uint8_t* prefix, char* out, size_t n) { + snprintf(out, n, "%s/d_%02x%02x%02x%02x.bin", DIR_PATH, prefix[0], prefix[1], prefix[2], prefix[3]); + } + + // ── HistArchive ────────────────────────────────────────────────────────── + void chAppend(ChHistEntry& e) override { + char p[64]; + if (ready() && chPath(e.ch_idx, p, sizeof(p))) append(p, e); + } + void chUpdate(const ChHistEntry& e) override { + char p[64]; + if (ready() && chPath(e.ch_idx, p, sizeof(p))) update(p, e); + } + void dmAppend(DmHistEntry& e) override { + char p[64]; + if (!ready()) return; + dmPath(e.prefix, p, sizeof(p)); + append(p, e); + } + void dmUpdate(const DmHistEntry& e) override { + char p[64]; + if (!ready()) return; + dmPath(e.prefix, p, sizeof(p)); + update(p, e); + } + + // ── Reading ────────────────────────────────────────────────────────────── + // How many records `path` holds (0 when missing or unreadable). + template + int total(const char* path) { + FILE* f = fopen(path, "rb"); + if (!f) return 0; + Hdr h; + int n = readHdr(f, h) ? (int)h.count : 0; + fclose(f); + return n; + } + + // A window of the conversation, oldest first: `n` records ending `skip` + // before the newest. Returns how many were read. + template + int window(const char* path, int skip, int n, E* out) { + FILE* f = fopen(path, "rb"); + if (!f) return 0; + Hdr h; + int got = 0; + if (readHdr(f, h) && skip < (int)h.count) { + int avail = (int)h.count - skip; + int m = n < avail ? n : avail; + uint32_t k0 = h.count - skip - m; // index from the oldest + for (int i = 0; i < m; i++) { + uint32_t slot = (h.head + k0 + i) % h.cap; + if (fseek(f, sizeof(Hdr) + (long)slot * sizeof(E), SEEK_SET) != 0) break; + if (fread(&out[got], sizeof(E), 1, f) != 1) break; + got++; + } + } + fclose(f); + return got; + } + + // ── After a reboot ─────────────────────────────────────────────────────── + // Puts the newest entries back in the RAM ring, as many as it holds, in + // time order across all conversations. Channels that no longer exist are + // skipped; a channel's entries get its current slot. + void restore(MessageHistory& hist) { + if (!ready()) return; + uint32_t keys[MAX_GROUP_CHANNELS]; + bool have[MAX_GROUP_CHANNELS]; + for (int i = 0; i < MAX_GROUP_CHANNELS; i++) { + ChannelDetails ch; + have[i] = the_mesh.getChannel(i, ch) && ch.name[0]; + keys[i] = have[i] ? chKey(ch) : 0; + } + restoreKind(hist, 'c', MessageHistory::CH_HIST_MAX, keys, have); + restoreKind(hist, 'd', MessageHistory::DM_HIST_MAX, keys, have); + } + + // ── Settings > Storage ─────────────────────────────────────────────────── + // Every file to the current "kept per conversation" (the newest stay). + void applyKeep() { + if (!ready()) return; + forEachFile([this](const char* path, char kind) { + if (kind == 'c') resize(path, _keep); + else resize(path, _keep); + }); + } + void clearAll() { + if (!ready()) return; + forEachFile([](const char* path, char) { remove(path); }); + } + +private: + bool _ok = false, _tried = false; + uint32_t _last_try = 0; + uint32_t _keep = KEEP[KEEP_DEFAULT]; + + template + bool readHdr(FILE* f, Hdr& h) { + if (fseek(f, 0, SEEK_SET) != 0 || fread(&h, sizeof(h), 1, f) != 1) return false; + return h.magic == MAGIC && h.ver == VERSION && h.rec_size == sizeof(E) && h.cap > 0 && h.count <= h.cap; + } + static bool writeHdr(FILE* f, const Hdr& h) { + return fseek(f, 0, SEEK_SET) == 0 && fwrite(&h, sizeof(h), 1, f) == 1; + } + + template + void append(const char* path, E& e) { + resize(path, _keep); // the setting changed since this file was written + FILE* f = fopen(path, "r+b"); + Hdr h; + if (!f || !readHdr(f, h)) { // new (or unreadable: start it again) + if (f) fclose(f); + f = fopen(path, "w+b"); + if (!f) return; + h = { MAGIC, VERSION, (uint16_t)sizeof(E), _keep, 0, 0, 1 }; + } + uint32_t slot; + if (h.count < h.cap) { slot = (h.head + h.count) % h.cap; h.count++; } + else { slot = h.head; h.head = (h.head + 1) % h.cap; } + e.arc_id = h.next_id++; + if (fseek(f, sizeof(Hdr) + (long)slot * sizeof(E), SEEK_SET) == 0) fwrite(&e, sizeof(E), 1, f); + writeHdr(f, h); + fclose(f); + } + + template + void update(const char* path, const E& e) { + FILE* f = fopen(path, "r+b"); + if (!f) return; + Hdr h; + if (readHdr(f, h)) { + uint32_t first = h.next_id - h.count; + if (e.arc_id >= first && e.arc_id < h.next_id) { + uint32_t slot = (h.head + (e.arc_id - first)) % h.cap; + if (fseek(f, sizeof(Hdr) + (long)slot * sizeof(E), SEEK_SET) == 0) fwrite(&e, sizeof(E), 1, f); + } + } + fclose(f); + } + + // Rewrites `path` with room for `cap` records, keeping the newest. + template + void resize(const char* path, uint32_t cap) { + FILE* f = fopen(path, "rb"); + if (!f) return; + Hdr h; + if (!readHdr(f, h) || h.cap == cap) { fclose(f); return; } + uint32_t m = h.count < cap ? h.count : cap; + E* buf = m ? (E*)malloc((size_t)m * sizeof(E)) : nullptr; // PSRAM on the L2 (large block) + if (m && !buf) { fclose(f); return; } + uint32_t k0 = h.count - m; + uint32_t got = 0; + for (uint32_t i = 0; i < m; i++) { + uint32_t slot = (h.head + k0 + i) % h.cap; + if (fseek(f, sizeof(Hdr) + (long)slot * sizeof(E), SEEK_SET) != 0 || fread(&buf[got], sizeof(E), 1, f) != 1) break; + got++; + } + fclose(f); + char tmp[72]; + snprintf(tmp, sizeof(tmp), "%s.tmp", path); + FILE* o = fopen(tmp, "wb"); + if (o) { + Hdr nh = { MAGIC, VERSION, (uint16_t)sizeof(E), cap, 0, got, h.next_id }; + nh.next_id = h.next_id; // ids of the kept records stay as they were + bool ok = fwrite(&nh, sizeof(nh), 1, o) == 1 && (got == 0 || fwrite(buf, sizeof(E), got, o) == got); + fclose(o); + if (ok) { remove(path); rename(tmp, path); } // FAT's rename doesn't replace + else remove(tmp); + } + free(buf); + } + + template + void forEachFile(F fn) { + DIR* d = opendir(DIR_PATH); + if (!d) return; + // Collected first: removing / renaming while readdir() walks the folder + // isn't safe on FAT. + static const int MAX_FILES = 512; + char (*names)[20] = (char(*)[20])malloc(MAX_FILES * 20); + int n = 0; + if (names) { + while (struct dirent* de = readdir(d)) { + const char* nm = de->d_name; + if ((nm[0] == 'c' || nm[0] == 'd') && nm[1] == '_' && strlen(nm) == 14 && !strcmp(nm + 10, ".bin") && n < MAX_FILES) + snprintf(names[n++], 20, "%s", nm); + } + } + closedir(d); + for (int i = 0; i < n; i++) { + char p[64]; + snprintf(p, sizeof(p), "%s/%s", DIR_PATH, names[i]); + fn(p, names[i][0]); + } + free(names); + } + + struct Pick { uint32_t ts; uint32_t id; uint16_t file; }; + + template + void restoreKind(MessageHistory& hist, char kind, int ring, const uint32_t* keys, const bool* have) { + static const int MAX_FILES = 256; + char (*paths)[64] = (char(*)[64])malloc(MAX_FILES * 64); + int8_t* slot_of = (int8_t*)malloc(MAX_FILES); + int files = 0; + if (!paths || !slot_of) { free(paths); free(slot_of); return; } + forEachFile([&](const char* path, char k) { + if (k != kind || files >= MAX_FILES) return; + int8_t ch = -1; + if (kind == 'c') { + uint32_t key = strtoul(strrchr(path, '_') + 1, nullptr, 16); + for (int i = 0; i < MAX_GROUP_CHANNELS; i++) if (have[i] && keys[i] == key) { ch = (int8_t)i; break; } + if (ch < 0) return; // that channel is gone + } + snprintf(paths[files], 64, "%s", path); + slot_of[files++] = ch; + }); + + // The newest `ring` of each file by timestamp, then the newest `ring` + // over all of them. + Pick* picks = (Pick*)malloc((size_t)files * ring * sizeof(Pick) + sizeof(Pick)); + int np = 0; + if (picks) { + for (int fi = 0; fi < files; fi++) { + FILE* f = fopen(paths[fi], "rb"); + if (!f) continue; + Hdr h; + if (readHdr(f, h)) { + uint32_t m = h.count < (uint32_t)ring ? h.count : (uint32_t)ring; + uint32_t k0 = h.count - m, first = h.next_id - h.count; + for (uint32_t i = 0; i < m; i++) { + uint32_t slot = (h.head + k0 + i) % h.cap; + uint32_t ts; + if (fseek(f, sizeof(Hdr) + (long)slot * sizeof(E) + offsetof(E, timestamp), SEEK_SET) != 0 || + fread(&ts, 4, 1, f) != 1) break; + picks[np++] = { ts, first + k0 + i, (uint16_t)fi }; + } + } + fclose(f); + } + std::stable_sort(picks, picks + np, [](const Pick& a, const Pick& b) { return a.ts < b.ts; }); + int from = np > ring ? np - ring : 0; + for (int i = from; i < np; i++) { + FILE* f = fopen(paths[picks[i].file], "rb"); + if (!f) continue; + Hdr h; + E e; + if (readHdr(f, h)) { + uint32_t first = h.next_id - h.count; + uint32_t slot = (h.head + (picks[i].id - first)) % h.cap; + if (fseek(f, sizeof(Hdr) + (long)slot * sizeof(E), SEEK_SET) == 0 && fread(&e, sizeof(E), 1, f) == 1) + put(hist, e, slot_of[picks[i].file]); + } + fclose(f); + } + } + free(picks); + free(paths); + free(slot_of); + } + static void put(MessageHistory& h, ChHistEntry& e, int8_t ch) { e.ch_idx = (uint8_t)ch; h.restoreCh(e); } + static void put(MessageHistory& h, DmHistEntry& e, int8_t) { h.restoreDm(e); } +}; + +} // namespace histstore diff --git a/examples/companion_radio/ui-lvgl/LvglPort.h b/examples/companion_radio/ui-lvgl/LvglPort.h index fa599982..730b114e 100644 --- a/examples/companion_radio/ui-lvgl/LvglPort.h +++ b/examples/companion_radio/ui-lvgl/LvglPort.h @@ -12,7 +12,13 @@ #include #include #include + #include #include + #include + #include + #if ESP_ARDUINO_VERSION_MAJOR >= 3 + #include + #endif #include #elif defined(SIM_PLATFORM) && defined(__EMSCRIPTEN__) #include @@ -192,6 +198,76 @@ static void saveAccent(int idx) { p.putUChar("accent", (uint8_t)idx); p.end(); } +// Settings > Storage > Kept per conversation (an index into histstore::KEEP). +static int loadHistKeep() { + Preferences p; + if (!p.begin("mc_ui", true)) return -1; + int v = p.getChar("hkeep", -1); + p.end(); + return v; +} +static void saveHistKeep(int idx) { + Preferences p; + if (!p.begin("mc_ui", false)) return; + p.putChar("hkeep", (int8_t)idx); + p.end(); +} +// Filesystem size and space in use (Settings > Storage), plus the card's own +// size -- a card whose FAT partition is small (e.g. written by a Raspberry Pi +// imager) shows both. The first free-space count on a big card takes a moment. +static bool sdInfo(uint64_t& total, uint64_t& used, uint64_t& card) { + if (!mountStorage()) return false; + card = SD_MMC.cardSize(); +#if ESP_ARDUINO_VERSION_MAJOR >= 3 + uint64_t fs_free = 0; + if (esp_vfs_fat_info("/sdcard", &total, &fs_free) == ESP_OK) { // asks the mounted volume, not drive 0: + used = total - fs_free; + return total > 0; + } +#endif + total = SD_MMC.totalBytes(); + used = SD_MMC.usedBytes(); + return total > 0; +} +// Why the device last started (Diagnostics). +static const char* resetReason() { + switch (esp_reset_reason()) { + case ESP_RST_POWERON: return "Power on"; + case ESP_RST_SW: return "Restart"; + case ESP_RST_PANIC: return "Crash"; + case ESP_RST_INT_WDT: + case ESP_RST_TASK_WDT: + case ESP_RST_WDT: return "Watchdog"; + case ESP_RST_BROWNOUT: return "Low voltage"; + case ESP_RST_DEEPSLEEP: return "Wake from sleep"; + case ESP_RST_EXT: return "Reset pin"; +#if ESP_ARDUINO_VERSION_MAJOR >= 3 + case ESP_RST_USB: return "USB"; +#endif + default: return "Other"; + } +} +// The last crash the core dump partition holds: task and address (decode +// the address with the build's firmware.elf, or read the whole dump over USB). +static bool crashSummary(char* out, size_t n) { +#if ESP_ARDUINO_VERSION_MAJOR >= 3 + if (esp_core_dump_image_check() != ESP_OK) return false; + esp_core_dump_summary_t sum; + if (esp_core_dump_get_summary(&sum) != ESP_OK) return false; + snprintf(out, n, "%.10s %08lx", sum.exc_task, (unsigned long)sum.exc_pc); + return true; +#else + (void)out; (void)n; + return false; +#endif +} +// The internal flash file system (contacts, channels, settings, trail...). +static const char* const FLASH_ROOT = "/spiffs"; +static bool flashInfo(uint64_t& total, uint64_t& used) { + total = SPIFFS.totalBytes(); + used = SPIFFS.usedBytes(); + return total > 0; +} static void netBegin(const char* ssid, const char* pass) { WiFi.mode(WIFI_STA); @@ -426,6 +502,16 @@ static void savePin(const char* pin) { snprintf(s_pin, sizeof(s_pin), "%s", pin) static int s_accent = 0; static int loadAccent() { return s_accent; } static void saveAccent(int idx) { s_accent = idx; } +static int s_hist_keep = -1; +static int loadHistKeep() { return s_hist_keep; } +static void saveHistKeep(int idx) { s_hist_keep = idx; } +// The browser has no card: a nominal 32 GB, used = what the files add up to +// (the storage screen counts them anyway; it passes that in). +static bool sdInfo(uint64_t& total, uint64_t& used, uint64_t& card) { total = card = 32ULL << 30; used = 0; return true; } +static const char* const FLASH_ROOT = "/sim_data"; +static const char* resetReason() { return "Power on"; } +static bool crashSummary(char*, size_t) { return false; } +static bool flashInfo(uint64_t& total, uint64_t& used) { total = 1536ULL << 10; used = 0; return true; } static bool s_wifi_on = true; static bool wifiAllowed() { return s_wifi_on; } static void setWifiAllowed(bool on) { s_wifi_on = on; } diff --git a/examples/companion_radio/ui-lvgl/NavMap.h b/examples/companion_radio/ui-lvgl/NavMap.h index 797b18fe..badd7eb7 100644 --- a/examples/companion_radio/ui-lvgl/NavMap.h +++ b/examples/companion_radio/ui-lvgl/NavMap.h @@ -391,6 +391,31 @@ void UITask::navFrameTarget() { layoutMap(); } +// Frame the whole trail (after loading one). +void UITask::navFrameTrail() { + const TrailStore& ts = _core->trail.store(); + if (ts.empty()) return; + double x0 = 1, x1 = 0, y0 = 1, y1 = 0; + for (int i = 0; i < ts.count(); i++) { + double x = navmap::normX(ts.at(i).lon_1e6), y = navmap::normY(ts.at(i).lat_1e6); + if (x < x0) x0 = x; + if (x > x1) x1 = x; + if (y < y0) y0 = y; + if (y > y1) y1 = y; + } + _map_follow = false; + int w = _map_area ? lv_obj_get_width(_map_area) : 320; + int h = (_map_area ? lv_obj_get_height(_map_area) : 218) - navmap::BAR_H; + double dx = (x1 - x0) * mapview::TILE_PX, dy = (y1 - y0) * mapview::TILE_PX; + int z = mapview::MAX_Z - 1; + while (z > mapview::MIN_Z && (dx * (1 << z) > w - 110 || dy * (1 << z) > h - 60)) z--; // clear of the buttons + _map_z = z; + double n = (double)(1 << z); + _map_cx = (x0 + x1) / 2 * n; + _map_cy = (y0 + y1) / 2 * n + navmap::BAR_H / 2.0 / mapview::TILE_PX; + layoutMap(); +} + void UITask::navSetTarget(uint8_t kind, const uint8_t* key, int32_t lat, int32_t lon, const char* name) { navmap::s_tb.stop(); // a chosen target replaces walking the trail back _core->locator.setTarget(kind, key, lat, lon, name); @@ -761,7 +786,8 @@ void UITask::shareToMessage(const char* text) { namespace navmap { enum : uint8_t { TL_TRAIL_TOGGLE, TL_TRAIL_SAVE, TL_TRAIL_LOAD, TL_TRAIL_RESET, TL_TRAIL_GPX, TL_TRACKBACK, TL_SHARE_TOGGLE, TL_SHARE_ONCE, TL_DOWNLOAD, TL_OPTIONS, - TL_WP_HERE, TL_WP_COORDS, TL_SPOT_ADD, TL_SPOT_GO }; + TL_WP_HERE, TL_WP_COORDS, TL_SPOT_ADD, TL_SPOT_GO, + TL_ST_LOAD, TL_ST_GPX, TL_ST_DELETE }; // Live-share targets offered in the dropdown: channels, then favourite contacts. static const int SHARE_TARGETS = MAX_GROUP_CHANNELS + 16; @@ -786,8 +812,72 @@ struct FilePrint { size_t print(const __FlashStringHelper* s) { return print(reinterpret_cast(s)); } }; +// FILE* in the shape TrailStore's writeTo / readFrom / exportGpxFromFile use. +struct FileRW { + FILE* f; + size_t write(const uint8_t* b, size_t n) { return fwrite(b, 1, n, f); } + int read(uint8_t* b, size_t n) { return (int)fread(b, 1, n, f); } +}; + +// Saved trails on the card (L2): /sdcard/trails/trail-YYYYMMDD-HHMM.trl, the +// same format as the one internal slot (TrailEngine's /trail), which the +// list also offers -- the low-battery auto-save still goes there. +static const char* const TRAILS_DIR = "/sdcard/trails"; +static const int ST_MAX = 40; +static char s_st_names[ST_MAX][28]; // newest first +static int s_st_n = 0; +static int s_st_sel = -2; // the one the popup is about; -1 = the internal slot +static uint32_t s_st_del_armed_ms = 0; +static lv_obj_t* s_st_del_lbl = nullptr; + +// "trail-20260926-1405.trl" -> "26 Sep 2026 14:05" (else the name). +static void trailTitle(const char* name, char* out, size_t n) { + static const char* MON[] = { "Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec" }; + int y, mo, d, h, mi; + if (sscanf(name, "trail-%4d%2d%2d-%2d%2d", &y, &mo, &d, &h, &mi) == 5 && mo >= 1 && mo <= 12) + snprintf(out, n, "%d %s %d %02d:%02d", d, MON[mo - 1], y, h, mi); + else snprintf(out, n, "%s", name); +} + +// Points, distance and recorded time of a saved trail, from its file. +template +static bool trailSummary(F& io, int& points, float& meters, uint32_t& secs) { + uint16_t cnt = 0; + uint32_t accum = 0; + if (!persist::readHeader(io, TrailStore::SAVE_MAGIC, TrailStore::SAVE_VERSION, cnt)) return false; + if (io.read((uint8_t*)&accum, sizeof(accum)) != (int)sizeof(accum)) return false; + points = 0; meters = 0; secs = accum / 1000; + TrailPoint prev, p; + for (uint16_t i = 0; i < cnt; i++) { + if (io.read((uint8_t*)&p, sizeof(p)) != (int)sizeof(p)) break; + if (points > 0 && !(p.flags & TRAIL_FLAG_SEG_START)) + meters += TrailStore::haversineMeters(prev.lat_1e6, prev.lon_1e6, p.lat_1e6, p.lon_1e6); + prev = p; + points++; + } + return true; +} + +static void scanTrails() { + s_st_n = 0; + DIR* d = opendir(TRAILS_DIR); + if (!d) return; + while (struct dirent* de = readdir(d)) { + size_t l = strlen(de->d_name); + if (l < 5 || l >= sizeof(s_st_names[0]) || strcmp(de->d_name + l - 4, ".trl") != 0) continue; + if (s_st_n < ST_MAX) snprintf(s_st_names[s_st_n++], sizeof(s_st_names[0]), "%s", de->d_name); + } + closedir(d); + // Newest first: the names carry the date. + qsort(s_st_names, s_st_n, sizeof(s_st_names[0]), [](const void* a, const void* b) { + return -strcmp((const char*)a, (const char*)b); + }); +} + } // namespace navmap +static void onSavedTrail(lv_event_t* e) { s_ui->savedTrailPopup((int)(intptr_t)lv_event_get_user_data(e)); } + static void onNavTools(lv_event_t* e) { (void)e; s_ui->navToolsPopup(); } static void onLiveTiles(lv_event_t* e) { s_ui->setLiveTiles(lv_obj_has_state((lv_obj_t*)lv_event_get_target(e), LV_STATE_CHECKED)); @@ -969,6 +1059,137 @@ bool UITask::exportTrailGpx(char* name_out, size_t n) { return ok; } +// The live trail to a new file in /sdcard/trails (named by the local time). +bool UITask::saveTrailToCard(char* name_out, size_t n) { + char path[64]; + uint32_t now = rtc_clock.getCurrentTime(); + if (now > 1000000000UL) { + time_t t = (time_t)((int64_t)now + (int64_t)_prefs->tz_offset_hours * 3600); + struct tm* ti = gmtime(&t); + snprintf(path, sizeof(path), "%s/trail-%04d%02d%02d-%02d%02d.trl", navmap::TRAILS_DIR, ti->tm_year + 1900, + ti->tm_mon + 1, ti->tm_mday, ti->tm_hour, ti->tm_min); + } else { + snprintf(path, sizeof(path), "%s/trail-%lu.trl", navmap::TRAILS_DIR, (unsigned long)(millis() / 1000)); + } + mapview::makeParents(path); + FILE* f = fopen(path, "wb"); + if (!f) return false; + navmap::FileRW io{ f }; + bool ok = _core->trail.store().writeTo(io); + ok = (fclose(f) == 0) && ok; + if (!ok) remove(path); + snprintf(name_out, n, "%s", strrchr(path, '/') + 1); + return ok; +} + +// Map tools > Load, with a card: the saved trails, newest first, and the +// device's own slot (manual saves without a card, the low-battery auto-save). +void UITask::savedTrailsPopup() { + lv_obj_t* panel = navPopupPanel("Saved trails", true); + lv_obj_t* list = lv_obj_create(panel); + styleSurface(list, theme::BG); + lv_obj_set_width(list, LV_PCT(100)); + lv_obj_set_flex_grow(list, 1); + lv_obj_set_flex_flow(list, LV_FLEX_FLOW_COLUMN); + lv_obj_set_style_pad_row(list, theme::GAP, 0); + lv_obj_set_scrollbar_mode(list, LV_SCROLLBAR_MODE_ACTIVE); + navmap::scanTrails(); + for (int i = 0; i < navmap::s_st_n; i++) { + char title[32], sub[48], path[64]; + navmap::trailTitle(navmap::s_st_names[i], title, sizeof(title)); + snprintf(path, sizeof(path), "%s/%s", navmap::TRAILS_DIR, navmap::s_st_names[i]); + struct stat st; + long pts = stat(path, &st) == 0 ? ((long)st.st_size - 12) / (long)sizeof(TrailPoint) : 0; + snprintf(sub, sizeof(sub), "%ld points", pts > 0 ? pts : 0); + listRow(list, title, sub, onSavedTrail, (void*)(intptr_t)i); + } + if (TrailEngine::savedExists()) listRow(list, "Saved on the device", "Without a card, or on low battery", onSavedTrail, (void*)(intptr_t)-1); + if (lv_obj_get_child_count(list) == 0) label(list, "No saved trails yet - Save one first.", THEME_FONT_BODY, theme::TEXT_MUTED); +} + +// One saved trail: what it holds, Load / GPX / Delete. +void UITask::savedTrailPopup(int idx) { + if (idx >= navmap::s_st_n) return; + navmap::s_st_sel = idx; + navmap::s_st_del_armed_ms = 0; + char title[32]; + if (idx < 0) snprintf(title, sizeof(title), "Saved on the device"); + else navmap::trailTitle(navmap::s_st_names[idx], title, sizeof(title)); + lv_obj_t* panel = navPopupPanel(title, false); + + int pts = 0; float m = 0; uint32_t secs = 0; bool ok = false; + if (idx >= 0) { + char path[64]; + snprintf(path, sizeof(path), "%s/%s", navmap::TRAILS_DIR, navmap::s_st_names[idx]); + if (FILE* f = fopen(path, "rb")) { navmap::FileRW io{ f }; ok = navmap::trailSummary(io, pts, m, secs); fclose(f); } + } else if (DataStore* ds = the_mesh.getDataStore()) { + File f = ds->openRead(TrailEngine::TRAIL_FILE); + if (f) { ok = navmap::trailSummary(f, pts, m, secs); f.close(); } + } + char dist[12], dur[12], info[64]; + geo::fmtDist(dist, sizeof(dist), m / 1000.0f, _prefs && _prefs->units_imperial); + navmap::fmtDuration(dur, sizeof(dur), secs); + if (ok) snprintf(info, sizeof(info), "%s - %s - %d points", dist, dur, pts); + else snprintf(info, sizeof(info), "Can't read this file"); + label(panel, info, THEME_FONT_BODY, theme::TEXT); + if (!_core->trail.store().empty()) + label(panel, "Loading replaces the trail on the map.", THEME_FONT_SMALL, theme::TEXT_MUTED); + lv_obj_t* r = toolRow(panel); + lv_obj_t* lb = toolButton(r, LV_SYMBOL_DIRECTORY " Load", navmap::TL_ST_LOAD, true); + stylePrimary(lv_obj_get_parent(lb)); + if (idx >= 0) toolButton(r, LV_SYMBOL_SD_CARD " GPX", navmap::TL_ST_GPX, false); + navmap::s_st_del_lbl = toolButton(r, LV_SYMBOL_TRASH " Delete", navmap::TL_ST_DELETE, false); +} + +void UITask::savedTrailAction(uint8_t act) { + int idx = navmap::s_st_sel; + if (idx >= navmap::s_st_n || idx < -1) return; + char path[64] = ""; + if (idx >= 0) snprintf(path, sizeof(path), "%s/%s", navmap::TRAILS_DIR, navmap::s_st_names[idx]); + if (act == navmap::TL_ST_DELETE) { + if (!navmap::s_st_del_armed_ms || millis() - navmap::s_st_del_armed_ms > 3000) { // second tap confirms + navmap::s_st_del_armed_ms = millis() | 1; + if (navmap::s_st_del_lbl) lv_label_set_text(navmap::s_st_del_lbl, "Delete?"); + return; + } + bool ok = idx >= 0 ? remove(path) == 0 + : (the_mesh.getDataStore() && the_mesh.getDataStore()->removeFile(TrailEngine::TRAIL_FILE)); + showToast(ok ? "Trail deleted" : "Delete failed"); + savedTrailsPopup(); + return; + } + if (act == navmap::TL_ST_GPX && idx >= 0) { + char gpx[64]; + snprintf(gpx, sizeof(gpx), "%.*s.gpx", (int)(strlen(path) - 4), path); + FILE* in = fopen(path, "rb"); + FILE* out = in ? fopen(gpx, "w") : nullptr; + bool ok = false; + if (in && out) { + navmap::FileRW io{ in }; + navmap::FilePrint pr(out); + ok = TrailStore::exportGpxFromFile(io, pr, _core->waypoints.store()) > 0 && pr.ok; + } + if (in) fclose(in); + if (out) ok = (fclose(out) == 0) && ok; + char t[64]; + if (ok) { snprintf(t, sizeof(t), "Saved trails/%s", strrchr(gpx, '/') + 1); showToast(t, 3500); } + else showToast("Can't write to the SD card"); + return; + } + // Load: replaces the live trail (recording stops). + bool ok = false; + if (idx >= 0) { + if (FILE* f = fopen(path, "rb")) { navmap::FileRW io{ f }; ok = _core->trail.store().readFrom(io); fclose(f); } + } else { + ok = _core->trail.load() == TrailEngine::FILE_OK; + } + navClosePopup(); + showToast(ok ? "Trail loaded" : "Load failed"); + rebuildMapMarkers(); + layoutMap(); + if (ok) navFrameTrail(); +} + void UITask::navToolAction(uint8_t act) { TrailEngine& tr = _core->trail; switch (act) { @@ -984,17 +1205,30 @@ void UITask::navToolAction(uint8_t act) { } break; case navmap::TL_TRAIL_SAVE: { + if (tr.store().empty()) { showToast("No trail to save"); break; } + char name[32], t[64]; + if (lvport::mountStorage()) { // on the card, one file per save + if (saveTrailToCard(name, sizeof(name))) { snprintf(t, sizeof(t), "Saved trails/%s", name); showToast(t, 3000); } + else showToast("Can't write to the SD card"); + break; + } TrailEngine::FileResult r = tr.save(); showToast(r == TrailEngine::FILE_OK ? "Trail saved" : "Save failed"); break; } case navmap::TL_TRAIL_LOAD: { + if (lvport::mountStorage()) { savedTrailsPopup(); return; } TrailEngine::FileResult r = tr.load(); showToast(r == TrailEngine::FILE_OK ? "Trail loaded" : r == TrailEngine::FILE_MISSING ? "No saved trail" : "Load failed"); rebuildMapMarkers(); layoutMap(); break; } + case navmap::TL_ST_LOAD: + case navmap::TL_ST_GPX: + case navmap::TL_ST_DELETE: + savedTrailAction(act); + return; case navmap::TL_TRAIL_RESET: if (!_nav_reset_armed_ms || millis() - _nav_reset_armed_ms > 3000) { // second tap within 3 s confirms _nav_reset_armed_ms = millis() | 1; diff --git a/examples/companion_radio/ui-lvgl/StorageScreen.h b/examples/companion_radio/ui-lvgl/StorageScreen.h new file mode 100644 index 00000000..857e066c --- /dev/null +++ b/examples/companion_radio/ui-lvgl/StorageScreen.h @@ -0,0 +1,306 @@ +#pragma once +// Settings > Storage: how full the SD card and the internal flash are and +// what takes the card's space (maps, message history, GPX trails, the rest), +// how many messages each conversation keeps (HistoryStore.h) and deleting +// the history. +// +// The card's files are counted in the background while the screen is open +// (a map can be tens of thousands of tiles), a few milliseconds per loop. +// +// Single-TU fragment: included at the end of ui-lvgl/UITask.cpp. + +namespace storeview { + +enum Cat : uint8_t { C_MAPS, C_MSGS, C_TRAILS, C_OTHER, C_COUNT }; +static const char* const CAT_NAME[C_COUNT] = { "Maps", "Messages", "Trails (GPX)", "Other" }; + +static uint64_t s_bytes[C_COUNT]; +static uint32_t s_files = 0; +static bool s_walking = false; + +// Folder walk, one entry at a time: a stack of open folders. +static const int DEPTH = 8; +static DIR* s_dir[DEPTH]; +static size_t s_plen[DEPTH]; +static uint8_t s_dcat[DEPTH]; +static int s_depth = 0; +static char s_path[192]; + +static bool s_sd_read = false, s_sd_ok = false; +static uint64_t s_sd_total = 0, s_sd_used = 0, s_sd_card = 0; +static lv_obj_t* s_sd_bar = nullptr; +static lv_obj_t* s_sd_lbl = nullptr; +static lv_obj_t* s_cat_val[C_COUNT]; +static lv_obj_t* s_status = nullptr; +static lv_obj_t* s_clear_lbl = nullptr; +static uint32_t s_clear_armed_ms = 0; +static uint32_t s_shown_ms = 0; + +static void fmtBytes(char* out, size_t n, uint64_t b) { + if (b < 1024) snprintf(out, n, "%u B", (unsigned)b); + else if (b < (1ULL << 20)) snprintf(out, n, "%u KB", (unsigned)((b + 512) >> 10)); + else if (b < (1ULL << 30)) snprintf(out, n, "%.1f MB", b / 1048576.0); + else snprintf(out, n, "%.1f GB", b / 1073741824.0); +} + +static void stopWalk() { + while (s_depth > 0) closedir(s_dir[--s_depth]); + s_walking = false; +} + +static void startWalk() { + stopWalk(); + memset(s_bytes, 0, sizeof(s_bytes)); + s_files = 0; + snprintf(s_path, sizeof(s_path), "/sdcard"); + if (DIR* d = opendir(s_path)) { + s_dir[0] = d; + s_plen[0] = strlen(s_path); + s_dcat[0] = C_OTHER; + s_depth = 1; + s_walking = true; + } +} + +// What a top-level folder counts as. +static uint8_t topCat(const char* name) { + if (!strcmp(name, "maps")) return C_MAPS; + if (!strcmp(name, "meshcore")) return C_MSGS; + if (!strcmp(name, "trails")) return C_TRAILS; + return C_OTHER; +} + +// Walks for up to `budget_ms`; false once done. +static bool stepWalk(uint32_t budget_ms) { + uint32_t t0 = millis(); + while (s_depth > 0 && millis() - t0 < budget_ms) { + int top = s_depth - 1; + struct dirent* de = readdir(s_dir[top]); + if (!de) { + closedir(s_dir[top]); + s_depth--; + if (s_depth > 0) s_path[s_plen[s_depth - 1]] = '\0'; + continue; + } + if (de->d_name[0] == '.') continue; // ".", "..", hidden files + size_t base = s_plen[top]; + snprintf(s_path + base, sizeof(s_path) - base, "/%s", de->d_name); + uint8_t cat = top == 0 ? topCat(de->d_name) : s_dcat[top]; + struct stat st; + if (stat(s_path, &st) == 0) { + if (S_ISDIR(st.st_mode)) { + if (s_depth < DEPTH) { + if (DIR* d = opendir(s_path)) { + s_dir[s_depth] = d; + s_plen[s_depth] = strlen(s_path); + s_dcat[s_depth] = cat; + s_depth++; + continue; // the path stays extended while inside + } + } + } else { + s_bytes[cat] += (uint64_t)st.st_size; + s_files++; + } + } + s_path[base] = '\0'; + } + if (s_depth == 0) s_walking = false; + return s_walking; +} + +// Size of everything under `root` (small trees only: the flash in the sim). +static uint64_t treeBytes(const char* root, int depth = 0) { + uint64_t sum = 0; + DIR* d = opendir(root); + if (!d) return 0; + while (struct dirent* de = readdir(d)) { + if (de->d_name[0] == '.') continue; + char p[160]; + snprintf(p, sizeof(p), "%s/%s", root, de->d_name); + struct stat st; + if (stat(p, &st) != 0) continue; + if (S_ISDIR(st.st_mode)) { if (depth < 4) sum += treeBytes(p, depth + 1); } + else sum += (uint64_t)st.st_size; + } + closedir(d); + return sum; +} + +// A bar with "x free of y" under it. +static lv_obj_t* usageBar(lv_obj_t* parent, lv_obj_t** lbl) { + lv_obj_t* card = lv_obj_create(parent); + styleSurface(card, theme::SURFACE); + lv_obj_remove_flag(card, LV_OBJ_FLAG_SCROLLABLE); + lv_obj_set_size(card, LV_PCT(100), LV_SIZE_CONTENT); + lv_obj_set_style_radius(card, theme::RADIUS, 0); + lv_obj_set_style_pad_all(card, theme::PAD, 0); + lv_obj_set_style_pad_row(card, 6, 0); + lv_obj_set_flex_flow(card, LV_FLEX_FLOW_COLUMN); + lv_obj_t* bar = lv_bar_create(card); + lv_obj_set_size(bar, LV_PCT(100), 10); + lv_bar_set_range(bar, 0, 1000); + lv_obj_set_style_bg_color(bar, lv_color_hex(theme::BG), LV_PART_MAIN); + lv_obj_set_style_bg_opa(bar, LV_OPA_COVER, LV_PART_MAIN); + lv_obj_set_style_bg_color(bar, lv_color_hex(theme::ACCENT), LV_PART_INDICATOR); + *lbl = label(card, "", THEME_FONT_SMALL, theme::TEXT_MUTED); + return bar; +} + +static void showUsage(lv_obj_t* bar, lv_obj_t* lbl, uint64_t total, uint64_t used) { + if (!bar || !lbl) return; + if (used > total) used = total; + lv_bar_set_value(bar, total ? (int32_t)(used * 1000 / total) : 0, LV_ANIM_OFF); + char u[16], t[16], f[16], line[64]; + fmtBytes(u, sizeof(u), used); + fmtBytes(t, sizeof(t), total); + fmtBytes(f, sizeof(f), total - used); + snprintf(line, sizeof(line), "%s used of %s - %s free", u, t, f); + lv_label_set_text(lbl, line); +} + +// Name left, size right. +static lv_obj_t* sizeRow(lv_obj_t* parent, const char* name) { + lv_obj_t* row = lv_obj_create(parent); + styleSurface(row, theme::SURFACE); + lv_obj_remove_flag(row, LV_OBJ_FLAG_SCROLLABLE); + lv_obj_set_size(row, LV_PCT(100), 34); + lv_obj_set_style_radius(row, theme::RADIUS, 0); + lv_obj_align(label(row, name, THEME_FONT_BODY, theme::TEXT), LV_ALIGN_LEFT_MID, theme::PAD, 0); + lv_obj_t* v = label(row, "...", THEME_FONT_BODY, theme::ACCENT); + lv_obj_align(v, LV_ALIGN_RIGHT_MID, -theme::PAD, 0); + return v; +} + +static void refreshSizes() { + for (int c = 0; c < C_COUNT; c++) { + if (!s_cat_val[c]) continue; + char b[16]; + fmtBytes(b, sizeof(b), s_bytes[c]); + lv_label_set_text(s_cat_val[c], b); + } + if (s_status) { + char t[48]; + snprintf(t, sizeof(t), s_walking ? "Counting files... %lu" : "%lu files", (unsigned long)s_files); + lv_label_set_text(s_status, t); + } +} + +} // namespace storeview + +static void onOpenStorage(lv_event_t* e) { (void)e; s_ui->showStorage(); } +static void onHistKeep(lv_event_t* e) { + s_ui->storageKeep((int)lv_dropdown_get_selected((lv_obj_t*)lv_event_get_target(e))); +} +static void onHistClear(lv_event_t* e) { (void)e; s_ui->storageClearHistory(); } + +void UITask::showStorage() { + _screen = SCR_STORAGE; + buildStorage(); +} + +void UITask::buildStorage() { + using namespace storeview; + lv_obj_t* body = newScreen("Storage", true); + s_sd_bar = s_sd_lbl = s_status = s_clear_lbl = nullptr; + memset(s_cat_val, 0, sizeof(s_cat_val)); + s_clear_armed_ms = 0; + + sectionTitle(body, "SD CARD"); + if (!lvport::mountStorage()) { + label(body, "No SD card.", THEME_FONT_BODY, theme::TEXT_MUTED); + } else { + s_sd_bar = usageBar(body, &s_sd_lbl); + lv_label_set_text(s_sd_lbl, "Reading the card..."); + for (int c = 0; c < C_COUNT; c++) s_cat_val[c] = sizeRow(body, CAT_NAME[c]); + s_status = label(body, "", THEME_FONT_SMALL, theme::TEXT_MUTED); + s_sd_read = false; // read in pollStorage(): the first read of a big card takes a moment + startWalk(); + } + + sectionTitle(body, "DEVICE"); + lv_obj_t* flbl; + lv_obj_t* fbar = usageBar(body, &flbl); + uint64_t ft = 0, fu = 0; + if (lvport::flashInfo(ft, fu)) { + if (fu == 0) fu = treeBytes(lvport::FLASH_ROOT); // the sim only counts files + showUsage(fbar, flbl, ft, fu); + } else { + lv_label_set_text(flbl, "Not available"); + } + label(body, "Contacts, channels, settings, saved trail, waypoints.", THEME_FONT_SMALL, theme::TEXT_MUTED); + + sectionTitle(body, "MESSAGES"); + int keep_idx = histstore::KEEP_DEFAULT; + for (int i = 0; i < histstore::KEEP_COUNT; i++) if (histstore::KEEP[i] == s_archive.keep()) keep_idx = i; + lv_obj_t* dd = dropdownRow(body, "Kept per chat", histstore::KEEP_OPTS, keep_idx); + lv_obj_remove_event_cb(dd, onKeyboardAlphabet); + lv_obj_add_event_cb(dd, onHistKeep, LV_EVENT_VALUE_CHANGED, NULL); + char note[96]; + snprintf(note, sizeof(note), "Each channel and contact keeps its newest ones on the card (%u KB per 100).", + (unsigned)((sizeof(ChHistEntry) * 100 + 512) / 1024)); + lv_obj_t* n = label(body, note, THEME_FONT_SMALL, theme::TEXT_MUTED); + lv_label_set_long_mode(n, LV_LABEL_LONG_WRAP); + lv_obj_set_width(n, LV_PCT(100)); + lv_obj_t* row = listRow(body, LV_SYMBOL_TRASH " Delete message history", "Every chat, on the card and in memory", + onHistClear, NULL); + s_clear_lbl = lv_obj_get_child(row, 0); + s_shown_ms = 0; +} + +void UITask::pollStorage() { + using namespace storeview; + if (s_sd_bar && !s_sd_read) { + s_sd_read = true; + s_sd_ok = lvport::sdInfo(s_sd_total, s_sd_used, s_sd_card); + if (!s_sd_ok) lv_label_set_text(s_sd_lbl, "Can't read the card"); + else if (s_sd_card > s_sd_total + s_sd_total / 4) { // most of the card sits outside the partition + char c[16], t[16], line[160]; + fmtBytes(c, sizeof(c), s_sd_card); + fmtBytes(t, sizeof(t), s_sd_total); + snprintf(line, sizeof(line), "The card is %s but its FAT partition only %s. " + "Format it as one FAT32 partition on a computer to use all of it.", c, t); + lv_obj_t* n = label(lv_obj_get_parent(s_sd_bar), line, THEME_FONT_SMALL, theme::ACCENT); + lv_label_set_long_mode(n, LV_LABEL_LONG_WRAP); + lv_obj_set_width(n, LV_PCT(100)); + } + } + if (s_walking) stepWalk(12); + if (millis() - s_shown_ms < 300 && s_walking) return; // labels a few times a second + s_shown_ms = millis(); + refreshSizes(); + if (s_sd_ok) { + uint64_t used = s_sd_used; + if (used == 0) for (int c = 0; c < C_COUNT; c++) used += s_bytes[c]; // the sim counts files + showUsage(s_sd_bar, s_sd_lbl, s_sd_total, used); + } + if (s_clear_armed_ms && millis() - s_clear_armed_ms > 4000) { // the confirm tap timed out + s_clear_armed_ms = 0; + if (s_clear_lbl) lv_label_set_text(s_clear_lbl, LV_SYMBOL_TRASH " Delete message history"); + } +} + +void UITask::storageKeep(int idx) { + if (idx < 0 || idx >= histstore::KEEP_COUNT) return; + lvport::saveHistKeep(idx); + s_archive.setKeep(histstore::KEEP[idx]); + s_archive.applyKeep(); // trims (or makes room in) every file now + showToast("Saved"); + storeview::startWalk(); +} + +void UITask::storageClearHistory() { + using namespace storeview; + if (!s_clear_armed_ms) { // first tap: ask + s_clear_armed_ms = millis(); + if (s_clear_lbl) lv_label_set_text(s_clear_lbl, LV_SYMBOL_TRASH " Tap again to delete"); + return; + } + s_clear_armed_ms = 0; + s_archive.clearAll(); + _core->history.clearAll(); + _core->markAllRead(); + if (s_clear_lbl) lv_label_set_text(s_clear_lbl, LV_SYMBOL_TRASH " Delete message history"); + showToast("Message history deleted"); + startWalk(); +} diff --git a/examples/companion_radio/ui-lvgl/UITask.cpp b/examples/companion_radio/ui-lvgl/UITask.cpp index c3204400..24683911 100644 --- a/examples/companion_radio/ui-lvgl/UITask.cpp +++ b/examples/companion_radio/ui-lvgl/UITask.cpp @@ -34,6 +34,9 @@ template static T* psramBuf(size_t n) { #include "Theme.h" #include "Anim.h" #include "LvglPort.h" +#include "HistoryStore.h" +static histstore::SdArchive s_archive; // message history on the SD card +namespace storeview { static void stopWalk(); } // StorageScreen.h #include "../ui-core/KeyboardData.h" #include "Keyboard.h" @@ -177,6 +180,21 @@ static void fmtDate(char* b, size_t n, const struct tm& ti, const NodePrefs* p) snprintf(b, n, "%s%s %d %s %d", (p && p->clock_12h) ? (ti.tm_hour < 12 ? "AM " : "PM ") : "", DOW[ti.tm_wday], ti.tm_mday, MON[ti.tm_mon], ti.tm_year + 1900); } +// A message's age ("12s" / "5m" / "3h" / "2d"). After a restart the clock runs +// from the build date until GPS or the app sets it, so messages restored from +// the card look newer than "now": those show when they came ("25 Sep 14:05"). +static const NodePrefs* s_prefs = nullptr; // for the free helpers below; set in begin() +static bool clockBehind(uint32_t now, uint32_t ts) { return ts > 1000000000UL && ts > now + 120; } +static void fmtMsgAge(char* b, size_t n, uint32_t now, uint32_t ts, const NodePrefs* p) { + if (!clockBehind(now, ts)) { geo::fmtAgeShort(b, (int)n, now, ts ? ts : now); return; } + static const char* MON[] = { "Jan", "Feb", "Mar", "Apr", "May", "Jun", + "Jul", "Aug", "Sep", "Oct", "Nov", "Dec" }; + time_t t = (time_t)((int64_t)ts + (int64_t)(p ? p->tz_offset_hours : 0) * 3600); + struct tm ti = *gmtime(&t); + char clk[12]; + fmtClock(clk, sizeof(clk), ti, p, true); + snprintf(b, n, "%d %s %s", ti.tm_mday, MON[ti.tm_mon], clk); +} static void contactName(const uint8_t* prefix, char* out, size_t n) { ContactInfo c; @@ -191,9 +209,16 @@ void UITask::begin(DisplayDriver* display_drv, SensorManager* sensors, NodePrefs _display = display_drv; _sensors = sensors; _prefs = node_prefs; + s_prefs = node_prefs; _core = new UiCore(); _core->begin(node_prefs, sensors, this); + { // history kept on the SD card: back into the ring, then every new entry to it + int hk = lvport::loadHistKeep(); + s_archive.setKeep(histstore::KEEP[hk >= 0 && hk < histstore::KEEP_COUNT ? hk : histstore::KEEP_DEFAULT]); + s_archive.restore(_core->history); + _core->history.setArchive(&s_archive); + } msgtext::seedQuick(node_prefs); // "OK" in quick message 1 on first boot _nearby = new NearbyModel(); _nearby->bindModel(_core, node_prefs); @@ -378,6 +403,7 @@ void UITask::loop() { } if (_screen == SCR_MAP) mapLoop(); if (_screen == SCR_WIFI) pollWifiScan(); + if (_screen == SCR_STORAGE) pollStorage(); if (_screen == SCR_ADMIN) adminPoll(); roomPoll(); if (_screen == SCR_CLOCK && (int32_t)(millis() - _next_clock_ms) >= 0) { @@ -814,6 +840,7 @@ void UITask::back() { else showHome(); break; case SCR_WIFI: showSettings(); break; + case SCR_STORAGE: storeview::stopWalk(); showSettings(); break; default: break; } _nav_back = false; // only closed a popup @@ -1773,6 +1800,7 @@ void UITask::setKeyboardVisible(bool show) { void UITask::openChannel(uint8_t channel_idx) { _thread_is_channel = true; _thread_channel = channel_idx; + _thread_skip = 0; _core->history.setChUnread(channel_idx, 0); _screen = SCR_THREAD; buildThread(); @@ -1780,6 +1808,7 @@ void UITask::openChannel(uint8_t channel_idx) { void UITask::openDM(const uint8_t* pub_key) { _thread_is_channel = false; + _thread_skip = 0; memset(_thread_key, 0, sizeof(_thread_key)); ContactInfo c; if (MessageHistory::contactByPrefix(pub_key, c)) memcpy(_thread_key, c.id.pub_key, PUB_KEY_SIZE); @@ -1888,7 +1917,13 @@ static const int THREAD_MAX_SHOWN = 50; // newest bubbles built per conversati static MsgLoc* s_msg_locs = psramBuf(THREAD_MAX_SHOWN); static int s_msg_loc_n = 0; -// What a held bubble is about: its history ring entry, sender, position slot. +// The messages shown, oldest first: copies from the SD card's history (which +// holds far more than the RAM ring) or from the ring, one page at a time. +static ChHistEntry* s_th_ch = psramBuf(THREAD_MAX_SHOWN); +static DmHistEntry* s_th_dm = psramBuf(THREAD_MAX_SHOWN); + +// What a held bubble is about: its entry (index into s_th_ch / s_th_dm), +// sender, position slot. struct MsgMeta { int pos; bool channel; bool own; int loc; char from[32]; }; static MsgMeta* s_msg_meta = psramBuf(THREAD_MAX_SHOWN); static int s_msg_meta_n = 0; @@ -1952,7 +1987,7 @@ static void bubble(lv_obj_t* list, const char* from, const char* text, bool own, char meta[32]; uint32_t now = rtc_clock.getCurrentTime(); - geo::fmtAgeShort(meta, sizeof(meta), now, ts ? ts : now); // "12s" / "5m" / "3h" / "2d" + fmtMsgAge(meta, sizeof(meta), now, ts, s_prefs); // "12s" / "5m" / "3h" / "2d" // Channel messages: "Sender 5m" on one line above the text, which keeps // the bubble two lines tall -- matters with the keyboard up. @@ -2029,22 +2064,85 @@ void UITask::messageLocationAction(int idx, bool save) { refreshNavBar(); } +// The page of the open conversation into s_th_ch / s_th_dm (oldest first): +// THREAD_MAX_SHOWN messages ending _thread_skip before the newest. From the +// SD card's copy when it has at least what the ring has, else from the ring. +// `total` = messages in that source. +int UITask::loadThreadPage(int& total) { + const MessageHistory& h = _core->history; + char path[64]; + if (_thread_is_channel) { + int ring = h.histCountForChannel(_thread_channel); + int arc = s_archive.ready() && s_archive.chPath(_thread_channel, path, sizeof(path)) + ? s_archive.total(path) : 0; + if (arc > 0 && arc >= ring) { + total = arc; + return s_archive.window(path, _thread_skip, THREAD_MAX_SHOWN, s_th_ch); + } + total = ring; + int m = ring - _thread_skip; + if (m > THREAD_MAX_SHOWN) m = THREAD_MAX_SHOWN; + for (int i = 0; i < m; i++) s_th_ch[i] = h.chAtPos(h.histEntryForChannel(_thread_channel, _thread_skip + m - 1 - i)); + return m > 0 ? m : 0; + } + int ring = h.dmHistCountForContact(_thread_key); + int arc = 0; + if (s_archive.ready()) { + histstore::SdArchive::dmPath(_thread_key, path, sizeof(path)); + arc = s_archive.total(path); + } + if (arc > 0 && arc >= ring) { + total = arc; + return s_archive.window(path, _thread_skip, THREAD_MAX_SHOWN, s_th_dm); + } + total = ring; + int m = ring - _thread_skip; + if (m > THREAD_MAX_SHOWN) m = THREAD_MAX_SHOWN; + for (int i = 0; i < m; i++) s_th_dm[i] = h.dmAtPos(h.dmHistEntryForContact(_thread_key, _thread_skip + m - 1 - i)); + return m > 0 ? m : 0; +} + +static void onThreadPage(lv_event_t* e) { s_ui->threadPage((int)(intptr_t)lv_event_get_user_data(e)); } + +// "Older messages" / "Newer messages" at the ends of a page. +static void pageButton(lv_obj_t* list, const char* text, int dir) { + lv_obj_t* b = lv_button_create(list); + lv_obj_set_size(b, LV_PCT(100), 32); + lv_obj_set_style_bg_color(b, lv_color_hex(theme::SURFACE), 0); + lv_obj_add_event_cb(b, onThreadPage, LV_EVENT_CLICKED, (void*)(intptr_t)dir); + lv_obj_center(label(b, text, THEME_FONT_SMALL, theme::TEXT_MUTED)); +} + +void UITask::threadPage(int dir) { + _thread_skip += dir > 0 ? THREAD_MAX_SHOWN : -THREAD_MAX_SHOWN; + if (_thread_skip < 0) _thread_skip = 0; + _thread_scroll_top = dir < 0; // newer: read on from the top of that page + refreshThread(); +} + void UITask::refreshThread() { if (!_thread_list) return; _thread_dirty = false; _thread_sig = threadSignature(); lv_obj_clean(_thread_list); const MessageHistory& h = _core->history; - const int MAX_SHOWN = THREAD_MAX_SHOWN; s_msg_loc_n = 0; s_msg_meta_n = 0; + int total = 0; + int n = loadThreadPage(total); + if (n == 0 && _thread_skip > 0) { // the page went away (history trimmed) + _thread_skip = 0; + n = loadThreadPage(total); + } + if (total > _thread_skip + n) { + char t[40]; + snprintf(t, sizeof(t), LV_SYMBOL_UP " Older messages (%d)", total - _thread_skip - n); + pageButton(_thread_list, t, 1); + } if (_thread_is_channel) { - int n = h.histCountForChannel(_thread_channel); - if (n > MAX_SHOWN) n = MAX_SHOWN; - for (int j = n - 1; j >= 0; j--) { // oldest first - int pos = h.histEntryForChannel(_thread_channel, j); - const ChHistEntry& e = h.chAtPos(pos); + for (int i = 0; i < n; i++) { // oldest first + const ChHistEntry& e = s_th_ch[i]; // Channel text is "Sender: body"; our own posts are filed as "Me: body". char from[40] = ""; const char* body = e.text; @@ -2067,16 +2165,13 @@ void UITask::refreshThread() { } else if (own) st = ""; int loc = own ? -1 : noteMsgLocation(body, from); bubble(_thread_list, own ? NULL : from, body, own, e.timestamp, st, col, - loc, noteMsgMeta(pos, true, own, loc, own ? "" : from)); + loc, noteMsgMeta(i, true, own, loc, own ? "" : from)); } } else { - int n = h.dmHistCountForContact(_thread_key); - if (n > MAX_SHOWN) n = MAX_SHOWN; ContactInfo tc; bool room = MessageHistory::contactByPrefix(_thread_key, tc) && tc.type == ADV_TYPE_ROOM; - for (int j = n - 1; j >= 0; j--) { - int pos = h.dmHistEntryForContact(_thread_key, j); - const DmHistEntry& e = h.dmAtPos(pos); + for (int i = 0; i < n; i++) { + const DmHistEntry& e = s_th_dm[i]; const char* st = NULL; uint32_t col = theme::TEXT_MUTED; if (e.outgoing) { switch (h.dmEffectiveStatus(e)) { @@ -2093,13 +2188,14 @@ void UITask::refreshThread() { else if (!e.outgoing) contactName(_thread_key, who, sizeof(who)); int loc = e.outgoing ? -1 : noteMsgLocation(text, who); bubble(_thread_list, room && !e.outgoing ? who : NULL, text, e.outgoing, e.timestamp, st, col, - loc, e.outgoing ? -1 : noteMsgMeta(pos, false, false, loc, who)); // nothing to show for our own DM + loc, e.outgoing ? -1 : noteMsgMeta(i, false, false, loc, who)); // nothing to show for our own DM } } - if (lv_obj_get_child_count(_thread_list) == 0) - label(_thread_list, "No messages yet", THEME_FONT_BODY, theme::TEXT_MUTED); + if (_thread_skip > 0) pageButton(_thread_list, LV_SYMBOL_DOWN " Newer messages", -1); + if (n == 0) label(_thread_list, "No messages yet", THEME_FONT_BODY, theme::TEXT_MUTED); lv_obj_update_layout(_thread_list); - lv_obj_scroll_to_y(_thread_list, LV_COORD_MAX, LV_ANIM_OFF); + lv_obj_scroll_to_y(_thread_list, _thread_scroll_top ? 0 : LV_COORD_MAX, LV_ANIM_OFF); + _thread_scroll_top = false; } // To the open conversation (channel or DM). The caller refreshes the thread. @@ -2386,6 +2482,7 @@ void UITask::showSettings() { } static void onOpenRadio(lv_event_t* e); // RadioScreen.h +static void onOpenStorage(lv_event_t* e); // StorageScreen.h void UITask::buildSettings() { // The order of the original (ui-new): display, sound, radio, system, @@ -2419,6 +2516,7 @@ void UITask::buildSettings() { if (_core->gpsEnabled()) lv_obj_add_state(sw, LV_STATE_CHECKED); lv_obj_add_event_cb(sw, onGpsSwitch, LV_EVENT_VALUE_CHANGED, NULL); } + listRow(body, LV_SYMBOL_SD_CARD " Storage", "SD card, message history", onOpenStorage, NULL); listRow(body, LV_SYMBOL_REFRESH " Reboot", NULL, onPowerRow, (void*)(uintptr_t)1); listRow(body, LV_SYMBOL_POWER " Power off", NULL, onPowerRow, (void*)(uintptr_t)0); sectionTitle(body, "KEYBOARD"); @@ -2466,4 +2564,5 @@ void UITask::setKeyboardAlphabets(int main_idx, int alt_sel) { #include "SoundScreen.h" #include "QuickScreen.h" #include "OtaScreen.h" +#include "StorageScreen.h" #include "Splash.h" diff --git a/examples/companion_radio/ui-lvgl/UITask.h b/examples/companion_radio/ui-lvgl/UITask.h index 5bfca51b..755e9b5b 100644 --- a/examples/companion_radio/ui-lvgl/UITask.h +++ b/examples/companion_radio/ui-lvgl/UITask.h @@ -208,6 +208,19 @@ public: void nodeNameDone(bool ok); void powerPopup(bool restart); void unlockScreen(); + void threadPage(int dir); // +1 older, -1 newer + // Saved trails on the SD card (NavMap.h) + bool saveTrailToCard(char* name_out, size_t n); + void savedTrailsPopup(); + void savedTrailPopup(int idx); // -1 = the device's own slot + void savedTrailAction(uint8_t act); + void navFrameTrail(); + // Settings > Storage (StorageScreen.h) + void showStorage(); + void buildStorage(); + void pollStorage(); + void storageKeep(int idx); + void storageClearHistory(); bool locked() const; void pinKey(const char* key); // the lock screen's keypad void pinSetupPopup(); // Settings > Display & power > Screen PIN @@ -236,7 +249,7 @@ public: void openAdminFor(const ContactInfo& ci, bool from_picker) { (void)ci; (void)from_picker; } private: - enum Screen : uint8_t { SCR_HOME, SCR_CHATS, SCR_CONTACTS, SCR_THREAD, SCR_SETTINGS, SCR_NEARBY, SCR_NODE, SCR_MAP, SCR_WIFI, SCR_SETTINGS_NAV, SCR_CLOCK, SCR_RADIO, SCR_CHANNEL_EDIT, SCR_ADMIN, SCR_BOT, SCR_FAVS, SCR_DIAG, SCR_COMPASS, SCR_SCOPES, SCR_REPEATER, SCR_MELODY, SCR_QUICK, SCR_ADMIN_PICK, SCR_OTA }; + enum Screen : uint8_t { SCR_HOME, SCR_CHATS, SCR_CONTACTS, SCR_THREAD, SCR_SETTINGS, SCR_NEARBY, SCR_NODE, SCR_MAP, SCR_WIFI, SCR_SETTINGS_NAV, SCR_CLOCK, SCR_RADIO, SCR_CHANNEL_EDIT, SCR_ADMIN, SCR_BOT, SCR_FAVS, SCR_DIAG, SCR_COMPASS, SCR_SCOPES, SCR_REPEATER, SCR_MELODY, SCR_QUICK, SCR_ADMIN_PICK, SCR_OTA, SCR_STORAGE }; void buildStatusBar(); void refreshStatusBar(); @@ -392,6 +405,9 @@ private: // Open conversation (SCR_THREAD) bool _thread_is_channel = false; + int _thread_skip = 0; // newest messages above the page shown (Older / Newer) + bool _thread_scroll_top = false; + int loadThreadPage(int& total); uint8_t _thread_channel = 0; uint8_t _thread_key[PUB_KEY_SIZE] = {0}; bool _thread_dirty = false; diff --git a/solo/wio-tracker-l2/platformio.ini b/solo/wio-tracker-l2/platformio.ini index 726ee666..42d240d4 100644 --- a/solo/wio-tracker-l2/platformio.ini +++ b/solo/wio-tracker-l2/platformio.ini @@ -68,6 +68,7 @@ build_flags = -D HIST_CH_MAX=256 ; all channels together, ~50 KB -D HIST_DM_MAX=128 ; all DMs together, ~27 KB -D NEARBY_MAX=64 + -D HIST_ARCHIVE ; message history also on the SD card (ui-lvgl/HistoryStore.h) build_src_filter = ${Wio_Tracker_L2.build_src_filter} + + diff --git a/src/helpers/sensors/MicroNMEALocationProvider.h b/src/helpers/sensors/MicroNMEALocationProvider.h index b76756d7..e3855011 100644 --- a/src/helpers/sensors/MicroNMEALocationProvider.h +++ b/src/helpers/sensors/MicroNMEALocationProvider.h @@ -39,6 +39,7 @@ class MicroNMEALocationProvider : public LocationProvider { char _nmeaBuffer[100]; + uint32_t _rx_chars = 0; MicroNMEA nmea; mesh::RTCClock* _clock; Stream* _gps_serial; @@ -122,6 +123,9 @@ public : return alt; } long satellitesCount() override { return nmea.getNumSatellites(); } + // Bytes read from the receiver so far: still at 0 (or not moving) = no NMEA + // arriving at all, as opposed to NMEA without a fix (a UI diagnostics row). + uint32_t rxChars() const { return _rx_chars; } long getHDOP() override { return nmea.getHDOP(); } bool isValid() override { return nmea.isValid(); } @@ -138,6 +142,7 @@ public : while (_gps_serial->available()) { char c = _gps_serial->read(); + _rx_chars++; #ifdef GPS_NMEA_DEBUG Serial.print(c); #endif diff --git a/variants/sim/build_wasm.sh b/variants/sim/build_wasm.sh index a5c93f0e..dee96276 100755 --- a/variants/sim/build_wasm.sh +++ b/variants/sim/build_wasm.sh @@ -184,7 +184,7 @@ if [ "$SIM_UI" = "lvgl" ]; then done # The L2's PSRAM-sized limits (solo/wio-tracker-l2/platformio.ini). DEFINES=("${kept[@]}" -DDISPLAY_CLASS=SimLcdDisplay -DMAX_GROUP_CHANNELS=40 -DUI_ZOOM=1 -DLV_CONF_INCLUDE_SIMPLE - -DTRAIL_CAPACITY=4096 -DWAYPOINT_CAPACITY=64 -DHIST_CH_MAX=256 -DHIST_DM_MAX=128 -DNEARBY_MAX=64) + -DTRAIL_CAPACITY=4096 -DWAYPOINT_CAPACITY=64 -DHIST_CH_MAX=256 -DHIST_DM_MAX=128 -DNEARBY_MAX=64 -DHIST_ARCHIVE) INCLUDES+=("-I$LVGL_DIR") LINK_EXTRA=(-sFETCH=1) # map downloads (LvglPort.h) go through emscripten_fetch fi diff --git a/variants/wio-tracker-l2/target.h b/variants/wio-tracker-l2/target.h index d08701ab..145c464e 100644 --- a/variants/wio-tracker-l2/target.h +++ b/variants/wio-tracker-l2/target.h @@ -18,6 +18,7 @@ extern WioTrackerL2Board board; extern WRAPPER_CLASS radio_driver; extern AutoDiscoverRTCClock rtc_clock; extern EnvironmentSensorManager sensors; +extern MicroNMEALocationProvider gps; #ifdef DISPLAY_CLASS extern DISPLAY_CLASS display;