mirror of
https://github.com/MarekZegare4/MeshCore-Solo.git
synced 2026-10-09 11:16:39 +00:00
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:
@@ -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)
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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; }
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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"
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user