feat(prefs): v2.0 settings file -- the struct as it is, behind a CRC

/prefs holds a header (magic, format version, size, CRC-32), the node's
position and NodePrefs as it is in memory, in place of /new_prefs' two
hand-kept field lists and 0xC0DE00xx schema bumps. A new field goes at the
end of the struct; moving one bumps PREFS_VERSION. sanitize() keeps every
value in range.

The first boot after an update from before 2.0 imports only the first
block of the old file, laid out the same in upstream MeshCore and Solo
(name, position, radio, BLE PIN, scope), then deletes it; everything else
starts from the defaults. Contacts, channels, scopes, waypoints and trails
are untouched.

Gone: the page-order and GPS-interval migrations, the unused
trail_interval_idx / trail_units_idx / repeat_extra_scopes; fav_sort_off
and msg_wake_screen_off become fav_sort and msg_wake (on by default).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Jakub
2026-10-03 00:12:18 +02:00
co-authored by Claude Opus 5.5
parent 9597e1e2a4
commit c3782cb8e5
12 changed files with 268 additions and 893 deletions
+10 -6
View File
@@ -348,12 +348,16 @@ slow e-ink refresh:
Device settings live in one `NodePrefs` struct (`NodePrefs.h`), saved via Device settings live in one `NodePrefs` struct (`NodePrefs.h`), saved via
`the_mesh.savePrefs()` and loaded by `DataStore.cpp`. Rules when adding a field: `the_mesh.savePrefs()` and loaded by `DataStore.cpp`. Rules when adding a field:
- **Append only**, and bump `NodePrefs::SCHEMA_SENTINEL`. Serialization is - `/prefs` holds the struct as it is in memory, behind a header with a format
binary-positional, so order is the on-disk format; never insert in the middle. version and a CRC. **Add a new field at the end of the struct**; nothing
- Add a matching `rd(...)` in `DataStore::loadPrefsInt()` and a `file.write(...)` else is needed: an older file is shorter, so the field keeps its default
in `savePrefs()`, in the same position, and **clamp on load** (an upgrader's (set in `MyMesh`'s constructor).
file lacks the field and reads stray bytes — clamp to a sane default). Saves - Moving, resizing or removing a field shifts the ones after it: bump
are atomic (temp-file + rename), so a crash mid-save can't corrupt settings. `NodePrefs::PREFS_VERSION`, and every device starts again from the
defaults.
- Give a field with a range a line in `sanitize()` (`DataStore.cpp`), so a
damaged byte reads as the default. Saves are atomic (temp-file + rename),
so a crash mid-save can't corrupt settings.
**The `_dirty` convention:** a multi-field editor screen mutates `_node_prefs` **The `_dirty` convention:** a multi-field editor screen mutates `_node_prefs`
live for instant feedback but only persists once, on exit, gated by a `_dirty` live for instant feedback but only persists once, on exit, gated by a `_dirty`
+198 -653
View File
@@ -217,674 +217,219 @@ bool DataStore::saveMainIdentity(const mesh::LocalIdentity &identity) {
return identity_store.save("_main", identity); return identity_store.save("_main", identity);
} }
void DataStore::loadPrefs(NodePrefs& prefs, double& node_lat, double& node_lon) { // ── Prefs ──
if (_fs->exists("/new_prefs")) { // /prefs: a header, the node's position, then NodePrefs as it is in memory
loadPrefsInt("/new_prefs", prefs, node_lat, node_lon); // new filename // (see the comment at the top of NodePrefs.h). Only the build that wrote it
} else if (_fs->exists("/node_prefs")) { // reads it back, so the struct's padding and alignment are no concern.
loadPrefsInt("/node_prefs", prefs, node_lat, node_lon); struct PrefsHeader {
savePrefs(prefs, node_lat, node_lon); // save to new filename uint32_t magic; // PREFS_MAGIC
_fs->remove("/node_prefs"); // remove old uint16_t version; // NodePrefs::PREFS_VERSION
uint16_t size; // sizeof(NodePrefs) when written
uint32_t crc; // CRC-32 of the position and the struct's bytes
};
static const uint32_t PREFS_MAGIC = 0x534F4C4F; // "SOLO"
static uint32_t crc32(uint32_t crc, const uint8_t* p, size_t n) {
crc = ~crc;
while (n--) {
crc ^= *p++;
for (int k = 0; k < 8; k++) crc = (crc >> 1) ^ (0xEDB88320u & (0u - (crc & 1)));
} }
return ~crc;
} }
void DataStore::loadPrefsInt(const char *filename, NodePrefs& _prefs, double& node_lat, double& node_lon) { // Every value inside its range, whatever the file held: a damaged byte reads
// Set hardware defaults before reading — if the file is older and lacks these fields, // as the default rather than as a setting no screen can show.
// the compile-time values apply rather than the zero from memset in MyMesh::begin(). static void sanitize(NodePrefs& p) {
#ifdef DISPLAY_ROTATION #if !FEAT_JOYSTICK_ROTATION_SETTING
_prefs.display_rotation = DISPLAY_ROTATION; p.joystick_rotation = 0; // no setting for it on this build
#endif #endif
// 0 is a valid SNR threshold, so the "off" state needs its own sentinel set { // only the duty-cycle presets Settings offers
// before reading — an older file lacking this field must read as disabled,
// not as "filter everything below 0 dB".
_prefs.repeat_min_snr = NodePrefs::REPEAT_SNR_DISABLED;
File file = openRead(_fs, filename);
if (!file) return;
uint8_t pad[8];
// Core fields — present in every saved file (written unconditionally since the beginning).
file.read((uint8_t *)&_prefs.airtime_factor, sizeof(_prefs.airtime_factor));
file.read((uint8_t *)_prefs.node_name, sizeof(_prefs.node_name));
file.read(pad, 4);
file.read((uint8_t *)&node_lat, sizeof(node_lat));
file.read((uint8_t *)&node_lon, sizeof(node_lon));
file.read((uint8_t *)&_prefs.freq, sizeof(_prefs.freq));
file.read((uint8_t *)&_prefs.sf, sizeof(_prefs.sf));
file.read((uint8_t *)&_prefs.cr, sizeof(_prefs.cr));
file.read((uint8_t *)&_prefs.client_repeat, sizeof(_prefs.client_repeat));
file.read((uint8_t *)&_prefs.manual_add_contacts, sizeof(_prefs.manual_add_contacts));
file.read((uint8_t *)&_prefs.bw, sizeof(_prefs.bw));
file.read((uint8_t *)&_prefs.tx_power_dbm, sizeof(_prefs.tx_power_dbm));
file.read((uint8_t *)&_prefs.telemetry_mode_base, sizeof(_prefs.telemetry_mode_base));
file.read((uint8_t *)&_prefs.telemetry_mode_loc, sizeof(_prefs.telemetry_mode_loc));
file.read((uint8_t *)&_prefs.telemetry_mode_env, sizeof(_prefs.telemetry_mode_env));
file.read((uint8_t *)&_prefs.rx_delay_base, sizeof(_prefs.rx_delay_base));
file.read((uint8_t *)&_prefs.advert_loc_policy, sizeof(_prefs.advert_loc_policy));
file.read((uint8_t *)&_prefs.multi_acks, sizeof(_prefs.multi_acks));
file.read((uint8_t *)&_prefs.path_hash_mode, sizeof(_prefs.path_hash_mode));
file.read(pad, 1);
file.read((uint8_t *)&_prefs.ble_pin, sizeof(_prefs.ble_pin));
file.read((uint8_t *)&_prefs.buzzer_quiet, sizeof(_prefs.buzzer_quiet));
file.read((uint8_t *)&_prefs.gps_enabled, sizeof(_prefs.gps_enabled));
file.read((uint8_t *)&_prefs.gps_interval, sizeof(_prefs.gps_interval));
// Only today's duty-cycle presets are meaningful now (see SettingsScreen's
// GPS_DUTY_OPTS). Anything else -- out of range, or a leftover value from
// the pre-v1.13 "GPS Interval" setting this byte used to hold (its own
// 30s option isn't one of today's presets) -- wouldn't match a Settings
// choice, so "GPS pwr" would misleadingly show OFF while still silently
// duty-cycling GPS on that stale value. Snap anything unrecognised to OFF.
{
static const uint32_t GPS_DUTY_PRESETS[] = { 0, 60, 300, 900, 1800, 3600 }; static const uint32_t GPS_DUTY_PRESETS[] = { 0, 60, 300, 900, 1800, 3600 };
bool known = false; bool known = false;
for (uint32_t v : GPS_DUTY_PRESETS) { if (_prefs.gps_interval == v) { known = true; break; } } for (uint32_t v : GPS_DUTY_PRESETS) if (p.gps_interval == v) { known = true; break; }
if (!known) _prefs.gps_interval = 0; if (!known) p.gps_interval = 0;
} }
file.read((uint8_t *)&_prefs.autoadd_config, sizeof(_prefs.autoadd_config)); auto flag = [](uint8_t& v, uint8_t def) { if (v > 1) v = def; };
file.read((uint8_t *)&_prefs.autoadd_max_hops, sizeof(_prefs.autoadd_max_hops)); if (p.ringtone_len > 32) p.ringtone_len = 0;
file.read((uint8_t *)&_prefs.rx_boosted_gain, sizeof(_prefs.rx_boosted_gain)); if (p.ringtone2_len > 32) p.ringtone2_len = 0;
file.read((uint8_t *)_prefs.default_scope_name, sizeof(_prefs.default_scope_name)); if (p.notif_melody_dm > 3) p.notif_melody_dm = 0;
file.read((uint8_t *)_prefs.default_scope_key, sizeof(_prefs.default_scope_key)); if (p.notif_melody_ch > 3) p.notif_melody_ch = 0;
file.read((uint8_t *)&_prefs.display_brightness, sizeof(_prefs.display_brightness)); if (p.notif_melody_ad > 3) p.notif_melody_ad = 0;
file.read((uint8_t *)&_prefs.auto_off_secs, sizeof(_prefs.auto_off_secs)); if (p.page_order_set != NodePrefs::PAGE_ORDER_MAGIC) p.page_order_set = 0;
file.read((uint8_t *)&_prefs.tz_offset_hours, sizeof(_prefs.tz_offset_hours)); for (uint8_t i = 0; i < NodePrefs::PAGE_ORDER_LEN; i++)
file.read((uint8_t *)&_prefs.low_batt_mv, sizeof(_prefs.low_batt_mv)); if (p.page_order[i] > NodePrefs::HPB_COUNT) p.page_order[i] = 0;
file.read((uint8_t *)&_prefs.batt_display_mode, sizeof(_prefs.batt_display_mode)); flag(p.repeat_skip_adverts, 0);
if (p.repeat_max_hops > 64) p.repeat_max_hops = 0;
// Extension fields — append-only, newest at the bottom. if (p.repeat_delay_boost > 8) p.repeat_delay_boost = 0;
// Each read is gated on file.available(); fields absent in older files stay at their if (p.repeat_min_snr != NodePrefs::REPEAT_SNR_DISABLED && (p.repeat_min_snr < -20 || p.repeat_min_snr > 10))
// zero-initialised or hardware-default values set above. p.repeat_min_snr = NodePrefs::REPEAT_SNR_DISABLED; // the UI's -20..10
auto rd = [&](void* p, size_t n) { flag(p.repeat_suppress_dup, 0);
if (file.available() >= (int)n) file.read((uint8_t*)p, n); flag(p.repeat_scope_only, 0);
}; flag(p.repeater_use_profile, 0);
float lo, hi;
rd(_prefs.custom_msgs, sizeof(_prefs.custom_msgs)); radio_driver.getFreqBounds(lo, hi);
rd(&_prefs.ch_notif_override, sizeof(_prefs.ch_notif_override)); if (!isValidRepeaterProfile(p.repeater_freq, p.repeater_bw, p.repeater_sf, p.repeater_cr, lo, hi))
rd(&_prefs.ch_notif_muted, sizeof(_prefs.ch_notif_muted)); seedDefaultRepeaterProfile(p);
rd(&_prefs.dm_show_all, sizeof(_prefs.dm_show_all)); flag(p.track_shared_loc, 0);
rd(&_prefs.room_fav_only, sizeof(_prefs.room_fav_only)); flag(p.loc_share_enabled, 0);
rd(&_prefs.buzzer_volume, sizeof(_prefs.buzzer_volume)); flag(p.loc_share_target_type, 0);
rd(&_prefs.ringtone_bpm_idx, sizeof(_prefs.ringtone_bpm_idx)); if (p.loc_share_channel_idx >= MAX_GROUP_CHANNELS) p.loc_share_channel_idx = 0;
rd(&_prefs.ringtone_len, sizeof(_prefs.ringtone_len)); if (p.loc_share_move_idx >= NodePrefs::LOC_SHARE_MOVE_COUNT) p.loc_share_move_idx = 1;
if (_prefs.ringtone_len > 32) _prefs.ringtone_len = 0; if (p.loc_share_interval_idx >= NodePrefs::LOC_SHARE_INTERVAL_COUNT) p.loc_share_interval_idx = 1;
rd(_prefs.ringtone_notes, sizeof(_prefs.ringtone_notes)); if (p.loc_share_heartbeat_idx >= NodePrefs::LOC_SHARE_HEARTBEAT_COUNT) p.loc_share_heartbeat_idx = 0;
rd(&_prefs.home_pages_mask, sizeof(_prefs.home_pages_mask)); if (p.loc_share_scope > ScopeList::MAX_SCOPE_ENTRIES + 1) p.loc_share_scope = 0;
rd(&_prefs.bot_enabled, sizeof(_prefs.bot_enabled)); if (p.loc_share_duration_idx >= NodePrefs::LOC_SHARE_DURATION_COUNT) p.loc_share_duration_idx = 0;
rd(&_prefs.bot_channel_enabled, sizeof(_prefs.bot_channel_enabled)); flag(p.locator_enabled, 0);
rd(&_prefs.bot_channel_idx, sizeof(_prefs.bot_channel_idx)); flag(p.locator_has_target, 0);
rd(_prefs.bot_trigger, sizeof(_prefs.bot_trigger)); flag(p.locator_beeper, 0);
rd(_prefs.bot_reply_dm, sizeof(_prefs.bot_reply_dm)); if (p.locator_radius_idx >= NodePrefs::LOCATOR_RADIUS_COUNT) p.locator_radius_idx = 1;
rd(_prefs.bot_reply_ch, sizeof(_prefs.bot_reply_ch)); if (p.locator_mode >= NodePrefs::LOCATOR_MODE_COUNT) p.locator_mode = 0;
rd(&_prefs.clock_hide_seconds, sizeof(_prefs.clock_hide_seconds)); if (p.locator_target_kind > 2) p.locator_target_kind = 0;
rd(&_prefs.buzzer_auto, sizeof(_prefs.buzzer_auto)); p.locator_label[sizeof(p.locator_label) - 1] = '\0';
rd(_prefs.dm_notif, sizeof(_prefs.dm_notif)); if (p.trail_autopause_idx >= NodePrefs::TRAIL_AUTOPAUSE_COUNT) p.trail_autopause_idx = 0;
rd(_prefs.dashboard_fields, sizeof(_prefs.dashboard_fields)); flag(p.trail_autosave_lowbatt, 0);
rd(&_prefs.advert_auto_interval_sec, sizeof(_prefs.advert_auto_interval_sec)); if (p.gps_avg_idx >= NodePrefs::GPS_AVG_COUNT) p.gps_avg_idx = 0;
rd(&_prefs.ringtone2_bpm_idx, sizeof(_prefs.ringtone2_bpm_idx)); flag(p.alarm_on, 0);
rd(&_prefs.ringtone2_len, sizeof(_prefs.ringtone2_len)); if (p.alarm_hour > 23) p.alarm_hour = 0;
if (_prefs.ringtone2_len > 32) _prefs.ringtone2_len = 0; if (p.alarm_min > 59) p.alarm_min = 0;
rd(_prefs.ringtone2_notes, sizeof(_prefs.ringtone2_notes)); if (NodePrefs::alarmRepeatIdxForMask(p.alarm_repeat_mask) == 0) p.alarm_repeat_mask = 0;
rd(&_prefs.notif_melody_dm, sizeof(_prefs.notif_melody_dm)); flag(p.keyboard_type, 0);
if (_prefs.notif_melody_dm > 3) _prefs.notif_melody_dm = 0; if (p.keyboard_alt_alphabet >= NodePrefs::KB_ALPHABET_COUNT) p.keyboard_alt_alphabet = 0;
rd(&_prefs.notif_melody_ch, sizeof(_prefs.notif_melody_ch)); if (p.keyboard_main_alphabet >= NodePrefs::KB_ALPHABET_COUNT) p.keyboard_main_alphabet = 0;
if (_prefs.notif_melody_ch > 3) _prefs.notif_melody_ch = 0; flag(p.keyboard_cardkb_compact, 0);
rd(&_prefs.ch_notif_melody_set, sizeof(_prefs.ch_notif_melody_set)); flag(p.bot_commands_enabled, 0);
rd(&_prefs.ch_notif_melody_2, sizeof(_prefs.ch_notif_melody_2)); flag(p.bot_commands_ch, 0);
rd(_prefs.dm_melody, sizeof(_prefs.dm_melody)); flag(p.bot_commands_room, 0);
rd(&_prefs.auto_lock, sizeof(_prefs.auto_lock)); flag(p.bot_actions_dm, 0);
rd(&_prefs.clock_12h, sizeof(_prefs.clock_12h)); flag(p.bot_actions_ch, 0);
rd(&_prefs.use_lemon_font, sizeof(_prefs.use_lemon_font)); flag(p.bot_actions_room, 0);
rd(&_prefs.display_rotation, sizeof(_prefs.display_rotation)); flag(p.bot_dm_scope, 0);
rd(_prefs.page_order, NodePrefs::PAGE_ORDER_LEN_V1); // tail slots read below (append-only) flag(p.bot_room_enabled, 0);
rd(&_prefs.joystick_rotation, sizeof(_prefs.joystick_rotation)); if (p.bot_quiet_start > 23) p.bot_quiet_start = 0;
#if !FEAT_JOYSTICK_ROTATION_SETTING if (p.bot_quiet_end > 23) p.bot_quiet_end = 0;
// No UI to change it on this build, so force the default — this also corrects flag(p.use_lemon_font, 0);
// a stale value migrated from another build (e.g. e-ink rotation=2). On builds flag(p.units_imperial, 0);
// that DO expose the setting the stored value is kept; the old code clobbered flag(p.trail_show_pace, 0);
// it unconditionally (FEAT_* was undefined here because Features.h wasn't if (p.advert_sound_scope > 1) p.advert_sound_scope = ADVERT_SOUND_SCOPE_ALL;
// included → `#if !FEAT_…` was always true), so the setting never persisted. flag(p.rx_powersave, 0);
_prefs.joystick_rotation = 0; flag(p.tx_apc, 0);
#endif if (p.dm_resend_count > 5) p.dm_resend_count = 2;
rd(&_prefs.eink_full_refresh_every, sizeof(_prefs.eink_full_refresh_every)); if (p.gpio1_mode > 4) p.gpio1_mode = 0; // gpio1/2 can be analog (AIN0/AIN5)
rd(&_prefs.page_order_set, sizeof(_prefs.page_order_set)); if (p.gpio2_mode > 4) p.gpio2_mode = 0;
// Migration: pre-magic firmware wrote page_order without a flag. If we see a plausible if (p.gpio3_mode > 3) p.gpio3_mode = 0;
// first entry from such a save, accept it once — savePrefs will then persist the magic. if (p.gpio4_mode > 3) p.gpio4_mode = 0;
if (_prefs.page_order_set != NodePrefs::PAGE_ORDER_MAGIC if (p.interference_threshold > 30) p.interference_threshold = 0;
&& _prefs.page_order[0] >= 1 && _prefs.page_order[0] <= NodePrefs::HPB_COUNT) { flag(p.cad_enabled, 0);
_prefs.page_order_set = NodePrefs::PAGE_ORDER_MAGIC; for (uint8_t i = 0; i < NodePrefs::FAVOURITES_COUNT; i++)
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.contact_expiry_idx >= NodePrefs::CONTACT_EXPIRY_COUNT) p.contact_expiry_idx = 0;
if (p.display_brightness_pct > 100) p.display_brightness_pct = 0;
flag(p.quiet_hours, 0);
if (p.quiet_from > 23) p.quiet_from = 22;
if (p.quiet_to > 23) p.quiet_to = 7;
if (!battery::validCurve(p.batt_curve_mv)) memset(p.batt_curve_mv, 0, sizeof(p.batt_curve_mv));
flag(p.lock_compact, 0);
} }
rd(_prefs.favourite_contacts, sizeof(_prefs.favourite_contacts)); // The settings a firmware before 2.0 kept in /new_prefs that still matter
rd(&_prefs.trail_interval_idx, sizeof(_prefs.trail_interval_idx)); // after the update: its first block, laid out the same in upstream MeshCore
rd(&_prefs.trail_min_delta_idx, sizeof(_prefs.trail_min_delta_idx)); // and in every Solo -- the node's name and position, its radio, the BLE PIN
rd(&_prefs.trail_units_idx, sizeof(_prefs.trail_units_idx)); // and the default scope. Without them a device would come back on the
rd(&_prefs.ch_fav_bitmask, sizeof(_prefs.ch_fav_bitmask)); // default radio, deaf to its mesh. The rest starts from the defaults.
rd(&_prefs.ch_fav_only, sizeof(_prefs.ch_fav_only)); static void importOldPrefs(File& f, NodePrefs& p, double& lat, double& lon) {
rd(&_prefs.notif_melody_ad, sizeof(_prefs.notif_melody_ad)); uint8_t pad[4];
rd(&_prefs.units_imperial, sizeof(_prefs.units_imperial)); f.read((uint8_t*)&p.airtime_factor, sizeof(p.airtime_factor));
rd(&_prefs.trail_show_pace, sizeof(_prefs.trail_show_pace)); f.read((uint8_t*)p.node_name, sizeof(p.node_name));
rd(&_prefs.advert_sound_scope, sizeof(_prefs.advert_sound_scope)); f.read(pad, 4);
rd(&_prefs.rx_powersave, sizeof(_prefs.rx_powersave)); f.read((uint8_t*)&lat, sizeof(lat));
rd(&_prefs.tx_apc, sizeof(_prefs.tx_apc)); f.read((uint8_t*)&lon, sizeof(lon));
rd(&_prefs.dm_resend_count, sizeof(_prefs.dm_resend_count)); f.read((uint8_t*)&p.freq, sizeof(p.freq));
rd(&_prefs.bot_commands_enabled, sizeof(_prefs.bot_commands_enabled)); f.read((uint8_t*)&p.sf, sizeof(p.sf));
rd(&_prefs.bot_quiet_start, sizeof(_prefs.bot_quiet_start)); f.read((uint8_t*)&p.cr, sizeof(p.cr));
rd(&_prefs.bot_quiet_end, sizeof(_prefs.bot_quiet_end)); f.read((uint8_t*)&p.client_repeat, sizeof(p.client_repeat));
rd(_prefs.bot_trigger_ch, sizeof(_prefs.bot_trigger_ch)); f.read((uint8_t*)&p.manual_add_contacts, sizeof(p.manual_add_contacts));
rd(_prefs.user_radio_presets, sizeof(_prefs.user_radio_presets)); f.read((uint8_t*)&p.bw, sizeof(p.bw));
// Repeater forwarding-filter knobs -- an older file's stray bytes here f.read((uint8_t*)&p.tx_power_dbm, sizeof(p.tx_power_dbm));
// clamp back to their "off" defaults. f.read((uint8_t*)&p.telemetry_mode_base, sizeof(p.telemetry_mode_base));
rd(&_prefs.repeat_skip_adverts, sizeof(_prefs.repeat_skip_adverts)); f.read((uint8_t*)&p.telemetry_mode_loc, sizeof(p.telemetry_mode_loc));
rd(&_prefs.repeat_max_hops, sizeof(_prefs.repeat_max_hops)); f.read((uint8_t*)&p.telemetry_mode_env, sizeof(p.telemetry_mode_env));
rd(&_prefs.repeat_delay_boost, sizeof(_prefs.repeat_delay_boost)); f.read((uint8_t*)&p.rx_delay_base, sizeof(p.rx_delay_base));
rd(&_prefs.repeat_min_snr, sizeof(_prefs.repeat_min_snr)); f.read((uint8_t*)&p.advert_loc_policy, sizeof(p.advert_loc_policy));
rd(&_prefs.repeat_suppress_dup, sizeof(_prefs.repeat_suppress_dup)); f.read((uint8_t*)&p.multi_acks, sizeof(p.multi_acks));
if (_prefs.repeat_skip_adverts > 1) _prefs.repeat_skip_adverts = 0; f.read((uint8_t*)&p.path_hash_mode, sizeof(p.path_hash_mode));
if (_prefs.repeat_max_hops > 64) _prefs.repeat_max_hops = 0; f.read(pad, 1);
if (_prefs.repeat_delay_boost > 8) _prefs.repeat_delay_boost = 0; f.read((uint8_t*)&p.ble_pin, sizeof(p.ble_pin));
if (_prefs.repeat_min_snr != NodePrefs::REPEAT_SNR_DISABLED && f.read((uint8_t*)&p.buzzer_quiet, sizeof(p.buzzer_quiet));
(_prefs.repeat_min_snr < -20 || _prefs.repeat_min_snr > 10)) f.read((uint8_t*)&p.gps_enabled, sizeof(p.gps_enabled));
_prefs.repeat_min_snr = NodePrefs::REPEAT_SNR_DISABLED; // match the UI's -20..10 range f.read((uint8_t*)&p.gps_interval, sizeof(p.gps_interval));
if (_prefs.repeat_suppress_dup > 1) _prefs.repeat_suppress_dup = 0; f.read((uint8_t*)&p.autoadd_config, sizeof(p.autoadd_config));
// NOTE: repeat_scope_only/repeat_extra_scopes are read at the TAIL, not here f.read((uint8_t*)&p.autoadd_max_hops, sizeof(p.autoadd_max_hops));
// beside their siblings — see the append-only rule in NodePrefs.h. f.read((uint8_t*)&p.rx_boosted_gain, sizeof(p.rx_boosted_gain));
rd(&_prefs.repeater_use_profile, sizeof(_prefs.repeater_use_profile)); f.read((uint8_t*)p.default_scope_name, sizeof(p.default_scope_name));
rd(&_prefs.repeater_freq, sizeof(_prefs.repeater_freq)); f.read((uint8_t*)p.default_scope_key, sizeof(p.default_scope_key));
rd(&_prefs.repeater_bw, sizeof(_prefs.repeater_bw)); p.node_name[sizeof(p.node_name) - 1] = '\0';
rd(&_prefs.repeater_sf, sizeof(_prefs.repeater_sf)); p.default_scope_name[sizeof(p.default_scope_name) - 1] = '\0';
rd(&_prefs.repeater_cr, sizeof(_prefs.repeater_cr)); if (p.client_repeat > 1) p.client_repeat = 0;
// Stray byte from an older file clamps to "off". if (p.buzzer_quiet > 1) p.buzzer_quiet = 0;
rd(&_prefs.track_shared_loc, sizeof(_prefs.track_shared_loc));
if (_prefs.track_shared_loc > 1) _prefs.track_shared_loc = 0;
// Live location sharing. Stray bytes from an older file clamp each field
// back to its default (sharing off).
rd(&_prefs.loc_share_enabled, sizeof(_prefs.loc_share_enabled));
rd(&_prefs.loc_share_target_type, sizeof(_prefs.loc_share_target_type));
rd(&_prefs.loc_share_channel_idx, sizeof(_prefs.loc_share_channel_idx));
rd(_prefs.loc_share_dm_prefix, sizeof(_prefs.loc_share_dm_prefix));
rd(&_prefs.loc_share_move_idx, sizeof(_prefs.loc_share_move_idx));
rd(&_prefs.loc_share_interval_idx, sizeof(_prefs.loc_share_interval_idx));
rd(&_prefs.loc_share_heartbeat_idx, sizeof(_prefs.loc_share_heartbeat_idx));
if (_prefs.loc_share_enabled > 1) _prefs.loc_share_enabled = 0;
if (_prefs.loc_share_target_type > 1) _prefs.loc_share_target_type = 0;
if (_prefs.loc_share_channel_idx >= MAX_GROUP_CHANNELS) _prefs.loc_share_channel_idx = 0;
if (_prefs.loc_share_move_idx >= NodePrefs::LOC_SHARE_MOVE_COUNT) _prefs.loc_share_move_idx = 1;
if (_prefs.loc_share_interval_idx >= NodePrefs::LOC_SHARE_INTERVAL_COUNT) _prefs.loc_share_interval_idx = 1;
if (_prefs.loc_share_heartbeat_idx >= NodePrefs::LOC_SHARE_HEARTBEAT_COUNT) _prefs.loc_share_heartbeat_idx = 0;
// Locator + trail auto-pause. Stray bytes from an older file clamp both
// back to off.
rd(&_prefs.locator_enabled, sizeof(_prefs.locator_enabled));
rd(&_prefs.locator_has_target, sizeof(_prefs.locator_has_target));
rd(&_prefs.locator_radius_idx, sizeof(_prefs.locator_radius_idx));
rd(&_prefs.locator_mode, sizeof(_prefs.locator_mode));
rd(&_prefs.locator_lat_1e6, sizeof(_prefs.locator_lat_1e6));
rd(&_prefs.locator_lon_1e6, sizeof(_prefs.locator_lon_1e6));
rd(_prefs.locator_label, sizeof(_prefs.locator_label));
rd(&_prefs.trail_autopause_idx, sizeof(_prefs.trail_autopause_idx));
if (_prefs.locator_enabled > 1) _prefs.locator_enabled = 0;
if (_prefs.locator_has_target > 1) _prefs.locator_has_target = 0;
if (_prefs.locator_radius_idx >= NodePrefs::LOCATOR_RADIUS_COUNT) _prefs.locator_radius_idx = 1;
if (_prefs.locator_mode >= NodePrefs::LOCATOR_MODE_COUNT) _prefs.locator_mode = 0;
if (_prefs.trail_autopause_idx >= NodePrefs::TRAIL_AUTOPAUSE_COUNT) _prefs.trail_autopause_idx = 0;
_prefs.locator_label[sizeof(_prefs.locator_label) - 1] = '\0';
// Locator proximity beeper.
rd(&_prefs.locator_beeper, sizeof(_prefs.locator_beeper));
if (_prefs.locator_beeper > 1) _prefs.locator_beeper = 0;
// Locator can target a live contact (kind + pubkey prefix).
rd(&_prefs.locator_target_kind, sizeof(_prefs.locator_target_kind));
rd(_prefs.locator_key, sizeof(_prefs.locator_key));
if (_prefs.locator_target_kind > 2) _prefs.locator_target_kind = 0;
// GPS-averaging duration for waypoint marking.
rd(&_prefs.gps_avg_idx, sizeof(_prefs.gps_avg_idx));
if (_prefs.gps_avg_idx >= NodePrefs::GPS_AVG_COUNT) _prefs.gps_avg_idx = 0;
// One-shot alarm clock (local time-of-day + armed flag).
rd(&_prefs.alarm_on, sizeof(_prefs.alarm_on));
rd(&_prefs.alarm_hour, sizeof(_prefs.alarm_hour));
rd(&_prefs.alarm_min, sizeof(_prefs.alarm_min));
if (_prefs.alarm_on > 1) _prefs.alarm_on = 0;
if (_prefs.alarm_hour > 23) _prefs.alarm_hour = 0;
if (_prefs.alarm_min > 59) _prefs.alarm_min = 0;
// Keyboard type (QWERTY/T9). Stray bytes from an older file clamp back to
// the QWERTY default.
rd(&_prefs.keyboard_type, sizeof(_prefs.keyboard_type));
if (_prefs.keyboard_type > 1) _prefs.keyboard_type = 0;
// A stray/garbage byte here means no valid saved profile, same as a
// never-configured device -- default to a profile in the same band as the
// companion's own network (_prefs.freq, already read above) rather than
// "Current" -- a repeater silently following the companion onto whatever
// private network it later joins isn't the MeshCore community norm; that
// stays opt-in. Band-matched rather than a flat frequency so the default
// can't land outside what's legal where the companion is set up.
if (_prefs.repeater_use_profile > 1) _prefs.repeater_use_profile = 0;
float rpt_lo, rpt_hi; radio_driver.getFreqBounds(rpt_lo, rpt_hi);
if (!isValidRepeaterProfile(_prefs.repeater_freq, _prefs.repeater_bw, _prefs.repeater_sf, _prefs.repeater_cr, rpt_lo, rpt_hi)) {
seedDefaultRepeaterProfile(_prefs);
}
// Bot commands + quiet-hours. Stray bytes from an older file clamp to
// off / no quiet hours.
if (_prefs.bot_commands_enabled > 1) _prefs.bot_commands_enabled = 0;
if (_prefs.bot_quiet_start > 23) _prefs.bot_quiet_start = 0;
if (_prefs.bot_quiet_end > 23) _prefs.bot_quiet_end = 0;
// These fields were appended over successive schema bumps; an older file
// can leave stray bytes here, so clamp out-of-range values back to defaults.
// Values for notif_melody_ad: 0=built-in, 1=melody1, 2=melody2, 3=none.
if (_prefs.notif_melody_ad > 3) _prefs.notif_melody_ad = 0;
// A stale value >1 from an older multi-font build would read as "Lemon" (all
// sites test != 0) until the user toggles Font; clamp it to default here.
if (_prefs.use_lemon_font > 1) _prefs.use_lemon_font = 0;
if (_prefs.units_imperial > 1) _prefs.units_imperial = 0;
if (_prefs.trail_show_pace > 1) _prefs.trail_show_pace = 0;
if (_prefs.advert_sound_scope > 1) _prefs.advert_sound_scope = ADVERT_SOUND_SCOPE_ALL;
if (_prefs.rx_powersave > 1) _prefs.rx_powersave = 0;
if (_prefs.tx_apc > 1) _prefs.tx_apc = 0;
// An old (0xC0DE0009) file leaves the low byte of its sentinel here (0x09),
// which is out of range — fall back to the default of 2 resends.
if (_prefs.dm_resend_count > 5) _prefs.dm_resend_count = 2;
// page_order grew 11 → 13 so Shutdown and Map become reorderable. The extra
// slots are appended here at the tail (not inline) so an older, shorter
// save still loads without shifting every field after it. Stray/EOF bytes
// here clamp to 0 (empty); ensurePageOrderInit then appends the missing
// pages into the freed slots.
for (uint8_t i = NodePrefs::PAGE_ORDER_LEN_V1; i < NodePrefs::PAGE_ORDER_LEN; i++) {
rd(&_prefs.page_order[i], sizeof(_prefs.page_order[i]));
if (_prefs.page_order[i] > NodePrefs::HPB_COUNT) _prefs.page_order[i] = 0;
} }
// trail_autosave_lowbatt. rd() zero-inits when absent and the clamp below // `prefs` comes in holding the defaults; what the file holds replaces them.
// turns any stray value into 0 (off). void DataStore::loadPrefs(NodePrefs& prefs, double& node_lat, double& node_lon) {
rd(&_prefs.trail_autosave_lowbatt, sizeof(_prefs.trail_autosave_lowbatt)); File file = openRead(_fs, "/prefs");
if (_prefs.trail_autosave_lowbatt > 1) _prefs.trail_autosave_lowbatt = 0;
// alarm_repeat_mask. Any value that isn't one of the four presets (stray
// bytes from an older file included) clamps to 0 (no repeat / one-shot).
rd(&_prefs.alarm_repeat_mask, sizeof(_prefs.alarm_repeat_mask));
if (NodePrefs::alarmRepeatIdxForMask(_prefs.alarm_repeat_mask) == 0) _prefs.alarm_repeat_mask = 0;
// keyboard_alt_alphabet. Stray/EOF bytes clamp to 0 (Latin only).
rd(&_prefs.keyboard_alt_alphabet, sizeof(_prefs.keyboard_alt_alphabet));
if (_prefs.keyboard_alt_alphabet >= NodePrefs::KB_ALPHABET_COUNT) _prefs.keyboard_alt_alphabet = 0;
// bot_dm_scope + the room-server bot fields. rd() zero-inits when absent,
// so an older file defaults to bot_dm_scope=0 (all DMs) and the room bot
// fully disabled (bot_room_enabled clamps to 0; an empty trigger never
// matches even if somehow set).
rd(&_prefs.bot_dm_scope, sizeof(_prefs.bot_dm_scope));
if (_prefs.bot_dm_scope > 1) _prefs.bot_dm_scope = 0;
rd(&_prefs.bot_room_enabled, sizeof(_prefs.bot_room_enabled));
if (_prefs.bot_room_enabled > 1) _prefs.bot_room_enabled = 0;
rd(_prefs.bot_room_prefix, sizeof(_prefs.bot_room_prefix));
rd(_prefs.bot_trigger_room, sizeof(_prefs.bot_trigger_room));
rd(_prefs.bot_reply_room, sizeof(_prefs.bot_reply_room));
// Per-target bot-commands toggle for channel/room too (DM keeps the
// original field). Stray/missing bytes clamp both to 0 (off) -- an
// upgrader from a version old enough to lack these re-enables channel/room
// bot commands manually rather than inheriting the old shared setting.
rd(&_prefs.bot_commands_ch, sizeof(_prefs.bot_commands_ch));
if (_prefs.bot_commands_ch > 1) _prefs.bot_commands_ch = 0;
rd(&_prefs.bot_commands_room, sizeof(_prefs.bot_commands_room));
if (_prefs.bot_commands_room > 1) _prefs.bot_commands_room = 0;
// keyboard_main_alphabet. Stray/EOF bytes clamp to 0 (Latin).
rd(&_prefs.keyboard_main_alphabet, sizeof(_prefs.keyboard_main_alphabet));
if (_prefs.keyboard_main_alphabet >= NodePrefs::KB_ALPHABET_COUNT) _prefs.keyboard_main_alphabet = 0;
// Per-target bot-actions toggles. Stray/missing bytes clamp to 0 (off) --
// these gate state-changing bot commands (!buzz/!gps/!advert), so an
// upgrader must opt in deliberately rather than get them silently enabled.
rd(&_prefs.bot_actions_dm, sizeof(_prefs.bot_actions_dm));
if (_prefs.bot_actions_dm > 1) _prefs.bot_actions_dm = 0;
rd(&_prefs.bot_actions_ch, sizeof(_prefs.bot_actions_ch));
if (_prefs.bot_actions_ch > 1) _prefs.bot_actions_ch = 0;
rd(&_prefs.bot_actions_room, sizeof(_prefs.bot_actions_room));
if (_prefs.bot_actions_room > 1) _prefs.bot_actions_room = 0;
// User-assignable GPIO pin modes (0=Off 1=In 2=Out-low 3=Out-high
// 4=Analog). Stray/missing bytes clamp to 0 (off). gpio1/gpio2 (AIN0/AIN5)
// allow mode 4; gpio3/gpio4 have no ADC channel, so their clamp stops at 3
// -- a stray 4 there falls back to Off rather than doing something undefined.
rd(&_prefs.gpio1_mode, sizeof(_prefs.gpio1_mode));
if (_prefs.gpio1_mode > 4) _prefs.gpio1_mode = 0;
rd(&_prefs.gpio2_mode, sizeof(_prefs.gpio2_mode));
if (_prefs.gpio2_mode > 4) _prefs.gpio2_mode = 0;
rd(&_prefs.gpio3_mode, sizeof(_prefs.gpio3_mode));
if (_prefs.gpio3_mode > 3) _prefs.gpio3_mode = 0;
rd(&_prefs.gpio4_mode, sizeof(_prefs.gpio4_mode));
if (_prefs.gpio4_mode > 3) _prefs.gpio4_mode = 0;
// External-keyboard compact-display toggle. Missing byte clamps to 0
// (full grid, unchanged behaviour for upgraders).
rd(&_prefs.keyboard_cardkb_compact, sizeof(_prefs.keyboard_cardkb_compact));
if (_prefs.keyboard_cardkb_compact > 1) _prefs.keyboard_cardkb_compact = 0;
// interference_threshold + cad_enabled. An older, shorter file has no bytes
// here, so these can read that file's own sentinel tail rather than zeroes;
// clamp both to 0/off. interference_threshold is dB above the measured
// noise floor (see RadioLibWrapper::isChannelActive()); anything past
// ~30 dB would never trigger anyway, so treat it as a stray byte and fall
// back to off.
rd(&_prefs.interference_threshold, sizeof(_prefs.interference_threshold));
if (_prefs.interference_threshold > 30) _prefs.interference_threshold = 0;
rd(&_prefs.cad_enabled, sizeof(_prefs.cad_enabled));
if (_prefs.cad_enabled > 1) _prefs.cad_enabled = 0;
// repeat_scope_only + repeat_extra_scopes, read at the tail (an earlier,
// now-burned layout had them mid-stream, which shifted every later field
// when reading an older file — see NodePrefs.h). Missing bytes clamp the
// flag to 0 (off) and leave the name list zero-initialised (empty string).
rd(&_prefs.repeat_scope_only, sizeof(_prefs.repeat_scope_only));
if (_prefs.repeat_scope_only > 1) _prefs.repeat_scope_only = 0;
rd(_prefs.repeat_extra_scopes, sizeof(_prefs.repeat_extra_scopes));
_prefs.repeat_extra_scopes[sizeof(_prefs.repeat_extra_scopes) - 1] = '\0';
// favourite_kinds. An older, shorter file has its own sentinel tail
// sitting here instead -- clamp anything unknown to CONTACT, which is
// what every slot saved before this field existed actually was.
rd(_prefs.favourite_kinds, sizeof(_prefs.favourite_kinds));
for (uint8_t i = 0; i < NodePrefs::FAVOURITES_COUNT; i++) {
if (_prefs.favourite_kinds[i] > NodePrefs::FAV_KIND_MAX)
_prefs.favourite_kinds[i] = NodePrefs::FAV_KIND_CONTACT;
}
// fav_sort_off. Inverted (see NodePrefs), so both a stray byte from an
// older file and a file that ends before this field clamp/zero to 0 =
// favourites on top, the default.
rd(&_prefs.fav_sort_off, sizeof(_prefs.fav_sort_off));
if (_prefs.fav_sort_off > 1) _prefs.fav_sort_off = 0;
// msg_wake_screen_off. Inverted (see NodePrefs), so both a stray byte from
// an older file and a file that ends before this field clamp/zero to 0 =
// wake screen for incoming msgs, the default.
rd(&_prefs.msg_wake_screen_off, sizeof(_prefs.msg_wake_screen_off));
if (_prefs.msg_wake_screen_off > 1) _prefs.msg_wake_screen_off = 0;
// repeat_extra_scope_mask + ch_scope_idx. An older, shorter file has stray
// sentinel-tail bytes sitting here instead -- read as-is; unlike the fields
// above these can't be range-clamped (every bit/byte value is technically a
// "valid" mask or index), so an upgrade from a version old enough to lack
// these two fields may see them start on a stray pick rather than empty.
// Re-saving prefs (e.g. any Settings change) overwrites it for good.
rd(&_prefs.repeat_extra_scope_mask, sizeof(_prefs.repeat_extra_scope_mask));
rd(_prefs.ch_scope_idx, sizeof(_prefs.ch_scope_idx));
// contact_expiry_idx. Stray byte from an older file clamps anything outside
// the real option range (see NodePrefs::contactExpiryDays) back to 0/Off --
// an upgrader must opt into pruning deliberately.
rd(&_prefs.contact_expiry_idx, sizeof(_prefs.contact_expiry_idx));
if (_prefs.contact_expiry_idx >= NodePrefs::CONTACT_EXPIRY_COUNT) _prefs.contact_expiry_idx = 0;
// loc_share_scope. 0 = follow the target. A stray byte from an older file is
// out of range (or is zeroed below on the sentinel transition) -> follow.
rd(&_prefs.loc_share_scope, sizeof(_prefs.loc_share_scope));
if (_prefs.loc_share_scope > ScopeList::MAX_SCOPE_ENTRIES + 1) _prefs.loc_share_scope = 0;
// loc_share_duration_idx. A stray byte from an older file is out of range -> 1 h.
rd(&_prefs.loc_share_duration_idx, sizeof(_prefs.loc_share_duration_idx));
if (_prefs.loc_share_duration_idx >= NodePrefs::LOC_SHARE_DURATION_COUNT) _prefs.loc_share_duration_idx = 0;
// display_brightness_pct. Over 100 -> 0 (use the level); an older file's
// sentinel byte landing here is zeroed below on the sentinel mismatch.
rd(&_prefs.display_brightness_pct, sizeof(_prefs.display_brightness_pct));
if (_prefs.display_brightness_pct > 100) _prefs.display_brightness_pct = 0;
// quiet_hours / quiet_from / quiet_to. Out of range -> off, 22-7; an older
// file's sentinel bytes landing here are reset below on the mismatch.
rd(&_prefs.quiet_hours, sizeof(_prefs.quiet_hours));
rd(&_prefs.quiet_from, sizeof(_prefs.quiet_from));
rd(&_prefs.quiet_to, sizeof(_prefs.quiet_to));
if (_prefs.quiet_hours > 1) _prefs.quiet_hours = 0;
if (_prefs.quiet_from > 23) _prefs.quiet_from = 22;
if (_prefs.quiet_to > 23) _prefs.quiet_to = 7;
// lock_screen_password / _salt: a salted SHA-256 of the screen PIN, all
// zeros = no PIN. An older file's sentinel bytes landing here are cleared
// below on the mismatch.
rd(_prefs.lock_screen_password, sizeof(_prefs.lock_screen_password));
rd(_prefs.lock_screen_password_salt, sizeof(_prefs.lock_screen_password_salt));
// batt_curve_mv: any point out of range or not rising -> the built-in curve.
rd(_prefs.batt_curve_mv, sizeof(_prefs.batt_curve_mv));
if (!battery::validCurve(_prefs.batt_curve_mv)) memset(_prefs.batt_curve_mv, 0, sizeof(_prefs.batt_curve_mv));
rd(&_prefs.lock_compact, sizeof(_prefs.lock_compact));
if (_prefs.lock_compact > 1) _prefs.lock_compact = 0;
// Schema sentinel: bumped on layout changes. Mismatch means an older file
// (or a different schema); rd() and the clamps above already keep every
// field within its valid range regardless, so we just log it here —
// next savePrefs() writes the current sentinel.
uint32_t sentinel = 0;
rd(&sentinel, sizeof(sentinel));
if (sentinel != NodePrefs::SCHEMA_SENTINEL) {
MESH_DEBUG_PRINTLN("prefs schema sentinel mismatch: got 0x%08X, expected 0x%08X — re-saving on next change",
(unsigned)sentinel, (unsigned)NodePrefs::SCHEMA_SENTINEL);
// 0xC0DE0030 → 0xC0DE0031: the lock-screen PIN appended; from an older
// file it holds its sentinel's bytes, which would read as a PIN nobody knows.
// 0xC0DE0031 → 0xC0DE0032: the battery curve appended; an older file
// leaves stray bytes in it, so the built-in curve.
// 0xC0DE0032 → 0xC0DE0033: the lock screen look appended; big by default.
if (sentinel < 0xC0DE0033) _prefs.lock_compact = 0;
if (sentinel < 0xC0DE0032) memset(_prefs.batt_curve_mv, 0, sizeof(_prefs.batt_curve_mv));
if (sentinel < 0xC0DE0031) {
memset(_prefs.lock_screen_password, 0, sizeof(_prefs.lock_screen_password));
memset(_prefs.lock_screen_password_salt, 0, sizeof(_prefs.lock_screen_password_salt));
}
// 0xC0DE002F → 0xC0DE0030: quiet hours appended; from an older file they
// hold its sentinel's bytes, so start off, 22:00-07:00.
if (sentinel < 0xC0DE0030) { _prefs.quiet_hours = 0; _prefs.quiet_from = 22; _prefs.quiet_to = 7; }
// 0xC0DE002E → 0xC0DE002F: display_brightness_pct appended; from an older
// file it holds a stray sentinel byte (0x2E = "46 %"), so start from the level.
if (sentinel < 0xC0DE002F) _prefs.display_brightness_pct = 0;
// 0xC0DE002A (v1.27) → 0xC0DE002B: repeat_extra_scope_mask + ch_scope_idx
// appended. Unlike the range-clamped fields, these can't be left with whatever
// stray bytes rd() picked up from a pre-0x2B file's own sentinel tail:
// every bit/byte value is "valid" (any mask or index could be a real pick),
// so garbage here isn't caught by a range clamp -- it just silently
// masquerades as a real one, and can even reactivate later once the scope
// list grows long enough to reach an index that used to be out of range.
// Zero both outright on this transition; a fresh scope list is empty
// anyway, so there's nothing genuine to lose.
if (sentinel < 0xC0DE002B) {
_prefs.repeat_extra_scope_mask = 0;
memset(_prefs.ch_scope_idx, 0, sizeof(_prefs.ch_scope_idx));
}
// 0xC0DE002C → 0xC0DE002D: loc_share_scope appended. A 0x2C file's sentinel
// tail (0x2C) lands here and is in range, so zero it explicitly.
if (sentinel < 0xC0DE002D) _prefs.loc_share_scope = 0;
}
file.close();
}
void DataStore::savePrefs(const NodePrefs& _prefs, double node_lat, double node_lon) {
// Atomic temp-then-rename (see commitTempFile) so an interrupted save can't
// wipe settings; loadPrefs() still validates the tail sentinel on read.
File file = ::openWrite(_fs, "/new_prefs.tmp");
if (file) { if (file) {
uint8_t pad[8]; PrefsHeader h;
memset(pad, 0, sizeof(pad)); NodePrefs* tmp = new NodePrefs(prefs); // a field the file is too short for keeps its default
double lat = 0, lon = 0;
file.write((uint8_t *)&_prefs.airtime_factor, sizeof(float)); bool ok = file.read((uint8_t*)&h, sizeof(h)) == sizeof(h)
file.write((uint8_t *)_prefs.node_name, sizeof(_prefs.node_name)); && h.magic == PREFS_MAGIC && h.version == NodePrefs::PREFS_VERSION
file.write(pad, 4); && file.read((uint8_t*)&lat, sizeof(lat)) == sizeof(lat)
file.write((uint8_t *)&node_lat, sizeof(node_lat)); && file.read((uint8_t*)&lon, sizeof(lon)) == sizeof(lon);
file.write((uint8_t *)&node_lon, sizeof(node_lon));
file.write((uint8_t *)&_prefs.freq, sizeof(_prefs.freq));
file.write((uint8_t *)&_prefs.sf, sizeof(_prefs.sf));
file.write((uint8_t *)&_prefs.cr, sizeof(_prefs.cr));
file.write((uint8_t *)&_prefs.client_repeat, sizeof(_prefs.client_repeat));
file.write((uint8_t *)&_prefs.manual_add_contacts, sizeof(_prefs.manual_add_contacts));
file.write((uint8_t *)&_prefs.bw, sizeof(_prefs.bw));
file.write((uint8_t *)&_prefs.tx_power_dbm, sizeof(_prefs.tx_power_dbm));
file.write((uint8_t *)&_prefs.telemetry_mode_base, sizeof(_prefs.telemetry_mode_base));
file.write((uint8_t *)&_prefs.telemetry_mode_loc, sizeof(_prefs.telemetry_mode_loc));
file.write((uint8_t *)&_prefs.telemetry_mode_env, sizeof(_prefs.telemetry_mode_env));
file.write((uint8_t *)&_prefs.rx_delay_base, sizeof(_prefs.rx_delay_base));
file.write((uint8_t *)&_prefs.advert_loc_policy, sizeof(_prefs.advert_loc_policy));
file.write((uint8_t *)&_prefs.multi_acks, sizeof(_prefs.multi_acks));
file.write((uint8_t *)&_prefs.path_hash_mode, sizeof(_prefs.path_hash_mode));
file.write(pad, 1);
file.write((uint8_t *)&_prefs.ble_pin, sizeof(_prefs.ble_pin));
file.write((uint8_t *)&_prefs.buzzer_quiet, sizeof(_prefs.buzzer_quiet));
file.write((uint8_t *)&_prefs.gps_enabled, sizeof(_prefs.gps_enabled));
file.write((uint8_t *)&_prefs.gps_interval, sizeof(_prefs.gps_interval));
file.write((uint8_t *)&_prefs.autoadd_config, sizeof(_prefs.autoadd_config));
file.write((uint8_t *)&_prefs.autoadd_max_hops, sizeof(_prefs.autoadd_max_hops));
file.write((uint8_t *)&_prefs.rx_boosted_gain, sizeof(_prefs.rx_boosted_gain));
file.write((uint8_t *)_prefs.default_scope_name, sizeof(_prefs.default_scope_name));
file.write((uint8_t *)_prefs.default_scope_key, sizeof(_prefs.default_scope_key));
file.write((uint8_t *)&_prefs.display_brightness, sizeof(_prefs.display_brightness));
file.write((uint8_t *)&_prefs.auto_off_secs, sizeof(_prefs.auto_off_secs));
file.write((uint8_t *)&_prefs.tz_offset_hours, sizeof(_prefs.tz_offset_hours));
file.write((uint8_t *)&_prefs.low_batt_mv, sizeof(_prefs.low_batt_mv));
file.write((uint8_t *)&_prefs.batt_display_mode, sizeof(_prefs.batt_display_mode));
file.write((uint8_t *)_prefs.custom_msgs, sizeof(_prefs.custom_msgs));
file.write((uint8_t *)&_prefs.ch_notif_override, sizeof(_prefs.ch_notif_override));
file.write((uint8_t *)&_prefs.ch_notif_muted, sizeof(_prefs.ch_notif_muted));
file.write((uint8_t *)&_prefs.dm_show_all, sizeof(_prefs.dm_show_all));
file.write((uint8_t *)&_prefs.room_fav_only, sizeof(_prefs.room_fav_only));
file.write((uint8_t *)&_prefs.buzzer_volume, sizeof(_prefs.buzzer_volume));
file.write((uint8_t *)&_prefs.ringtone_bpm_idx, sizeof(_prefs.ringtone_bpm_idx));
file.write((uint8_t *)&_prefs.ringtone_len, sizeof(_prefs.ringtone_len));
file.write((uint8_t *)_prefs.ringtone_notes, sizeof(_prefs.ringtone_notes));
file.write((uint8_t *)&_prefs.home_pages_mask, sizeof(_prefs.home_pages_mask));
file.write((uint8_t *)&_prefs.bot_enabled, sizeof(_prefs.bot_enabled));
file.write((uint8_t *)&_prefs.bot_channel_enabled, sizeof(_prefs.bot_channel_enabled));
file.write((uint8_t *)&_prefs.bot_channel_idx, sizeof(_prefs.bot_channel_idx));
file.write((uint8_t *)_prefs.bot_trigger, sizeof(_prefs.bot_trigger));
file.write((uint8_t *)_prefs.bot_reply_dm, sizeof(_prefs.bot_reply_dm));
file.write((uint8_t *)_prefs.bot_reply_ch, sizeof(_prefs.bot_reply_ch));
file.write((uint8_t *)&_prefs.clock_hide_seconds, sizeof(_prefs.clock_hide_seconds));
file.write((uint8_t *)&_prefs.buzzer_auto, sizeof(_prefs.buzzer_auto));
file.write((uint8_t *)_prefs.dm_notif, sizeof(_prefs.dm_notif));
file.write((uint8_t *)_prefs.dashboard_fields, sizeof(_prefs.dashboard_fields));
file.write((uint8_t *)&_prefs.advert_auto_interval_sec, sizeof(_prefs.advert_auto_interval_sec));
file.write((uint8_t *)&_prefs.ringtone2_bpm_idx, sizeof(_prefs.ringtone2_bpm_idx));
file.write((uint8_t *)&_prefs.ringtone2_len, sizeof(_prefs.ringtone2_len));
file.write((uint8_t *)_prefs.ringtone2_notes, sizeof(_prefs.ringtone2_notes));
file.write((uint8_t *)&_prefs.notif_melody_dm, sizeof(_prefs.notif_melody_dm));
file.write((uint8_t *)&_prefs.notif_melody_ch, sizeof(_prefs.notif_melody_ch));
file.write((uint8_t *)&_prefs.ch_notif_melody_set, sizeof(_prefs.ch_notif_melody_set));
file.write((uint8_t *)&_prefs.ch_notif_melody_2, sizeof(_prefs.ch_notif_melody_2));
file.write((uint8_t *)_prefs.dm_melody, sizeof(_prefs.dm_melody));
file.write((uint8_t *)&_prefs.auto_lock, sizeof(_prefs.auto_lock));
file.write((uint8_t *)&_prefs.clock_12h, sizeof(_prefs.clock_12h));
file.write((uint8_t *)&_prefs.use_lemon_font, sizeof(_prefs.use_lemon_font));
file.write((uint8_t *)&_prefs.display_rotation, sizeof(_prefs.display_rotation));
file.write((uint8_t *)_prefs.page_order, NodePrefs::PAGE_ORDER_LEN_V1); // head; tail slots written below
file.write((uint8_t *)&_prefs.joystick_rotation, sizeof(_prefs.joystick_rotation));
file.write((uint8_t *)&_prefs.eink_full_refresh_every, sizeof(_prefs.eink_full_refresh_every));
file.write((uint8_t *)&_prefs.page_order_set, sizeof(_prefs.page_order_set));
file.write((uint8_t *)_prefs.favourite_contacts, sizeof(_prefs.favourite_contacts));
file.write((uint8_t *)&_prefs.trail_interval_idx, sizeof(_prefs.trail_interval_idx));
file.write((uint8_t *)&_prefs.trail_min_delta_idx, sizeof(_prefs.trail_min_delta_idx));
file.write((uint8_t *)&_prefs.trail_units_idx, sizeof(_prefs.trail_units_idx));
file.write((uint8_t *)&_prefs.ch_fav_bitmask, sizeof(_prefs.ch_fav_bitmask));
file.write((uint8_t *)&_prefs.ch_fav_only, sizeof(_prefs.ch_fav_only));
file.write((uint8_t *)&_prefs.notif_melody_ad, sizeof(_prefs.notif_melody_ad));
file.write((uint8_t *)&_prefs.units_imperial, sizeof(_prefs.units_imperial));
file.write((uint8_t *)&_prefs.trail_show_pace, sizeof(_prefs.trail_show_pace));
file.write((uint8_t *)&_prefs.advert_sound_scope, sizeof(_prefs.advert_sound_scope));
file.write((uint8_t *)&_prefs.rx_powersave, sizeof(_prefs.rx_powersave));
file.write((uint8_t *)&_prefs.tx_apc, sizeof(_prefs.tx_apc));
file.write((uint8_t *)&_prefs.dm_resend_count, sizeof(_prefs.dm_resend_count));
file.write((uint8_t *)&_prefs.bot_commands_enabled, sizeof(_prefs.bot_commands_enabled));
file.write((uint8_t *)&_prefs.bot_quiet_start, sizeof(_prefs.bot_quiet_start));
file.write((uint8_t *)&_prefs.bot_quiet_end, sizeof(_prefs.bot_quiet_end));
file.write((uint8_t *)_prefs.bot_trigger_ch, sizeof(_prefs.bot_trigger_ch));
file.write((uint8_t *)_prefs.user_radio_presets, sizeof(_prefs.user_radio_presets));
file.write((uint8_t *)&_prefs.repeat_skip_adverts, sizeof(_prefs.repeat_skip_adverts));
file.write((uint8_t *)&_prefs.repeat_max_hops, sizeof(_prefs.repeat_max_hops));
file.write((uint8_t *)&_prefs.repeat_delay_boost, sizeof(_prefs.repeat_delay_boost));
file.write((uint8_t *)&_prefs.repeat_min_snr, sizeof(_prefs.repeat_min_snr));
file.write((uint8_t *)&_prefs.repeat_suppress_dup, sizeof(_prefs.repeat_suppress_dup));
// repeat_scope_only/repeat_extra_scopes are written at the TAIL — see loadPrefsInt().
file.write((uint8_t *)&_prefs.repeater_use_profile, sizeof(_prefs.repeater_use_profile));
file.write((uint8_t *)&_prefs.repeater_freq, sizeof(_prefs.repeater_freq));
file.write((uint8_t *)&_prefs.repeater_bw, sizeof(_prefs.repeater_bw));
file.write((uint8_t *)&_prefs.repeater_sf, sizeof(_prefs.repeater_sf));
file.write((uint8_t *)&_prefs.repeater_cr, sizeof(_prefs.repeater_cr));
file.write((uint8_t *)&_prefs.track_shared_loc, sizeof(_prefs.track_shared_loc));
file.write((uint8_t *)&_prefs.loc_share_enabled, sizeof(_prefs.loc_share_enabled));
file.write((uint8_t *)&_prefs.loc_share_target_type, sizeof(_prefs.loc_share_target_type));
file.write((uint8_t *)&_prefs.loc_share_channel_idx, sizeof(_prefs.loc_share_channel_idx));
file.write((uint8_t *)_prefs.loc_share_dm_prefix, sizeof(_prefs.loc_share_dm_prefix));
file.write((uint8_t *)&_prefs.loc_share_move_idx, sizeof(_prefs.loc_share_move_idx));
file.write((uint8_t *)&_prefs.loc_share_interval_idx, sizeof(_prefs.loc_share_interval_idx));
file.write((uint8_t *)&_prefs.loc_share_heartbeat_idx, sizeof(_prefs.loc_share_heartbeat_idx));
file.write((uint8_t *)&_prefs.locator_enabled, sizeof(_prefs.locator_enabled));
file.write((uint8_t *)&_prefs.locator_has_target, sizeof(_prefs.locator_has_target));
file.write((uint8_t *)&_prefs.locator_radius_idx, sizeof(_prefs.locator_radius_idx));
file.write((uint8_t *)&_prefs.locator_mode, sizeof(_prefs.locator_mode));
file.write((uint8_t *)&_prefs.locator_lat_1e6, sizeof(_prefs.locator_lat_1e6));
file.write((uint8_t *)&_prefs.locator_lon_1e6, sizeof(_prefs.locator_lon_1e6));
file.write((uint8_t *)_prefs.locator_label, sizeof(_prefs.locator_label));
file.write((uint8_t *)&_prefs.trail_autopause_idx, sizeof(_prefs.trail_autopause_idx));
file.write((uint8_t *)&_prefs.locator_beeper, sizeof(_prefs.locator_beeper));
file.write((uint8_t *)&_prefs.locator_target_kind, sizeof(_prefs.locator_target_kind));
file.write((uint8_t *)_prefs.locator_key, sizeof(_prefs.locator_key));
file.write((uint8_t *)&_prefs.gps_avg_idx, sizeof(_prefs.gps_avg_idx));
file.write((uint8_t *)&_prefs.alarm_on, sizeof(_prefs.alarm_on));
file.write((uint8_t *)&_prefs.alarm_hour, sizeof(_prefs.alarm_hour));
file.write((uint8_t *)&_prefs.alarm_min, sizeof(_prefs.alarm_min));
file.write((uint8_t *)&_prefs.keyboard_type, sizeof(_prefs.keyboard_type));
// page_order tail slots (see loadPrefsInt): entries beyond PAGE_ORDER_LEN_V1,
// appended here so the on-disk head stays the original 11 bytes.
file.write((uint8_t *)&_prefs.page_order[NodePrefs::PAGE_ORDER_LEN_V1],
NodePrefs::PAGE_ORDER_LEN - NodePrefs::PAGE_ORDER_LEN_V1);
file.write((uint8_t *)&_prefs.trail_autosave_lowbatt, sizeof(_prefs.trail_autosave_lowbatt));
file.write((uint8_t *)&_prefs.alarm_repeat_mask, sizeof(_prefs.alarm_repeat_mask));
file.write((uint8_t *)&_prefs.keyboard_alt_alphabet, sizeof(_prefs.keyboard_alt_alphabet));
file.write((uint8_t *)&_prefs.bot_dm_scope, sizeof(_prefs.bot_dm_scope));
file.write((uint8_t *)&_prefs.bot_room_enabled, sizeof(_prefs.bot_room_enabled));
file.write((uint8_t *)_prefs.bot_room_prefix, sizeof(_prefs.bot_room_prefix));
file.write((uint8_t *)_prefs.bot_trigger_room, sizeof(_prefs.bot_trigger_room));
file.write((uint8_t *)_prefs.bot_reply_room, sizeof(_prefs.bot_reply_room));
file.write((uint8_t *)&_prefs.bot_commands_ch, sizeof(_prefs.bot_commands_ch));
file.write((uint8_t *)&_prefs.bot_commands_room, sizeof(_prefs.bot_commands_room));
file.write((uint8_t *)&_prefs.keyboard_main_alphabet, sizeof(_prefs.keyboard_main_alphabet));
file.write((uint8_t *)&_prefs.bot_actions_dm, sizeof(_prefs.bot_actions_dm));
file.write((uint8_t *)&_prefs.bot_actions_ch, sizeof(_prefs.bot_actions_ch));
file.write((uint8_t *)&_prefs.bot_actions_room, sizeof(_prefs.bot_actions_room));
file.write((uint8_t *)&_prefs.gpio1_mode, sizeof(_prefs.gpio1_mode));
file.write((uint8_t *)&_prefs.gpio2_mode, sizeof(_prefs.gpio2_mode));
file.write((uint8_t *)&_prefs.gpio3_mode, sizeof(_prefs.gpio3_mode));
file.write((uint8_t *)&_prefs.gpio4_mode, sizeof(_prefs.gpio4_mode));
file.write((uint8_t *)&_prefs.keyboard_cardkb_compact, sizeof(_prefs.keyboard_cardkb_compact));
file.write((uint8_t *)&_prefs.interference_threshold, sizeof(_prefs.interference_threshold));
file.write((uint8_t *)&_prefs.cad_enabled, sizeof(_prefs.cad_enabled));
file.write((uint8_t *)&_prefs.repeat_scope_only, sizeof(_prefs.repeat_scope_only));
file.write((uint8_t *)_prefs.repeat_extra_scopes, sizeof(_prefs.repeat_extra_scopes));
file.write((uint8_t *)_prefs.favourite_kinds, sizeof(_prefs.favourite_kinds));
file.write((uint8_t *)&_prefs.fav_sort_off, sizeof(_prefs.fav_sort_off));
file.write((uint8_t *)&_prefs.msg_wake_screen_off, sizeof(_prefs.msg_wake_screen_off));
file.write((uint8_t *)&_prefs.repeat_extra_scope_mask, sizeof(_prefs.repeat_extra_scope_mask));
file.write((uint8_t *)_prefs.ch_scope_idx, sizeof(_prefs.ch_scope_idx));
file.write((uint8_t *)&_prefs.contact_expiry_idx, sizeof(_prefs.contact_expiry_idx));
file.write((uint8_t *)&_prefs.loc_share_scope, sizeof(_prefs.loc_share_scope));
file.write((uint8_t *)&_prefs.loc_share_duration_idx, sizeof(_prefs.loc_share_duration_idx));
file.write((uint8_t *)&_prefs.display_brightness_pct, sizeof(_prefs.display_brightness_pct));
file.write((uint8_t *)&_prefs.quiet_hours, sizeof(_prefs.quiet_hours));
file.write((uint8_t *)&_prefs.quiet_from, sizeof(_prefs.quiet_from));
file.write((uint8_t *)&_prefs.quiet_to, sizeof(_prefs.quiet_to));
file.write((uint8_t *)_prefs.lock_screen_password, sizeof(_prefs.lock_screen_password));
file.write((uint8_t *)_prefs.lock_screen_password_salt, sizeof(_prefs.lock_screen_password_salt));
file.write((uint8_t *)_prefs.batt_curve_mv, sizeof(_prefs.batt_curve_mv));
file.write((uint8_t *)&_prefs.lock_compact, sizeof(_prefs.lock_compact));
// Tail sentinel — must be last. See NodePrefs::SCHEMA_SENTINEL. Its write is
// the one we check: once the flash fills, writes return 0, so a good
// sentinel write means the whole record fit. Only then swap it in.
uint32_t sentinel = NodePrefs::SCHEMA_SENTINEL;
bool ok = (file.write((uint8_t *)&sentinel, sizeof(sentinel)) == sizeof(sentinel));
file.close();
if (ok) { if (ok) {
commitTempFile(_fs, "/new_prefs.tmp", "/new_prefs"); const size_t n = h.size < sizeof(NodePrefs) ? h.size : sizeof(NodePrefs);
} else { ok = file.read((uint8_t*)tmp, n) == (int)n;
_fs->remove("/new_prefs.tmp"); // keep the previous good /new_prefs uint32_t crc = crc32(0, (const uint8_t*)&lat, sizeof(lat));
crc = crc32(crc, (const uint8_t*)&lon, sizeof(lon));
ok = ok && crc32(crc, (const uint8_t*)tmp, n) == h.crc;
}
file.close();
if (ok) { prefs = *tmp; node_lat = lat; node_lon = lon; }
else MESH_DEBUG_PRINTLN("prefs: /prefs unreadable (another version or damaged), defaults kept");
delete tmp;
} else { // first boot after an update from before 2.0 (/node_prefs: older still, the same layout)
for (const char* old : { "/new_prefs", "/node_prefs" }) {
if (!_fs->exists(old)) continue;
if ((file = openRead(_fs, old))) {
importOldPrefs(file, prefs, node_lat, node_lon);
file.close();
savePrefs(prefs, node_lat, node_lon);
}
_fs->remove(old);
break;
} }
} }
sanitize(prefs);
}
void DataStore::savePrefs(const NodePrefs& prefs, double node_lat, double node_lon) {
// Atomic temp-then-rename (see commitTempFile): an interrupted save leaves
// the previous /prefs in place.
File file = ::openWrite(_fs, "/prefs.tmp");
if (!file) return;
PrefsHeader h = { PREFS_MAGIC, NodePrefs::PREFS_VERSION, (uint16_t)sizeof(NodePrefs), 0 };
h.crc = crc32(0, (const uint8_t*)&node_lat, sizeof(node_lat));
h.crc = crc32(h.crc, (const uint8_t*)&node_lon, sizeof(node_lon));
h.crc = crc32(h.crc, (const uint8_t*)&prefs, sizeof(NodePrefs));
// Once the flash fills, writes come back short: only a whole record replaces the old one.
bool ok = file.write((const uint8_t*)&h, sizeof(h)) == sizeof(h)
&& file.write((const uint8_t*)&node_lat, sizeof(node_lat)) == sizeof(node_lat)
&& file.write((const uint8_t*)&node_lon, sizeof(node_lon)) == sizeof(node_lon)
&& file.write((const uint8_t*)&prefs, sizeof(NodePrefs)) == sizeof(NodePrefs);
file.close();
if (ok) commitTempFile(_fs, "/prefs.tmp", "/prefs");
else _fs->remove("/prefs.tmp");
} }
void DataStore::saveRTCTime() { void DataStore::saveRTCTime() {
-1
View File
@@ -20,7 +20,6 @@ class DataStore {
mesh::RTCClock* _clock; mesh::RTCClock* _clock;
IdentityStore identity_store; IdentityStore identity_store;
void loadPrefsInt(const char *filename, NodePrefs& prefs, double& node_lat, double& node_lon);
#if defined(NRF52_PLATFORM) || defined(STM32_PLATFORM) #if defined(NRF52_PLATFORM) || defined(STM32_PLATFORM)
void checkAdvBlobFile(); void checkAdvBlobFile();
#endif #endif
+7 -5
View File
@@ -1848,14 +1848,15 @@ MyMesh::MyMesh(mesh::Radio &radio, mesh::RNG &rng, mesh::RTCClock &rtc, SimpleMe
_prefs.bw = LORA_BW; _prefs.bw = LORA_BW;
_prefs.cr = LORA_CR; _prefs.cr = LORA_CR;
_prefs.tx_power_dbm = LORA_TX_POWER; _prefs.tx_power_dbm = LORA_TX_POWER;
// Repeater profile default for a true first boot (no prefs file yet, so // The repeater's own radio profile, in the companion's band.
// loadPrefs() below is a no-op) — same band-matched seed as the upgrade
// path in DataStore.cpp.
seedDefaultRepeaterProfile(_prefs); seedDefaultRepeaterProfile(_prefs);
_prefs.gps_enabled = 0; // GPS disabled by default _prefs.gps_enabled = 0; // GPS disabled by default
_prefs.gps_interval = 0; // No automatic GPS updates by default _prefs.gps_interval = 0; // No automatic GPS updates by default
_prefs.radio_fem_rxgain = 1; _prefs.radio_fem_rxgain = 1;
_prefs.radio_fem_txgain = 0; _prefs.radio_fem_txgain = 0;
#ifdef DISPLAY_ROTATION
_prefs.display_rotation = DISPLAY_ROTATION;
#endif
_prefs.display_brightness = 2; // medium brightness by default _prefs.display_brightness = 2; // medium brightness by default
_prefs.buzzer_volume = 4; // max volume by default _prefs.buzzer_volume = 4; // max volume by default
_prefs.quiet_from = 22; // quiet hours (off by default) 22:00-07:00 _prefs.quiet_from = 22; // quiet hours (off by default) 22:00-07:00
@@ -1877,6 +1878,8 @@ MyMesh::MyMesh(mesh::Radio &radio, mesh::RNG &rng, mesh::RTCClock &rtc, SimpleMe
_prefs.bot_quiet_start = 0; _prefs.bot_quiet_start = 0;
_prefs.bot_quiet_end = 0; // start==end → quiet hours disabled _prefs.bot_quiet_end = 0; // start==end → quiet hours disabled
_prefs.dm_show_all = 1; // show all contacts by default _prefs.dm_show_all = 1; // show all contacts by default
_prefs.fav_sort = 1; // favourites first in lists
_prefs.msg_wake = 1; // a new message turns the screen on
_prefs.dm_resend_count = 2; // auto-resend on-device DMs twice by default _prefs.dm_resend_count = 2; // auto-resend on-device DMs twice by default
memset(_prefs.dm_notif, 0, sizeof(_prefs.dm_notif)); memset(_prefs.dm_notif, 0, sizeof(_prefs.dm_notif));
_prefs.auto_off_secs = 15; // 15 seconds auto-off by default _prefs.auto_off_secs = 15; // 15 seconds auto-off by default
@@ -1886,8 +1889,7 @@ MyMesh::MyMesh(mesh::Radio &radio, mesh::RNG &rng, mesh::RTCClock &rtc, SimpleMe
_prefs.batt_display_mode = 0; // icon by default _prefs.batt_display_mode = 0; // icon by default
//_prefs.rx_delay_base = 10.0f; enable once new algo fixed //_prefs.rx_delay_base = 10.0f; enable once new algo fixed
_prefs.client_repeat = 0; _prefs.client_repeat = 0;
// 0 dB is a real threshold, so "off" is its own sentinel. DataStore sets it // 0 dB is a real threshold, so "off" is its own sentinel.
// too, but only when a prefs file exists -- a fresh device must start off.
_prefs.repeat_min_snr = NodePrefs::REPEAT_SNR_DISABLED; _prefs.repeat_min_snr = NodePrefs::REPEAT_SNR_DISABLED;
#if defined(USE_SX1262) || defined(USE_SX1268) #if defined(USE_SX1262) || defined(USE_SX1268)
#ifdef SX126X_RX_BOOSTED_GAIN #ifdef SX126X_RX_BOOSTED_GAIN
+30 -180
View File
@@ -45,14 +45,11 @@ static inline float defaultRepeaterFreqForBand(float companion_freq) {
#define ADVERT_SOUND_SCOPE_ZERO_HOP 1 #define ADVERT_SOUND_SCOPE_ZERO_HOP 1
struct NodePrefs { // persisted to file struct NodePrefs { // persisted to file
// Fields below are grouped thematically for readability. This grouping is // Saved to /prefs as it is in memory (DataStore::savePrefs): a new field
// purely a source-level convenience: on-disk order is defined solely by the // goes at the END of the struct and needs nothing else -- an older file is
// explicit rd()/wr() call sequence in DataStore::loadPrefsInt()/savePrefs(), // shorter, so the field keeps its default (MyMesh's constructor). Moving,
// not by this struct's declaration order, so reordering fields here never // resizing or removing a field shifts the ones after it: bump PREFS_VERSION
// touches the file format. That file format is still strictly append-only // then, and every device starts again from the defaults.
// (see the serialization tripwire below) — a handful of fields below note
// that their on-disk position is at the struct's tail even though they're
// declared here beside their logical siblings.
// ── Identity & scope ────────────────────────────────────────────────── // ── Identity & scope ──────────────────────────────────────────────────
char node_name[32]; char node_name[32];
@@ -96,9 +93,7 @@ struct NodePrefs { // persisted to file
// External LoRa FEM gain (LNA/PA), from upstream companion-v1.17.1 — // External LoRa FEM gain (LNA/PA), from upstream companion-v1.17.1 —
// board-level only right now (BaseCustomBoard::setLoRaFemLnaEnabled/ // board-level only right now (BaseCustomBoard::setLoRaFemLnaEnabled/
// PaGainEnabled, both default no-op false), no companion CLI/UI to set // PaGainEnabled, both default no-op false), no companion CLI/UI to set
// these yet, so — matching upstream's own decision — NOT persisted here: // these yet: they keep the constructor default (MyMesh.cpp).
// always reset to the constructor default (MyMesh.cpp) on boot. Not part
// of the DataStore save/load tripwire below.
uint8_t radio_fem_rxgain; uint8_t radio_fem_rxgain;
uint8_t radio_fem_txgain; uint8_t radio_fem_txgain;
// User-saved radio presets, written by the "Save current..." entry in the // User-saved radio presets, written by the "Save current..." entry in the
@@ -136,9 +131,6 @@ struct NodePrefs { // persisted to file
int8_t repeat_min_snr; int8_t repeat_min_snr;
static const int8_t REPEAT_SNR_DISABLED = -128; static const int8_t REPEAT_SNR_DISABLED = -128;
uint8_t repeat_suppress_dup; uint8_t repeat_suppress_dup;
// On-disk position is at the struct's append-only tail (see the
// serialization tripwire below), even though grouped here with the rest of
// repeat_* for readability.
// repeat_scope_only: 1 = only forward flood packets matching this device's // repeat_scope_only: 1 = only forward flood packets matching this device's
// own scope (Settings > Radio > Scope, default_scope_key) or one of the // own scope (Settings > Radio > Scope, default_scope_key) or one of the
// repeat_extra_scopes below — drops unscoped floods and floods tagged for // repeat_extra_scopes below — drops unscoped floods and floods tagged for
@@ -146,20 +138,10 @@ struct NodePrefs { // persisted to file
// no scope is configured at all, so enabling this on an unconfigured // no scope is configured at all, so enabling this on an unconfigured
// device can't silently blackhole all flood traffic. // device can't silently blackhole all flood traffic.
uint8_t repeat_scope_only; uint8_t repeat_scope_only;
// Extra region names this repeater also relays for, beyond its own // The scopes this repeater also relays for, beyond its own. Bit i = scope-list index
// Settings > Radio > Scope (comma-separated, e.g. "eu,de") — see
// MyMesh::rebuildRepeatScopes(). Relay-only: never affects what scope the
// companion's own messages send under, only what repeat_scope_only accepts.
// Superseded by repeat_extra_scope_mask below (a scope-list toggle,
// replacing free-typed names); left allocated/unused rather than removed
// so the on-disk layout of every field after it stays put.
char repeat_extra_scopes[24];
// On-disk position is at the struct's append-only tail (0xC0DE002B), even
// though grouped here with the rest of repeat_*. Bit i = scope-list index
// (i+1) is in this repeater's accept set (index 0, "*", isn't a real scope // (i+1) is in this repeater's accept set (index 0, "*", isn't a real scope
// so isn't toggleable here) — see ScopeList/MyMesh::rebuildRepeatScopes(). // so isn't toggleable here) — see ScopeList/MyMesh::rebuildRepeatScopes().
// Same MAX_REPEAT_SCOPES=4 runtime cap as before, just resolved from the // At most MAX_REPEAT_SCOPES (4) of them count.
// shared named-scope list instead of comma-tokenizing repeat_extra_scopes.
uint16_t repeat_extra_scope_mask; uint16_t repeat_extra_scope_mask;
// Optional dedicated radio profile for repeater mode. When repeater_use_profile // Optional dedicated radio profile for repeater mode. When repeater_use_profile
// is 1, enabling the repeater switches the radio to repeater_freq/bw/sf/cr and // is 1, enabling the repeater switches the radio to repeater_freq/bw/sf/cr and
@@ -207,9 +189,7 @@ struct NodePrefs { // persisted to file
// Validity gated by page_order_set magic (see below) — not by entry value range, // Validity gated by page_order_set magic (see below) — not by entry value range,
// so a junk byte in 1..HPB_COUNT cannot trigger custom-order mode. // so a junk byte in 1..HPB_COUNT cannot trigger custom-order mode.
// Declared as a literal (not HPB_COUNT) so the field offset stays stable across // Declared as a literal (not HPB_COUNT) so the field offset stays stable across
// builds that add HomePageBit entries. The first PAGE_ORDER_LEN_V1 bytes persist // builds that add HomePageBit entries.
// at this offset (backward-compatible); the remaining slots live at the file
// tail (see DataStore) so pre-0x0019 saves still load without shifting.
uint8_t page_order[13]; uint8_t page_order[13];
uint8_t page_order_set; // 0xA5 = page_order is user-configured; anything else = use default uint8_t page_order_set; // 0xA5 = page_order is user-configured; anything else = use default
static const uint8_t PAGE_ORDER_MAGIC = 0xA5; static const uint8_t PAGE_ORDER_MAGIC = 0xA5;
@@ -232,9 +212,7 @@ struct NodePrefs { // persisted to file
// Portuguese/Nordic) used to each be their own full alt-alphabet page here; // Portuguese/Nordic) used to each be their own full alt-alphabet page here;
// they're now reached instead by holding Enter on a Latin letter that has // they're now reached instead by holding Enter on a Latin letter that has
// accented variants (see KeyboardWidget.h's KB_ACCENT_VARIANTS), so this // accented variants (see KeyboardWidget.h's KB_ACCENT_VARIANTS), so this
// enum only covers actual non-Latin scripts. Not a schema change: an old // enum only covers actual non-Latin scripts.
// saved value of 3+ (one of the removed languages) just clamps to 0 (Latin)
// via DataStore.cpp's existing `>= KB_ALPHABET_COUNT` range check.
static const uint8_t KB_ALPHABET_LATIN_ONLY = 0; static const uint8_t KB_ALPHABET_LATIN_ONLY = 0;
static const uint8_t KB_ALPHABET_CYRILLIC = 1; static const uint8_t KB_ALPHABET_CYRILLIC = 1;
static const uint8_t KB_ALPHABET_GREEK = 2; static const uint8_t KB_ALPHABET_GREEK = 2;
@@ -243,17 +221,12 @@ struct NodePrefs { // persisted to file
static const char* L[KB_ALPHABET_COUNT] = { "Latin", "Cyrillic", "Greek" }; static const char* L[KB_ALPHABET_COUNT] = { "Latin", "Cyrillic", "Greek" };
return L[idx < KB_ALPHABET_COUNT ? idx : 0]; return L[idx < KB_ALPHABET_COUNT ? idx : 0];
} }
// On-disk position is at the struct's append-only tail (see the
// serialization tripwire below); declared here with keyboard_type for
// readability.
uint8_t keyboard_alt_alphabet; uint8_t keyboard_alt_alphabet;
// Which script (KB_ALPHABET_LATIN_ONLY/CYRILLIC/GREEK) occupies the on-screen // Which script (KB_ALPHABET_LATIN_ONLY/CYRILLIC/GREEK) occupies the on-screen
// keyboard's page 0 -- its default/opening page -- vs. keyboard_alt_alphabet // keyboard's page 0 -- its default/opening page -- vs. keyboard_alt_alphabet
// above, which occupies page 1. Settings > Keyboard's Main/Additional rows. // above, which occupies page 1. Settings > Keyboard's Main/Additional rows.
// Equal to keyboard_alt_alphabet means no second page (see KeyboardWidget's // Equal to keyboard_alt_alphabet means no second page (see KeyboardWidget's
// hasAltAlphabet()). On-disk position is at the tail (see the serialization // hasAltAlphabet()). Default 0 (Latin).
// tripwire below); default 0 (Latin) matches the keyboard's original always-
// Latin-main behaviour for upgraders.
uint8_t keyboard_main_alphabet; uint8_t keyboard_main_alphabet;
// Settings > Keyboard's "Ext. KB" row (boards with a second I2C bus for an // Settings > Keyboard's "Ext. KB" row (boards with a second I2C bus for an
// optional CardKB, see ENV_PIN_SDA/ENV_PIN_SCL, only). When on, the // optional CardKB, see ENV_PIN_SDA/ENV_PIN_SCL, only). When on, the
@@ -262,8 +235,7 @@ struct NodePrefs { // persisted to file
// one-line status (current script/page, caps) instead; the accent and // one-line status (current script/page, caps) instead; the accent and
// placeholder popups still render on top exactly as before (see // placeholder popups still render on top exactly as before (see
// KeyboardWidget::render()). Manual toggle rather than auto-detected, so it // KeyboardWidget::render()). Manual toggle rather than auto-detected, so it
// stays put even if the module is briefly unplugged. Default 0 (full grid, // stays put even if the module is briefly unplugged. Default 0 (full grid).
// unchanged behaviour) on upgrade.
uint8_t keyboard_cardkb_compact; uint8_t keyboard_cardkb_compact;
// ── Clock & alarm ───────────────────────────────────────────────────── // ── Clock & alarm ─────────────────────────────────────────────────────
@@ -284,9 +256,7 @@ struct NodePrefs { // persisted to file
uint8_t alarm_hour; // 0-23, local time uint8_t alarm_hour; // 0-23, local time
uint8_t alarm_min; // 0-59 uint8_t alarm_min; // 0-59
// Repeat-days bitmask — see the alarm doc comment above for the full // Repeat-days bitmask — see the alarm doc comment above for the full
// explanation (bit i = 1<<tm_wday). On-disk position is at the struct's // explanation (bit i = 1<<tm_wday).
// append-only tail (see the serialization tripwire below); grouped here
// with alarm_on/hour/min purely for source readability.
uint8_t alarm_repeat_mask; uint8_t alarm_repeat_mask;
static const uint8_t ALARM_REPEAT_NONE = 0x00; // one-shot (default) static const uint8_t ALARM_REPEAT_NONE = 0x00; // one-shot (default)
static const uint8_t ALARM_REPEAT_DAILY = 0x7F; // every day static const uint8_t ALARM_REPEAT_DAILY = 0x7F; // every day
@@ -325,12 +295,9 @@ struct NodePrefs { // persisted to file
uint8_t quiet_hours; // 0=off (default), 1=on uint8_t quiet_hours; // 0=off (default), 1=on
uint8_t quiet_from; // default 22 uint8_t quiet_from; // default 22
uint8_t quiet_to; // default 7 uint8_t quiet_to; // default 7
// Settings > Display > "Msg wake". Stored inverted (same reason as // Settings > Display > "Msg wake": an incoming message turns the display on
// fav_sort_off below) so both a fresh memset and an older prefs file (no // (UITask::newMsg()) if it was off and no companion app is already showing it.
// bytes here at all) mean "on" -- today's behaviour, where an incoming uint8_t msg_wake; // 1=wake display for incoming msgs (default), 0=not
// message turns the display on (UITask::newMsg()) if it was off and no
// companion app is already showing it.
uint8_t msg_wake_screen_off; // 0=wake display for incoming msgs (default), 1=disabled
uint8_t ringtone_bpm_idx; // index into {60,90,120,150,180} uint8_t ringtone_bpm_idx; // index into {60,90,120,150,180}
uint8_t ringtone_len; // number of notes in custom ringtone (0 = use default) uint8_t ringtone_len; // number of notes in custom ringtone (0 = use default)
uint8_t ringtone_notes[32]; // packed: bits0-2=pitch, bits3-4=octave-4, bits5-6=dur_idx uint8_t ringtone_notes[32]; // packed: bits0-2=pitch, bits3-4=octave-4, bits5-6=dur_idx
@@ -347,11 +314,8 @@ struct NodePrefs { // persisted to file
// Per-channel melody override (2 bitmasks, 1 bit per channel) // Per-channel melody override (2 bitmasks, 1 bit per channel)
uint64_t ch_notif_melody_set; // bit i = channel i has explicit melody [del→onChannelRemoved] uint64_t ch_notif_melody_set; // bit i = channel i has explicit melody [del→onChannelRemoved]
uint64_t ch_notif_melody_2; // bit i = use melody 2 (else melody 1, when set bit is set) uint64_t ch_notif_melody_2; // bit i = use melody 2 (else melody 1, when set bit is set)
// On-disk position is at the struct's append-only tail (0xC0DE002B), even // Scope-list index per channel (see ScopeList) -- 0 ("*"/unscoped) by
// though grouped here with the other per-channel overrides. Scope-list // default. Fixed at 64 slots (not
// index per channel (see ScopeList) -- 0 ("*"/unscoped) is the correct
// zero-init default, matching today's unconfigured behaviour exactly, so
// no migration is needed for this field itself. Fixed at 64 slots (not
// MAX_GROUP_CHANNELS, which varies by board/variant and would make // MAX_GROUP_CHANNELS, which varies by board/variant and would make
// sizeof(NodePrefs) variant-dependent) -- same implicit channel-count cap // sizeof(NodePrefs) variant-dependent) -- same implicit channel-count cap
// every ch_notif_*/ch_fav_bitmask uint64_t bitmask above already has. // every ch_notif_*/ch_fav_bitmask uint64_t bitmask above already has.
@@ -397,17 +361,13 @@ struct NodePrefs { // persisted to file
// Per-target Commands toggle for channel/room, splitting what used to be // Per-target Commands toggle for channel/room, splitting what used to be
// one bot_commands_enabled shared across all three (see that field's doc // one bot_commands_enabled shared across all three (see that field's doc
// comment) — e.g. answer !ping in DM but stay quiet on a public channel. // comment) — e.g. answer !ping in DM but stay quiet on a public channel.
// Upgraders: DataStore seeds both from the old shared bot_commands_enabled
// on first load past the schema bump, so existing behaviour is preserved
// until the user deliberately splits them apart.
uint8_t bot_commands_room; uint8_t bot_commands_room;
// Per-target toggle for bot *action* commands (!buzz/!gps/!advert) -- // Per-target toggle for bot *action* commands (!buzz/!gps/!advert) --
// separate from bot_commands_ch/bot_commands_room above, which only gate // separate from bot_commands_ch/bot_commands_room above, which only gate
// the read-only query commands (!ping/!batt/...). Nested under that // the read-only query commands (!ping/!batt/...). Nested under that
// Commands toggle (Commands=off means neither queries nor actions run for // Commands toggle (Commands=off means neither queries nor actions run for
// that target); Actions=on additionally lets the state-changing commands // that target); Actions=on additionally lets the state-changing commands
// through. Default 0 (off) -- these change device behaviour remotely, so // through. Default 0 (off) -- these change device behaviour remotely.
// upgraders don't get them silently enabled.
uint8_t bot_actions_room; uint8_t bot_actions_room;
uint8_t bot_quiet_start; // quiet-hours start hour, local 0-23 (start==end → disabled) uint8_t bot_quiet_start; // quiet-hours start hour, local 0-23 (start==end → disabled)
uint8_t bot_quiet_end; // quiet-hours end hour, local 0-23 uint8_t bot_quiet_end; // quiet-hours end hour, local 0-23
@@ -420,9 +380,7 @@ struct NodePrefs { // persisted to file
uint8_t units_imperial; uint8_t units_imperial;
// GPS trail cadence. Logging on/off is a runtime state (Tools › Trail), // GPS trail cadence. Logging on/off is a runtime state (Tools › Trail),
// not a persisted preference. // not a persisted preference.
uint8_t trail_interval_idx; // reserved — sampling cadence is now fixed at TrailStore::SAMPLING_SECS
uint8_t trail_min_delta_idx; // min-distance gate level (0=finest..3); metres or feet per units_imperial uint8_t trail_min_delta_idx; // min-distance gate level (0=finest..3); metres or feet per units_imperial
uint8_t trail_units_idx; // legacy: old combined speed/pace+unit index (km/h, mph, min/km, min/mi)
// Trail Summary readout: 0=speed (km/h or mph), 1=pace (min/km or min/mi). // Trail Summary readout: 0=speed (km/h or mph), 1=pace (min/km or min/mi).
// The km-vs-mi choice now comes from units_imperial, so this is just the mode. // The km-vs-mi choice now comes from units_imperial, so this is just the mode.
uint8_t trail_show_pace; uint8_t trail_show_pace;
@@ -509,21 +467,15 @@ struct NodePrefs { // persisted to file
static const uint8_t FAV_KIND_MAX = 1; static const uint8_t FAV_KIND_MAX = 1;
uint8_t favourite_contacts[FAVOURITES_COUNT][FAVOURITE_PREFIX_LEN]; // [del→onContactRemoved/onChannelRemoved] uint8_t favourite_contacts[FAVOURITES_COUNT][FAVOURITE_PREFIX_LEN]; // [del→onContactRemoved/onChannelRemoved]
// What each favourite_contacts[] slot holds (see FAV_KIND_* above). // What each favourite_contacts[] slot holds (see FAV_KIND_* above).
// On-disk position is at the struct's append-only tail (see the // Zero-init = every slot is a contact.
// serialization tripwire below), even though declared here beside
// favourite_contacts for readability. Zero-init = every slot is a contact,
// which is what pre-0x28 saves are.
uint8_t favourite_kinds[FAVOURITES_COUNT]; // [del→onContactRemoved/onChannelRemoved] -- clearFavouriteSlot() clears both fields together, called from either handler depending on the slot's kind uint8_t favourite_kinds[FAVOURITES_COUNT]; // [del→onContactRemoved/onChannelRemoved] -- clearFavouriteSlot() clears both fields together, called from either handler depending on the slot's kind
// Settings > Contacts > "Favs top". Stored inverted so that both a fresh // Settings > Contacts > "Favs top".
// memset and an older prefs file (no bytes here at all) mean "on", which is uint8_t fav_sort; // 1 = favourites first in every list (default), 0 = natural order
// the default -- a positive flag would read back as off for every upgrader.
uint8_t fav_sort_off; // 0 = favourites first in every list (default), 1 = natural order
// Settings > Contacts > "Expire" + "Prune now". Index into // Settings > Contacts > "Expire" + "Prune now". Index into
// contactExpiryDays()/contactExpiryLabel() below (0=Off/never, 1=7d, 2=30d, // contactExpiryDays()/contactExpiryLabel() below (0=Off/never, 1=7d, 2=30d,
// 3=90d) -- a contact whose ContactInfo::lastmod is older than this is // 3=90d) -- a contact whose ContactInfo::lastmod is older than this is
// eligible for the manual Prune-now sweep. Favourites are always exempt // eligible for the manual Prune-now sweep. Favourites are always exempt
// regardless of age. On-disk position is the struct's append-only tail (see // regardless of age.
// the serialization tripwire below), same as fav_sort_off above.
uint8_t contact_expiry_idx; // 0 = off (default) uint8_t contact_expiry_idx; // 0 = off (default)
// ── Advert ───────────────────────────────────────────────────────────── // ── Advert ─────────────────────────────────────────────────────────────
@@ -539,7 +491,7 @@ struct NodePrefs { // persisted to file
// 0=Off 1=Input 2=Output(low) 3=Output(high) 4=Analog. Mode 4 is only // 0=Off 1=Input 2=Output(low) 3=Output(high) 4=Analog. Mode 4 is only
// meaningful for gpio1/gpio2 (the nRF52840's AIN0/AIN5 -- gpio3/gpio4 have // meaningful for gpio1/gpio2 (the nRF52840's AIN0/AIN5 -- gpio3/gpio4 have
// no ADC channel), so those two fields are clamped to 0-3 on load, not 0-4 // no ADC channel), so those two fields are clamped to 0-3 on load, not 0-4
// (see DataStore::loadPrefsInt). Default 0 (off/disconnected) on upgrade. // (see DataStore's sanitize). Default 0 (off/disconnected).
uint8_t gpio1_mode; uint8_t gpio1_mode;
uint8_t gpio2_mode; uint8_t gpio2_mode;
uint8_t gpio3_mode; uint8_t gpio3_mode;
@@ -548,7 +500,7 @@ struct NodePrefs { // persisted to file
// ── Custom messages ──────────────────────────────────────────────────── // ── Custom messages ────────────────────────────────────────────────────
char custom_msgs[10][140]; // user-defined quick messages (supports {loc}, {time}) char custom_msgs[10][140]; // user-defined quick messages (supports {loc}, {time})
// ── Screen lock PIN (0xC0DE0031) ─────────────────────────────────────── // ── Screen lock PIN ────────────────────────────────────────────────────
// SHA-256 of salt + PIN (see ScreenLock.h); all zeros = no PIN. It locks the // SHA-256 of salt + PIN (see ScreenLock.h); all zeros = no PIN. It locks the
// screen only: the radio and the app link keep working. // screen only: the radio and the app link keep working.
static const uint8_t LOCK_HASH_LEN = 32; static const uint8_t LOCK_HASH_LEN = 32;
@@ -556,14 +508,14 @@ struct NodePrefs { // persisted to file
uint8_t lock_screen_password[LOCK_HASH_LEN]; uint8_t lock_screen_password[LOCK_HASH_LEN];
uint8_t lock_screen_password_salt[LOCK_SALT_LEN]; uint8_t lock_screen_password_salt[LOCK_SALT_LEN];
// ── Battery curve (0xC0DE0032) ───────────────────────────────────────── // ── Battery curve ──────────────────────────────────────────────────────
// The cell voltage (mV) at 0, 10, ... 100 %, for the battery percentage // The cell voltage (mV) at 0, 10, ... 100 %, for the battery percentage
// (ui-core/Battery.h). All zeros = the built-in LiPo curve. Settings > // (ui-core/Battery.h). All zeros = the built-in LiPo curve. Settings >
// Battery curve edits it; anything not rising point to point reads as unset. // Battery curve edits it; anything not rising point to point reads as unset.
static const uint8_t BATT_CURVE_PTS = 11; static const uint8_t BATT_CURVE_PTS = 11;
uint16_t batt_curve_mv[BATT_CURVE_PTS]; uint16_t batt_curve_mv[BATT_CURVE_PTS];
// ── Lock screen look (0xC0DE0033) ────────────────────────────────────── // ── Lock screen look ───────────────────────────────────────────────────
// ui-new: 0 = big clock + date + unread pill, 1 = compact clock with the // ui-new: 0 = big clock + date + unread pill, 1 = compact clock with the
// clock fields as rows below. The L2 has its own lock screen. // clock fields as rows below. The L2 has its own lock screen.
uint8_t lock_compact; uint8_t lock_compact;
@@ -646,16 +598,9 @@ struct NodePrefs { // persisted to file
return L[idx < CONTACT_EXPIRY_COUNT ? idx : 0]; return L[idx < CONTACT_EXPIRY_COUNT ? idx : 0];
} }
// Tail sentinel written at the end of /new_prefs. Bump the low byte when // /prefs' layout (see the comment at the top). A file of another version
// adding/removing/reordering fields in DataStore::savePrefs/loadPrefsInt so // isn't read: the defaults stand.
// older saves are detected on load and skipped (zero-init defaults kept). static const uint16_t PREFS_VERSION = 1;
// High 24 bits identify the file format; low byte is the schema revision.
// 0xC0DE0026 is BURNED — it briefly named a layout that put repeat_scope_only
// + repeat_extra_scopes in the middle of the stream (next to the other
// repeat_* fields) instead of at the tail, which shifted every field after
// them by 25 bytes when loading an older file. Never released, but a dev
// build wrote it, so the number must not be reused for anything else.
static const uint32_t SCHEMA_SENTINEL = 0xC0DE0033;
// Bit-index for each home page. Used by page_order (entries store bit+1) and // Bit-index for each home page. Used by page_order (entries store bit+1) and
// by home_pages_mask. Single source of truth — both HomeScreen::pageBit/bitToPage // by home_pages_mask. Single source of truth — both HomeScreen::pageBit/bitToPage
@@ -681,11 +626,6 @@ struct NodePrefs { // persisted to file
// every page can be reordered. Any page still missing from a saved order is // every page can be reordered. Any page still missing from a saved order is
// appended at navigation time via buildVisibleOrder's fallback. // appended at navigation time via buildVisibleOrder's fallback.
static const uint8_t PAGE_ORDER_LEN = 13; static const uint8_t PAGE_ORDER_LEN = 13;
// Bytes of page_order persisted at the original file offset. Slots beyond this
// (PAGE_ORDER_LEN - PAGE_ORDER_LEN_V1) are stored at the file tail, so a
// pre-0x0019 save — whose order was exactly this long — loads without shifting
// every field written after page_order.
static const uint8_t PAGE_ORDER_LEN_V1 = 11;
// Bitmasks for home_pages_mask (bit=1 → page visible; 0 field = all visible). // Bitmasks for home_pages_mask (bit=1 → page visible; 0 field = all visible).
// SETTINGS and QUICK_MSG have no mask bit — they're always visible. // SETTINGS and QUICK_MSG have no mask bit — they're always visible.
@@ -758,96 +698,6 @@ static inline bool inQuietHours(const NodePrefs& p, uint32_t utc) {
return p.quiet_hours && localHour(utc, p.tz_offset_hours, h) && hourInWindow(h, p.quiet_from, p.quiet_to); return p.quiet_hours && localHour(utc, p.tz_offset_hours, h) && hourInWindow(h, p.quiet_from, p.quiet_to);
} }
// ── Serialization tripwire ───────────────────────────────────────────────────
// NodePrefs is written/read field-by-field, in order, by DataStore::savePrefs()
// and loadPrefsInt(); the on-disk format IS the struct's field layout. There is
// no automatic check that those two hand-written sequences match the struct, so
// a forgotten read/write silently misaligns EVERY field after it.
//
// This assert is the manual checkpoint. Changing a data member changes sizeof
// and trips it. When it trips, do ALL of the following, then update the number:
// 0. put the new field at the TAIL of the struct, even when it logically
// belongs beside older siblings. loadPrefsInt()'s rd() is a plain
// sequential reader gated only on file.available() — there is no per-field
// versioning — so a field inserted mid-stream is read out of an older
// file's bytes and shifts EVERY field after it (repeat_scope_only +
// repeat_extra_scopes did exactly that in the burned 0xC0DE0026 layout).
// "In struct order" below therefore means "appended in both places".
// 1. add the field's rd(...) in DataStore::loadPrefsInt(), in struct order
// 2. add the field's write(...) in DataStore::savePrefs(), in struct order
// 3. clamp it on load (an upgrader's file lacks it → stray bytes; the first
// few are that file's own 4-byte sentinel tail, so a plausible-looking
// value like 0x23 shows up rather than 0)
// 4. bump SCHEMA_SENTINEL's low byte
// (Padding can also shift sizeof; a "false" trip just means re-check + rebump.)
// keyboard_cardkb_compact (0xC0DE0023) also landed in existing tail padding --
// confirmed via a real build -- leaving sizeof unchanged at 2720.
// keyboard_main_alphabet (added in an earlier bump) landed in existing tail
// padding -- confirmed via a real build's sizeof() -- so that bump left the
// size unchanged. bot_actions_dm/ch/room and gpio1..4_mode (the last two
// bumps, 7 more uint8_t total) added 8 bytes, not 7 -- one byte of tail
// padding got consumed along the way. 2720 confirmed via a real
// WioTrackerL1Eink_companion_solo_dual build.
// interference_threshold + cad_enabled (0xC0DE0024) are the new struct tail --
// added 8 bytes, not 2 -- the 2 real bytes rounded the struct up to its next
// alignment boundary. Confirmed via a real Heltec_v3_companion_radio_ble build.
// repeat_scope_only (0xC0DE0025) landed in the padding left over from the
// 0xC0DE0024 bump -- confirmed via a real build, sizeof unchanged at 2728.
// repeat_extra_scopes[24] (0xC0DE0026) added exactly 24 bytes, no leftover
// padding this time -- confirmed via a real build, sizeof now 2752.
// 0xC0DE0027 moved those same two fields out of the repeat_* group and down to
// the struct tail (the 0xC0DE0026 mid-stream layout was unreadable for older
// files, see the sentinel comment) -- padding worked out identically either
// way, so sizeof stays 2752. Confirmed via a real Heltec_v3 build.
// favourite_kinds[6] (0xC0DE0028) added 8 bytes, not 6 -- the struct had no
// spare tail padding left after 0xC0DE0026, so the 6 real bytes rounded up to
// the next alignment boundary. Confirmed via a real Heltec_v3 build.
// fav_sort_off (0xC0DE0029) landed in the 2 bytes of padding the 0xC0DE0028
// bump left over -- confirmed via a real Heltec_v3 build, sizeof unchanged.
//
// 2026-09: fields reordered into thematic groups for source readability (see
// the comment at the top of the struct) -- NOT a schema change. No field was
// added, removed, resized, or re-sequenced in DataStore's rd()/wr() calls, so
// the on-disk format and SCHEMA_SENTINEL are untouched. The reorder happened
// to let the compiler pack same-size fields together with less padding,
// dropping sizeof from 2760 to 2752 -- confirmed via a real
// Heltec_v3_companion_radio_ble build. Purely a compile-time in-memory
// layout change; this static_assert exists precisely to catch that kind of
// accidental drift, so the trip was expected here and this comment is that
// re-check. dashboard_fields[3] was then moved from the favourites group to
// Display (a better thematic fit), which shifted padding again and put
// sizeof back at 2760 -- also confirmed via a real
// Heltec_v3_companion_radio_ble build. Still no schema change.
// msg_wake_screen_off (0xC0DE002A) landed in the 1 byte of padding the
// 0xC0DE0029 bump left over -- confirmed via a real sim_companion_radio
// (native) build, sizeof unchanged at 2760.
// repeat_extra_scope_mask + ch_scope_idx[64] (0xC0DE002B) added 64 bytes,
// not 66 -- the struct had 2 bytes of spare tail padding left over from an
// earlier bump -- confirmed via a real sim_companion_radio (native) build
// and a real WioTrackerL1_companion_solo_dual (nRF52/ARM) build, sizeof
// 2824 on both.
// contact_expiry_idx (0xC0DE002C) landed in existing padding elsewhere in
// the struct -- confirmed via a real sim_companion_radio (native) build and a
// real WioTrackerL1_companion_solo_dual (nRF52/ARM) build, sizeof unchanged
// at 2824 on both.
// loc_share_scope (0xC0DE002D) landed in existing padding next to the other
// loc_share_* bytes -- confirmed via real sim_companion_radio (native),
// WioTrackerL1_companion_solo_dual (nRF52/ARM) and Heltec_v3_companion_radio_ble
// (ESP32) builds, sizeof unchanged at 2824. loc_share_duration_idx (0xC0DE002E)
// likewise (sim build; see the check below). display_brightness_pct (0xC0DE002F)
// likewise (L2 ESP32 + L1 nRF52 builds).
// quiet_hours / quiet_from / quiet_to (0xC0DE0030) added 8 bytes, not 3 (next
// to buzzer_auto, rounded up to the alignment) -- sizeof 2832, confirmed via real
// WioTrackerL1_companion_solo_dual (nRF52/ARM) and L2 (ESP32) builds.
// lock_screen_password / _salt (0xC0DE0031) added 48 bytes -- sizeof 2880.
// batt_curve_mv[11] (0xC0DE0032) added 16 bytes, not 22 (6 went into tail
// padding) -- sizeof 2896, confirmed via real L1 (nRF52) and L2 (ESP32) builds.
// lock_compact (0xC0DE0033) added 8 bytes, not 1 (the struct rounds up to its
// alignment) -- sizeof 2904, confirmed via real L1 (nRF52) and L2 (ESP32) builds.
static_assert(sizeof(NodePrefs) == 2904,
"NodePrefs layout changed — sync DataStore save/load + clamp, bump "
"SCHEMA_SENTINEL, then update this size (see steps above).");
// Bounds for a usable repeater radio profile. The frequency range is passed in // Bounds for a usable repeater radio profile. The frequency range is passed in
// by the caller from RadioLibWrapper::getFreqBounds() — the radio chip's own // by the caller from RadioLibWrapper::getFreqBounds() — the radio chip's own
// validated range — so a profile that passes here is guaranteed to actually take // validated range — so a profile that passes here is guaranteed to actually take
+4 -5
View File
@@ -512,9 +512,8 @@ extern "C" EMSCRIPTEN_KEEPALIVE void sim_radio_get_params(float* out_freq, float
// A brand-new device's home_pages_mask defaults to 0 = all pages visible // A brand-new device's home_pages_mask defaults to 0 = all pages visible
// (see NodePrefs.h and MyMesh.cpp) -- this is now a no-op on a freshly // (see NodePrefs.h and MyMesh.cpp) -- this is now a no-op on a freshly
// booted sim instance, but is kept for a saved-prefs instance whose mask // booted sim instance, but is kept for a saved-prefs instance whose mask
// was narrowed by an actual Settings > Home Pages visit (an upgrader whose // was narrowed by an actual Settings > Home Pages visit (a visitor who
// IDBFS identity predates this default, or a visitor who toggled some // toggled some pages off before this hook runs on a later boot).
// pages off before this hook runs on a later boot).
extern "C" EMSCRIPTEN_KEEPALIVE int sim_test_show_all_home_pages() { extern "C" EMSCRIPTEN_KEEPALIVE int sim_test_show_all_home_pages() {
if (!g_sim_ready) return 0; if (!g_sim_ready) return 0;
NodePrefs* prefs = the_mesh.getNodePrefs(); NodePrefs* prefs = the_mesh.getNodePrefs();
@@ -581,7 +580,7 @@ extern "C" EMSCRIPTEN_KEEPALIVE int sim_test_disable_screen_timeout() {
return 1; return 1;
} }
// Sets NodePrefs::msg_wake_screen_off directly (the same field Settings > // Sets NodePrefs::msg_wake directly (the same field Settings >
// Display > "Msg wake" toggles -- SettingsScreen.h's MSG_WAKE item), so a test // Display > "Msg wake" toggles -- SettingsScreen.h's MSG_WAKE item), so a test
// harness can verify UITask::newMsg()'s wake-gating without scripting the // harness can verify UITask::newMsg()'s wake-gating without scripting the
// on-device Settings accordion navigation key-by-key. // on-device Settings accordion navigation key-by-key.
@@ -589,7 +588,7 @@ extern "C" EMSCRIPTEN_KEEPALIVE int sim_test_set_msg_wake_disabled(int disabled)
if (!g_sim_ready) return 0; if (!g_sim_ready) return 0;
NodePrefs* prefs = the_mesh.getNodePrefs(); NodePrefs* prefs = the_mesh.getNodePrefs();
if (!prefs) return 0; if (!prefs) return 0;
prefs->msg_wake_screen_off = disabled ? 1 : 0; prefs->msg_wake = disabled ? 0 : 1;
return 1; return 1;
} }
@@ -290,7 +290,7 @@ protected:
void sortStored() { void sortStored() {
uint32_t now_ts = rtc_clock.getCurrentTime(); uint32_t now_ts = rtc_clock.getCurrentTime();
const bool fav_first = !(_prefs && _prefs->fav_sort_off); const bool fav_first = (!_prefs || _prefs->fav_sort);
for (int i = 0; i < _count - 1; i++) { for (int i = 0; i < _count - 1; i++) {
int best = i; int best = i;
for (int j = i + 1; j < _count; j++) { for (int j = i + 1; j < _count; j++) {
@@ -195,8 +195,7 @@ static const Setting ALL[] = {
IDX("Brightness", nullptr, SEC_DISPLAY, display_brightness, 5, optBrightness, applyDisplay), IDX("Brightness", nullptr, SEC_DISPLAY, display_brightness, 5, optBrightness, applyDisplay),
MAP("Screen off after", "Without a touch", SEC_DISPLAY, auto_off_secs, AUTO_OFF, optAutoOff, nullptr), MAP("Screen off after", "Without a touch", SEC_DISPLAY, auto_off_secs, AUTO_OFF, optAutoOff, nullptr),
MAP("Wake on message", "Screen on for new messages", SEC_DISPLAY, msg_wake_screen_off, INVERTED, SW("Wake on message", "Screen on for new messages", SEC_DISPLAY, msg_wake, nullptr),
nullptr, nullptr),
SW("Lock screen", "Slide to unlock after sleep", SEC_DISPLAY, auto_lock, nullptr), SW("Lock screen", "Slide to unlock after sleep", SEC_DISPLAY, auto_lock, nullptr),
IDX("Lock clock", "Big, or compact with the clock fields", SEC_DISPLAY, lock_compact, 2, optLockLook, nullptr), IDX("Lock clock", "Big, or compact with the clock fields", SEC_DISPLAY, lock_compact, 2, optLockLook, nullptr),
IDX("Battery display", "In the status bar", SEC_DISPLAY, batt_display_mode, 3, optBattDisplay, nullptr), IDX("Battery display", "In the status bar", SEC_DISPLAY, batt_display_mode, 3, optBattDisplay, nullptr),
@@ -215,7 +214,7 @@ static const Setting ALL[] = {
IDX("Contact expiry", "Prune after inactivity", SEC_CONTACTS, contact_expiry_idx, IDX("Contact expiry", "Prune after inactivity", SEC_CONTACTS, contact_expiry_idx,
NodePrefs::CONTACT_EXPIRY_COUNT, optExpiry, nullptr), NodePrefs::CONTACT_EXPIRY_COUNT, optExpiry, nullptr),
MAP("Favourites first", "In contact and node lists", SEC_CONTACTS, fav_sort_off, INVERTED, nullptr, nullptr), SW("Favourites first", "In contact and node lists", SEC_CONTACTS, fav_sort, nullptr),
// On / Off / Auto spans two fields (soundctl::setMode): the frontend's own row. // On / Off / Auto spans two fields (soundctl::setMode): the frontend's own row.
IDX("Volume", nullptr, SEC_SOUND, buzzer_volume, 5, optVolume, applySound), IDX("Volume", nullptr, SEC_SOUND, buzzer_volume, 5, optVolume, applySound),
@@ -234,12 +233,12 @@ static const int COUNT = (int)(sizeof(ALL) / sizeof(ALL[0]));
// eight-character value); a setting not listed uses its own. // eight-character value); a setting not listed uses its own.
static const struct { uint16_t offset; const char* text; } SHORT_LABELS[] = { static const struct { uint16_t offset; const char* text; } SHORT_LABELS[] = {
{ NP_OFF(display_brightness), "Bright" }, { NP_OFF(auto_off_secs), "Auto off" }, { NP_OFF(display_brightness), "Bright" }, { NP_OFF(auto_off_secs), "Auto off" },
{ NP_OFF(msg_wake_screen_off), "Msg wake" }, { NP_OFF(auto_lock), "Auto lock" }, { NP_OFF(msg_wake), "Msg wake" }, { NP_OFF(auto_lock), "Auto lock" },
{ NP_OFF(batt_display_mode), "Batt disp" }, { NP_OFF(low_batt_mv), "Low batt" }, { NP_OFF(batt_display_mode), "Batt disp" }, { NP_OFF(low_batt_mv), "Low batt" },
{ NP_OFF(gps_interval), "GPS pwr" }, { NP_OFF(clock_12h), "12h clock" }, { NP_OFF(gps_interval), "GPS pwr" }, { NP_OFF(clock_12h), "12h clock" },
{ NP_OFF(clock_hide_seconds), "Seconds" }, { NP_OFF(units_imperial), "Imperial" }, { NP_OFF(clock_hide_seconds), "Seconds" }, { NP_OFF(units_imperial), "Imperial" },
{ NP_OFF(dm_resend_count), "Resend" }, { NP_OFF(contact_expiry_idx), "Expire" }, { NP_OFF(dm_resend_count), "Resend" }, { NP_OFF(contact_expiry_idx), "Expire" },
{ NP_OFF(fav_sort_off), "Favs top" }, { NP_OFF(buzzer_volume), "Buzzer vol" }, { NP_OFF(fav_sort), "Favs top" }, { NP_OFF(buzzer_volume), "Buzzer vol" },
{ NP_OFF(quiet_hours), "Quiet hrs" }, { NP_OFF(quiet_from), " from" }, { NP_OFF(quiet_hours), "Quiet hrs" }, { NP_OFF(quiet_from), " from" },
{ NP_OFF(quiet_to), " until" }, { NP_OFF(notif_melody_dm), "DM sound" }, { NP_OFF(quiet_to), " until" }, { NP_OFF(notif_melody_dm), "DM sound" },
{ NP_OFF(notif_melody_ch), "Ch sound" }, { NP_OFF(notif_melody_ad), "AD sound" }, { NP_OFF(notif_melody_ch), "Ch sound" }, { NP_OFF(notif_melody_ad), "AD sound" },
+3 -3
View File
@@ -2248,7 +2248,7 @@ void UITask::onMessageArrived(const UiEvent& ev) {
} }
// Wake for the message unless an app is already showing it, or the user // Wake for the message unless an app is already showing it, or the user
// turned message-wake off. // turned message-wake off.
bool wake_disabled = _prefs && (_prefs->msg_wake_screen_off || inQuietHours(*_prefs, rtc_clock.getCurrentTime())); // quiet hours: dark too bool wake_disabled = _prefs && (!_prefs->msg_wake || inQuietHours(*_prefs, rtc_clock.getCurrentTime())); // quiet hours: dark too
if (_asleep && !wake_disabled && !isClientConnected()) wake(); if (_asleep && !wake_disabled && !isClientConnected()) wake();
else if (!_asleep) lv_display_trigger_activity(NULL); else if (!_asleep) lv_display_trigger_activity(NULL);
bool open_here = _screen == SCR_THREAD && bool open_here = _screen == SCR_THREAD &&
@@ -3226,7 +3226,7 @@ void UITask::fillChats() {
bool ch_fav_only = _prefs && _prefs->ch_fav_only; bool ch_fav_only = _prefs && _prefs->ch_fav_only;
chatSection(body, "CHANNELS", FOLD_CHANNELS, _core->history.getTotalChannelUnread(), CF_CHANNELS, ch_fav_only); chatSection(body, "CHANNELS", FOLD_CHANNELS, _core->history.getTotalChannelUnread(), CF_CHANNELS, ch_fav_only);
own(); own();
bool fav_first = !(_prefs && _prefs->fav_sort_off); bool fav_first = !_prefs || _prefs->fav_sort;
int ch_rows = 0; int ch_rows = 0;
for (int pass = fav_first ? 0 : 1; pass < 2 && !chatFolded(FOLD_CHANNELS); pass++) { for (int pass = fav_first ? 0 : 1; pass < 2 && !chatFolded(FOLD_CHANNELS); pass++) {
for (int i = 0; i < MAX_GROUP_CHANNELS; i++) { for (int i = 0; i < MAX_GROUP_CHANNELS; i++) {
@@ -4636,7 +4636,7 @@ void UITask::buildSchemaSettings() {
lv_obj_t* g = group(body, "SCREEN"); lv_obj_t* g = group(body, "SCREEN");
schemaRow(g, SETTING(display_brightness)); schemaRow(g, SETTING(display_brightness));
schemaRow(g, SETTING(auto_off_secs)); schemaRow(g, SETTING(auto_off_secs));
schemaRow(g, SETTING(msg_wake_screen_off)); schemaRow(g, SETTING(msg_wake));
lv_obj_t* tw = switchRow(g, "Tap to wake", "Off: only the top button wakes it", nullptr); // in NVS lv_obj_t* tw = switchRow(g, "Tap to wake", "Off: only the top button wakes it", nullptr); // in NVS
if (_tap_wake) lv_obj_add_state(tw, LV_STATE_CHECKED); if (_tap_wake) lv_obj_add_state(tw, LV_STATE_CHECKED);
lv_obj_add_event_cb(tw, onTapWake, LV_EVENT_VALUE_CHANGED, NULL); lv_obj_add_event_cb(tw, onTapWake, LV_EVENT_VALUE_CHANGED, NULL);
@@ -741,7 +741,7 @@ class MessagesScreen : public UIScreen {
if (fav_only && !fav) continue; if (fav_only && !fav) continue;
uint8_t k = rooms ? 0 : _history.dmHistCountForContact(c.id.pub_key); uint8_t k = rooms ? 0 : _history.dmHistCountForContact(c.id.pub_key);
if (k > 127) k = 127; if (k > 127) k = 127;
if (fav && !(p && p->fav_sort_off)) k += 128; if (fav && (!p || p->fav_sort)) k += 128;
keys[_num_contacts] = k; keys[_num_contacts] = k;
// Store the RAW table index (not the bare loop counter) -- every other // Store the RAW table index (not the bare loop counter) -- every other
// call site in this file (render()'s mesh_idx, toggleFav(), the // call site in this file (render()'s mesh_idx, toggleFav(), the
@@ -772,7 +772,7 @@ class MessagesScreen : public UIScreen {
_channel_indices[_num_channels++] = (uint8_t)i; _channel_indices[_num_channels++] = (uint8_t)i;
} }
// Favourites to the front, everything else keeping channel-slot order. // Favourites to the front, everything else keeping channel-slot order.
if (!p || p->fav_sort_off) return; if (!p || !p->fav_sort) return;
int front = 0; int front = 0;
for (int i = 0; i < _num_channels; i++) { for (int i = 0; i < _num_channels; i++) {
if (!chIsFav(_channel_indices[i])) continue; if (!chIsFav(_channel_indices[i])) continue;
@@ -325,46 +325,23 @@ class SettingsScreen : public UIScreen {
} }
// Initialises page_order to the default display sequence if not already set. // Initialises page_order to the default display sequence if not already set.
// Also repairs a partially-initialised order where CLOCK is absent; migrates // Also repairs a partially-initialised order where CLOCK is absent, and
// older orders by inserting FAVOURITES after CLOCK; and appends any pages that // appends any pages absent from a saved order (pages a later firmware added).
// are absent from a stale saved order (e.g. TOOLS/MESSAGES added by later firmware).
void ensurePageOrderInit(NodePrefs* p) const { void ensurePageOrderInit(NodePrefs* p) const {
if (!p) return; if (!p) return;
if (p->page_order_set == NodePrefs::PAGE_ORDER_MAGIC) { if (p->page_order_set == NodePrefs::PAGE_ORDER_MAGIC) {
bool has_clock = false; bool has_clock = false;
bool has_fav = false;
int len = 0;
int clock_at = -1;
for (int i = 0; i < NodePrefs::PAGE_ORDER_LEN; i++) { for (int i = 0; i < NodePrefs::PAGE_ORDER_LEN; i++) {
uint8_t v = p->page_order[i]; uint8_t v = p->page_order[i];
if (v < 1 || v > NodePrefs::HPB_COUNT) break; if (v < 1 || v > NodePrefs::HPB_COUNT) break;
if ((int)(v - 1) == NodePrefs::HPB_CLOCK) { has_clock = true; clock_at = i; } if ((int)(v - 1) == NodePrefs::HPB_CLOCK) has_clock = true;
if ((int)(v - 1) == NodePrefs::HPB_FAVOURITES) { has_fav = true; }
len = i + 1;
} }
if (!has_clock) { if (!has_clock) {
// Corrupted/partial — full re-init below. // Corrupted/partial — full re-init below.
memset(p->page_order, 0, sizeof(p->page_order)); memset(p->page_order, 0, sizeof(p->page_order));
} else { } else {
if (!has_fav) {
// Insert FAVOURITES right after CLOCK. Real orders are shorter than
// PAGE_ORDER_LEN so there's room; only a pathologically full order would
// drop its last entry, which buildVisibleOrder's fallback re-appends.
int insert_at = clock_at + 1;
// Guard against a saved order with all PAGE_ORDER_LEN slots already
// valid and CLOCK in the last one: insert_at would be PAGE_ORDER_LEN,
// one past the array, and the shift loop below wouldn't run (tail is
// clamped to the last index) to catch it -- the write would land one
// byte past page_order, into whatever NodePrefs field follows.
if (insert_at < NodePrefs::PAGE_ORDER_LEN) {
int tail = (len < NodePrefs::PAGE_ORDER_LEN) ? len : NodePrefs::PAGE_ORDER_LEN - 1;
for (int i = tail; i > insert_at; i--) p->page_order[i] = p->page_order[i - 1];
p->page_order[insert_at] = NodePrefs::HPB_FAVOURITES + 1;
}
}
// Append any pages that are absent from the saved order (e.g. added by a // Append any pages that are absent from the saved order (e.g. added by a
// later firmware version). Recount first since the block above may have // later firmware version).
// just inserted FAVOURITES.
{ {
uint16_t present = 0; uint16_t present = 0;
int cur_len = 0; int cur_len = 0;
@@ -375,8 +352,8 @@ class SettingsScreen : public UIScreen {
cur_len++; cur_len++;
} }
// Every page has a slot now (PAGE_ORDER_LEN == HPB_COUNT), so all pages // Every page has a slot now (PAGE_ORDER_LEN == HPB_COUNT), so all pages
// are required — any missing from a stale saved order (SHUTDOWN and MAP // are required — any missing from a saved order is appended into the
// for pre-0x0019 upgraders) is appended into the free tail slots below. // free tail slots below.
static const uint8_t REQUIRED[] = { static const uint8_t REQUIRED[] = {
NodePrefs::HPB_CLOCK, NodePrefs::HPB_FAVOURITES, NodePrefs::HPB_CLOCK, NodePrefs::HPB_FAVOURITES,
NodePrefs::HPB_RECENT, NodePrefs::HPB_RADIO, NodePrefs::HPB_RECENT, NodePrefs::HPB_RADIO,
+1 -1
View File
@@ -2202,7 +2202,7 @@ void UITask::onMessageArrived(const UiEvent& ev) {
} }
if (_display != NULL && !_locked) { if (_display != NULL && !_locked) {
bool wake_disabled = _node_prefs && (_node_prefs->msg_wake_screen_off || inQuietHours(*_node_prefs, rtc_clock.getCurrentTime())); bool wake_disabled = _node_prefs && (!_node_prefs->msg_wake || inQuietHours(*_node_prefs, rtc_clock.getCurrentTime()));
if (!wake_disabled && !_display->isOn() && !isClientConnected()) { // wake for the msg unless an app (BLE/USB) is already showing it, or the user disabled msg-wake if (!wake_disabled && !_display->isOn() && !isClientConnected()) { // wake for the msg unless an app (BLE/USB) is already showing it, or the user disabled msg-wake
_display->turnOn(); _display->turnOn();
} }