From bf7b2626459f3751fb1d6f44259ed6bb44796b13 Mon Sep 17 00:00:00 2001 From: Jakub <106778416+MarekZegare4@users.noreply.github.com> Date: Fri, 30 Oct 2026 09:46:43 +0100 Subject: [PATCH] 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 --- docs/solo/developer/build-flags.md | 1 + docs/solo/developer/feature-matrix.md | 2 +- examples/companion_radio/AbstractUITask.h | 5 +- examples/companion_radio/DataStore.cpp | 1 + examples/companion_radio/NodePrefs.h | 10 +- examples/companion_radio/ui-core/HistoryLog.h | 332 ++++++++++++++++++ .../companion_radio/ui-core/MessageHistory.h | 19 +- .../companion_radio/ui-core/SettingsCli.h | 2 +- .../companion_radio/ui-core/SettingsSchema.h | 9 + examples/companion_radio/ui-core/UiCore.h | 12 + .../companion_radio/ui-lvgl/HistoryStore.h | 7 +- examples/companion_radio/ui-new/UITask.cpp | 3 + solo/wio-tracker-l1-eink/platformio.ini | 1 + solo/wio-tracker-l1/platformio.ini | 1 + src/helpers/ui/DisplayDriver.h | 1 + src/helpers/ui/GxEPDDisplay.cpp | 13 + src/helpers/ui/GxEPDDisplay.h | 1 + 17 files changed, 407 insertions(+), 13 deletions(-) create mode 100644 examples/companion_radio/ui-core/HistoryLog.h diff --git a/docs/solo/developer/build-flags.md b/docs/solo/developer/build-flags.md index 6d09adae..0bc306cb 100644 --- a/docs/solo/developer/build-flags.md +++ b/docs/solo/developer/build-flags.md @@ -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. | diff --git a/docs/solo/developer/feature-matrix.md b/docs/solo/developer/feature-matrix.md index c09e296f..5bf29554 100644 --- a/docs/solo/developer/feature-matrix.md +++ b/docs/solo/developer/feature-matrix.md @@ -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 | diff --git a/examples/companion_radio/AbstractUITask.h b/examples/companion_radio/AbstractUITask.h index 09b3a5c0..4e07b185 100644 --- a/examples/companion_radio/AbstractUITask.h +++ b/examples/companion_radio/AbstractUITask.h @@ -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(); diff --git a/examples/companion_radio/DataStore.cpp b/examples/companion_radio/DataStore.cpp index 5465da1f..96705945 100644 --- a/examples/companion_radio/DataStore.cpp +++ b/examples/companion_radio/DataStore.cpp @@ -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; diff --git a/examples/companion_radio/NodePrefs.h b/examples/companion_radio/NodePrefs.h index 70256754..1e3ac5f8 100644 --- a/examples/companion_radio/NodePrefs.h +++ b/examples/companion_radio/NodePrefs.h @@ -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); diff --git a/examples/companion_radio/ui-core/HistoryLog.h b/examples/companion_radio/ui-core/HistoryLog.h new file mode 100644 index 00000000..cd8a9ca8 --- /dev/null +++ b/examples/companion_radio/ui-core/HistoryLog.h @@ -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 }; +}; diff --git a/examples/companion_radio/ui-core/MessageHistory.h b/examples/companion_radio/ui-core/MessageHistory.h index a2d3ebf4..785f21e4 100644 --- a/examples/companion_radio/ui-core/MessageHistory.h +++ b/examples/companion_radio/ui-core/MessageHistory.h @@ -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; diff --git a/examples/companion_radio/ui-core/SettingsCli.h b/examples/companion_radio/ui-core/SettingsCli.h index f0ac7880..098e0ed2 100644 --- a/examples/companion_radio/ui-core/SettingsCli.h +++ b/examples/companion_radio/ui-core/SettingsCli.h @@ -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), diff --git a/examples/companion_radio/ui-core/SettingsSchema.h b/examples/companion_radio/ui-core/SettingsSchema.h index 76df3337..4090b343 100644 --- a/examples/companion_radio/ui-core/SettingsSchema.h +++ b/examples/companion_radio/ui-core/SettingsSchema.h @@ -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), diff --git a/examples/companion_radio/ui-core/UiCore.h b/examples/companion_radio/ui-core/UiCore.h index f15c4af2..fa15391b 100644 --- a/examples/companion_radio/ui-core/UiCore.h +++ b/examples/companion_radio/ui-core/UiCore.h @@ -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 diff --git a/examples/companion_radio/ui-lvgl/HistoryStore.h b/examples/companion_radio/ui-lvgl/HistoryStore.h index e053c1a3..22a90e44 100644 --- a/examples/companion_radio/ui-lvgl/HistoryStore.h +++ b/examples/companion_radio/ui-lvgl/HistoryStore.h @@ -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; diff --git a/examples/companion_radio/ui-new/UITask.cpp b/examples/companion_radio/ui-new/UITask.cpp index 3129315e..0135fcdc 100644 --- a/examples/companion_radio/ui-new/UITask.cpp +++ b/examples/companion_radio/ui-new/UITask.cpp @@ -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 - diff --git a/solo/wio-tracker-l1-eink/platformio.ini b/solo/wio-tracker-l1-eink/platformio.ini index 1da1b21a..f0de6a3b 100644 --- a/solo/wio-tracker-l1-eink/platformio.ini +++ b/solo/wio-tracker-l1-eink/platformio.ini @@ -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 diff --git a/solo/wio-tracker-l1/platformio.ini b/solo/wio-tracker-l1/platformio.ini index ca33b6a8..224db215 100644 --- a/solo/wio-tracker-l1/platformio.ini +++ b/solo/wio-tracker-l1/platformio.ini @@ -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 diff --git a/src/helpers/ui/DisplayDriver.h b/src/helpers/ui/DisplayDriver.h index 6d0070e5..886ec607 100644 --- a/src/helpers/ui/DisplayDriver.h +++ b/src/helpers/ui/DisplayDriver.h @@ -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; diff --git a/src/helpers/ui/GxEPDDisplay.cpp b/src/helpers/ui/GxEPDDisplay.cpp index 1873c0e1..1d8105f6 100644 --- a/src/helpers/ui/GxEPDDisplay.cpp +++ b/src/helpers/ui/GxEPDDisplay.cpp @@ -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(); diff --git a/src/helpers/ui/GxEPDDisplay.h b/src/helpers/ui/GxEPDDisplay.h index 669e8483..c357750c 100644 --- a/src/helpers/ui/GxEPDDisplay.h +++ b/src/helpers/ui/GxEPDDisplay.h @@ -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; }