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:
Jakub
2026-10-30 09:46:43 +01:00
co-authored by Claude Opus 5.5
parent 751cbc1e79
commit bf7b262645
17 changed files with 407 additions and 13 deletions
+1
View File
@@ -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. |
+1 -1
View File
@@ -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 |
+4 -1
View File
@@ -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();
+1
View File
@@ -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;
+9 -1
View File
@@ -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),
+12
View File
@@ -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 -
+1
View File
@@ -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
+1
View File
@@ -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
+1
View File
@@ -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;
+13
View File
@@ -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();
+1
View File
@@ -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; }