refactor(prefs): group NodePrefs fields thematically; add real NodePrefs unit tests

NodePrefs.h's field declaration order used to just be historical append
order (on-disk format is defined solely by DataStore's explicit rd()/wr()
sequence, not struct layout), making the file hard to navigate. Reordered
fields into thematic groups (radio, repeater, bot, GPS/trail/location,
display/keyboard, etc.) with no on-disk/schema change; fixed two comments
that had gone stale (favourite_contacts/_kinds' [del→...] tags only named
one of the two handlers that actually clear them; dashboard_fields was
miscategorized under favourites). sizeof(NodePrefs) shifted twice as a
side effect of packing (2760→2752→2760) — verified via real builds on all
four canonical envs and re-checked against the serialization tripwire.

Also replaced test_companion_node_prefs.cpp's dead body (a disabled test
against a saveSerial/loadSerial API this struct never got) with real
coverage of the pure helper functions NodePrefs.h already carries -- band
bucketing, repeater-profile bounds, alarm-repeat round-trip, and every
option-lookup table, including their inconsistent out-of-range fallback
behaviour.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
Jakub
2026-09-01 21:56:57 +02:00
co-authored by Claude Sonnet 5
parent 1f7f1455a2
commit 300f5fab0b
2 changed files with 467 additions and 364 deletions
+320 -299
View File
@@ -44,27 +44,54 @@ static inline float defaultRepeaterFreqForBand(float companion_freq) {
#define ADVERT_SOUND_SCOPE_ZERO_HOP 1
struct NodePrefs { // persisted to file
float airtime_factor;
// 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.
// ── Identity & scope ──────────────────────────────────────────────────
char node_name[32];
char default_scope_name[31];
uint8_t default_scope_key[16];
// ── Radio (companion's own link) ──────────────────────────────────────
float airtime_factor;
float freq;
uint8_t sf;
uint8_t cr;
uint8_t multi_acks;
uint8_t manual_add_contacts;
float bw;
int8_t tx_power_dbm;
uint8_t telemetry_mode_base;
uint8_t telemetry_mode_loc;
uint8_t telemetry_mode_env;
float rx_delay_base;
uint32_t ble_pin;
uint8_t advert_loc_policy;
uint8_t buzzer_quiet;
uint8_t buzzer_volume; // 0=min..4=max, default 4
uint8_t gps_enabled; // GPS enabled flag (0=disabled, 1=enabled)
uint32_t gps_interval; // GPS duty-cycle sleep window in seconds (0 = disabled, GPS stays continuous)
uint8_t autoadd_config; // bitmask for auto-add contacts config
uint8_t multi_acks;
uint8_t path_hash_mode; // which path mode to use when sending
// Hardware duty-cycle receive (battery saver): 0=continuous RX (default), 1=on.
// The SX126x cycles RX↔sleep on its own and wakes on a preamble — cuts average
// RX current at the cost of a little receive latency. See RadioLibWrapper
// power-save (startReceiveDutyCycleAuto).
uint8_t rx_powersave;
uint8_t rx_boosted_gain; // SX126x RX boosted gain mode (0=power saving, 1=boosted)
// Adaptive Power Control: 0=off (fixed tx_power_dbm, default), 1=on. When on,
// tx_power_dbm is treated as a ceiling and the radio's actual power is lowered
// at runtime on strong links (good ACK SNR), saving TX energy. Never persisted
// below the ceiling, so disabling restores the user's configured power.
uint8_t tx_apc;
// RSSI-based interference detection (relative to the radio's own noise
// floor) and hardware Channel Activity Detection before TX. See
// MyMesh::getInterferenceThreshold()/getCADEnabled() — CAD is also
// auto-enabled whenever rx_powersave is active, regardless of this flag,
// since duty-cycle sleep leaves the noise floor stale. Both default 0/off,
// and neither has a Settings/CLI toggle yet on companion_radio — persisted
// and wired for a future manual override.
uint8_t interference_threshold;
uint8_t cad_enabled;
// Auto-resend for on-device DMs: number of extra send attempts (0..5) made when
// no end-to-end ACK arrives before the deadline, before the delivery marker
// shows ✗. 0 = no auto-resend (single attempt). Default 2.
uint8_t dm_resend_count;
// 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
@@ -73,122 +100,6 @@ struct NodePrefs { // persisted to file
// of the DataStore save/load tripwire below.
uint8_t radio_fem_rxgain;
uint8_t radio_fem_txgain;
uint8_t client_repeat;
uint8_t path_hash_mode; // which path mode to use when sending
uint8_t autoadd_max_hops; // 0 = no limit, 1 = direct (0 hops), N = up to N-1 hops (max 64)
char default_scope_name[31];
uint8_t default_scope_key[16];
uint8_t display_brightness; // 0=min..4=max, default 2 (medium)
uint16_t auto_off_secs; // display auto-off: 0=never, else seconds (default 15)
uint8_t auto_lock; // 0=disabled, 1=lock screen when display turns off
int8_t tz_offset_hours; // timezone offset from UTC, -12..+14 (default 0)
uint16_t low_batt_mv; // auto-shutdown threshold: 0=disabled, 3000-3500 mV
uint8_t batt_display_mode; // 0=icon, 1=percent, 2=voltage
char custom_msgs[10][140]; // user-defined quick messages (supports {loc}, {time})
uint64_t ch_notif_override; // bitmask: bit i = channel i has explicit notification setting [del→onChannelRemoved]
uint64_t ch_notif_muted; // bitmask: bit i = channel i muted (only if override bit set) [del→onChannelRemoved]
uint8_t dm_show_all; // 0=favourites only, 1=all chat contacts (default)
uint8_t room_fav_only; // 0=all room servers (default), 1=favourites only
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
uint16_t home_pages_mask; // bitmask of visible home pages (bit0=Clock..bit8=Shutdown); 0=all visible
uint8_t bot_enabled; // 0=disabled, 1=DM trigger-reply active — DM only; channel/room have their own bot_channel_enabled/bot_room_enabled and don't depend on this
uint8_t bot_channel_enabled; // 0=disabled, 1=channel bot active for bot_channel_idx
uint8_t bot_channel_idx; // channel index for channel bot [del→onChannelRemoved]
char bot_trigger[64]; // DM trigger phrase (case-insensitive contains; "*" = any DM)
char bot_reply_dm[140]; // auto-reply text for DM
char bot_reply_ch[140]; // auto-reply text for channel
char bot_trigger_ch[64]; // channel trigger phrase (independent of DM; "*" = any channel msg)
uint8_t bot_commands_enabled; // 0=off, 1=answer !ping/!batt/!loc/!time/!help DM commands — DM only, see bot_commands_ch/bot_commands_room below for the other two targets
uint8_t bot_quiet_start; // quiet-hours start hour, local 0-23 (start==end → disabled)
uint8_t bot_quiet_end; // quiet-hours end hour, local 0-23
uint8_t clock_hide_seconds; // 0=show HH:MM:SS/refresh 1s (default), 1=hide/refresh 60s
uint8_t clock_12h; // 0=24h (default), 1=12h with AM/PM
uint8_t buzzer_auto; // 0=manual (default), 1=auto-mute when BT connected
struct DmNotifEntry { uint8_t prefix[4]; uint8_t state; }; // state: 0=default,1=muted,2=force-on
static const int DM_NOTIF_TABLE_MAX = 16;
DmNotifEntry dm_notif[DM_NOTIF_TABLE_MAX]; // 16*5 = 80 bytes [del→onContactRemoved]
uint8_t dashboard_fields[3]; // 0=None,1=Batt V,2=Temp,3=Hum,4=Pres,5=GPS,6=Alt,7=Lux,8=CO2,9=Nodes,10=Msgs,11=Batt %
uint32_t advert_auto_interval_sec; // periodic 0-hop advert with GPS: 0=off, else seconds
// Second melody slot (same packing as ringtone_*)
uint8_t ringtone2_bpm_idx;
uint8_t ringtone2_len;
uint8_t ringtone2_notes[32];
// Global melodies for notifications: 0=built-in, 1=melody1, 2=melody2, 3=none
uint8_t notif_melody_dm;
uint8_t notif_melody_ch;
uint8_t notif_melody_ad;
// Advert sound filter: 0=all adverts, 1=zero-hop only
uint8_t advert_sound_scope;
// 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)
// Per-DM melody table
struct DmMelodyEntry { uint8_t prefix[4]; uint8_t slot; }; // slot: 0=global,1=melody1,2=melody2
static const int DM_MELODY_TABLE_MAX = 16;
DmMelodyEntry dm_melody[DM_MELODY_TABLE_MAX]; // [del→onContactRemoved]
uint8_t use_lemon_font; // 0=default Adafruit font, 1=Lemon font (Unicode, pixel-accurate wrap)
uint8_t display_rotation; // 0-3; only used on e-ink displays
// Home screen page order: each byte = HomePageBit + 1. 0 terminates the list.
// 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.
uint8_t page_order[13];
uint8_t joystick_rotation; // 0-3 steps CW; independent of display_rotation
uint8_t eink_full_refresh_every; // index into {0,5,10,20,30}: full refresh every N partials (0=off)
uint8_t page_order_set; // 0xA5 = page_order is user-configured; anything else = use default
static const uint8_t PAGE_ORDER_MAGIC = 0xA5;
// Favourites dial: 6 pinned targets. Layout transposes between landscape
// (3×2) and portrait (2×3). What a slot holds depends on favourite_kinds[]
// (stored at the struct tail, see there):
// FAV_KIND_CONTACT — first 6 bytes of pub_key; covers chat contacts and
// room servers alike. All-zero = empty slot (a real pub_key starts with
// 6 zero bytes with probability 2^-48).
// FAV_KIND_CHANNEL — channel index in byte 0, rest zero. Never empty:
// channel 0 is all-zero, so emptiness is decided by the kind first.
static const uint8_t FAVOURITES_COUNT = 6;
static const uint8_t FAVOURITE_PREFIX_LEN = 6;
static const uint8_t FAV_KIND_CONTACT = 0;
static const uint8_t FAV_KIND_CHANNEL = 1;
static const uint8_t FAV_KIND_MAX = 1;
uint8_t favourite_contacts[FAVOURITES_COUNT][FAVOURITE_PREFIX_LEN]; // [del→onContactRemoved]
// 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)
uint64_t ch_fav_bitmask; // bit i = channel i is marked as favourite [del→onChannelRemoved]
uint8_t ch_fav_only; // 0=show all channels (default), 1=show favourites only
// Global measurement system for every distance/speed shown in the UI
// (Nearby, Trail, navigate-to-point). 0=metric (default), 1=imperial.
uint8_t units_imperial;
// 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;
// Hardware duty-cycle receive (battery saver): 0=continuous RX (default), 1=on.
// The SX126x cycles RX↔sleep on its own and wakes on a preamble — cuts average
// RX current at the cost of a little receive latency. See RadioLibWrapper
// power-save (startReceiveDutyCycleAuto).
uint8_t rx_powersave;
// Adaptive Power Control: 0=off (fixed tx_power_dbm, default), 1=on. When on,
// tx_power_dbm is treated as a ceiling and the radio's actual power is lowered
// at runtime on strong links (good ACK SNR), saving TX energy. Never persisted
// below the ceiling, so disabling restores the user's configured power.
uint8_t tx_apc;
// Auto-resend for on-device DMs: number of extra send attempts (0..5) made when
// no end-to-end ACK arrives before the deadline, before the delivery marker
// shows ✗. 0 = no auto-resend (single attempt). Default 2.
uint8_t dm_resend_count;
// User-saved radio presets, written by the "Save current..." entry in the
// shared preset picker (Settings > Radio and Tools > Repeater both populate
// these same slots). name[0] == '\0' marks an empty slot.
@@ -202,6 +113,8 @@ struct NodePrefs { // persisted to file
static const uint8_t USER_RADIO_PRESET_MAX = 4;
UserRadioPreset user_radio_presets[USER_RADIO_PRESET_MAX];
// ── Repeater ───────────────────────────────────────────────────────────
uint8_t client_repeat;
// Repeater forwarding filters — only consulted when client_repeat is on, via
// MyMesh::allowPacketForward(). Both default to off (0) so behaviour is
// unchanged until the user opts in (Tools > Repeater).
@@ -216,16 +129,27 @@ struct NodePrefs { // persisted to file
// fringe traffic isn't re-flooded. REPEAT_SNR_DISABLED (-128) = off.
// repeat_suppress_dup: 1 = cancel a queued retransmit when the same flood is
// overheard from another node first (less redundant airtime in dense mesh).
// (repeat_scope_only / repeat_extra_scopes belong to this group too, but live
// at the struct tail — the file format is strictly append-only, so a new
// field can never be slotted in next to its logical siblings.)
uint8_t repeat_skip_adverts;
uint8_t repeat_max_hops;
uint8_t repeat_delay_boost;
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
// a different community. A no-op (forwards everything, unchanged) while
// 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.
char repeat_extra_scopes[24];
// 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
// disabling restores the companion's freq/bw/sf/cr (the fields above). 0 = the
@@ -240,106 +164,48 @@ struct NodePrefs { // persisted to file
uint8_t repeater_sf;
uint8_t repeater_cr;
// Track positions shared by other nodes via [LOC] messages (LiveTrackStore).
// 0 = ignore shared positions (default), 1 = parse incoming DM/channel [LOC]
// shares and update the live-track table (Nearby "Live" view / map).
uint8_t track_shared_loc;
// ── Telemetry ──────────────────────────────────────────────────────────
uint8_t telemetry_mode_base;
uint8_t telemetry_mode_loc;
uint8_t telemetry_mode_env;
// Live location sharing — a message-based "beacon". When enabled, the device
// periodically sends a [LOC] message to the chosen target while it moves.
// Configured from the Map (Trail screen) "Live share" menu.
uint8_t loc_share_enabled; // 0=off (default), 1=auto-sharing on
uint8_t loc_share_target_type; // 0=channel, 1=DM contact
uint8_t loc_share_channel_idx; // target channel index (when target_type==0) [del→onChannelRemoved]
uint8_t loc_share_dm_prefix[6]; // target contact pubkey prefix (when target_type==1) [del→onContactRemoved]
uint8_t loc_share_move_idx; // movement gate level (index into locShareMoveMeters)
uint8_t loc_share_interval_idx; // min send interval (index into locShareIntervalSecs)
uint8_t loc_share_heartbeat_idx; // stationary heartbeat (index into locShareHeartbeatSecs)
// ── Contacts (auto-add) ───────────────────────────────────────────────
uint8_t manual_add_contacts;
uint8_t autoadd_config; // bitmask for auto-add contacts config
uint8_t autoadd_max_hops; // 0 = no limit, 1 = direct (0 hops), N = up to N-1 hops (max 64)
// Locator — a single geofence around a saved point. When enabled the device
// watches its own GPS fix and beeps + shows an alert when it crosses into
// (arrive) or out of (leave) the radius. The target coordinate/label is a
// snapshot of a chosen waypoint, so the alert survives the waypoint being
// edited or deleted. Configured from Tools Locator.
uint8_t locator_enabled; // 0=off (default), 1=armed
uint8_t locator_has_target; // 0=no target chosen yet, 1=target set
uint8_t locator_radius_idx; // index into locatorRadiusMeters
uint8_t locator_mode; // 0=arrive, 1=leave, 2=both
int32_t locator_lat_1e6; // target latitude (1e6-scaled; last-known for a contact)
int32_t locator_lon_1e6; // target longitude (1e6-scaled; last-known for a contact)
char locator_label[12]; // target name for the alert text (WAYPOINT_LABEL_LEN)
// Target can be a static waypoint or a live contact: for a contact the engine
// re-reads the latest [LOC] position each evaluation (keyed by pubkey prefix),
// so the geofence follows a moving person ("alert when my friend is near").
uint8_t locator_target_kind; // 0=waypoint (static), 1=live contact
uint8_t locator_key[6]; // contact pubkey prefix when target_kind==1 [del→onContactRemoved]
// ── Connectivity (BLE) ────────────────────────────────────────────────
uint32_t ble_pin;
// Trail auto-pause — when tracking, automatically freeze the trail (timer +
// sampling) after the device has sat still for this long, and resume on the
// next real movement. 0 = off. Index into trailAutoPauseSecs.
uint8_t trail_autopause_idx;
// Locator proximity beeper — when on (and the alert is armed with a target),
// the device ticks while inside the radius and shortens the gap between ticks
// the closer it gets to the target, like a homing beeper. Independent of the
// discrete arrive/leave alert (locator_mode).
uint8_t locator_beeper; // 0=off (default), 1=on
// GPS averaging for waypoint marking — when set, "Mark here" samples the GPS
// fix for this many seconds and stores the mean position, for a more accurate
// mark than a single instantaneous fix. 0 = off (instant mark, the default).
// Index into gpsAvgSecs(). [Tools Trail Settings Mark avg]
uint8_t gps_avg_idx;
// Alarm clock — a wake alarm, configured from the Clock page (Enter Alarm).
// Stored as a local time-of-day; the actual fire instant is (re)computed as an
// absolute time in tickBackground(), which is what makes it robust to RTC
// re-syncs: the mesh (every inbound packet), the companion app, GPS and the
// CLI can all jump getCurrentTime() at any moment, but an absolute target
// instant stays correct across small corrections and still fires (late) if
// the clock jumps over it. With alarm_repeat_mask == 0 it's one-shot: fires
// once, then alarm_on clears. A non-zero mask instead re-arms it for the next
// matching weekday (see UITask::computeAlarmNextFire/evaluateAlarm) and
// alarm_on stays set. The minutnik (countdown) and stoper (stopwatch) are
// runtime-only and not persisted.
uint8_t alarm_on; // 0=off (default), 1=armed
uint8_t alarm_hour; // 0-23, local time
uint8_t alarm_min; // 0-59
// Repeat days as a bitmask, bit i = 1<<tm_wday (Sunday=0 .. Saturday=6, per
// struct tm — matches what computeAlarmNextFire() already reads from
// gmtime()). 0 = no repeat (one-shot, the original/default behaviour).
// Cycled through the presets below via the alarm's Repeat row.
static const uint8_t ALARM_REPEAT_NONE = 0x00; // one-shot (default)
static const uint8_t ALARM_REPEAT_DAILY = 0x7F; // every day
static const uint8_t ALARM_REPEAT_WEEKDAYS = 0x3E; // Mon-Fri
static const uint8_t ALARM_REPEAT_WEEKENDS = 0x41; // Sat+Sun
static const uint8_t ALARM_REPEAT_COUNT = 4;
static uint8_t alarmRepeatMaskForIdx(uint8_t idx) {
static const uint8_t M[ALARM_REPEAT_COUNT] =
{ ALARM_REPEAT_NONE, ALARM_REPEAT_DAILY, ALARM_REPEAT_WEEKDAYS, ALARM_REPEAT_WEEKENDS };
return M[idx < ALARM_REPEAT_COUNT ? idx : 0];
}
// Reverse lookup for display: an arbitrary mask (e.g. loaded from a future
// custom-day picker) that doesn't match a preset just reads as "OFF" here —
// it still fires correctly, computeAlarmNextFire() reads the raw mask.
static uint8_t alarmRepeatIdxForMask(uint8_t mask) {
switch (mask) {
case ALARM_REPEAT_DAILY: return 1;
case ALARM_REPEAT_WEEKDAYS: return 2;
case ALARM_REPEAT_WEEKENDS: return 3;
default: return 0;
}
}
static const char* alarmRepeatLabel(uint8_t idx) {
static const char* L[ALARM_REPEAT_COUNT] = { "OFF", "Daily", "Weekdays", "Weekends" };
return L[idx < ALARM_REPEAT_COUNT ? idx : 0];
}
// ── Power & battery ───────────────────────────────────────────────────
uint16_t low_batt_mv; // auto-shutdown threshold: 0=disabled, 3000-3500 mV
uint8_t batt_display_mode; // 0=icon, 1=percent, 2=voltage
// ── Display & keyboard ────────────────────────────────────────────────
uint8_t display_brightness; // 0=min..4=max, default 2 (medium)
uint16_t auto_off_secs; // display auto-off: 0=never, else seconds (default 15)
uint8_t auto_lock; // 0=disabled, 1=lock screen when display turns off
int8_t tz_offset_hours; // timezone offset from UTC, -12..+14 (default 0)
uint8_t use_lemon_font; // 0=default Adafruit font, 1=Lemon font (Unicode, pixel-accurate wrap)
uint8_t display_rotation; // 0-3; only used on e-ink displays
uint8_t joystick_rotation; // 0-3 steps CW; independent of display_rotation
uint8_t eink_full_refresh_every; // index into {0,5,10,20,30}: full refresh every N partials (0=off)
uint16_t home_pages_mask; // bitmask of visible home pages (bit0=Clock..bit8=Shutdown); 0=all visible
uint8_t dashboard_fields[3]; // 0=None,1=Batt V,2=Temp,3=Hum,4=Pres,5=GPS,6=Alt,7=Lux,8=CO2,9=Nodes,10=Msgs,11=Batt %
// Home screen page order: each byte = HomePageBit + 1. 0 terminates the list.
// 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.
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;
// On-screen keyboard layout, shared across every text-entry screen (Settings >
// Keyboard). 0=ABC grid, alphabetical order (default), 1=T9 multi-tap
// (phone-keypad groups, cycled with repeated Enter presses — see KeyboardWidget.h).
uint8_t keyboard_type;
// Additional (non-Latin) keyboard alphabet, orthogonal to keyboard_type above
// — either layout style (ABC grid or T9) can show any alphabet's characters.
// 0 = Latin only (default): the keyboard's existing #@/abc page-cycle key
@@ -366,28 +232,123 @@ struct NodePrefs { // persisted to file
static const char* L[KB_ALPHABET_COUNT] = { "Latin", "Cyrillic", "Greek" };
return L[idx < KB_ALPHABET_COUNT ? idx : 0];
}
// Auto-save the live GPS trail to /trail on shutdown (covers the low-battery
// auto-shutdown, which otherwise loses the whole route). Only writes when the
// trail has points and the toggle is on. 0 = off (default), 1 = on.
// [Tools Trail Settings Auto-save]
uint8_t trail_autosave_lowbatt;
// Appended at the tail (see the alarm doc comment above for the field itself
// and the serialization tripwire below for why new fields land here, not
// inline with alarm_on/hour/min).
uint8_t alarm_repeat_mask;
// Appended at the tail (see the keyboard_alt_alphabet doc comment above).
// 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.
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
// on-screen keyboard skips drawing its full letter grid + special-row icons
// -- an external-keyboard typist never looks at them -- and shows a compact
// 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.
uint8_t keyboard_cardkb_compact;
// ── Clock & alarm ─────────────────────────────────────────────────────
uint8_t clock_hide_seconds; // 0=show HH:MM:SS/refresh 1s (default), 1=hide/refresh 60s
uint8_t clock_12h; // 0=24h (default), 1=12h with AM/PM
// Alarm clock — a wake alarm, configured from the Clock page (Enter Alarm).
// Stored as a local time-of-day; the actual fire instant is (re)computed as an
// absolute time in tickBackground(), which is what makes it robust to RTC
// re-syncs: the mesh (every inbound packet), the companion app, GPS and the
// CLI can all jump getCurrentTime() at any moment, but an absolute target
// instant stays correct across small corrections and still fires (late) if
// the clock jumps over it. With alarm_repeat_mask == 0 it's one-shot: fires
// once, then alarm_on clears. A non-zero mask instead re-arms it for the next
// matching weekday (see UITask::computeAlarmNextFire/evaluateAlarm) and
// alarm_on stays set. The minutnik (countdown) and stoper (stopwatch) are
// runtime-only and not persisted.
uint8_t alarm_on; // 0=off (default), 1=armed
uint8_t alarm_hour; // 0-23, local time
uint8_t alarm_min; // 0-59
// Repeat-days bitmask — see the alarm doc comment above for the full
// explanation (bit i = 1<<tm_wday). On-disk position is at the struct's
// append-only tail (see the serialization tripwire below); grouped here
// with alarm_on/hour/min purely for source readability.
uint8_t alarm_repeat_mask;
static const uint8_t ALARM_REPEAT_NONE = 0x00; // one-shot (default)
static const uint8_t ALARM_REPEAT_DAILY = 0x7F; // every day
static const uint8_t ALARM_REPEAT_WEEKDAYS = 0x3E; // Mon-Fri
static const uint8_t ALARM_REPEAT_WEEKENDS = 0x41; // Sat+Sun
static const uint8_t ALARM_REPEAT_COUNT = 4;
static uint8_t alarmRepeatMaskForIdx(uint8_t idx) {
static const uint8_t M[ALARM_REPEAT_COUNT] =
{ ALARM_REPEAT_NONE, ALARM_REPEAT_DAILY, ALARM_REPEAT_WEEKDAYS, ALARM_REPEAT_WEEKENDS };
return M[idx < ALARM_REPEAT_COUNT ? idx : 0];
}
// Reverse lookup for display: an arbitrary mask (e.g. loaded from a future
// custom-day picker) that doesn't match a preset just reads as "OFF" here —
// it still fires correctly, computeAlarmNextFire() reads the raw mask.
static uint8_t alarmRepeatIdxForMask(uint8_t mask) {
switch (mask) {
case ALARM_REPEAT_DAILY: return 1;
case ALARM_REPEAT_WEEKDAYS: return 2;
case ALARM_REPEAT_WEEKENDS: return 3;
default: return 0;
}
}
static const char* alarmRepeatLabel(uint8_t idx) {
static const char* L[ALARM_REPEAT_COUNT] = { "OFF", "Daily", "Weekdays", "Weekends" };
return L[idx < ALARM_REPEAT_COUNT ? idx : 0];
}
// ── Buzzer & notifications ────────────────────────────────────────────
uint8_t buzzer_quiet;
uint8_t buzzer_volume; // 0=min..4=max, default 4
uint8_t buzzer_auto; // 0=manual (default), 1=auto-mute when BT connected
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
// Second melody slot (same packing as ringtone_*)
uint8_t ringtone2_bpm_idx;
uint8_t ringtone2_len;
uint8_t ringtone2_notes[32];
// Global melodies for notifications: 0=built-in, 1=melody1, 2=melody2, 3=none
uint8_t notif_melody_dm;
uint8_t notif_melody_ch;
uint8_t notif_melody_ad;
uint64_t ch_notif_override; // bitmask: bit i = channel i has explicit notification setting [del→onChannelRemoved]
uint64_t ch_notif_muted; // bitmask: bit i = channel i muted (only if override bit set) [del→onChannelRemoved]
// 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)
struct DmNotifEntry { uint8_t prefix[4]; uint8_t state; }; // state: 0=default,1=muted,2=force-on
static const int DM_NOTIF_TABLE_MAX = 16;
DmNotifEntry dm_notif[DM_NOTIF_TABLE_MAX]; // 16*5 = 80 bytes [del→onContactRemoved]
// Per-DM melody table
struct DmMelodyEntry { uint8_t prefix[4]; uint8_t slot; }; // slot: 0=global,1=melody1,2=melody2
static const int DM_MELODY_TABLE_MAX = 16;
DmMelodyEntry dm_melody[DM_MELODY_TABLE_MAX]; // [del→onContactRemoved]
// ── Bot ────────────────────────────────────────────────────────────────
uint8_t bot_enabled; // 0=disabled, 1=DM trigger-reply active — DM only; channel/room have their own bot_channel_enabled/bot_room_enabled and don't depend on this
char bot_trigger[64]; // DM trigger phrase (case-insensitive contains; "*" = any DM)
char bot_reply_dm[140]; // auto-reply text for DM
uint8_t bot_commands_enabled; // 0=off, 1=answer !ping/!batt/!loc/!time/!help DM commands — DM only, see bot_commands_ch/bot_commands_room below for the other two targets
uint8_t bot_actions_dm;
// Bot DM allow-list: who is allowed to trigger a DM auto-reply or run a
// command. 0 = all (default, matches the original bot_enabled behaviour).
// 1 = favourites only — gate on ContactInfo::flags bit 0, the same
// "favourite"/starred bit the Messages screen's DM list filter (dm_show_all)
// already reads, so no separate allow-list storage is needed.
uint8_t bot_dm_scope;
uint8_t bot_channel_enabled; // 0=disabled, 1=channel bot active for bot_channel_idx
uint8_t bot_channel_idx; // channel index for channel bot [del→onChannelRemoved]
char bot_reply_ch[140]; // auto-reply text for channel
char bot_trigger_ch[64]; // channel trigger phrase (independent of DM; "*" = any channel msg)
uint8_t bot_commands_ch;
uint8_t bot_actions_ch;
// Room-server auto-reply bot — same trigger/reply/command shape as the DM
// and channel bots above, but targets a single room server (like the
// channel bot targets a single channel) since posting requires that room's
@@ -399,25 +360,13 @@ struct NodePrefs { // persisted to file
uint8_t bot_room_prefix[6]; // target room's pubkey prefix [del→onContactRemoved]
char bot_trigger_room[64]; // room trigger phrase (independent of DM/channel; "*" = any post)
char bot_reply_room[140]; // auto-reply text for the room
// 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_ch;
uint8_t bot_commands_room;
// 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()). Appended at the tail (see the serialization tripwire
// below); default 0 (Latin) matches the keyboard's original always-Latin-
// main behaviour for upgraders.
uint8_t keyboard_main_alphabet;
// 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
@@ -425,10 +374,112 @@ struct NodePrefs { // persisted to file
// 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.
uint8_t bot_actions_dm;
uint8_t bot_actions_ch;
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
// ── GPS, trail & location sharing ─────────────────────────────────────
uint8_t gps_enabled; // GPS enabled flag (0=disabled, 1=enabled)
uint32_t gps_interval; // GPS duty-cycle sleep window in seconds (0 = disabled, GPS stays continuous)
// Global measurement system for every distance/speed shown in the UI
// (Nearby, Trail, navigate-to-point). 0=metric (default), 1=imperial.
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;
// Trail auto-pause — when tracking, automatically freeze the trail (timer +
// sampling) after the device has sat still for this long, and resume on the
// next real movement. 0 = off. Index into trailAutoPauseSecs.
uint8_t trail_autopause_idx;
// Auto-save the live GPS trail to /trail on shutdown (covers the low-battery
// auto-shutdown, which otherwise loses the whole route). Only writes when the
// trail has points and the toggle is on. 0 = off (default), 1 = on.
// [Tools Trail Settings Auto-save]
uint8_t trail_autosave_lowbatt;
// GPS averaging for waypoint marking — when set, "Mark here" samples the GPS
// fix for this many seconds and stores the mean position, for a more accurate
// mark than a single instantaneous fix. 0 = off (instant mark, the default).
// Index into gpsAvgSecs(). [Tools Trail Settings Mark avg]
uint8_t gps_avg_idx;
// Track positions shared by other nodes via [LOC] messages (LiveTrackStore).
// 0 = ignore shared positions (default), 1 = parse incoming DM/channel [LOC]
// shares and update the live-track table (Nearby "Live" view / map).
uint8_t track_shared_loc;
// Live location sharing — a message-based "beacon". When enabled, the device
// periodically sends a [LOC] message to the chosen target while it moves.
// Configured from the Map (Trail screen) "Live share" menu.
uint8_t loc_share_enabled; // 0=off (default), 1=auto-sharing on
uint8_t loc_share_target_type; // 0=channel, 1=DM contact
uint8_t loc_share_channel_idx; // target channel index (when target_type==0) [del→onChannelRemoved]
uint8_t loc_share_dm_prefix[6]; // target contact pubkey prefix (when target_type==1) [del→onContactRemoved]
uint8_t loc_share_move_idx; // movement gate level (index into locShareMoveMeters)
uint8_t loc_share_interval_idx; // min send interval (index into locShareIntervalSecs)
uint8_t loc_share_heartbeat_idx; // stationary heartbeat (index into locShareHeartbeatSecs)
// Locator — a single geofence around a saved point. When enabled the device
// watches its own GPS fix and beeps + shows an alert when it crosses into
// (arrive) or out of (leave) the radius. The target coordinate/label is a
// snapshot of a chosen waypoint, so the alert survives the waypoint being
// edited or deleted. Configured from Tools Locator.
uint8_t locator_enabled; // 0=off (default), 1=armed
uint8_t locator_has_target; // 0=no target chosen yet, 1=target set
uint8_t locator_radius_idx; // index into locatorRadiusMeters
uint8_t locator_mode; // 0=arrive, 1=leave, 2=both
int32_t locator_lat_1e6; // target latitude (1e6-scaled; last-known for a contact)
int32_t locator_lon_1e6; // target longitude (1e6-scaled; last-known for a contact)
char locator_label[12]; // target name for the alert text (WAYPOINT_LABEL_LEN)
// Target can be a static waypoint or a live contact: for a contact the engine
// re-reads the latest [LOC] position each evaluation (keyed by pubkey prefix),
// so the geofence follows a moving person ("alert when my friend is near").
uint8_t locator_target_kind; // 0=waypoint (static), 1=live contact
uint8_t locator_key[6]; // contact pubkey prefix when target_kind==1 [del→onContactRemoved]
// Locator proximity beeper — when on (and the alert is armed with a target),
// the device ticks while inside the radius and shortens the gap between ticks
// the closer it gets to the target, like a homing beeper. Independent of the
// discrete arrive/leave alert (locator_mode).
uint8_t locator_beeper; // 0=off (default), 1=on
// ── Contacts, channels & favourites ───────────────────────────────────
uint8_t dm_show_all; // 0=favourites only, 1=all chat contacts (default)
uint8_t room_fav_only; // 0=all room servers (default), 1=favourites only
uint64_t ch_fav_bitmask; // bit i = channel i is marked as favourite [del→onChannelRemoved]
uint8_t ch_fav_only; // 0=show all channels (default), 1=show favourites only
// Favourites dial: 6 pinned targets. Layout transposes between landscape
// (3×2) and portrait (2×3). What a slot holds depends on favourite_kinds[]
// below:
// FAV_KIND_CONTACT — first 6 bytes of pub_key; covers chat contacts and
// room servers alike. All-zero = empty slot (a real pub_key starts with
// 6 zero bytes with probability 2^-48).
// FAV_KIND_CHANNEL — channel index in byte 0, rest zero. Never empty:
// channel 0 is all-zero, so emptiness is decided by the kind first.
static const uint8_t FAVOURITES_COUNT = 6;
static const uint8_t FAVOURITE_PREFIX_LEN = 6;
static const uint8_t FAV_KIND_CONTACT = 0;
static const uint8_t FAV_KIND_CHANNEL = 1;
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.
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
// ── Advert ─────────────────────────────────────────────────────────────
uint8_t advert_loc_policy;
uint32_t advert_auto_interval_sec; // periodic 0-hop advert with GPS: 0=off, else seconds
// Advert sound filter: 0=all adverts, 1=zero-hop only
uint8_t advert_sound_scope;
// ── GPIO ───────────────────────────────────────────────────────────────
// User-assignable GPIO pins (board-specific, see PIN_GPIO1..4 in the
// variant header -- currently Wio Tracker L1 only). One byte per pin packs
// direction + output level (+ analog, gpio1/2 only) into one field:
@@ -441,52 +492,8 @@ struct NodePrefs { // persisted to file
uint8_t gpio3_mode;
uint8_t gpio4_mode;
// 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
// on-screen keyboard skips drawing its full letter grid + special-row icons
// -- an external-keyboard typist never looks at them -- and shows a compact
// 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.
uint8_t keyboard_cardkb_compact;
// RSSI-based interference detection (relative to the radio's own noise
// floor) and hardware Channel Activity Detection before TX. See
// MyMesh::getInterferenceThreshold()/getCADEnabled() — CAD is also
// auto-enabled whenever rx_powersave is active, regardless of this flag,
// since duty-cycle sleep leaves the noise floor stale. Both default 0/off,
// and neither has a Settings/CLI toggle yet on companion_radio — persisted
// and wired for a future manual override.
uint8_t interference_threshold;
uint8_t cad_enabled;
// Repeater scope filter — logically part of the repeat_* group far above, but
// the on-disk format is append-only (see the tripwire note at the bottom of
// this file), so it has to live here at the tail.
// 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
// a different community. A no-op (forwards everything, unchanged) while
// 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.
char repeat_extra_scopes[24];
// What each favourite_contacts[] slot holds (see FAV_KIND_* by that field).
// Appended here, not next to it, because the on-disk format is append-only.
// Zero-init = every slot is a contact, which is what pre-0x28 saves are.
uint8_t favourite_kinds[FAVOURITES_COUNT]; // [del→onChannelRemoved]
// 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
// ── Custom messages ────────────────────────────────────────────────────
char custom_msgs[10][140]; // user-defined quick messages (supports {loc}, {time})
// Single source of truth for the live-share option tables (shared by the Map
// UI labels and the auto-send engine in UITask).
@@ -685,6 +692,20 @@ struct NodePrefs { // persisted to file
// 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.
static_assert(sizeof(NodePrefs) == 2760,
"NodePrefs layout changed — sync DataStore save/load + clamp, bump "
"SCHEMA_SENTINEL, then update this size (see steps above).");
@@ -1,83 +1,165 @@
#include <gtest/gtest.h>
#include <cstdio>
#include <cstring>
#include <string>
#include <Arduino.h>
#include "../../examples/companion_radio/NodePrefs.h"
class ReplayStream : public Stream {
const char* _text;
int _pos = 0;
int _len;
// These target the pure, hardware-independent helpers declared alongside
// NodePrefs (free functions and NodePrefs:: static lookup tables). They build
// and run on the `native` host env with no board/filesystem mocking, unlike
// DataStore::savePrefs()/loadPrefsInt() (the actual on-disk read/write path),
// which pulls in FILESYSTEM/File (Adafruit_LittleFS or fs::FS, depending on
// platform) plus IdentityStore/ContactInfo/ChannelDetails/target.h — real
// coverage of that path needs a filesystem mock this test file doesn't have.
public:
explicit ReplayStream(const char* text) : _text(text), _len(strlen(text)) { }
TEST(DefaultRepeaterFreqForBand, PicksTheBandBelowTheCompanionFrequency) {
EXPECT_FLOAT_EQ(433.000f, defaultRepeaterFreqForBand(490.0f)); // < 500
EXPECT_FLOAT_EQ(869.495f, defaultRepeaterFreqForBand(500.0f)); // 500 boundary
EXPECT_FLOAT_EQ(869.495f, defaultRepeaterFreqForBand(868.0f)); // 500..890
EXPECT_FLOAT_EQ(918.000f, defaultRepeaterFreqForBand(890.0f)); // 890 boundary
EXPECT_FLOAT_EQ(918.000f, defaultRepeaterFreqForBand(915.0f)); // >= 890
}
int available() override { return _len - _pos; }
int read() override { return _pos < _len ? _text[_pos++] : -1; }
int peek() override { return _pos < _len ? _text[_pos] : -1; }
};
TEST(IsValidRepeaterProfile, AcceptsAProfileWithinAllBounds) {
EXPECT_TRUE(isValidRepeaterProfile(868.0f, 250.0f, 10, 5, 850.0f, 930.0f));
}
class CaptureStream : public Stream {
std::string _text;
TEST(IsValidRepeaterProfile, RejectsFrequencyOutsideTheChipRange) {
EXPECT_FALSE(isValidRepeaterProfile(800.0f, 250.0f, 10, 5, 850.0f, 930.0f));
EXPECT_FALSE(isValidRepeaterProfile(950.0f, 250.0f, 10, 5, 850.0f, 930.0f));
}
size_t emit(long long value) {
char text[24];
int length = snprintf(text, sizeof(text), "%lld", value);
return write(reinterpret_cast<const uint8_t*>(text), length);
TEST(IsValidRepeaterProfile, RejectsSpreadingFactorOutsideFiveToTwelve) {
EXPECT_FALSE(isValidRepeaterProfile(868.0f, 250.0f, 4, 5, 850.0f, 930.0f));
EXPECT_FALSE(isValidRepeaterProfile(868.0f, 250.0f, 13, 5, 850.0f, 930.0f));
}
TEST(IsValidRepeaterProfile, RejectsCodingRateOutsideFiveToEight) {
EXPECT_FALSE(isValidRepeaterProfile(868.0f, 250.0f, 10, 4, 850.0f, 930.0f));
EXPECT_FALSE(isValidRepeaterProfile(868.0f, 250.0f, 10, 9, 850.0f, 930.0f));
}
TEST(IsValidRepeaterProfile, RejectsBandwidthOutsideSevenTo510) {
EXPECT_FALSE(isValidRepeaterProfile(868.0f, 6.9f, 10, 5, 850.0f, 930.0f));
EXPECT_FALSE(isValidRepeaterProfile(868.0f, 510.1f, 10, 5, 850.0f, 930.0f));
}
TEST(SeedDefaultRepeaterProfile, SeedsFromTheCompanionFrequencyBand) {
NodePrefs prefs{};
prefs.freq = 915.0f;
seedDefaultRepeaterProfile(prefs);
EXPECT_EQ(1, prefs.repeater_use_profile);
EXPECT_FLOAT_EQ(918.000f, prefs.repeater_freq);
EXPECT_FLOAT_EQ((float)LORA_BW, prefs.repeater_bw);
EXPECT_EQ((uint8_t)LORA_SF, prefs.repeater_sf);
EXPECT_EQ((uint8_t)LORA_CR, prefs.repeater_cr);
}
TEST(AlarmRepeatRoundTrip, EveryPresetIndexRoundTripsThroughItsMask) {
for (uint8_t idx = 0; idx < NodePrefs::ALARM_REPEAT_COUNT; idx++) {
uint8_t mask = NodePrefs::alarmRepeatMaskForIdx(idx);
EXPECT_EQ(idx, NodePrefs::alarmRepeatIdxForMask(mask)) << "idx=" << (int)idx;
}
}
TEST(AlarmRepeatRoundTrip, OutOfRangeIndexClampsToNone) {
// Cast to a prvalue: EXPECT_EQ binds its arguments by const T&, which would
// otherwise ODR-use this in-class-initialized static const and need an
// out-of-line definition that doesn't exist.
EXPECT_EQ((uint8_t)NodePrefs::ALARM_REPEAT_NONE, NodePrefs::alarmRepeatMaskForIdx(99));
}
TEST(AlarmRepeatRoundTrip, AnArbitraryNonPresetMaskReadsAsOff) {
// Documents the reverse-lookup's documented behaviour: a mask that doesn't
// match a preset (e.g. a future custom-day picker's value) reads as index 0
// ("OFF") even though it isn't actually the all-zero NONE mask.
EXPECT_EQ(0, NodePrefs::alarmRepeatIdxForMask(0x15));
}
TEST(AlarmRepeatLabel, MatchesEachPresetIndex) {
EXPECT_STREQ("OFF", NodePrefs::alarmRepeatLabel(0));
EXPECT_STREQ("Daily", NodePrefs::alarmRepeatLabel(1));
EXPECT_STREQ("Weekdays", NodePrefs::alarmRepeatLabel(2));
EXPECT_STREQ("Weekends", NodePrefs::alarmRepeatLabel(3));
}
TEST(AlarmRepeatLabel, OutOfRangeIndexClampsToOff) {
EXPECT_STREQ("OFF", NodePrefs::alarmRepeatLabel(99));
}
TEST(KeyboardAlphabetLabel, MatchesEachAlphabetIndex) {
EXPECT_STREQ("Latin", NodePrefs::keyboardAlphabetLabel(NodePrefs::KB_ALPHABET_LATIN_ONLY));
EXPECT_STREQ("Cyrillic", NodePrefs::keyboardAlphabetLabel(NodePrefs::KB_ALPHABET_CYRILLIC));
EXPECT_STREQ("Greek", NodePrefs::keyboardAlphabetLabel(NodePrefs::KB_ALPHABET_GREEK));
}
public:
size_t write(uint8_t value) override {
_text.push_back(static_cast<char>(value));
return 1;
}
TEST(KeyboardAlphabetLabel, OutOfRangeIndexClampsToLatin) {
EXPECT_STREQ("Latin", NodePrefs::keyboardAlphabetLabel(99));
}
TEST(HomePageLabel, MatchesEveryBitIndexInOrder) {
EXPECT_STREQ("Clock", NodePrefs::homePageLabel(NodePrefs::HPB_CLOCK));
EXPECT_STREQ("Tools", NodePrefs::homePageLabel(NodePrefs::HPB_TOOLS));
EXPECT_STREQ("Messages", NodePrefs::homePageLabel(NodePrefs::HPB_QUICK_MSG));
EXPECT_STREQ("Favourites", NodePrefs::homePageLabel(NodePrefs::HPB_FAVOURITES));
EXPECT_STREQ("Map", NodePrefs::homePageLabel(NodePrefs::HPB_MAP));
}
TEST(HomePageLabel, OutOfRangeBitReturnsEmptyString) {
EXPECT_STREQ("", NodePrefs::homePageLabel(NodePrefs::HPB_COUNT));
}
TEST(OptionTables, InRangeIndicesReturnTheDocumentedValues) {
EXPECT_EQ(250, NodePrefs::locShareMoveMeters(2));
EXPECT_EQ(120, NodePrefs::locShareIntervalSecs(2));
EXPECT_EQ(900, NodePrefs::locShareHeartbeatSecs(2));
EXPECT_EQ(500, NodePrefs::locatorRadiusMeters(3));
EXPECT_STREQ("Both", NodePrefs::locatorModeLabel(2));
EXPECT_EQ(10, NodePrefs::gpsAvgSecs(2));
EXPECT_STREQ("10s", NodePrefs::gpsAvgLabel(2));
EXPECT_EQ(120, NodePrefs::trailAutoPauseSecs(2));
EXPECT_STREQ("2m", NodePrefs::trailAutoPauseLabel(2));
}
// Each option table clamps an out-of-range index independently, and they
// don't all agree on which in-range index to fall back to (1 for the
// loc-share/locator tables, 0 for the GPS-averaging/trail-autopause ones) —
// this pins down that existing, slightly inconsistent behaviour so a future
// change to any one table doesn't silently change another's fallback.
TEST(OptionTables, OutOfRangeIndexFallsBackPerTable) {
EXPECT_EQ(NodePrefs::locShareMoveMeters(1), NodePrefs::locShareMoveMeters(99));
EXPECT_EQ(NodePrefs::locShareIntervalSecs(1), NodePrefs::locShareIntervalSecs(99));
EXPECT_EQ(NodePrefs::locShareHeartbeatSecs(0), NodePrefs::locShareHeartbeatSecs(99));
EXPECT_EQ(NodePrefs::locatorRadiusMeters(1), NodePrefs::locatorRadiusMeters(99));
EXPECT_STREQ("Arrive", NodePrefs::locatorModeLabel(99));
EXPECT_EQ(NodePrefs::gpsAvgSecs(0), NodePrefs::gpsAvgSecs(99));
EXPECT_EQ(NodePrefs::trailAutoPauseSecs(0), NodePrefs::trailAutoPauseSecs(99));
}
TEST(BuildRTTTLString, EmptyWhenLengthIsZero) {
char buf[64] = "unchanged";
NodePrefs::buildRTTTLString(nullptr, 0, 0, buf, sizeof(buf));
EXPECT_STREQ("", buf);
}
TEST(BuildRTTTLString, EncodesANoteAndARestWithTheChosenTempo) {
// pitch=2('d'), octave offset=1 (-> octave 5), dur_idx=1 (-> 8th note)
const uint8_t note = 2 | (1 << 3) | (1 << 5);
// pitch=0 (rest), dur_idx=0 (-> 4th note)
const uint8_t rest = 0;
const uint8_t notes[2] = { note, rest };
size_t write(const uint8_t* buffer, size_t size) override {
_text.append(reinterpret_cast<const char*>(buffer), size);
return size;
}
char buf[64];
NodePrefs::buildRTTTLString(notes, 2, /*bpm_idx=*/1, buf, sizeof(buf));
EXPECT_STREQ("Ring:d=8,o=5,b=90:8d5,4p", buf);
}
size_t print(unsigned char value, int = DEC) override { return emit(value); }
size_t print(int value, int = DEC) override { return emit(value); }
size_t print(unsigned int value, int = DEC) override { return emit(value); }
size_t print(long value, int = DEC) override { return emit(value); }
size_t print(unsigned long value, int = DEC) override { return emit(value); }
size_t print(long long value, int = DEC) override { return emit(value); }
size_t print(unsigned long long value, int = DEC) override { return emit(value); }
const std::string& text() const { return _text; }
};
#if 0
// Re-enable test once we can SET fem_ values in companion
TEST(CompanionNodePrefs, RxGainSettingsRoundTripIndependently) {
NodePrefs saved;
saved.rx_boosted_gain = 0;
saved.radio_fem_rxgain = 1;
saved.radio_fem_txgain = 0;
CaptureStream output;
ASSERT_TRUE(saved.saveSerial(output));
EXPECT_NE(std::string::npos, output.text().find("rxgain:0"));
EXPECT_NE(std::string::npos, output.text().find("fem_rxgain:1"));
EXPECT_NE(std::string::npos, output.text().find("fem_txgain:0"));
ReplayStream input("{radio:{rxgain:1,fem_rxgain:0,fem_txgain:1}}");
NodePrefs loaded;
loaded.rx_boosted_gain = 0;
loaded.radio_fem_rxgain = 1;
loaded.radio_fem_txgain = 0;
ASSERT_TRUE(loaded.loadSerial(input));
EXPECT_EQ(1, loaded.rx_boosted_gain);
EXPECT_EQ(0, loaded.radio_fem_rxgain);
EXPECT_EQ(1, loaded.radio_fem_txgain);
TEST(BuildRTTTLString, OutOfRangeBpmIndexClampsToTheMiddlePreset) {
const uint8_t note = 0; // a single rest is enough to isolate the tempo
char buf[64];
NodePrefs::buildRTTTLString(&note, 1, /*bpm_idx=*/9, buf, sizeof(buf));
EXPECT_STREQ("Ring:d=8,o=5,b=120:4p", buf);
}
#endif
int main(int argc, char** argv) {
::testing::InitGoogleTest(&argc, argv);