refactor(companion): shared shutdown steps, SD channel key by secret, fetch race, small UI fixes

- UITaskBase holds what both UIs' shutdown() and low-battery check share:
  the saves before the goodbye sound, the power-down (GPS through its
  provider) and the battery EMA and threshold.
- L2 SD archive: a channel's file is keyed by its secret only, so renaming
  keeps the history; old files are renamed once at boot.
- L2 fetch: abandoning a request and the task finishing it are one critical
  section (it could stay DONE for good); TileCache gets the read-side barrier.
- Text from a repeater or sensor no longer rings on the device (nothing to
  open); a settings switch reads any non-zero value as on; the message
  toast never cuts a letter in half; ui-new's GPS helpers ask UiCore.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Jakub
2026-10-12 18:08:31 +02:00
co-authored by Claude Opus 5.5
parent 28d8291e33
commit 01a068be7d
8 changed files with 82 additions and 65 deletions
+31
View File
@@ -47,6 +47,37 @@ protected:
// of its loop() (MyMesh::loop() used to push it at the end of its own). // of its loop() (MyMesh::loop() used to push it at the end of its own).
void pollConnection() { setHasConnection(_serial->isBLEConnected()); } void pollConnection() { setHasConnection(_serial->isBLEConnected()); }
// ── The parts of shutdown() and the low-battery check every UI shares ──
// Before the goodbye sound: whatever a screen not yet left still holds in
// NodePrefs (they save on exit), the clock, the contacts' lazy write.
static void saveBeforeShutdown() {
the_mesh.savePrefs();
the_mesh.saveRTCTime();
the_mesh.flushDirtyContacts();
}
// After it: reboot, or the display, radio and GPS off and the board down.
// The GPS goes through its provider: pins keep their state in nRF52
// 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();
radio_driver.powerOff();
if (sensors) {
LocationProvider* loc = sensors->getLocationProvider();
if (loc) loc->stop();
}
_board->powerOff();
}
// Settings > Power > Battery shutdown: `mv` smoothed with battEma() every
// 8 s, under the threshold and not on USB power (charging would loop).
static uint16_t battEma(uint16_t prev, uint16_t raw) {
return prev == 0 ? raw : (uint16_t)((prev * 4u + raw) / 5u); // alpha 0.2: the ADC jitters under load
}
bool lowBatteryShutdownDue(uint16_t mv, const NodePrefs* prefs) const {
uint16_t low = prefs ? prefs->low_batt_mv : 0;
return low > 0 && mv > 0 && mv < low && !_board->isExternalPowered();
}
public: public:
void setHasConnection(bool connected) { void setHasConnection(bool connected) {
bool prev = _connected; bool prev = _connected;
@@ -391,6 +391,7 @@ static void writeRaw(NodePrefs& p, const Setting& s, int32_t v) {
// (e.g. an auto-off time set on another frontend). // (e.g. an auto-off time set on another frontend).
static uint8_t get(const NodePrefs& p, const Setting& s) { static uint8_t get(const NodePrefs& p, const Setting& s) {
int32_t raw = readRaw(p, s); int32_t raw = readRaw(p, s);
if (!s.values && s.count == 2 && !s.option) return raw != 0; // a switch: any non-zero is on (advert_loc_policy 2 from the app)
if (!s.values) return raw >= 0 && raw < s.count ? (uint8_t)raw : 0; if (!s.values) return raw >= 0 && raw < s.count ? (uint8_t)raw : 0;
uint8_t best = 0; uint8_t best = 0;
int32_t best_d = INT32_MAX; int32_t best_d = INT32_MAX;
@@ -205,6 +205,9 @@ public:
const uint8_t* extra, int extra_len, const char* text) override { const uint8_t* extra, int extra_len, const char* text) override {
// we only want to show text messages on display, not cli data // we only want to show text messages on display, not cli data
if (!(txt_type == TXT_TYPE_PLAIN || txt_type == TXT_TYPE_SIGNED_PLAIN)) return; if (!(txt_type == TXT_TYPE_PLAIN || txt_type == TXT_TYPE_SIGNED_PLAIN)) return;
// Only chats and rooms have a conversation on the device: text from a
// repeater or sensor would ring with nothing to open (the app still gets it).
if (from.type != ADV_TYPE_CHAT && from.type != ADV_TYPE_ROOM) return;
if (from.type == ADV_TYPE_ROOM && _room_unread < _queue_len) _room_unread++; if (from.type == ADV_TYPE_ROOM && _room_unread < _queue_len) _room_unread++;
if (from.type == ADV_TYPE_CHAT) dm_unread.onIncoming(from.id.pub_key); // before the ring insert below if (from.type == ADV_TYPE_CHAT) dm_unread.onIncoming(from.id.pub_key); // before the ring insert below
UiEvent& ev = pushMessageArrived(from.type == ADV_TYPE_ROOM ? UIEventType::roomMessage : UIEventType::contactMessage, UiEvent& ev = pushMessageArrived(from.type == ADV_TYPE_ROOM ? UIEventType::roomMessage : UIEventType::contactMessage,
@@ -46,8 +46,14 @@ struct Hdr {
uint32_t cap, head, count, next_id; uint32_t cap, head, count, next_id;
}; };
// FNV-1a over the channel's name and secret. // FNV-1a over the channel's secret: renaming a channel keeps its archive.
static uint32_t chKey(const ChannelDetails& ch) { 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;
}
// Earlier builds hashed the name in too (see migrateKeys()).
static uint32_t chKeyNamed(const ChannelDetails& ch) {
uint32_t h = 2166136261u; uint32_t h = 2166136261u;
for (const char* p = ch.name; *p; p++) { h ^= (uint8_t)*p; h *= 16777619u; } 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; } for (size_t i = 0; i < sizeof(ch.channel.secret); i++) { h ^= ch.channel.secret[i]; h *= 16777619u; }
@@ -148,6 +154,7 @@ public:
// skipped; a channel's entries get its current slot. // skipped; a channel's entries get its current slot.
void restore(MessageHistory& hist) { void restore(MessageHistory& hist) {
if (!ready()) return; if (!ready()) return;
migrateKeys();
uint32_t keys[MAX_GROUP_CHANNELS]; uint32_t keys[MAX_GROUP_CHANNELS];
bool have[MAX_GROUP_CHANNELS]; bool have[MAX_GROUP_CHANNELS];
for (int i = 0; i < MAX_GROUP_CHANNELS; i++) { for (int i = 0; i < MAX_GROUP_CHANNELS; i++) {
@@ -174,6 +181,20 @@ public:
} }
private: private:
// Files named by the old name+secret key move to the secret-only name, once
// (a file already there under the new name wins).
void migrateKeys() {
for (int i = 0; i < MAX_GROUP_CHANNELS; i++) {
ChannelDetails ch;
if (!the_mesh.getChannel(i, ch) || !ch.name[0]) continue;
char from[64], to[64];
snprintf(from, sizeof(from), "%s/c_%08lx.bin", DIR_PATH, (unsigned long)chKeyNamed(ch));
snprintf(to, sizeof(to), "%s/c_%08lx.bin", DIR_PATH, (unsigned long)chKey(ch));
struct stat st;
if (stat(from, &st) == 0 && stat(to, &st) != 0) rename(from, to);
}
}
bool _ok = false, _tried = false; bool _ok = false, _tried = false;
uint32_t _last_try = 0; uint32_t _last_try = 0;
uint32_t _keep = KEEP[KEEP_DEFAULT]; uint32_t _keep = KEEP[KEEP_DEFAULT];
+9 -1
View File
@@ -647,6 +647,7 @@ static uint8_t* s_fbuf = nullptr;
static size_t s_fcap = 0; static size_t s_fcap = 0;
static volatile size_t s_flen = 0; static volatile size_t s_flen = 0;
static volatile bool s_fabandoned = false; static volatile bool s_fabandoned = false;
static portMUX_TYPE s_fmux = portMUX_INITIALIZER_UNLOCKED;
static TaskHandle_t s_ftask = nullptr; static TaskHandle_t s_ftask = nullptr;
static uint32_t s_fstart_ms = 0; static uint32_t s_fstart_ms = 0;
static const uint32_t FETCH_MAX_MS = 30000; // a body still trickling in after this is cut off static const uint32_t FETCH_MAX_MS = 30000; // a body still trickling in after this is cut off
@@ -727,8 +728,10 @@ static void fetchTask(void*) {
snprintf(s_ferr, sizeof(s_ferr), "bad URL"); snprintf(s_ferr, sizeof(s_ferr), "bad URL");
} }
s_fcode = code; s_fcode = code;
portENTER_CRITICAL(&s_fmux); // against fetchAbandon() on the UI core: test and set as one
if (s_fabandoned) { s_fabandoned = false; s_fstate = F_IDLE; } // nobody wants it any more if (s_fabandoned) { s_fabandoned = false; s_fstate = F_IDLE; } // nobody wants it any more
else s_fstate = code == 200 ? F_DONE : F_FAILED; else s_fstate = code == 200 ? F_DONE : F_FAILED;
portEXIT_CRITICAL(&s_fmux);
} }
} }
@@ -756,8 +759,13 @@ static const uint8_t* fetchData(size_t& len) { len = s_flen; return s_fbuf; }
static void fetchRelease() { if (s_fstate == F_DONE || s_fstate == F_FAILED) s_fstate = F_IDLE; } static void fetchRelease() { if (s_fstate == F_DONE || s_fstate == F_FAILED) s_fstate = F_IDLE; }
// Stop caring about the request in flight; it completes (or fails) on its own. // Stop caring about the request in flight; it completes (or fails) on its own.
static void fetchAbandon() { static void fetchAbandon() {
// Locked with the task's finish: a request ending between the test and the
// set would otherwise stay DONE with nobody to release it (fetchStart() busy
// for good) and leave the flag to drop the next request's answer.
portENTER_CRITICAL(&s_fmux);
if (s_fstate == F_REQUESTED || s_fstate == F_BUSY) s_fabandoned = true; if (s_fstate == F_REQUESTED || s_fstate == F_BUSY) s_fabandoned = true;
else fetchRelease(); else if (s_fstate == F_DONE || s_fstate == F_FAILED) s_fstate = F_IDLE;
portEXIT_CRITICAL(&s_fmux);
} }
static const char* fetchError() { return s_ferr; } static const char* fetchError() { return s_ferr; }
// WiFi off: the response buffer (grown to the biggest tile or update chunk, // WiFi off: the response buffer (grown to the biggest tile or update chunk,
+4 -17
View File
@@ -382,9 +382,7 @@ uint32_t UITask::idleMillis(uint32_t lv_next) {
void UITask::shutdown(bool restart) { void UITask::shutdown(bool restart) {
s_prefs_dirty_ms = s_prefs_save_at = 0; s_prefs_dirty_ms = s_prefs_save_at = 0;
the_mesh.savePrefs(); saveBeforeShutdown();
the_mesh.saveRTCTime();
the_mesh.flushDirtyContacts();
if (!trailJournalTick(true)) _core->trail.onShutdown(); // no card: the internal slot, if enabled if (!trailJournalTick(true)) _core->trail.onShutdown(); // no card: the internal slot, if enabled
#ifdef PIN_BUZZER #ifdef PIN_BUZZER
_buzzer.shutdown(); // the goodbye sound, unless muted _buzzer.shutdown(); // the goodbye sound, unless muted
@@ -392,17 +390,7 @@ void UITask::shutdown(bool restart) {
for (uint32_t t0 = millis(); _buzzer.isPlaying() && millis() - t0 < 2500; ) { _buzzer.loop(); delay(10); } for (uint32_t t0 = millis(); _buzzer.isPlaying() && millis() - t0 < 2500; ) { _buzzer.loop(); delay(10); }
#endif #endif
#endif #endif
if (restart) { powerDown(restart, _display, _sensors);
_board->reboot();
} else {
if (_display) _display->turnOff();
radio_driver.powerOff();
if (_sensors) {
LocationProvider* loc = _sensors->getLocationProvider();
if (loc) loc->stop();
}
_board->powerOff();
}
} }
// Settings > Power > Battery shutdown (NodePrefs::low_batt_mv), as // Settings > Power > Battery shutdown (NodePrefs::low_batt_mv), as
@@ -411,9 +399,8 @@ void UITask::checkLowBattery() {
if ((int32_t)(millis() - _next_batt_ms) < 0) return; if ((int32_t)(millis() - _next_batt_ms) < 0) return;
_next_batt_ms = millis() + 8000; _next_batt_ms = millis() + 8000;
uint16_t raw = getBattMilliVolts(); uint16_t raw = getBattMilliVolts();
if (raw > 0) _batt_mv = _batt_mv == 0 ? raw : (uint16_t)((_batt_mv * 4u + raw) / 5u); // EMA, alpha 0.2 if (raw > 0) _batt_mv = battEma(_batt_mv, raw);
uint16_t low = _prefs ? _prefs->low_batt_mv : 0; if (!lowBatteryShutdownDue(_batt_mv, _prefs)) return;
if (low == 0 || _batt_mv == 0 || _batt_mv >= low || _board->isExternalPowered()) return;
wake(); wake();
showToast("Low battery - shutting down", 3000); showToast("Low battery - shutting down", 3000);
lv_timer_handler(); lv_timer_handler();
@@ -73,6 +73,9 @@ public:
struct Done { int z; int32_t x, y; TileProvider* src; bool overlay_missed; }; struct Done { int z; int32_t x, y; TileProvider* src; bool overlay_missed; };
bool poll(Done& done) { bool poll(Done& done) {
if (_job_state != JOB_DONE) return false; if (_job_state != JOB_DONE) return false;
#if defined(ESP32)
__sync_synchronize(); // pairs with runJob()'s: the state before the results it covers
#endif
done = { _job.z, _job.x, _job.y, _job.src, _job.overlay_missed }; done = { _job.z, _job.x, _job.y, _job.src, _job.overlay_missed };
bool keep = !_job_stale; bool keep = !_job_stale;
if (keep && !_job.present) { // remember the miss without holding a buffer slot if (keep && !_job.present) { // remember the miss without holding a buffer slot
+9 -46
View File
@@ -1,3 +1,4 @@
#include <helpers/UTF8Helpers.h>
#include "UITask.h" #include "UITask.h"
#include "../ui-core/SoundNotifier.h" #include "../ui-core/SoundNotifier.h"
#include "../ui-core/SoundControl.h" #include "../ui-core/SoundControl.h"
@@ -623,7 +624,8 @@ void UITask::onMessageArrived(const UiEvent& ev) {
_alert_expiry = (millis() + BADGE_MS) | 1; _alert_expiry = (millis() + BADGE_MS) | 1;
} else if (!in_view && style == NodePrefs::MSG_ALERT_NORMAL) { } else if (!in_view && style == NodePrefs::MSG_ALERT_NORMAL) {
char alert_buf[80]; char alert_buf[80];
snprintf(alert_buf, sizeof(alert_buf), "Msg: %.20s", ev.text); int cut = (int)mesh::validUtf8PrefixLength(ev.text, 20); // never half a letter
snprintf(alert_buf, sizeof(alert_buf), "Msg: %.*s", cut, ev.text);
showAlert(alert_buf, 2000); showAlert(alert_buf, 2000);
} }
@@ -835,9 +837,7 @@ void UITask::shutdown(bool restart){
// reboot." Unconditional and cheap: an unchanged NodePrefs still writes // reboot." Unconditional and cheap: an unchanged NodePrefs still writes
// identical bytes, same as this codebase's many other direct // identical bytes, same as this codebase's many other direct
// the_mesh.savePrefs() call sites already do without a dirty check. // the_mesh.savePrefs() call sites already do without a dirty check.
the_mesh.savePrefs(); saveBeforeShutdown();
the_mesh.saveRTCTime();
the_mesh.flushDirtyContacts();
// Auto-save the live GPS trail if enabled (covers low-battery auto-shutdown). // Auto-save the live GPS trail if enabled (covers low-battery auto-shutdown).
_core->trail.onShutdown(); _core->trail.onShutdown();
@@ -863,22 +863,7 @@ void UITask::shutdown(bool restart){
#endif // PIN_BUZZER #endif // PIN_BUZZER
if (restart) { powerDown(restart, _display, _sensors);
_board->reboot();
} else {
_display->turnOff();
radio_driver.powerOff();
// Power GPS down through its provider before SYSTEMOFF — GPIO pins retain
// state in NRF52 SYSTEMOFF, so otherwise the module keeps draining the
// battery. The provider handles the enable + reset pins and the correct
// active level. gps_enabled is persisted; applyGpsPrefs() restores it on
// the next boot.
if (_sensors) {
LocationProvider* loc = _sensors->getLocationProvider();
if (loc) loc->stop();
}
_board->powerOff();
}
} }
bool UITask::isButtonPressed() const { bool UITask::isButtonPressed() const {
@@ -1476,13 +1461,10 @@ void UITask::loop() {
// laggy for no benefit the sim actually needs. // laggy for no benefit the sim actually needs.
_batt_mv = raw; _batt_mv = raw;
#else #else
// EMA filter: alpha=0.2 (80% old, 20% new) — smooths ADC noise from uneven load _batt_mv = battEma(_batt_mv, raw);
_batt_mv = (_batt_mv == 0) ? raw : (uint16_t)((_batt_mv * 4u + raw) / 5u);
#endif #endif
} }
uint16_t low_mv = _node_prefs ? _node_prefs->low_batt_mv : 0; if (lowBatteryShutdownDue(_batt_mv, _node_prefs)) {
// Don't shut down while on external power (charging) — avoids a shutdown loop.
if (low_mv > 0 && _batt_mv > 0 && _batt_mv < low_mv && !board.isExternalPowered()) {
if (_display != NULL) { if (_display != NULL) {
_display->startFrame(); _display->startFrame();
_display->setTextSize(1); _display->setTextSize(1);
@@ -1668,27 +1650,8 @@ char UITask::handleTripleClick(char c) {
return 0; return 0;
} }
bool UITask::getGPSState() { bool UITask::getGPSState() { return _core && _core->gpsEnabled(); }
if (_sensors != NULL) { bool UITask::hasGPS() { return _core && _core->gpsAvailable(); }
int num = _sensors->getNumSettings();
for (int i = 0; i < num; i++) {
if (strcmp(_sensors->getSettingName(i), "gps") == 0) {
return !strcmp(_sensors->getSettingValue(i), "1");
}
}
}
return false;
}
bool UITask::hasGPS() {
if (_sensors != NULL) {
int num = _sensors->getNumSettings();
for (int i = 0; i < num; i++) {
if (strcmp(_sensors->getSettingName(i), "gps") == 0) return true;
}
}
return false;
}
void UITask::toggleGPS(bool announce) { void UITask::toggleGPS(bool announce) {
if (_node_prefs) applyGpsState(_node_prefs->gps_enabled == 0, announce); if (_node_prefs) applyGpsState(_node_prefs->gps_enabled == 0, announce);