diff --git a/docs/solo/developer/ui-framework.md b/docs/solo/developer/ui-framework.md index d6bb1390..15c27044 100644 --- a/docs/solo/developer/ui-framework.md +++ b/docs/solo/developer/ui-framework.md @@ -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` diff --git a/examples/companion_radio/DataStore.cpp b/examples/companion_radio/DataStore.cpp index ac2aeb6f..7367469b 100644 --- a/examples/companion_radio/DataStore.cpp +++ b/examples/companion_radio/DataStore.cpp @@ -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() { diff --git a/examples/companion_radio/DataStore.h b/examples/companion_radio/DataStore.h index b0035de3..5549460b 100644 --- a/examples/companion_radio/DataStore.h +++ b/examples/companion_radio/DataStore.h @@ -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 diff --git a/examples/companion_radio/MyMesh.cpp b/examples/companion_radio/MyMesh.cpp index 531074b0..d3b2c37d 100644 --- a/examples/companion_radio/MyMesh.cpp +++ b/examples/companion_radio/MyMesh.cpp @@ -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 diff --git a/examples/companion_radio/NodePrefs.h b/examples/companion_radio/NodePrefs.h index c5a7cf52..1622da15 100644 --- a/examples/companion_radio/NodePrefs.h +++ b/examples/companion_radio/NodePrefs.h @@ -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< 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 diff --git a/examples/companion_radio/main.cpp b/examples/companion_radio/main.cpp index b2af7491..d1749fbe 100644 --- a/examples/companion_radio/main.cpp +++ b/examples/companion_radio/main.cpp @@ -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; } diff --git a/examples/companion_radio/ui-core/NearbyModel.h b/examples/companion_radio/ui-core/NearbyModel.h index ce49c1ff..1c42196e 100644 --- a/examples/companion_radio/ui-core/NearbyModel.h +++ b/examples/companion_radio/ui-core/NearbyModel.h @@ -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++) { diff --git a/examples/companion_radio/ui-core/SettingsSchema.h b/examples/companion_radio/ui-core/SettingsSchema.h index 13ef8ba5..a1bb3208 100644 --- a/examples/companion_radio/ui-core/SettingsSchema.h +++ b/examples/companion_radio/ui-core/SettingsSchema.h @@ -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" }, diff --git a/examples/companion_radio/ui-lvgl/UITask.cpp b/examples/companion_radio/ui-lvgl/UITask.cpp index 8eac0b12..9519a5ad 100644 --- a/examples/companion_radio/ui-lvgl/UITask.cpp +++ b/examples/companion_radio/ui-lvgl/UITask.cpp @@ -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); diff --git a/examples/companion_radio/ui-new/MessagesScreen.h b/examples/companion_radio/ui-new/MessagesScreen.h index 6b0046cc..bb8e1b39 100644 --- a/examples/companion_radio/ui-new/MessagesScreen.h +++ b/examples/companion_radio/ui-new/MessagesScreen.h @@ -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; diff --git a/examples/companion_radio/ui-new/SettingsScreen.h b/examples/companion_radio/ui-new/SettingsScreen.h index bf430064..5cdc2dda 100644 --- a/examples/companion_radio/ui-new/SettingsScreen.h +++ b/examples/companion_radio/ui-new/SettingsScreen.h @@ -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, diff --git a/examples/companion_radio/ui-new/UITask.cpp b/examples/companion_radio/ui-new/UITask.cpp index 05b01953..79ea4929 100644 --- a/examples/companion_radio/ui-new/UITask.cpp +++ b/examples/companion_radio/ui-new/UITask.cpp @@ -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(); }