feat(ui-lvgl): roadmap stage 3 - message history on SD, Storage screen, saved trails

- Message history archived per conversation on the SD card (HIST_ARCHIVE):
  restored on boot, Older/Newer paging in threads, kept count selectable
- Settings > System > Storage: card and flash usage, breakdown by maps,
  messages, trails and other, kept-per-chat setting, delete history;
  reads the mounted volume and warns when the FAT partition is much
  smaller than the card
- Saved trails on the card: every Save is a new file, Load opens a list
  with load (framed on the map), GPX export and delete
- Diagnostics: GPS data rows, last start reason, last crash summary
- Messages restored before the clock is set show their date instead of 0s

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Jakub
2026-09-26 19:17:08 +02:00
co-authored by Claude Opus 5.5
parent 4f5357031f
commit 277c0cddf1
15 changed files with 1272 additions and 37 deletions
+12
View File
@@ -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/<name>.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)
+20 -5
View File
@@ -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
@@ -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];
@@ -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);
}
+35 -5
View File
@@ -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);
@@ -0,0 +1,350 @@
#pragma once
// Message history on the SD card (roadmap stage 3): one file per conversation
// under /sdcard/meshcore/history -- c_<key>.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_<prefix>.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 <stdio.h>
#include <string.h>
#include <stdlib.h>
#include <stddef.h>
#include <dirent.h>
#include <sys/stat.h>
#include <algorithm>
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 <typename E>
int total(const char* path) {
FILE* f = fopen(path, "rb");
if (!f) return 0;
Hdr h;
int n = readHdr<E>(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 <typename E>
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<E>(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<ChHistEntry>(hist, 'c', MessageHistory::CH_HIST_MAX, keys, have);
restoreKind<DmHistEntry>(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<ChHistEntry>(path, _keep);
else resize<DmHistEntry>(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 <typename E>
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 <typename E>
void append(const char* path, E& e) {
resize<E>(path, _keep); // the setting changed since this file was written
FILE* f = fopen(path, "r+b");
Hdr h;
if (!f || !readHdr<E>(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 <typename E>
void update(const char* path, const E& e) {
FILE* f = fopen(path, "r+b");
if (!f) return;
Hdr h;
if (readHdr<E>(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 <typename E>
void resize(const char* path, uint32_t cap) {
FILE* f = fopen(path, "rb");
if (!f) return;
Hdr h;
if (!readHdr<E>(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 <typename F>
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 <typename E>
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<E>(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<E>(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
@@ -12,7 +12,13 @@
#include <HTTPClient.h>
#include <WiFiClientSecure.h>
#include <Preferences.h>
#include <SPIFFS.h>
#include <esp_heap_caps.h>
#include <esp_system.h>
#include <esp_core_dump.h>
#if ESP_ARDUINO_VERSION_MAJOR >= 3
#include <esp_vfs_fat.h>
#endif
#include <mbedtls/platform.h>
#elif defined(SIM_PLATFORM) && defined(__EMSCRIPTEN__)
#include <emscripten/fetch.h>
@@ -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; }
+235 -1
View File
@@ -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<const char*>(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 <typename F>
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;
@@ -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();
}
+117 -18
View File
@@ -34,6 +34,9 @@ template <class T> 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<MsgLoc>(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<ChHistEntry>(THREAD_MAX_SHOWN);
static DmHistEntry* s_th_dm = psramBuf<DmHistEntry>(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<MsgMeta>(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<ChHistEntry>(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<DmHistEntry>(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"
+17 -1
View File
@@ -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;
+1
View File
@@ -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}
+<helpers/esp32/*.cpp>
+<helpers/ui/LGFXDisplay.cpp>
@@ -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
+1 -1
View File
@@ -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
+1
View File
@@ -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;