mirror of
https://github.com/MarekZegare4/MeshCore-Solo.git
synced 2026-11-01 10:21:59 +00:00
feat(l1): messages kept on flash across a power-off, and "Powered off" on e-ink
Settings > Messages > Keep messages (Off, 500, 1000, 2500 in all; off by default; HIST_FLASH, ui-core/HistoryLog.h). Two append-only logs on the QSPI flash, channels and DMs, restored into the RAM rings at the start. A batch is written a minute after the first unsaved change and at shutdown, as the L1's power switch gives no warning. Each segment has a directory of its own: a LittleFS 1.x commit erases one of a directory's two fixed blocks every time. E-ink panels keep their last frame, so a shutdown now leaves "Powered off" on it and puts the controller into deep sleep. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -117,4 +117,5 @@ values above that are set from the phone app or the CLI.
|
||||
| `ADVERT_NAME='"name"'` | Sets the default node name baked into a fresh device, instead of the hex of the first 4 bytes of its public key. Note the doubled quoting — it's a C string literal passed through a build flag. |
|
||||
| `MAP_VECTOR` | Wio Tracker L2: turns on the unfinished vector map (regions from `tools/maps/osm_vector.py`). Without it the vector map stays off whatever was saved. |
|
||||
| `HIST_ARCHIVE` | Wio Tracker L2: message history also kept on the SD card (`ui-lvgl/HistoryStore.h`). Set in its solo build. |
|
||||
| `HIST_FLASH` | Wio Tracker L1: message history also kept on the QSPI flash, across a power-off (`ui-core/HistoryLog.h`, Settings > Messages > Keep messages, off by default). Implies `HIST_ARCHIVE`'s hooks. Set in its solo builds. |
|
||||
| `MESHCORE_VERSION='"x.y"'` | Overrides the upstream MeshCore base-version string shown in diagnostics, for boards whose port hasn't been rebased onto the latest yet. Cosmetic only — doesn't change protocol behaviour. |
|
||||
|
||||
@@ -45,7 +45,7 @@ build flag it needs.
|
||||
| Channels, direct, rooms | yes | yes | yes | yes | yes, one screen with sections |
|
||||
| Opens on the first unread, "New" line | yes | yes | yes | yes | yes |
|
||||
| Bubbles | 2-line boxes | whole text | whole text | whole text | whole text |
|
||||
| History kept | 48 ch / 32 DM | same | same | same | 256 / 128 in memory, all on SD (`HIST_ARCHIVE`) |
|
||||
| History kept | 48 ch / 32 DM | same; the L1s can keep it on flash across a power-off (`HIST_FLASH`) | same | same | 256 / 128 in memory, all on SD (`HIST_ARCHIVE`) |
|
||||
| Path of a message | Path list | same | same | same | path diagram on the card |
|
||||
| Quick messages, placeholders | yes | yes | yes | yes | yes |
|
||||
| Bytes left while typing | yes | yes | yes | yes | yes |
|
||||
|
||||
@@ -60,7 +60,10 @@ protected:
|
||||
// SYSTEMOFF, so the module would go on draining the battery.
|
||||
void powerDown(bool restart, DisplayDriver* display, SensorManager* sensors) {
|
||||
if (restart) { _board->reboot(); return; }
|
||||
if (display) display->turnOff();
|
||||
if (display) {
|
||||
display->showPoweredOff();
|
||||
display->turnOff();
|
||||
}
|
||||
radio_driver.powerOff();
|
||||
if (sensors) {
|
||||
LocationProvider* loc = sensors->getLocationProvider();
|
||||
|
||||
@@ -348,6 +348,7 @@ static void sanitize(NodePrefs& p) {
|
||||
if (p.favourite_kinds[i] > NodePrefs::FAV_KIND_MAX) p.favourite_kinds[i] = NodePrefs::FAV_KIND_CONTACT;
|
||||
flag(p.fav_sort, 1);
|
||||
flag(p.msg_wake, 1);
|
||||
if (p.msg_keep >= NodePrefs::MSG_KEEP_COUNT) p.msg_keep = 0;
|
||||
if (p.contact_expiry_idx >= NodePrefs::CONTACT_EXPIRY_COUNT) p.contact_expiry_idx = 0;
|
||||
if (p.dst_rule >= NodePrefs::DST_COUNT) p.dst_rule = NodePrefs::DST_OFF;
|
||||
if (p.display_brightness_pct > 100) p.display_brightness_pct = 0;
|
||||
|
||||
@@ -330,6 +330,9 @@ struct NodePrefsFields {
|
||||
// Settings > Display > "Msg wake": an incoming message turns the display on
|
||||
// (UITask::newMsg()) if it was off and no companion app is already showing it.
|
||||
uint8_t msg_wake; // 1=wake display for incoming msgs (default), 0=not
|
||||
// Settings > Messages > Keep messages, boards with HIST_FLASH: how many
|
||||
// messages in all stay on flash (index into msgKeepTotal(); 0 = off, none).
|
||||
uint8_t msg_keep;
|
||||
// ui-new's new-message alert: the banner from the top, an envelope with a
|
||||
// count in the corner, or nothing (the sound and the wake still happen).
|
||||
static const uint8_t MSG_ALERT_NORMAL = 0, MSG_ALERT_COMPACT = 1, MSG_ALERT_OFF = 2;
|
||||
@@ -640,6 +643,11 @@ struct NodePrefsFields {
|
||||
static const char* L[CONTACT_EXPIRY_COUNT] = { "Off", "7d", "30d", "90d" };
|
||||
return L[idx < CONTACT_EXPIRY_COUNT ? idx : 0];
|
||||
}
|
||||
static const uint8_t MSG_KEEP_COUNT = 4;
|
||||
static uint16_t msgKeepTotal(uint8_t idx) {
|
||||
static const uint16_t N[MSG_KEEP_COUNT] = { 0, 500, 1000, 2500 };
|
||||
return N[idx < MSG_KEEP_COUNT ? idx : 0];
|
||||
}
|
||||
|
||||
// /prefs' layout (see the comment at the top). A file of another version
|
||||
// isn't read: the defaults stand.
|
||||
@@ -892,7 +900,7 @@ class NodePrefs : public NodePrefsFields, public ConfigSerializer {
|
||||
s.def("dm_all", dm_show_all); s.def("room_fav", room_fav_only);
|
||||
s.hex("ch_fav", ch_fav_bitmask); s.def("ch_fav_only", ch_fav_only);
|
||||
s.hex("favs", favourite_contacts); s.hex("fav_kinds", favourite_kinds);
|
||||
s.def("fav_sort", fav_sort); s.def("expiry", contact_expiry_idx);
|
||||
s.def("fav_sort", fav_sort); s.def("expiry", contact_expiry_idx); s.def("msg_keep", msg_keep);
|
||||
}
|
||||
void gpioSolo(Section& s) {
|
||||
s.def("m1", gpio1_mode); s.def("m2", gpio2_mode); s.def("m3", gpio3_mode); s.def("m4", gpio4_mode);
|
||||
|
||||
@@ -0,0 +1,332 @@
|
||||
#pragma once
|
||||
// Message history on the QSPI flash, for a board without an SD card (the L1s,
|
||||
// HIST_FLASH): Settings > Messages > Keep messages, NodePrefs::msg_keep. Two
|
||||
// append-only logs, channels and direct messages (a busy channel must not push
|
||||
// the DMs out), each a run of SEGMENTS segments of which the oldest is dropped
|
||||
// whole. The limit is a total: 2/3 of it for channels, 1/3 for DMs and rooms.
|
||||
//
|
||||
// Shaped by LittleFS 1.x, unlike the SD card's HistoryStore.h:
|
||||
// - Changing a file in the middle rewrites everything after that point, so
|
||||
// records are only ever appended.
|
||||
// - Every file commit erases one of its directory's two blocks, and those stay
|
||||
// put: nothing moves them to spread the wear. So a segment gets a directory
|
||||
// of its own, and dropping it lets a later one land on other blocks. A commit
|
||||
// here is one batch, so the two blocks see at most the segment's record count.
|
||||
// - A commit happens only on close, so a power cut mid-batch (the L1's switch
|
||||
// gives no warning) leaves the log as it was before the batch.
|
||||
//
|
||||
// A batch is written SAVE_DELAY_MS after the first change not yet saved -- not
|
||||
// after the last, or a busy channel would never get there -- and at shutdown().
|
||||
// A record is written once, so a message waits for its final delivery state;
|
||||
// until then the entries behind it wait too, to keep the log in order.
|
||||
//
|
||||
// A record: Rec, the path, the text, then its own total length as one byte, so
|
||||
// the log can be walked from the end. restore() fills the RAM rings from there.
|
||||
// A channel's messages carry channelKey(), so they follow the channel, not the
|
||||
// slot; those of a channel since deleted aren't restored.
|
||||
|
||||
class HistoryLog : public HistArchive {
|
||||
public:
|
||||
static const uint8_t SEGMENTS = 8;
|
||||
static const uint32_t SAVE_DELAY_MS = 60000;
|
||||
static const uint32_t SETTLE_SECS = 60; // an own channel post no repeater has echoed yet
|
||||
|
||||
void begin(NodePrefs* prefs, MessageHistory* history) {
|
||||
_prefs = prefs;
|
||||
_history = history;
|
||||
DataStore* ds = the_mesh.getDataStore();
|
||||
_fs = ds ? ds->getSecondaryFS() : nullptr;
|
||||
_on = _fs && prefs && prefs->msg_keep;
|
||||
if (_on) {
|
||||
scan();
|
||||
restore();
|
||||
}
|
||||
history->setArchive(this);
|
||||
}
|
||||
|
||||
// ── HistArchive ────────────────────────────────────────────────────────────
|
||||
// arc_id orders the entries of this run; a restored one keeps 0, already saved.
|
||||
void chAppend(ChHistEntry& e) override { e.arc_id = ++_seq; touch(); }
|
||||
void chUpdate(const ChHistEntry&) override { touch(); }
|
||||
void dmAppend(DmHistEntry& e) override { e.arc_id = ++_seq; touch(); }
|
||||
void dmUpdate(const DmHistEntry&) override { touch(); }
|
||||
|
||||
void loop() {
|
||||
bool on = _fs && _prefs && _prefs->msg_keep;
|
||||
if (on != _on) {
|
||||
_on = on;
|
||||
if (on) touch(); else forget();
|
||||
}
|
||||
if (_dirty && (int32_t)(millis() - _due) >= 0) flush(false);
|
||||
}
|
||||
|
||||
void onShutdown() { if (_dirty && _prefs->msg_keep) flush(true); }
|
||||
|
||||
private:
|
||||
static constexpr const char* ROOT = "/h";
|
||||
|
||||
struct __attribute__((packed)) Rec {
|
||||
uint32_t key; // channelKey(), or a DM's 4-byte public key prefix
|
||||
uint32_t timestamp;
|
||||
uint32_t msg_ts; // DM: the sender's timestamp, for dropping a replayed copy
|
||||
uint8_t flags; // AckState, | OUTGOING
|
||||
uint8_t path_len; // packed, as mesh::Packet::path_len
|
||||
uint8_t text_len;
|
||||
};
|
||||
static const uint8_t OUTGOING = 0x80, STATUS_MASK = 0x03;
|
||||
static const int REC_MAX = sizeof(Rec) + MAX_HIST_PATH_BYTES + MSG_TEXT_BUF + 1;
|
||||
|
||||
// A log: segments first..last (0 = none yet), in_last records in the newest.
|
||||
struct Log {
|
||||
char kind;
|
||||
uint32_t first, last;
|
||||
uint16_t in_last;
|
||||
};
|
||||
|
||||
void touch() {
|
||||
if (!_on || _dirty) return;
|
||||
_dirty = true;
|
||||
_due = millis() + SAVE_DELAY_MS;
|
||||
}
|
||||
|
||||
static void dirPath(const Log& l, uint32_t seg, char* out, size_t n) {
|
||||
snprintf(out, n, "%s/%c%lu", ROOT, l.kind, (unsigned long)seg);
|
||||
}
|
||||
static void filePath(const Log& l, uint32_t seg, char* out, size_t n) {
|
||||
snprintf(out, n, "%s/%c%lu/l", ROOT, l.kind, (unsigned long)seg);
|
||||
}
|
||||
uint16_t segRecords(const Log& l) const {
|
||||
uint32_t total = NodePrefs::msgKeepTotal(_prefs->msg_keep);
|
||||
return (uint16_t)((l.kind == 'c' ? total * 2 / 3 : total / 3) / SEGMENTS);
|
||||
}
|
||||
static int pathBytes(uint8_t path_len) {
|
||||
int n = ((path_len >> 6) + 1) * (path_len & 63);
|
||||
return n < MAX_HIST_PATH_BYTES ? n : MAX_HIST_PATH_BYTES;
|
||||
}
|
||||
static bool sane(const Rec& r, int len) {
|
||||
return r.text_len < MSG_TEXT_BUF && len == (int)sizeof(Rec) + pathBytes(r.path_len) + r.text_len + 1;
|
||||
}
|
||||
|
||||
// ── Writing ────────────────────────────────────────────────────────────────
|
||||
void flush(bool all) {
|
||||
uint32_t t0 = millis();
|
||||
_dirty = false;
|
||||
bool more = flushCh(all);
|
||||
if (flushDm(all)) more = true;
|
||||
MESH_DEBUG_PRINTLN("HistoryLog: flush %lu ms", (unsigned long)(millis() - t0));
|
||||
if (more) touch();
|
||||
}
|
||||
|
||||
bool flushCh(bool all) {
|
||||
File f(*_fs);
|
||||
bool more = false;
|
||||
uint32_t now = rtc_clock.getCurrentTime();
|
||||
for (int j = _history->chHistCount() - 1; j >= 0; j--) {
|
||||
const ChHistEntry& e = _history->chAtPos(_history->chHistPosNewest(j));
|
||||
if (e.arc_id <= _ch_done) continue;
|
||||
if (!all && e.relay_status == ACK_PENDING && now - e.timestamp < SETTLE_SECS) { more = true; break; }
|
||||
ChannelDetails ch;
|
||||
if (the_mesh.getChannel(e.ch_idx, ch) && ch.name[0] &&
|
||||
!put(_ch, f, channelKey(ch), e.timestamp, 0, e.relay_status, e.path_len, e.path, e.text)) break;
|
||||
_ch_done = e.arc_id;
|
||||
}
|
||||
if (f) f.close();
|
||||
return more;
|
||||
}
|
||||
|
||||
bool flushDm(bool all) {
|
||||
File f(*_fs);
|
||||
bool more = false;
|
||||
for (int j = _history->dmHistCount() - 1; j >= 0; j--) {
|
||||
const DmHistEntry& e = _history->dmAtPos(_history->dmHistPosNewest(j));
|
||||
if (e.arc_id <= _dm_done) continue;
|
||||
if (!all && e.ack_status == ACK_PENDING) { more = true; break; }
|
||||
uint32_t key;
|
||||
memcpy(&key, e.prefix, 4);
|
||||
if (!put(_dm, f, key, e.timestamp, e.msg_ts, e.ack_status | (e.outgoing ? OUTGOING : 0),
|
||||
e.path_len, e.path, e.text)) break;
|
||||
_dm_done = e.arc_id;
|
||||
}
|
||||
if (f) f.close();
|
||||
return more;
|
||||
}
|
||||
|
||||
bool put(Log& l, File& f, uint32_t key, uint32_t ts, uint32_t msg_ts, uint8_t flags, uint8_t path_len,
|
||||
const uint8_t* path, const char* text) {
|
||||
if (!f || !l.last || l.in_last >= segRecords(l)) {
|
||||
if (f) f.close();
|
||||
if (!l.last || l.in_last >= segRecords(l)) nextSegment(l);
|
||||
char p[24];
|
||||
filePath(l, l.last, p, sizeof(p));
|
||||
f = _fs->open(p, FILE_O_WRITE); // at the end of the file
|
||||
if (!f) return false;
|
||||
}
|
||||
uint8_t buf[REC_MAX];
|
||||
Rec r = { key, ts, msg_ts, flags, path_len, (uint8_t)strnlen(text, MSG_TEXT_BUF - 1) };
|
||||
int pn = pathBytes(path_len), n = 0;
|
||||
memcpy(buf, &r, sizeof(r)); n += sizeof(r);
|
||||
memcpy(buf + n, path, pn); n += pn;
|
||||
memcpy(buf + n, text, r.text_len); n += r.text_len;
|
||||
buf[n] = (uint8_t)(n + 1); n++;
|
||||
if ((int)f.write(buf, n) != n) return false;
|
||||
l.in_last++;
|
||||
return true;
|
||||
}
|
||||
|
||||
void nextSegment(Log& l) {
|
||||
char p[24];
|
||||
l.last++;
|
||||
if (!l.first) l.first = l.last;
|
||||
l.in_last = 0;
|
||||
_fs->mkdir(ROOT);
|
||||
dirPath(l, l.last, p, sizeof(p));
|
||||
_fs->mkdir(p);
|
||||
while (l.last - l.first >= SEGMENTS) {
|
||||
dirPath(l, l.first++, p, sizeof(p));
|
||||
_fs->rmdir_r(p);
|
||||
}
|
||||
}
|
||||
|
||||
void forget() {
|
||||
_fs->rmdir_r(ROOT);
|
||||
_dirty = false;
|
||||
_ch = { 'c', 0, 0, 0 };
|
||||
_dm = { 'd', 0, 0, 0 };
|
||||
_ch_done = _dm_done = 0; // on again: what the rings hold goes back in
|
||||
}
|
||||
|
||||
// ── Reading ────────────────────────────────────────────────────────────────
|
||||
// Finds each log's segments in ROOT.
|
||||
void scan() {
|
||||
File dir = _fs->open(ROOT, FILE_O_READ);
|
||||
if (!dir) return;
|
||||
File e = dir.openNextFile(FILE_O_READ);
|
||||
while (e) {
|
||||
const char* name = e.name();
|
||||
Log* l = name[0] == 'c' ? &_ch : name[0] == 'd' ? &_dm : nullptr;
|
||||
uint32_t seg = l ? strtoul(name + 1, nullptr, 10) : 0;
|
||||
if (seg) {
|
||||
if (!l->first || seg < l->first) l->first = seg;
|
||||
if (seg > l->last) l->last = seg;
|
||||
}
|
||||
e.close();
|
||||
e = dir.openNextFile(FILE_O_READ);
|
||||
}
|
||||
dir.close();
|
||||
_ch.in_last = countLast(_ch);
|
||||
_dm.in_last = countLast(_dm);
|
||||
}
|
||||
|
||||
uint16_t countLast(const Log& l) {
|
||||
if (!l.last) return 0;
|
||||
char p[24];
|
||||
filePath(l, l.last, p, sizeof(p));
|
||||
File f = _fs->open(p, FILE_O_READ);
|
||||
if (!f) return 0;
|
||||
uint16_t n = 0;
|
||||
uint8_t len;
|
||||
for (uint32_t pos = f.size(); pos > 0 && stepBack(f, pos, len); pos -= len) n++;
|
||||
f.close();
|
||||
return n;
|
||||
}
|
||||
|
||||
// The record ending at pos: its length, false when that makes no sense.
|
||||
static bool stepBack(File& f, uint32_t pos, uint8_t& len) {
|
||||
return f.seek(pos - 1) && f.read(&len, 1) == 1 && len > sizeof(Rec) && len <= pos;
|
||||
}
|
||||
|
||||
void restore() {
|
||||
uint32_t keys[MAX_GROUP_CHANNELS];
|
||||
for (int i = 0; i < MAX_GROUP_CHANNELS; i++) {
|
||||
ChannelDetails ch;
|
||||
keys[i] = the_mesh.getChannel(i, ch) && ch.name[0] ? channelKey(ch) : 0;
|
||||
}
|
||||
restoreLog(_ch, MessageHistory::CH_HIST_MAX, keys);
|
||||
restoreLog(_dm, MessageHistory::DM_HIST_MAX, nullptr);
|
||||
}
|
||||
|
||||
static int chSlot(const uint32_t* keys, uint32_t key) {
|
||||
for (int i = 0; key && i < MAX_GROUP_CHANNELS; i++) if (keys[i] == key) return i;
|
||||
return -1;
|
||||
}
|
||||
|
||||
// The newest `want` usable records into the ring, oldest first: back from
|
||||
// the end to where they start, then forward from there.
|
||||
void restoreLog(const Log& l, int want, const uint32_t* keys) {
|
||||
if (!l.last) return;
|
||||
char p[24];
|
||||
uint32_t seg0 = l.last, off0 = 0;
|
||||
int got = 0;
|
||||
for (uint32_t seg = l.last; seg >= l.first && got < want; seg--) {
|
||||
filePath(l, seg, p, sizeof(p));
|
||||
File f = _fs->open(p, FILE_O_READ);
|
||||
if (!f) continue;
|
||||
uint32_t pos = f.size();
|
||||
uint8_t len;
|
||||
while (pos > 0 && got < want && stepBack(f, pos, len)) {
|
||||
pos -= len;
|
||||
Rec r;
|
||||
if (!f.seek(pos) || f.read(&r, sizeof(r)) != (int)sizeof(r) || !sane(r, len)) { pos += len; break; }
|
||||
if (!keys || chSlot(keys, r.key) >= 0) got++;
|
||||
}
|
||||
seg0 = seg;
|
||||
off0 = pos;
|
||||
f.close();
|
||||
}
|
||||
for (uint32_t seg = seg0; seg <= l.last; seg++) {
|
||||
filePath(l, seg, p, sizeof(p));
|
||||
File f = _fs->open(p, FILE_O_READ);
|
||||
if (!f) continue;
|
||||
uint32_t size = f.size();
|
||||
for (uint32_t pos = seg == seg0 ? off0 : 0; pos < size; ) {
|
||||
uint8_t buf[REC_MAX];
|
||||
Rec r;
|
||||
if (!f.seek(pos) || f.read(&r, sizeof(r)) != (int)sizeof(r)) break;
|
||||
int rest = pathBytes(r.path_len) + r.text_len + 1;
|
||||
if (r.text_len >= MSG_TEXT_BUF || f.read(buf, rest) != rest) break;
|
||||
if (buf[rest - 1] != (uint8_t)(sizeof(r) + rest)) break;
|
||||
restoreRec(r, buf, keys);
|
||||
pos += sizeof(r) + rest;
|
||||
}
|
||||
f.close();
|
||||
}
|
||||
}
|
||||
|
||||
void restoreRec(const Rec& r, const uint8_t* buf, const uint32_t* keys) {
|
||||
int pn = pathBytes(r.path_len);
|
||||
if (keys) {
|
||||
int slot = chSlot(keys, r.key);
|
||||
if (slot < 0) return;
|
||||
ChHistEntry e;
|
||||
memset(&e, 0, sizeof(e));
|
||||
e.ch_idx = (uint8_t)slot;
|
||||
e.timestamp = r.timestamp;
|
||||
e.relay_status = r.flags & STATUS_MASK;
|
||||
e.path_len = r.path_len;
|
||||
memcpy(e.path, buf, pn);
|
||||
memcpy(e.text, buf + pn, r.text_len);
|
||||
_history->restoreCh(e);
|
||||
} else {
|
||||
DmHistEntry e;
|
||||
memset(&e, 0, sizeof(e));
|
||||
memcpy(e.prefix, &r.key, 4);
|
||||
e.outgoing = (r.flags & OUTGOING) ? 1 : 0;
|
||||
e.timestamp = r.timestamp;
|
||||
e.msg_ts = r.msg_ts;
|
||||
e.ack_status = r.flags & STATUS_MASK;
|
||||
e.path_len = r.path_len;
|
||||
memcpy(e.path, buf, pn);
|
||||
memcpy(e.text, buf + pn, r.text_len);
|
||||
_history->restoreDm(e);
|
||||
}
|
||||
}
|
||||
|
||||
NodePrefs* _prefs = nullptr;
|
||||
MessageHistory* _history = nullptr;
|
||||
FILESYSTEM* _fs = nullptr;
|
||||
bool _on = false, _dirty = false;
|
||||
uint32_t _due = 0;
|
||||
uint32_t _seq = 0, _ch_done = 0, _dm_done = 0; // arc_id: the last given, the last saved
|
||||
Log _ch = { 'c', 0, 0, 0 };
|
||||
Log _dm = { 'd', 0, 0, 0 };
|
||||
};
|
||||
@@ -11,6 +11,11 @@
|
||||
// MSG_TEXT_BUF and the two entry structs are file-scope (not nested) so the
|
||||
// phase machine in MessagesScreen keeps referring to them unqualified.
|
||||
|
||||
// The flash log (HistoryLog.h) is a kind of archive; it needs nothing more.
|
||||
#if defined(HIST_FLASH) && !defined(HIST_ARCHIVE)
|
||||
#define HIST_ARCHIVE
|
||||
#endif
|
||||
|
||||
// Outgoing-message delivery state. DM: a real end-to-end ACK (✓ delivered to
|
||||
// the recipient). Channel: only a "relayed into mesh" echo from a repeater (no
|
||||
// recipient ACK exists for floods), so a missing echo is NOT shown as failure.
|
||||
@@ -87,10 +92,18 @@ struct DmHistEntry {
|
||||
};
|
||||
|
||||
#ifdef HIST_ARCHIVE
|
||||
// FNV-1a over the channel's secret, what a kept message knows its channel by
|
||||
// (a slot can be reused): renaming a channel keeps its messages.
|
||||
inline uint32_t channelKey(const ChannelDetails& ch) {
|
||||
uint32_t h = 2166136261u;
|
||||
for (size_t i = 0; i < sizeof(ch.channel.secret); i++) { h ^= ch.channel.secret[i]; h *= 16777619u; }
|
||||
return h;
|
||||
}
|
||||
|
||||
// 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.
|
||||
// the SD card, ui-lvgl/HistoryStore.h; the L1s a log on flash, HistoryLog.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;
|
||||
|
||||
@@ -21,7 +21,7 @@ static const Key KEYS[] = {
|
||||
K("autoadd", manual_add_contacts), KB("autoadd.chat", autoadd_config, 0x02), KB("autoadd.repeater", autoadd_config, 0x04),
|
||||
KB("autoadd.room", autoadd_config, 0x08), KB("autoadd.sensor", autoadd_config, 0x10),
|
||||
KB("autoadd.overwrite", autoadd_config, 0x01), K("autoadd.max.hops", autoadd_max_hops),
|
||||
K("contacts.expiry", contact_expiry_idx), K("contacts.fav.first", fav_sort), K("contacts.channel.order", ch_sort), K("map.flow", flow_show), K("dm.resend", dm_resend_count),
|
||||
K("contacts.expiry", contact_expiry_idx), K("contacts.fav.first", fav_sort), K("contacts.channel.order", ch_sort), K("map.flow", flow_show), K("dm.resend", dm_resend_count), K("messages.keep", msg_keep),
|
||||
K("display.brightness", display_brightness), K("display.off", auto_off_secs), K("display.wake", msg_wake),
|
||||
K("display.alert", msg_alert), K("lock", auto_lock), K("lock.clock", lock_compact),
|
||||
K("display.text.size", ui_text_scale), K("display.icon.size", ui_icon_scale),
|
||||
|
||||
@@ -136,6 +136,11 @@ static const int32_t INVERTED[] = { 1, 0 }; // switch over a field stored as "
|
||||
static void optResend(uint8_t v, char* b, int n, const NodePrefs&) {
|
||||
if (v == 0) snprintf(b, n, "Off"); else snprintf(b, n, "%u more", (unsigned)v);
|
||||
}
|
||||
#ifdef HIST_FLASH
|
||||
static void optMsgKeep(uint8_t v, char* b, int n, const NodePrefs&) {
|
||||
if (v == 0) snprintf(b, n, "Off"); else snprintf(b, n, "%u", (unsigned)NodePrefs::msgKeepTotal(v));
|
||||
}
|
||||
#endif
|
||||
static void optBattDisplay(uint8_t v, char* b, int n, const NodePrefs&) {
|
||||
static const char* L[] = { "Icon", "Percent", "Voltage" };
|
||||
snprintf(b, n, "%s", L[v < 3 ? v : 0]);
|
||||
@@ -288,6 +293,10 @@ static const Setting ALL[] = {
|
||||
|
||||
IDX("Resend direct messages", "Retries without a tick", SEC_MESSAGES, dm_resend_count, 6,
|
||||
optResend, nullptr),
|
||||
#ifdef HIST_FLASH
|
||||
IDX("Keep messages", "On flash, after a power-off too", SEC_MESSAGES, msg_keep, NodePrefs::MSG_KEEP_COUNT,
|
||||
optMsgKeep, nullptr),
|
||||
#endif
|
||||
|
||||
IDX("Contact expiry", "Prune after inactivity", SEC_CONTACTS, contact_expiry_idx,
|
||||
NodePrefs::CONTACT_EXPIRY_COUNT, optExpiry, nullptr),
|
||||
|
||||
@@ -14,6 +14,9 @@
|
||||
// frontend does (the_mesh, rtc_clock).
|
||||
|
||||
#include "MessageHistory.h"
|
||||
#ifdef HIST_FLASH
|
||||
#include "HistoryLog.h"
|
||||
#endif
|
||||
#include "DmUnreadTable.h"
|
||||
#include "UiEvents.h"
|
||||
#include "UiCoreHost.h"
|
||||
@@ -47,6 +50,9 @@ public:
|
||||
locator.begin(prefs, &course, &live_share, &events);
|
||||
trail.begin(prefs, &course);
|
||||
waypoints.begin(&locator); // loads /waypoints
|
||||
#ifdef HIST_FLASH
|
||||
history_log.begin(prefs, &history);
|
||||
#endif
|
||||
}
|
||||
|
||||
// Driven from the frontend's loop(), before it drains `events`.
|
||||
@@ -55,6 +61,9 @@ public:
|
||||
// and finalise the ✗ marker; free DM unread slots the ring no longer backs.
|
||||
history.tickDmResends();
|
||||
dm_unread.reconcile(history);
|
||||
#ifdef HIST_FLASH
|
||||
history_log.loop();
|
||||
#endif
|
||||
clock.loop();
|
||||
course.loop();
|
||||
live_share.loop();
|
||||
@@ -81,6 +90,9 @@ public:
|
||||
|
||||
// ── Models ────────────────────────────────────────────────────────────────
|
||||
MessageHistory history; // channel + DM rings, delivery state, channel unread
|
||||
#ifdef HIST_FLASH
|
||||
HistoryLog history_log; // the same messages on flash, across a power-off
|
||||
#endif
|
||||
DmUnreadTable dm_unread; // per-contact DM unread counters
|
||||
WaypointModel waypoints; // saved places, persisted to /waypoints
|
||||
|
||||
|
||||
@@ -46,12 +46,7 @@ struct Hdr {
|
||||
uint32_t cap, head, count, next_id;
|
||||
};
|
||||
|
||||
// FNV-1a over the channel's secret: renaming a channel keeps its archive.
|
||||
static uint32_t chKey(const ChannelDetails& ch) {
|
||||
uint32_t h = 2166136261u;
|
||||
for (size_t i = 0; i < sizeof(ch.channel.secret); i++) { h ^= ch.channel.secret[i]; h *= 16777619u; }
|
||||
return h;
|
||||
}
|
||||
static uint32_t chKey(const ChannelDetails& ch) { return channelKey(ch); }
|
||||
// Earlier builds hashed the name in too (see migrateKeys()).
|
||||
static uint32_t chKeyNamed(const ChannelDetails& ch) {
|
||||
uint32_t h = 2166136261u;
|
||||
|
||||
@@ -896,6 +896,9 @@ void UITask::shutdown(bool restart){
|
||||
|
||||
// Auto-save the live GPS trail if enabled (covers low-battery auto-shutdown).
|
||||
_core->trail.onShutdown();
|
||||
#ifdef HIST_FLASH
|
||||
_core->history_log.onShutdown();
|
||||
#endif
|
||||
|
||||
#ifdef PIN_BUZZER
|
||||
/* note: we have a choice here -
|
||||
|
||||
@@ -28,6 +28,7 @@ build_flags = ${WioTrackerL1Eink.build_flags}
|
||||
-D DUAL_SERIAL=1
|
||||
-D ENABLE_SCREENSHOT
|
||||
-D JOYSTICK_UPRIGHT_ROTATION=1
|
||||
-D HIST_FLASH ; Settings > Messages > Keep messages (ui-core/HistoryLog.h)
|
||||
-Os
|
||||
extra_scripts = post:create-uf2.py
|
||||
|
||||
|
||||
@@ -20,5 +20,6 @@ build_flags = ${WioTrackerL1CompanionDual.build_flags}
|
||||
-D ENABLE_SCREENSHOT
|
||||
-D DUAL_SERIAL=1
|
||||
-D GPS_SKYVIEW ; Tools > Satellites (helpers/sensors/GpsSky.h)
|
||||
-D HIST_FLASH ; Settings > Messages > Keep messages (ui-core/HistoryLog.h)
|
||||
-Os
|
||||
extra_scripts = post:create-uf2.py
|
||||
|
||||
@@ -56,6 +56,7 @@ public:
|
||||
virtual void forceFullRefresh() {} // next refresh will be full for eink
|
||||
virtual void turnOn() = 0;
|
||||
virtual void turnOff() = 0;
|
||||
virtual void showPoweredOff() {} // e-ink: a panel keeps its last frame, so leave it saying so
|
||||
virtual void clear() = 0;
|
||||
virtual void startFrame(ColorVal bkg = UIColor::window_bkg) = 0;
|
||||
virtual void setTextSize(int sz) = 0;
|
||||
|
||||
@@ -76,6 +76,19 @@ void GxEPDDisplay::turnOff() {
|
||||
_isOn = false;
|
||||
}
|
||||
|
||||
// The last frame the panel will show, then the controller into deep sleep. Only
|
||||
// a shutdown gets here: a power switch cuts the board with no warning. A full
|
||||
// refresh, for a clean page, with no radio to serve during its busy wait.
|
||||
void GxEPDDisplay::showPoweredOff() {
|
||||
if (!_init) return;
|
||||
setBusyPumpFn(nullptr, nullptr);
|
||||
startFrame(GxEPD_WHITE);
|
||||
setTextSize(1);
|
||||
drawTextCentered(width() / 2, (height() - getLineHeight()) / 2, "Powered off");
|
||||
display.display(false);
|
||||
display.hibernate();
|
||||
}
|
||||
|
||||
void GxEPDDisplay::clear() {
|
||||
display.fillScreen(GxEPD_WHITE);
|
||||
forceRedraw();
|
||||
|
||||
@@ -109,6 +109,7 @@ public:
|
||||
bool isOn() override { return _isOn; }
|
||||
void turnOn() override;
|
||||
void turnOff() override;
|
||||
void showPoweredOff() override;
|
||||
void clear() override;
|
||||
void setDisplayRotation(uint8_t rot) override;
|
||||
void setFullRefreshInterval(uint8_t n) override { _full_refresh_interval = n; _partial_count = 0; }
|
||||
|
||||
Reference in New Issue
Block a user