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
`the_mesh.savePrefs()` and loaded by `DataStore.cpp`. Rules when adding a field:
- **Append only**, and bump `NodePrefs::SCHEMA_SENTINEL`. Serialization is
binary-positional, so order is the on-disk format; never insert in the middle.
- Add a matching `rd(...)` in `DataStore::loadPrefsInt()` and a `file.write(...)`
in `savePrefs()`, in the same position, and **clamp on load** (an upgrader's
file lacks the field and reads stray bytes — clamp to a sane default). Saves
are atomic (temp-file + rename), so a crash mid-save can't corrupt settings.
- `/prefs` holds the struct as it is in memory, behind a header with a format
version and a CRC. **Add a new field at the end of the struct**; nothing
else is needed: an older file is shorter, so the field keeps its default
(set in `MyMesh`'s constructor).
- Moving, resizing or removing a field shifts the ones after it: bump
`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`
live for instant feedback but only persists once, on exit, gated by a `_dirty`
+200 -655
View File
@@ -217,674 +217,219 @@ bool DataStore::saveMainIdentity(const mesh::LocalIdentity &identity) {
return identity_store.save("_main", identity);
}
void DataStore::loadPrefs(NodePrefs& prefs, double& node_lat, double& node_lon) {
if (_fs->exists("/new_prefs")) {
loadPrefsInt("/new_prefs", prefs, node_lat, node_lon); // new filename
} else if (_fs->exists("/node_prefs")) {
loadPrefsInt("/node_prefs", prefs, node_lat, node_lon);
savePrefs(prefs, node_lat, node_lon); // save to new filename
_fs->remove("/node_prefs"); // remove old
// ── Prefs ──
// /prefs: a header, the node's position, then NodePrefs as it is in memory
// (see the comment at the top of NodePrefs.h). Only the build that wrote it
// reads it back, so the struct's padding and alignment are no concern.
struct PrefsHeader {
uint32_t magic; // PREFS_MAGIC
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) {
// Set hardware defaults before reading — if the file is older and lacks these fields,
// the compile-time values apply rather than the zero from memset in MyMesh::begin().
#ifdef DISPLAY_ROTATION
_prefs.display_rotation = DISPLAY_ROTATION;
// Every value inside its range, whatever the file held: a damaged byte reads
// as the default rather than as a setting no screen can show.
static void sanitize(NodePrefs& p) {
#if !FEAT_JOYSTICK_ROTATION_SETTING
p.joystick_rotation = 0; // no setting for it on this build
#endif
// 0 is a valid SNR threshold, so the "off" state needs its own sentinel set
// 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.
{
{ // only the duty-cycle presets Settings offers
static const uint32_t GPS_DUTY_PRESETS[] = { 0, 60, 300, 900, 1800, 3600 };
bool known = false;
for (uint32_t v : GPS_DUTY_PRESETS) { if (_prefs.gps_interval == v) { known = true; break; } }
if (!known) _prefs.gps_interval = 0;
for (uint32_t v : GPS_DUTY_PRESETS) if (p.gps_interval == v) { known = true; break; }
if (!known) p.gps_interval = 0;
}
file.read((uint8_t *)&_prefs.autoadd_config, sizeof(_prefs.autoadd_config));
file.read((uint8_t *)&_prefs.autoadd_max_hops, sizeof(_prefs.autoadd_max_hops));
file.read((uint8_t *)&_prefs.rx_boosted_gain, sizeof(_prefs.rx_boosted_gain));
file.read((uint8_t *)_prefs.default_scope_name, sizeof(_prefs.default_scope_name));
file.read((uint8_t *)_prefs.default_scope_key, sizeof(_prefs.default_scope_key));
file.read((uint8_t *)&_prefs.display_brightness, sizeof(_prefs.display_brightness));
file.read((uint8_t *)&_prefs.auto_off_secs, sizeof(_prefs.auto_off_secs));
file.read((uint8_t *)&_prefs.tz_offset_hours, sizeof(_prefs.tz_offset_hours));
file.read((uint8_t *)&_prefs.low_batt_mv, sizeof(_prefs.low_batt_mv));
file.read((uint8_t *)&_prefs.batt_display_mode, sizeof(_prefs.batt_display_mode));
// Extension fields — append-only, newest at the bottom.
// Each read is gated on file.available(); fields absent in older files stay at their
// zero-initialised or hardware-default values set above.
auto rd = [&](void* p, size_t n) {
if (file.available() >= (int)n) file.read((uint8_t*)p, n);
};
rd(_prefs.custom_msgs, sizeof(_prefs.custom_msgs));
rd(&_prefs.ch_notif_override, sizeof(_prefs.ch_notif_override));
rd(&_prefs.ch_notif_muted, sizeof(_prefs.ch_notif_muted));
rd(&_prefs.dm_show_all, sizeof(_prefs.dm_show_all));
rd(&_prefs.room_fav_only, sizeof(_prefs.room_fav_only));
rd(&_prefs.buzzer_volume, sizeof(_prefs.buzzer_volume));
rd(&_prefs.ringtone_bpm_idx, sizeof(_prefs.ringtone_bpm_idx));
rd(&_prefs.ringtone_len, sizeof(_prefs.ringtone_len));
if (_prefs.ringtone_len > 32) _prefs.ringtone_len = 0;
rd(_prefs.ringtone_notes, sizeof(_prefs.ringtone_notes));
rd(&_prefs.home_pages_mask, sizeof(_prefs.home_pages_mask));
rd(&_prefs.bot_enabled, sizeof(_prefs.bot_enabled));
rd(&_prefs.bot_channel_enabled, sizeof(_prefs.bot_channel_enabled));
rd(&_prefs.bot_channel_idx, sizeof(_prefs.bot_channel_idx));
rd(_prefs.bot_trigger, sizeof(_prefs.bot_trigger));
rd(_prefs.bot_reply_dm, sizeof(_prefs.bot_reply_dm));
rd(_prefs.bot_reply_ch, sizeof(_prefs.bot_reply_ch));
rd(&_prefs.clock_hide_seconds, sizeof(_prefs.clock_hide_seconds));
rd(&_prefs.buzzer_auto, sizeof(_prefs.buzzer_auto));
rd(_prefs.dm_notif, sizeof(_prefs.dm_notif));
rd(_prefs.dashboard_fields, sizeof(_prefs.dashboard_fields));
rd(&_prefs.advert_auto_interval_sec, sizeof(_prefs.advert_auto_interval_sec));
rd(&_prefs.ringtone2_bpm_idx, sizeof(_prefs.ringtone2_bpm_idx));
rd(&_prefs.ringtone2_len, sizeof(_prefs.ringtone2_len));
if (_prefs.ringtone2_len > 32) _prefs.ringtone2_len = 0;
rd(_prefs.ringtone2_notes, sizeof(_prefs.ringtone2_notes));
rd(&_prefs.notif_melody_dm, sizeof(_prefs.notif_melody_dm));
if (_prefs.notif_melody_dm > 3) _prefs.notif_melody_dm = 0;
rd(&_prefs.notif_melody_ch, sizeof(_prefs.notif_melody_ch));
if (_prefs.notif_melody_ch > 3) _prefs.notif_melody_ch = 0;
rd(&_prefs.ch_notif_melody_set, sizeof(_prefs.ch_notif_melody_set));
rd(&_prefs.ch_notif_melody_2, sizeof(_prefs.ch_notif_melody_2));
rd(_prefs.dm_melody, sizeof(_prefs.dm_melody));
rd(&_prefs.auto_lock, sizeof(_prefs.auto_lock));
rd(&_prefs.clock_12h, sizeof(_prefs.clock_12h));
rd(&_prefs.use_lemon_font, sizeof(_prefs.use_lemon_font));
rd(&_prefs.display_rotation, sizeof(_prefs.display_rotation));
rd(_prefs.page_order, NodePrefs::PAGE_ORDER_LEN_V1); // tail slots read below (append-only)
rd(&_prefs.joystick_rotation, sizeof(_prefs.joystick_rotation));
#if !FEAT_JOYSTICK_ROTATION_SETTING
// No UI to change it on this build, so force the default — this also corrects
// a stale value migrated from another build (e.g. e-ink rotation=2). On builds
// that DO expose the setting the stored value is kept; the old code clobbered
// it unconditionally (FEAT_* was undefined here because Features.h wasn't
// included → `#if !FEAT_…` was always true), so the setting never persisted.
_prefs.joystick_rotation = 0;
#endif
rd(&_prefs.eink_full_refresh_every, sizeof(_prefs.eink_full_refresh_every));
rd(&_prefs.page_order_set, sizeof(_prefs.page_order_set));
// Migration: pre-magic firmware wrote page_order without a flag. If we see a plausible
// first entry from such a save, accept it once — savePrefs will then persist the magic.
if (_prefs.page_order_set != NodePrefs::PAGE_ORDER_MAGIC
&& _prefs.page_order[0] >= 1 && _prefs.page_order[0] <= NodePrefs::HPB_COUNT) {
_prefs.page_order_set = NodePrefs::PAGE_ORDER_MAGIC;
}
rd(_prefs.favourite_contacts, sizeof(_prefs.favourite_contacts));
rd(&_prefs.trail_interval_idx, sizeof(_prefs.trail_interval_idx));
rd(&_prefs.trail_min_delta_idx, sizeof(_prefs.trail_min_delta_idx));
rd(&_prefs.trail_units_idx, sizeof(_prefs.trail_units_idx));
rd(&_prefs.ch_fav_bitmask, sizeof(_prefs.ch_fav_bitmask));
rd(&_prefs.ch_fav_only, sizeof(_prefs.ch_fav_only));
rd(&_prefs.notif_melody_ad, sizeof(_prefs.notif_melody_ad));
rd(&_prefs.units_imperial, sizeof(_prefs.units_imperial));
rd(&_prefs.trail_show_pace, sizeof(_prefs.trail_show_pace));
rd(&_prefs.advert_sound_scope, sizeof(_prefs.advert_sound_scope));
rd(&_prefs.rx_powersave, sizeof(_prefs.rx_powersave));
rd(&_prefs.tx_apc, sizeof(_prefs.tx_apc));
rd(&_prefs.dm_resend_count, sizeof(_prefs.dm_resend_count));
rd(&_prefs.bot_commands_enabled, sizeof(_prefs.bot_commands_enabled));
rd(&_prefs.bot_quiet_start, sizeof(_prefs.bot_quiet_start));
rd(&_prefs.bot_quiet_end, sizeof(_prefs.bot_quiet_end));
rd(_prefs.bot_trigger_ch, sizeof(_prefs.bot_trigger_ch));
rd(_prefs.user_radio_presets, sizeof(_prefs.user_radio_presets));
// Repeater forwarding-filter knobs -- an older file's stray bytes here
// clamp back to their "off" defaults.
rd(&_prefs.repeat_skip_adverts, sizeof(_prefs.repeat_skip_adverts));
rd(&_prefs.repeat_max_hops, sizeof(_prefs.repeat_max_hops));
rd(&_prefs.repeat_delay_boost, sizeof(_prefs.repeat_delay_boost));
rd(&_prefs.repeat_min_snr, sizeof(_prefs.repeat_min_snr));
rd(&_prefs.repeat_suppress_dup, sizeof(_prefs.repeat_suppress_dup));
if (_prefs.repeat_skip_adverts > 1) _prefs.repeat_skip_adverts = 0;
if (_prefs.repeat_max_hops > 64) _prefs.repeat_max_hops = 0;
if (_prefs.repeat_delay_boost > 8) _prefs.repeat_delay_boost = 0;
if (_prefs.repeat_min_snr != NodePrefs::REPEAT_SNR_DISABLED &&
(_prefs.repeat_min_snr < -20 || _prefs.repeat_min_snr > 10))
_prefs.repeat_min_snr = NodePrefs::REPEAT_SNR_DISABLED; // match the UI's -20..10 range
if (_prefs.repeat_suppress_dup > 1) _prefs.repeat_suppress_dup = 0;
// NOTE: repeat_scope_only/repeat_extra_scopes are read at the TAIL, not here
// beside their siblings — see the append-only rule in NodePrefs.h.
rd(&_prefs.repeater_use_profile, sizeof(_prefs.repeater_use_profile));
rd(&_prefs.repeater_freq, sizeof(_prefs.repeater_freq));
rd(&_prefs.repeater_bw, sizeof(_prefs.repeater_bw));
rd(&_prefs.repeater_sf, sizeof(_prefs.repeater_sf));
rd(&_prefs.repeater_cr, sizeof(_prefs.repeater_cr));
// Stray byte from an older file clamps to "off".
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
// turns any stray value into 0 (off).
rd(&_prefs.trail_autosave_lowbatt, sizeof(_prefs.trail_autosave_lowbatt));
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();
auto flag = [](uint8_t& v, uint8_t def) { if (v > 1) v = def; };
if (p.ringtone_len > 32) p.ringtone_len = 0;
if (p.ringtone2_len > 32) p.ringtone2_len = 0;
if (p.notif_melody_dm > 3) p.notif_melody_dm = 0;
if (p.notif_melody_ch > 3) p.notif_melody_ch = 0;
if (p.notif_melody_ad > 3) p.notif_melody_ad = 0;
if (p.page_order_set != NodePrefs::PAGE_ORDER_MAGIC) p.page_order_set = 0;
for (uint8_t i = 0; i < NodePrefs::PAGE_ORDER_LEN; i++)
if (p.page_order[i] > NodePrefs::HPB_COUNT) p.page_order[i] = 0;
flag(p.repeat_skip_adverts, 0);
if (p.repeat_max_hops > 64) p.repeat_max_hops = 0;
if (p.repeat_delay_boost > 8) p.repeat_delay_boost = 0;
if (p.repeat_min_snr != NodePrefs::REPEAT_SNR_DISABLED && (p.repeat_min_snr < -20 || p.repeat_min_snr > 10))
p.repeat_min_snr = NodePrefs::REPEAT_SNR_DISABLED; // the UI's -20..10
flag(p.repeat_suppress_dup, 0);
flag(p.repeat_scope_only, 0);
flag(p.repeater_use_profile, 0);
float lo, hi;
radio_driver.getFreqBounds(lo, hi);
if (!isValidRepeaterProfile(p.repeater_freq, p.repeater_bw, p.repeater_sf, p.repeater_cr, lo, hi))
seedDefaultRepeaterProfile(p);
flag(p.track_shared_loc, 0);
flag(p.loc_share_enabled, 0);
flag(p.loc_share_target_type, 0);
if (p.loc_share_channel_idx >= MAX_GROUP_CHANNELS) p.loc_share_channel_idx = 0;
if (p.loc_share_move_idx >= NodePrefs::LOC_SHARE_MOVE_COUNT) p.loc_share_move_idx = 1;
if (p.loc_share_interval_idx >= NodePrefs::LOC_SHARE_INTERVAL_COUNT) p.loc_share_interval_idx = 1;
if (p.loc_share_heartbeat_idx >= NodePrefs::LOC_SHARE_HEARTBEAT_COUNT) p.loc_share_heartbeat_idx = 0;
if (p.loc_share_scope > ScopeList::MAX_SCOPE_ENTRIES + 1) p.loc_share_scope = 0;
if (p.loc_share_duration_idx >= NodePrefs::LOC_SHARE_DURATION_COUNT) p.loc_share_duration_idx = 0;
flag(p.locator_enabled, 0);
flag(p.locator_has_target, 0);
flag(p.locator_beeper, 0);
if (p.locator_radius_idx >= NodePrefs::LOCATOR_RADIUS_COUNT) p.locator_radius_idx = 1;
if (p.locator_mode >= NodePrefs::LOCATOR_MODE_COUNT) p.locator_mode = 0;
if (p.locator_target_kind > 2) p.locator_target_kind = 0;
p.locator_label[sizeof(p.locator_label) - 1] = '\0';
if (p.trail_autopause_idx >= NodePrefs::TRAIL_AUTOPAUSE_COUNT) p.trail_autopause_idx = 0;
flag(p.trail_autosave_lowbatt, 0);
if (p.gps_avg_idx >= NodePrefs::GPS_AVG_COUNT) p.gps_avg_idx = 0;
flag(p.alarm_on, 0);
if (p.alarm_hour > 23) p.alarm_hour = 0;
if (p.alarm_min > 59) p.alarm_min = 0;
if (NodePrefs::alarmRepeatIdxForMask(p.alarm_repeat_mask) == 0) p.alarm_repeat_mask = 0;
flag(p.keyboard_type, 0);
if (p.keyboard_alt_alphabet >= NodePrefs::KB_ALPHABET_COUNT) p.keyboard_alt_alphabet = 0;
if (p.keyboard_main_alphabet >= NodePrefs::KB_ALPHABET_COUNT) p.keyboard_main_alphabet = 0;
flag(p.keyboard_cardkb_compact, 0);
flag(p.bot_commands_enabled, 0);
flag(p.bot_commands_ch, 0);
flag(p.bot_commands_room, 0);
flag(p.bot_actions_dm, 0);
flag(p.bot_actions_ch, 0);
flag(p.bot_actions_room, 0);
flag(p.bot_dm_scope, 0);
flag(p.bot_room_enabled, 0);
if (p.bot_quiet_start > 23) p.bot_quiet_start = 0;
if (p.bot_quiet_end > 23) p.bot_quiet_end = 0;
flag(p.use_lemon_font, 0);
flag(p.units_imperial, 0);
flag(p.trail_show_pace, 0);
if (p.advert_sound_scope > 1) p.advert_sound_scope = ADVERT_SOUND_SCOPE_ALL;
flag(p.rx_powersave, 0);
flag(p.tx_apc, 0);
if (p.dm_resend_count > 5) p.dm_resend_count = 2;
if (p.gpio1_mode > 4) p.gpio1_mode = 0; // gpio1/2 can be analog (AIN0/AIN5)
if (p.gpio2_mode > 4) p.gpio2_mode = 0;
if (p.gpio3_mode > 3) p.gpio3_mode = 0;
if (p.gpio4_mode > 3) p.gpio4_mode = 0;
if (p.interference_threshold > 30) p.interference_threshold = 0;
flag(p.cad_enabled, 0);
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);
}
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");
// The settings a firmware before 2.0 kept in /new_prefs that still matter
// after the update: its first block, laid out the same in upstream MeshCore
// and in every Solo -- the node's name and position, its radio, the BLE PIN
// and the default scope. Without them a device would come back on the
// default radio, deaf to its mesh. The rest starts from the defaults.
static void importOldPrefs(File& f, NodePrefs& p, double& lat, double& lon) {
uint8_t pad[4];
f.read((uint8_t*)&p.airtime_factor, sizeof(p.airtime_factor));
f.read((uint8_t*)p.node_name, sizeof(p.node_name));
f.read(pad, 4);
f.read((uint8_t*)&lat, sizeof(lat));
f.read((uint8_t*)&lon, sizeof(lon));
f.read((uint8_t*)&p.freq, sizeof(p.freq));
f.read((uint8_t*)&p.sf, sizeof(p.sf));
f.read((uint8_t*)&p.cr, sizeof(p.cr));
f.read((uint8_t*)&p.client_repeat, sizeof(p.client_repeat));
f.read((uint8_t*)&p.manual_add_contacts, sizeof(p.manual_add_contacts));
f.read((uint8_t*)&p.bw, sizeof(p.bw));
f.read((uint8_t*)&p.tx_power_dbm, sizeof(p.tx_power_dbm));
f.read((uint8_t*)&p.telemetry_mode_base, sizeof(p.telemetry_mode_base));
f.read((uint8_t*)&p.telemetry_mode_loc, sizeof(p.telemetry_mode_loc));
f.read((uint8_t*)&p.telemetry_mode_env, sizeof(p.telemetry_mode_env));
f.read((uint8_t*)&p.rx_delay_base, sizeof(p.rx_delay_base));
f.read((uint8_t*)&p.advert_loc_policy, sizeof(p.advert_loc_policy));
f.read((uint8_t*)&p.multi_acks, sizeof(p.multi_acks));
f.read((uint8_t*)&p.path_hash_mode, sizeof(p.path_hash_mode));
f.read(pad, 1);
f.read((uint8_t*)&p.ble_pin, sizeof(p.ble_pin));
f.read((uint8_t*)&p.buzzer_quiet, sizeof(p.buzzer_quiet));
f.read((uint8_t*)&p.gps_enabled, sizeof(p.gps_enabled));
f.read((uint8_t*)&p.gps_interval, sizeof(p.gps_interval));
f.read((uint8_t*)&p.autoadd_config, sizeof(p.autoadd_config));
f.read((uint8_t*)&p.autoadd_max_hops, sizeof(p.autoadd_max_hops));
f.read((uint8_t*)&p.rx_boosted_gain, sizeof(p.rx_boosted_gain));
f.read((uint8_t*)p.default_scope_name, sizeof(p.default_scope_name));
f.read((uint8_t*)p.default_scope_key, sizeof(p.default_scope_key));
p.node_name[sizeof(p.node_name) - 1] = '\0';
p.default_scope_name[sizeof(p.default_scope_name) - 1] = '\0';
if (p.client_repeat > 1) p.client_repeat = 0;
if (p.buzzer_quiet > 1) p.buzzer_quiet = 0;
}
// `prefs` comes in holding the defaults; what the file holds replaces them.
void DataStore::loadPrefs(NodePrefs& prefs, double& node_lat, double& node_lon) {
File file = openRead(_fs, "/prefs");
if (file) {
uint8_t pad[8];
memset(pad, 0, sizeof(pad));
file.write((uint8_t *)&_prefs.airtime_factor, sizeof(float));
file.write((uint8_t *)_prefs.node_name, sizeof(_prefs.node_name));
file.write(pad, 4);
file.write((uint8_t *)&node_lat, sizeof(node_lat));
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();
PrefsHeader h;
NodePrefs* tmp = new NodePrefs(prefs); // a field the file is too short for keeps its default
double lat = 0, lon = 0;
bool ok = file.read((uint8_t*)&h, sizeof(h)) == sizeof(h)
&& h.magic == PREFS_MAGIC && h.version == NodePrefs::PREFS_VERSION
&& file.read((uint8_t*)&lat, sizeof(lat)) == sizeof(lat)
&& file.read((uint8_t*)&lon, sizeof(lon)) == sizeof(lon);
if (ok) {
commitTempFile(_fs, "/new_prefs.tmp", "/new_prefs");
} else {
_fs->remove("/new_prefs.tmp"); // keep the previous good /new_prefs
const size_t n = h.size < sizeof(NodePrefs) ? h.size : sizeof(NodePrefs);
ok = file.read((uint8_t*)tmp, n) == (int)n;
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() {
-1
View File
@@ -20,7 +20,6 @@ class DataStore {
mesh::RTCClock* _clock;
IdentityStore identity_store;
void loadPrefsInt(const char *filename, NodePrefs& prefs, double& node_lat, double& node_lon);
#if defined(NRF52_PLATFORM) || defined(STM32_PLATFORM)
void checkAdvBlobFile();
#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.cr = LORA_CR;
_prefs.tx_power_dbm = LORA_TX_POWER;
// Repeater profile default for a true first boot (no prefs file yet, so
// loadPrefs() below is a no-op) — same band-matched seed as the upgrade
// path in DataStore.cpp.
// The repeater's own radio profile, in the companion's band.
seedDefaultRepeaterProfile(_prefs);
_prefs.gps_enabled = 0; // GPS disabled by default
_prefs.gps_interval = 0; // No automatic GPS updates by default
_prefs.radio_fem_rxgain = 1;
_prefs.radio_fem_txgain = 0;
#ifdef DISPLAY_ROTATION
_prefs.display_rotation = DISPLAY_ROTATION;
#endif
_prefs.display_brightness = 2; // medium brightness by default
_prefs.buzzer_volume = 4; // max volume by default
_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_end = 0; // start==end → quiet hours disabled
_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
memset(_prefs.dm_notif, 0, sizeof(_prefs.dm_notif));
_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.rx_delay_base = 10.0f; enable once new algo fixed
_prefs.client_repeat = 0;
// 0 dB is a real threshold, so "off" is its own sentinel. DataStore sets it
// too, but only when a prefs file exists -- a fresh device must start off.
// 0 dB is a real threshold, so "off" is its own sentinel.
_prefs.repeat_min_snr = NodePrefs::REPEAT_SNR_DISABLED;
#if defined(USE_SX1262) || defined(USE_SX1268)
#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
struct NodePrefs { // persisted to file
// Fields below are grouped thematically for readability. This grouping is
// purely a source-level convenience: on-disk order is defined solely by the
// explicit rd()/wr() call sequence in DataStore::loadPrefsInt()/savePrefs(),
// not by this struct's declaration order, so reordering fields here never
// touches the file format. That file format is still strictly append-only
// (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.
// Saved to /prefs as it is in memory (DataStore::savePrefs): a new field
// goes at the END of the struct and needs nothing else -- an older file is
// shorter, so the field keeps its default (MyMesh's constructor). Moving,
// resizing or removing a field shifts the ones after it: bump PREFS_VERSION
// then, and every device starts again from the defaults.
// ── Identity & scope ──────────────────────────────────────────────────
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 —
// board-level only right now (BaseCustomBoard::setLoRaFemLnaEnabled/
// PaGainEnabled, both default no-op false), no companion CLI/UI to set
// these yet, so — matching upstream's own decision — NOT persisted here:
// always reset to the constructor default (MyMesh.cpp) on boot. Not part
// of the DataStore save/load tripwire below.
// these yet: they keep the constructor default (MyMesh.cpp).
uint8_t radio_fem_rxgain;
uint8_t radio_fem_txgain;
// 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;
static const int8_t REPEAT_SNR_DISABLED = -128;
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
// own scope (Settings > Radio > Scope, default_scope_key) or one of the
// 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
// device can't silently blackhole all flood traffic.
uint8_t repeat_scope_only;
// Extra region names this repeater also relays for, beyond its own
// 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
// The scopes this repeater also relays for, beyond its own. Bit i = scope-list index
// (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().
// Same MAX_REPEAT_SCOPES=4 runtime cap as before, just resolved from the
// shared named-scope list instead of comma-tokenizing repeat_extra_scopes.
// At most MAX_REPEAT_SCOPES (4) of them count.
uint16_t repeat_extra_scope_mask;
// 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
@@ -207,9 +189,7 @@ struct NodePrefs { // persisted to file
// 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.
// 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
// at this offset (backward-compatible); the remaining slots live at the file
// tail (see DataStore) so pre-0x0019 saves still load without shifting.
// builds that add HomePageBit entries.
uint8_t page_order[13];
uint8_t page_order_set; // 0xA5 = page_order is user-configured; anything else = use default
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;
// 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
// enum only covers actual non-Latin scripts. Not a schema change: an old
// saved value of 3+ (one of the removed languages) just clamps to 0 (Latin)
// via DataStore.cpp's existing `>= KB_ALPHABET_COUNT` range check.
// enum only covers actual non-Latin scripts.
static const uint8_t KB_ALPHABET_LATIN_ONLY = 0;
static const uint8_t KB_ALPHABET_CYRILLIC = 1;
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" };
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;
// Which script (KB_ALPHABET_LATIN_ONLY/CYRILLIC/GREEK) occupies the on-screen
// keyboard's page 0 -- its default/opening page -- vs. keyboard_alt_alphabet
// above, which occupies page 1. Settings > Keyboard's Main/Additional rows.
// Equal to keyboard_alt_alphabet means no second page (see KeyboardWidget's
// hasAltAlphabet()). On-disk position is at the tail (see the serialization
// tripwire below); default 0 (Latin) matches the keyboard's original always-
// Latin-main behaviour for upgraders.
// hasAltAlphabet()). Default 0 (Latin).
uint8_t keyboard_main_alphabet;
// 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
@@ -262,8 +235,7 @@ struct NodePrefs { // persisted to file
// one-line status (current script/page, caps) instead; the accent and
// placeholder popups still render on top exactly as before (see
// KeyboardWidget::render()). Manual toggle rather than auto-detected, so it
// stays put even if the module is briefly unplugged. Default 0 (full grid,
// unchanged behaviour) on upgrade.
// stays put even if the module is briefly unplugged. Default 0 (full grid).
uint8_t keyboard_cardkb_compact;
// ── Clock & alarm ─────────────────────────────────────────────────────
@@ -284,9 +256,7 @@ struct NodePrefs { // persisted to file
uint8_t alarm_hour; // 0-23, local time
uint8_t alarm_min; // 0-59
// 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
// append-only tail (see the serialization tripwire below); grouped here
// with alarm_on/hour/min purely for source readability.
// explanation (bit i = 1<<tm_wday).
uint8_t alarm_repeat_mask;
static const uint8_t ALARM_REPEAT_NONE = 0x00; // one-shot (default)
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_from; // default 22
uint8_t quiet_to; // default 7
// Settings > Display > "Msg wake". Stored inverted (same reason as
// fav_sort_off below) so both a fresh memset and an older prefs file (no
// bytes here at all) mean "on" -- today's behaviour, where an incoming
// message turns the display on (UITask::newMsg()) if it was off and no
// companion app is already showing it.
uint8_t msg_wake_screen_off; // 0=wake display for incoming msgs (default), 1=disabled
// Settings > Display > "Msg wake": an incoming message turns the display on
// (UITask::newMsg()) if it was off and no companion app is already showing it.
uint8_t msg_wake; // 1=wake display for incoming msgs (default), 0=not
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_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)
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)
// On-disk position is at the struct's append-only tail (0xC0DE002B), even
// though grouped here with the other per-channel overrides. Scope-list
// 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
// Scope-list index per channel (see ScopeList) -- 0 ("*"/unscoped) by
// default. Fixed at 64 slots (not
// MAX_GROUP_CHANNELS, which varies by board/variant and would make
// sizeof(NodePrefs) variant-dependent) -- same implicit channel-count cap
// 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
// 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.
// 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;
// Per-target toggle for bot *action* commands (!buzz/!gps/!advert) --
// separate from bot_commands_ch/bot_commands_room above, which only gate
// the read-only query commands (!ping/!batt/...). Nested under that
// Commands toggle (Commands=off means neither queries nor actions run for
// that target); Actions=on additionally lets the state-changing commands
// through. Default 0 (off) -- these change device behaviour remotely, so
// upgraders don't get them silently enabled.
// through. Default 0 (off) -- these change device behaviour remotely.
uint8_t bot_actions_room;
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
@@ -420,9 +380,7 @@ struct NodePrefs { // persisted to file
uint8_t units_imperial;
// GPS trail cadence. Logging on/off is a runtime state (Tools › Trail),
// 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_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).
// The km-vs-mi choice now comes from units_imperial, so this is just the mode.
uint8_t trail_show_pace;
@@ -509,21 +467,15 @@ struct NodePrefs { // persisted to file
static const uint8_t FAV_KIND_MAX = 1;
uint8_t favourite_contacts[FAVOURITES_COUNT][FAVOURITE_PREFIX_LEN]; // [del→onContactRemoved/onChannelRemoved]
// What each favourite_contacts[] slot holds (see FAV_KIND_* above).
// On-disk position is at the struct's append-only tail (see the
// 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.
// Zero-init = every slot is a contact.
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
// memset and an older prefs file (no bytes here at all) mean "on", which is
// 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 > "Favs top".
uint8_t fav_sort; // 1 = favourites first in every list (default), 0 = natural order
// Settings > Contacts > "Expire" + "Prune now". Index into
// contactExpiryDays()/contactExpiryLabel() below (0=Off/never, 1=7d, 2=30d,
// 3=90d) -- a contact whose ContactInfo::lastmod is older than this is
// 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
// the serialization tripwire below), same as fav_sort_off above.
// regardless of age.
uint8_t contact_expiry_idx; // 0 = off (default)
// ── 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
// 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
// (see DataStore::loadPrefsInt). Default 0 (off/disconnected) on upgrade.
// (see DataStore's sanitize). Default 0 (off/disconnected).
uint8_t gpio1_mode;
uint8_t gpio2_mode;
uint8_t gpio3_mode;
@@ -548,7 +500,7 @@ struct NodePrefs { // persisted to file
// ── Custom messages ────────────────────────────────────────────────────
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
// screen only: the radio and the app link keep working.
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_salt[LOCK_SALT_LEN];
// ── Battery curve (0xC0DE0032) ─────────────────────────────────────────
// ── Battery curve ──────────────────────────────────────────────────────
// The cell voltage (mV) at 0, 10, ... 100 %, for the battery percentage
// (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.
static const uint8_t BATT_CURVE_PTS = 11;
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
// clock fields as rows below. The L2 has its own lock screen.
uint8_t lock_compact;
@@ -646,16 +598,9 @@ struct NodePrefs { // persisted to file
return L[idx < CONTACT_EXPIRY_COUNT ? idx : 0];
}
// Tail sentinel written at the end of /new_prefs. Bump the low byte when
// adding/removing/reordering fields in DataStore::savePrefs/loadPrefsInt so
// older saves are detected on load and skipped (zero-init defaults kept).
// 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;
// /prefs' layout (see the comment at the top). A file of another version
// isn't read: the defaults stand.
static const uint16_t PREFS_VERSION = 1;
// 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
@@ -681,11 +626,6 @@ struct NodePrefs { // persisted to file
// every page can be reordered. Any page still missing from a saved order is
// appended at navigation time via buildVisibleOrder's fallback.
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).
// 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);
}
// ── 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
// by the caller from RadioLibWrapper::getFreqBounds() — the radio chip's own
// 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
// (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
// was narrowed by an actual Settings > Home Pages visit (an upgrader whose
// IDBFS identity predates this default, or a visitor who toggled some
// pages off before this hook runs on a later boot).
// was narrowed by an actual Settings > Home Pages visit (a visitor who
// toggled some pages off before this hook runs on a later boot).
extern "C" EMSCRIPTEN_KEEPALIVE int sim_test_show_all_home_pages() {
if (!g_sim_ready) return 0;
NodePrefs* prefs = the_mesh.getNodePrefs();
@@ -581,7 +580,7 @@ extern "C" EMSCRIPTEN_KEEPALIVE int sim_test_disable_screen_timeout() {
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
// harness can verify UITask::newMsg()'s wake-gating without scripting the
// 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;
NodePrefs* prefs = the_mesh.getNodePrefs();
if (!prefs) return 0;
prefs->msg_wake_screen_off = disabled ? 1 : 0;
prefs->msg_wake = disabled ? 0 : 1;
return 1;
}
@@ -290,7 +290,7 @@ protected:
void sortStored() {
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++) {
int best = i;
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),
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,
nullptr, nullptr),
SW("Wake on message", "Screen on for new messages", SEC_DISPLAY, msg_wake, 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("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,
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.
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.
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(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(gps_interval), "GPS pwr" }, { NP_OFF(clock_12h), "12h clock" },
{ 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(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_to), " until" }, { NP_OFF(notif_melody_dm), "DM 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
// 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();
else if (!_asleep) lv_display_trigger_activity(NULL);
bool open_here = _screen == SCR_THREAD &&
@@ -3226,7 +3226,7 @@ void UITask::fillChats() {
bool ch_fav_only = _prefs && _prefs->ch_fav_only;
chatSection(body, "CHANNELS", FOLD_CHANNELS, _core->history.getTotalChannelUnread(), CF_CHANNELS, ch_fav_only);
own();
bool fav_first = !(_prefs && _prefs->fav_sort_off);
bool fav_first = !_prefs || _prefs->fav_sort;
int ch_rows = 0;
for (int pass = fav_first ? 0 : 1; pass < 2 && !chatFolded(FOLD_CHANNELS); pass++) {
for (int i = 0; i < MAX_GROUP_CHANNELS; i++) {
@@ -4636,7 +4636,7 @@ void UITask::buildSchemaSettings() {
lv_obj_t* g = group(body, "SCREEN");
schemaRow(g, SETTING(display_brightness));
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
if (_tap_wake) lv_obj_add_state(tw, LV_STATE_CHECKED);
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;
uint8_t k = rooms ? 0 : _history.dmHistCountForContact(c.id.pub_key);
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;
// Store the RAW table index (not the bare loop counter) -- every other
// 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;
}
// 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;
for (int i = 0; i < _num_channels; i++) {
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.
// Also repairs a partially-initialised order where CLOCK is absent; migrates
// older orders by inserting FAVOURITES after CLOCK; and appends any pages that
// are absent from a stale saved order (e.g. TOOLS/MESSAGES added by later firmware).
// Also repairs a partially-initialised order where CLOCK is absent, and
// appends any pages absent from a saved order (pages a later firmware added).
void ensurePageOrderInit(NodePrefs* p) const {
if (!p) return;
if (p->page_order_set == NodePrefs::PAGE_ORDER_MAGIC) {
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++) {
uint8_t v = p->page_order[i];
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_FAVOURITES) { has_fav = true; }
len = i + 1;
if ((int)(v - 1) == NodePrefs::HPB_CLOCK) has_clock = true;
}
if (!has_clock) {
// Corrupted/partial — full re-init below.
memset(p->page_order, 0, sizeof(p->page_order));
} 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
// later firmware version). Recount first since the block above may have
// just inserted FAVOURITES.
// later firmware version).
{
uint16_t present = 0;
int cur_len = 0;
@@ -375,8 +352,8 @@ class SettingsScreen : public UIScreen {
cur_len++;
}
// 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
// for pre-0x0019 upgraders) is appended into the free tail slots below.
// are required — any missing from a saved order is appended into the
// free tail slots below.
static const uint8_t REQUIRED[] = {
NodePrefs::HPB_CLOCK, NodePrefs::HPB_FAVOURITES,
NodePrefs::HPB_RECENT, NodePrefs::HPB_RADIO,
+1 -1
View File
@@ -2202,7 +2202,7 @@ void UITask::onMessageArrived(const UiEvent& ev) {
}
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
_display->turnOn();
}