2025-06-01 09:25:17 -07:00
#pragma once
2026-05-23 20:03:45 +02:00
#include <cstdint>
#include <stdio.h>
2026-09-28 22:35:26 +02:00
#include <time.h>
2026-10-15 13:25:58 +02:00
#include <helpers/ConfigSerializer.h>
#include <helpers/DynamicConfigSerializer.h>
2025-04-20 17:40:58 -07:00
2026-06-20 09:07:00 +02:00
// Firmware's own boot-default radio params — used both as the companion's
// initial freq/sf/bw/cr (MyMesh.cpp) and, here, as the seed for a never-
// configured repeater profile (DataStore.cpp). Kept above any per-board
// target.h include so a board can still override via -D before this file
// is reached.
#ifndef LORA_FREQ
#define LORA_FREQ 915.0
#endif
#ifndef LORA_BW
#define LORA_BW 250
#endif
#ifndef LORA_SF
#define LORA_SF 10
#endif
#ifndef LORA_CR
#define LORA_CR 5
#endif
// Bucket a companion frequency into whichever of the three license-exempt
// bands MeshCore's app-driven repeat toggle historically restricted to (see
// repeat_freq_ranges in MyMesh.cpp: 433.000 / 869.495 / 918.000 MHz). Used to
// seed a never-configured repeater profile in the same legal band as the
// companion's own network (MyMesh.cpp begin(), DataStore.cpp) — a flat
// single frequency could land outside the bands allowed where the operator
// actually lives.
static inline float defaultRepeaterFreqForBand ( float companion_freq ) {
if ( companion_freq < 500.0f ) return 433.000f ;
if ( companion_freq < 890.0f ) return 869.495f ;
return 918.000f ;
}
2025-06-01 20:41:04 -07:00
#define TELEM_MODE_DENY 0
#define TELEM_MODE_ALLOW_FLAGS 1 // use contact.flags
#define TELEM_MODE_ALLOW_ALL 2
2025-05-05 11:21:55 +10:00
2025-06-22 16:21:04 +10:00
#define ADVERT_LOC_NONE 0
#define ADVERT_LOC_SHARE 1
2026-06-07 10:53:17 +02:00
#define ADVERT_SOUND_SCOPE_ALL 0
#define ADVERT_SOUND_SCOPE_ZERO_HOP 1
2026-10-15 13:25:58 +02:00
// The settings' fields. NodePrefs (below) adds how they're saved: /prefs.json,
// one key per field (NodePrefs::structure()). A new field goes anywhere here
// and gets a key there; a file without it leaves the default (MyMesh's
// constructor), and a key no field has any more is skipped.
struct NodePrefsFields {
2026-09-01 21:56:57 +02:00
// ── Identity & scope ──────────────────────────────────────────────────
2025-04-20 17:40:58 -07:00
char node_name [ 32 ];
2026-09-01 21:56:57 +02:00
char default_scope_name [ 31 ];
uint8_t default_scope_key [ 16 ];
// ── Radio (companion's own link) ──────────────────────────────────────
float airtime_factor ;
2025-04-20 17:40:58 -07:00
float freq ;
uint8_t sf ;
uint8_t cr ;
float bw ;
2026-01-03 20:35:14 +01:00
int8_t tx_power_dbm ;
2025-04-20 17:40:58 -07:00
float rx_delay_base ;
2026-09-01 21:56:57 +02:00
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 ;
2026-03-05 18:14:47 +00:00
uint8_t rx_boosted_gain ; // SX126x RX boosted gain mode (0=power saving, 1=boosted)
2026-09-01 21:56:57 +02:00
// 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,
2026-10-12 20:36:42 +02:00
// since duty-cycle sleep leaves the noise floor stale. Both default 0/off;
// set from Settings > Radio and the CLI (ui-core/SettingsSchema.h).
2026-09-01 21:56:57 +02:00
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 ;
2026-08-14 15:58:23 +02:00
// 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
2026-10-03 00:12:18 +02:00
// these yet: they keep the constructor default (MyMesh.cpp).
2026-08-14 15:58:23 +02:00
uint8_t radio_fem_rxgain ;
uint8_t radio_fem_txgain ;
2026-10-15 13:25:58 +02:00
// Retransmit delay factors (x airtime, the random window for a forwarded
// flood / direct packet) and the AGC reset interval (seconds / 4, 0 = off),
// as upstream's (CLI txdelay / direct.txdelay / agc.reset.interval).
float tx_delay_factor ;
float direct_tx_delay_factor ;
uint8_t agc_reset_interval ;
2026-06-21 22:32:59 +02:00
// 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.
2026-06-20 09:07:00 +02:00
struct UserRadioPreset {
char name [ 16 ];
float freq ;
float bw ;
uint8_t sf ;
uint8_t cr ;
};
static const uint8_t USER_RADIO_PRESET_MAX = 4 ;
UserRadioPreset user_radio_presets [ USER_RADIO_PRESET_MAX ];
2026-09-01 21:56:57 +02:00
// ── Repeater ───────────────────────────────────────────────────────────
uint8_t client_repeat ;
2026-06-21 22:32:59 +02:00
// Repeater forwarding filters — only consulted when client_repeat is on, via
2026-06-20 09:07:00 +02:00
// MyMesh::allowPacketForward(). Both default to off (0) so behaviour is
2026-06-21 22:32:59 +02:00
// unchanged until the user opts in (Tools > Repeater).
2026-06-20 09:07:00 +02:00
// repeat_skip_adverts: 1 = don't re-flood ADVERT packets (the highest-volume
// flood traffic); messages/acks still relay.
// repeat_max_hops: drop a flood packet once it has already travelled this
// many hops. 0 = no hop limit.
// repeat_delay_boost: extra retransmit-delay multiplier for FORWARDED floods
// only (own sends are unaffected) — a mobile companion yields to better-sited
// fixed repeaters. Effective delay = base * (1 + repeat_delay_boost). 0 = off.
// repeat_min_snr: drop a flood packet received below this SNR (dB), so marginal
// 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).
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 ;
2026-09-01 21:56:57 +02:00
// 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 ;
2026-10-03 00:12:18 +02:00
// The scopes this repeater also relays for, beyond its own. Bit i = scope-list index
2026-09-12 00:05:40 +02:00
// (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().
2026-10-03 00:12:18 +02:00
// At most MAX_REPEAT_SCOPES (4) of them count.
2026-09-12 00:05:40 +02:00
uint16_t repeat_extra_scope_mask ;
2026-06-20 09:07:00 +02:00
// 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
// repeater stays on the current companion frequency. Default (a never-configured
// device) is 1, seeded via defaultRepeaterFreqForBand(freq) + LORA_SF/BW/CR —
// repeating on whatever private network the companion later joins isn't the
// community norm, and the seed stays in the same legal band as the companion's
// own network rather than a flat frequency.
uint8_t repeater_use_profile ;
float repeater_freq ;
float repeater_bw ;
uint8_t repeater_sf ;
uint8_t repeater_cr ;
2026-09-01 21:56:57 +02:00
// ── Telemetry ──────────────────────────────────────────────────────────
uint8_t telemetry_mode_base ;
uint8_t telemetry_mode_loc ;
uint8_t telemetry_mode_env ;
2026-06-25 19:04:21 +02:00
2026-09-01 21:56:57 +02:00
// ── 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)
2026-06-25 19:04:21 +02:00
2026-09-01 21:56:57 +02:00
// ── Connectivity (BLE) ────────────────────────────────────────────────
uint32_t ble_pin ;
2026-10-12 20:36:42 +02:00
// 1 = the user switched Bluetooth off: it stays off after a reboot or
2026-10-15 14:04:58 +02:00
// hibernate (main.cpp). USB keeps working either way.
2026-10-12 20:36:42 +02:00
uint8_t ble_off ;
2026-10-15 14:04:58 +02:00
#ifdef ENABLE_WIFI_INTERFACE
// The app over WiFi (TCP), upstream's: an empty SSID uses the build's WIFI_SSID.
char wifi_ssid [ 33 ];
char wifi_pwd [ 64 ];
uint8_t wifi_enabled ;
#endif
2026-06-25 19:04:21 +02:00
2026-09-01 21:56:57 +02:00
// ── 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
2026-10-12 20:36:42 +02:00
// 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 ];
2026-06-30 01:54:01 +02:00
2026-09-01 21:56:57 +02:00
// ── 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 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)
2026-10-12 20:36:42 +02:00
// Text and icon size (ui-new, big e-ink panels): Settings > Display, only on
// builds that opt in (EINK_FONT_SCALE / STATUS_ICON_SCALE). ui_text_scale:
// the font's pixel multiplier, 1-5; 0 is the build's own (DataStore sanitize
// sets EINK_FONT_SCALE there). ui_icon_scale: Home's status icons, 0 = Auto
// (1.5x the text), else 1-8.
static const uint8_t UI_TEXT_SCALE_MAX = 5 , UI_ICON_SCALE_MAX = 8 ;
uint8_t ui_text_scale ;
uint8_t ui_icon_scale ;
2026-09-01 21:56:57 +02:00
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
2026-10-03 00:12:18 +02:00
// builds that add HomePageBit entries.
2026-09-01 21:56:57 +02:00
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 ;
2026-07-02 12:21:52 +02:00
// On-screen keyboard layout, shared across every text-entry screen (Settings >
2026-07-02 18:33:40 +02:00
// Keyboard). 0=ABC grid, alphabetical order (default), 1=T9 multi-tap
// (phone-keypad groups, cycled with repeated Enter presses — see KeyboardWidget.h).
2026-07-02 12:21:52 +02:00
uint8_t keyboard_type ;
2026-07-10 16:01:42 +02:00
// 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
// toggles between just the Latin letters page and the symbols page, as
// today. A non-zero value adds that alphabet's own page to the same cycle
// key (Latin → alphabet → symbols → Latin), so composing in it needs no new
// key, just Settings › Keyboard › Alphabet to pick which one is available.
// See KeyboardWidget.h for the per-alphabet grids (KB_CYRILLIC_CHARS etc.)
2026-07-17 19:38:51 +02:00
// and the misc-fixed font (src/helpers/ui/MiscFixedFont.h) for on-screen
// rendering — every alphabet here must be in its U+0020-04FF range.
2026-07-12 19:40:53 +02:00
//
2026-07-17 20:28:11 +02:00
// Latin-diacritic letters (Polish/Czech/Slovak/German/French/Spanish/
// 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
2026-10-03 00:12:18 +02:00
// enum only covers actual non-Latin scripts.
2026-07-10 16:01:42 +02:00
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 ;
2026-07-17 20:28:11 +02:00
static const uint8_t KB_ALPHABET_COUNT = 3 ;
2026-07-10 16:01:42 +02:00
static const char * keyboardAlphabetLabel ( uint8_t idx ) {
2026-07-17 20:28:11 +02:00
static const char * L [ KB_ALPHABET_COUNT ] = { "Latin" , "Cyrillic" , "Greek" };
2026-07-10 16:01:42 +02:00
return L [ idx < KB_ALPHABET_COUNT ? idx : 0 ];
}
uint8_t keyboard_alt_alphabet ;
2026-09-01 21:56:57 +02:00
// 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
2026-10-03 00:12:18 +02:00
// hasAltAlphabet()). Default 0 (Latin).
2026-09-01 21:56:57 +02:00
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
2026-10-03 00:12:18 +02:00
// stays put even if the module is briefly unplugged. Default 0 (full grid).
2026-09-01 21:56:57 +02:00
uint8_t keyboard_cardkb_compact ;
2026-07-10 16:01:42 +02:00
2026-09-01 21:56:57 +02:00
// ── 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
2026-10-29 14:09:25 +01:00
// Summer time: the rule that puts the clock an hour ahead for part of the
// year, on top of tz_offset_hours (then the standard, winter offset). Every
// local time goes through tzHoursAt() below.
enum : uint8_t { DST_OFF , DST_EU , DST_US , DST_AU , DST_NZ , DST_COUNT };
uint8_t dst_rule ;
2026-09-01 21:56:57 +02:00
// 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
2026-10-03 00:12:18 +02:00
// explanation (bit i = 1<<tm_wday).
2026-09-01 21:56:57 +02:00
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 ) {
2026-10-02 08:29:44 +02:00
static const char * L [ ALARM_REPEAT_COUNT ] = { "Once" , "Daily" , "Weekdays" , "Weekends" }; // 0: no repeat (as the L2 shows it)
2026-09-01 21:56:57 +02:00
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
2026-09-28 21:59:40 +02:00
// Quiet hours: muted from quiet_from to quiet_to (local hours 0-23, wrapping
// past midnight) while quiet_hours is on; the clock alarm still rings and a
// message doesn't wake the screen (inQuietHours() below). A manual mute /
// unmute during the window stands until it ends (soundctl::tick()).
uint8_t quiet_hours ; // 0=off (default), 1=on
uint8_t quiet_from ; // default 22
uint8_t quiet_to ; // default 7
2026-10-03 00:12:18 +02:00
// 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
2026-10-30 09:46:43 +01:00
// Settings > Messages > Keep messages, boards with HIST_FLASH: how many
// messages in all stay on flash (index into msgKeepTotal(); 0 = off, none).
uint8_t msg_keep ;
2026-10-30 10:01:29 +01:00
// Settings > Messages > Delete after: messages older than this go, from the
// list at once (index into contactExpiryDays(); 0 = never).
uint8_t msg_expire ;
2026-10-30 12:35:22 +01:00
// Channel options > Save: bit i = channel i isn't kept on flash, for one too
// busy to be worth the room [del→onChannelRemoved]
uint64_t ch_no_save ;
2026-10-12 20:36:42 +02:00
// ui-new's new-message alert: the banner from the top, an envelope with a
// count in the corner, or nothing (the sound and the wake still happen).
static const uint8_t MSG_ALERT_NORMAL = 0 , MSG_ALERT_COMPACT = 1 , MSG_ALERT_OFF = 2 ;
uint8_t msg_alert ;
2026-09-01 21:56:57 +02:00
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)
2026-10-06 14:09:22 +02:00
// Scope-list index per channel (see ScopeList): 0 = "*" (unscoped), 1.. a
// list entry, CH_SCOPE_DEFAULT (the default for every channel) = whatever
// the list's default is (MyMesh::channelScope()). Fixed at 64 slots (not
2026-09-12 00:05:40 +02:00
// 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.
static const uint8_t MAX_SCOPED_CHANNELS = 64 ;
2026-10-06 14:09:22 +02:00
static const uint8_t CH_SCOPE_DEFAULT = 0xFF ;
2026-09-12 00:05:40 +02:00
uint8_t ch_scope_idx [ MAX_SCOPED_CHANNELS ]; // [del→onChannelRemoved]
2026-09-01 21:56:57 +02:00
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 ;
2026-07-10 20:16:59 +02:00
// 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 ;
2026-09-01 21:56:57 +02:00
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 ;
2026-07-10 20:16:59 +02:00
// 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
// own login session (see MyMesh::sendRoomLogin/logoutRoom). If the device
// has never logged into this room, replies silently fail to send — same
// as a manual post would — so log in at least once from Messages > Rooms
// before relying on the bot there.
uint8_t bot_room_enabled ; // 0=disabled, 1=room bot active for bot_room_prefix
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.
uint8_t bot_commands_room ;
2026-07-21 20:30:03 +02:00
// 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
2026-10-03 00:12:18 +02:00
// through. Default 0 (off) -- these change device behaviour remotely.
2026-07-21 20:30:03 +02:00
uint8_t bot_actions_room ;
2026-09-01 21:56:57 +02:00
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
2026-10-26 19:46:28 +01:00
uint8_t bot_actions_scope ; // who may run the bot's actions (!gps !buzz !gpio ...): 0 = all, 1 = favourites
2026-07-21 20:30:03 +02:00
2026-09-01 21:56:57 +02:00
// ── 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_min_delta_idx ; // min-distance gate level (0=finest..3); metres or feet per 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 ;
// 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)
2026-09-19 14:22:27 +02:00
// Scope used for these [LOC] sends only. 0 = follow the target (a channel's own
// scope pick / the list default for a DM -- the behaviour before this field);
// n>0 = scope-list index (n-1), so 1 is "*" (unscoped). [del→MyMesh::removeScope]
uint8_t loc_share_scope ;
// How long one auto-share session runs before it switches itself off (index into
// locShareDurationMins). There is deliberately no "never" option. 0 = the
// shortest/default, so a zeroed field is already valid.
uint8_t loc_share_duration_idx ;
2026-09-25 19:20:42 +02:00
// Backlight as a percentage (1-100) for frontends with a brightness slider
// (ui-lvgl); 0 = use the 0-4 display_brightness level (ui-new, and the default).
uint8_t display_brightness_pct ;
2026-09-01 21:56:57 +02:00
// 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").
2026-09-25 19:20:42 +02:00
// Kind 2 is a channel [LOC] share: no key, followed by sender name (label).
uint8_t locator_target_kind ; // 0=waypoint (static), 1=live contact, 2=channel live share
2026-09-01 21:56:57 +02:00
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).
2026-10-03 00:12:18 +02:00
// Zero-init = every slot is a contact.
2026-09-01 21:56:57 +02:00
uint8_t favourite_kinds [ FAVOURITES_COUNT ]; // [del→onContactRemoved/onChannelRemoved] -- clearFavouriteSlot() clears both fields together, called from either handler depending on the slot's kind
2026-10-03 00:12:18 +02:00
// Settings > Contacts > "Favs top".
uint8_t fav_sort ; // 1 = favourites first in every list (default), 0 = natural order
2026-09-12 21:55:46 +02:00
// 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
2026-10-03 00:12:18 +02:00
// regardless of age.
2026-09-12 21:55:46 +02:00
uint8_t contact_expiry_idx ; // 0 = off (default)
2026-09-01 21:56:57 +02:00
// ── 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 ;
2026-10-12 20:36:42 +02:00
// Settings > Privacy: with "Position in adverts" on, the position is snapped
// to a fixed grid (MyMesh::createOwnAdvert): 0 = exact, 1 = ~100 m,
// 2 = ~500 m, 3 = ~1 km.
static const uint8_t ADV_LOC_PREC_COUNT = 4 ;
uint8_t advert_loc_prec ;
2026-09-01 21:56:57 +02:00
// ── GPIO ───────────────────────────────────────────────────────────────
2026-07-21 20:30:03 +02:00
// 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:
// 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
2026-10-03 00:12:18 +02:00
// (see DataStore's sanitize). Default 0 (off/disconnected).
2026-07-21 20:30:03 +02:00
uint8_t gpio1_mode ;
uint8_t gpio2_mode ;
uint8_t gpio3_mode ;
uint8_t gpio4_mode ;
2026-09-01 21:56:57 +02:00
// ── Custom messages ────────────────────────────────────────────────────
char custom_msgs [ 10 ][ 140 ]; // user-defined quick messages (supports {loc}, {time})
2026-08-31 15:02:01 +02:00
2026-10-12 20:36:42 +02:00
// ── Screen lock ────────────────────────────────────────────────────────
2026-09-29 00:06:01 +02:00
// 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 ;
static const uint8_t LOCK_SALT_LEN = 16 ;
uint8_t lock_screen_password [ LOCK_HASH_LEN ];
uint8_t lock_screen_password_salt [ LOCK_SALT_LEN ];
2026-10-02 11:58:56 +02:00
// 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 ;
2026-10-14 10:46:20 +02:00
// Settings > Contacts > "Channel order": how the channel lists are sorted
// (after favourites, when those go first): 0 = as the slots are (default),
// 1 = latest message first, 2 = by name. See chanctl::order().
static const uint8_t CH_SORT_COUNT = 3 ;
uint8_t ch_sort ;
2026-10-14 12:50:43 +02:00
// Map > options > "Packet flow": what the radio hears drawn on the map as
// lines between the nodes (ui-core/PacketFlow.h). 0 = off (default).
uint8_t flow_show ;
2026-10-02 11:58:56 +02:00
2026-06-25 19:04:21 +02:00
// Single source of truth for the live-share option tables (shared by the Map
// UI labels and the auto-send engine in UITask).
static const uint8_t LOC_SHARE_MOVE_COUNT = 4 ;
static uint16_t locShareMoveMeters ( uint8_t idx ) {
static const uint16_t M [ LOC_SHARE_MOVE_COUNT ] = { 50 , 100 , 250 , 500 };
return M [ idx < LOC_SHARE_MOVE_COUNT ? idx : 1 ];
}
static const uint8_t LOC_SHARE_INTERVAL_COUNT = 4 ;
static uint16_t locShareIntervalSecs ( uint8_t idx ) {
static const uint16_t S [ LOC_SHARE_INTERVAL_COUNT ] = { 30 , 60 , 120 , 300 };
return S [ idx < LOC_SHARE_INTERVAL_COUNT ? idx : 1 ];
}
2026-09-19 14:22:27 +02:00
static const uint8_t LOC_SHARE_DURATION_COUNT = 5 ;
static uint16_t locShareDurationMins ( uint8_t idx ) {
static const uint16_t D [ LOC_SHARE_DURATION_COUNT ] = { 60 , 120 , 240 , 480 , 720 }; // 1 / 2 / 4 / 8 / 12 h
return D [ idx < LOC_SHARE_DURATION_COUNT ? idx : 0 ];
}
2026-06-25 19:04:21 +02:00
static const uint8_t LOC_SHARE_HEARTBEAT_COUNT = 3 ;
static uint16_t locShareHeartbeatSecs ( uint8_t idx ) {
static const uint16_t H [ LOC_SHARE_HEARTBEAT_COUNT ] = { 0 , 300 , 900 }; // off / 5 min / 15 min
return H [ idx < LOC_SHARE_HEARTBEAT_COUNT ? idx : 0 ];
}
// Locator option tables (shared by the Tools › Locator UI labels and the
// evaluation engine in UITask).
static const uint8_t LOCATOR_RADIUS_COUNT = 5 ;
static uint16_t locatorRadiusMeters ( uint8_t idx ) {
static const uint16_t R [ LOCATOR_RADIUS_COUNT ] = { 50 , 100 , 250 , 500 , 1000 };
return R [ idx < LOCATOR_RADIUS_COUNT ? idx : 1 ];
}
static const uint8_t LOCATOR_MODE_COUNT = 3 ; // 0=arrive, 1=leave, 2=both
static const char * locatorModeLabel ( uint8_t m ) {
static const char * L [ LOCATOR_MODE_COUNT ] = { "Arrive" , "Leave" , "Both" };
return L [ m < LOCATOR_MODE_COUNT ? m : 0 ];
}
2026-06-29 19:19:43 +02:00
// GPS-averaging durations for waypoint marking (seconds). 0 = off (instant).
static const uint8_t GPS_AVG_COUNT = 4 ;
static uint16_t gpsAvgSecs ( uint8_t idx ) {
static const uint16_t S [ GPS_AVG_COUNT ] = { 0 , 5 , 10 , 30 };
return S [ idx < GPS_AVG_COUNT ? idx : 0 ];
}
static const char * gpsAvgLabel ( uint8_t idx ) {
2026-10-06 14:12:27 +02:00
static const char * L [ GPS_AVG_COUNT ] = { "Off" , "5s" , "10s" , "30s" };
2026-06-29 19:19:43 +02:00
return L [ idx < GPS_AVG_COUNT ? idx : 0 ];
}
2026-06-25 19:04:21 +02:00
// Trail auto-pause delays (seconds). 0 = off.
static const uint8_t TRAIL_AUTOPAUSE_COUNT = 4 ;
static uint16_t trailAutoPauseSecs ( uint8_t idx ) {
static const uint16_t S [ TRAIL_AUTOPAUSE_COUNT ] = { 0 , 60 , 120 , 300 }; // off / 1 / 2 / 5 min
return S [ idx < TRAIL_AUTOPAUSE_COUNT ? idx : 0 ];
}
static const char * trailAutoPauseLabel ( uint8_t idx ) {
2026-10-06 14:12:27 +02:00
static const char * L [ TRAIL_AUTOPAUSE_COUNT ] = { "Off" , "1m" , "2m" , "5m" };
2026-06-25 19:04:21 +02:00
return L [ idx < TRAIL_AUTOPAUSE_COUNT ? idx : 0 ];
}
// Movement under this many metres counts as "stationary" for auto-pause.
// Deliberately coarser than the trail min-delta gate (and independent of it)
// so GPS jitter while parked doesn't keep resetting the idle timer. Engine
// tuning only — not persisted.
static const uint16_t TRAIL_AUTOPAUSE_MOVE_M = 15 ;
2026-09-12 21:55:46 +02:00
// Contact-expiry thresholds (days) for contact_expiry_idx. Single source of
// truth for both the Settings > Contacts "Expire" row's label and the age
// MyMesh::countStaleContacts()/pruneStaleContacts() actually applies, so the
// number the user picks and the one enforced can't drift apart. 0 = off,
// which is also the clamp target for any out-of-range saved index.
static const uint8_t CONTACT_EXPIRY_COUNT = 4 ;
static uint16_t contactExpiryDays ( uint8_t idx ) {
static const uint16_t D [ CONTACT_EXPIRY_COUNT ] = { 0 , 7 , 30 , 90 };
return D [ idx < CONTACT_EXPIRY_COUNT ? idx : 0 ];
}
static const char * contactExpiryLabel ( uint8_t idx ) {
static const char * L [ CONTACT_EXPIRY_COUNT ] = { "Off" , "7d" , "30d" , "90d" };
return L [ idx < CONTACT_EXPIRY_COUNT ? idx : 0 ];
}
2026-10-30 09:46:43 +01:00
static const uint8_t MSG_KEEP_COUNT = 4 ;
static uint16_t msgKeepTotal ( uint8_t idx ) {
static const uint16_t N [ MSG_KEEP_COUNT ] = { 0 , 500 , 1000 , 2500 };
return N [ idx < MSG_KEEP_COUNT ? idx : 0 ];
}
2026-09-12 21:55:46 +02:00
2026-10-03 00:12:18 +02:00
// /prefs' layout (see the comment at the top). A file of another version
// isn't read: the defaults stand.
2026-05-24 22:26:56 +02:00
// 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
// and SettingsScreen::homePageBitIndex route their per-enum lookups through these.
enum HomePageBit : uint8_t {
2026-05-24 23:00:27 +02:00
HPB_CLOCK = 0 ,
HPB_RECENT = 1 ,
HPB_RADIO = 2 ,
HPB_BLUETOOTH = 3 ,
HPB_ADVERT = 4 ,
HPB_GPS = 5 ,
HPB_SENSORS = 6 ,
HPB_TOOLS = 7 ,
HPB_SHUTDOWN = 8 ,
HPB_SETTINGS = 9 ,
HPB_QUICK_MSG = 10 ,
HPB_FAVOURITES = 11 ,
2026-07-02 10:34:38 +02:00
HPB_MAP = 12 ,
HPB_COUNT = 13 ,
2026-05-24 22:26:56 +02:00
};
2026-07-02 12:01:40 +02:00
// Number of usable page_order[] slots — one per home page (== HPB_COUNT), so
// 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 ;
2026-05-24 23:00:27 +02:00
2026-05-23 20:03:45 +02:00
// Bitmasks for home_pages_mask (bit=1 → page visible; 0 field = all visible).
2026-05-24 22:26:56 +02:00
// SETTINGS and QUICK_MSG have no mask bit — they're always visible.
2026-05-24 23:00:27 +02:00
static const uint16_t HP_CLOCK = 1 << HPB_CLOCK ;
static const uint16_t HP_RECENT = 1 << HPB_RECENT ;
static const uint16_t HP_RADIO = 1 << HPB_RADIO ;
static const uint16_t HP_BLUETOOTH = 1 << HPB_BLUETOOTH ;
static const uint16_t HP_ADVERT = 1 << HPB_ADVERT ;
static const uint16_t HP_GPS = 1 << HPB_GPS ;
static const uint16_t HP_SENSORS = 1 << HPB_SENSORS ;
static const uint16_t HP_TOOLS = 1 << HPB_TOOLS ;
static const uint16_t HP_SHUTDOWN = 1 << HPB_SHUTDOWN ;
static const uint16_t HP_FAVOURITES = 1 << HPB_FAVOURITES ;
2026-07-02 10:34:38 +02:00
static const uint16_t HP_MAP = 1 << HPB_MAP ;
static const uint16_t HP_ALL = 0x01FF | HP_FAVOURITES | HP_MAP ;
2026-05-23 20:03:45 +02:00
2026-05-24 22:26:56 +02:00
// Label for home page by bit-index; returns "" for out-of-range.
// Array indices match HomePageBit values.
2026-05-23 20:03:45 +02:00
static const char * homePageLabel ( uint8_t bit ) {
2026-05-24 22:26:56 +02:00
static const char * labels [ HPB_COUNT ] = {
2026-10-01 23:51:47 +02:00
"Clock" , "Recent" , "Status" , "Bluetooth" , "Advert" , // Status: the old Radio slot (ui-new)
2026-07-02 10:34:38 +02:00
"GPS" , "Sensors" , "Tools" , "Shutdown" , "Settings" , "Messages" , "Favourites" , "Map"
2026-05-23 20:03:45 +02:00
};
2026-05-24 22:26:56 +02:00
return ( bit < HPB_COUNT ) ? labels [ bit ] : "" ;
2026-05-23 20:03:45 +02:00
}
static void buildRTTTLString ( const uint8_t * notes , uint8_t len , uint8_t bpm_idx ,
char * buf , int size ) {
static const uint16_t BPM_OPTS [] = { 60 , 90 , 120 , 150 , 180 };
static const uint8_t DUR_VALS [] = { 4 , 8 , 16 , 32 };
static const char PITCHES [] = { 'p' , 'c' , 'd' , 'e' , 'f' , 'g' , 'a' , 'b' };
if ( len == 0 ) { buf [ 0 ] = '\0' ; return ; }
uint16_t bpm = BPM_OPTS [ bpm_idx < 5 ? bpm_idx : 2 ];
int pos = snprintf ( buf , size , "Ring:d=8,o=5,b=%u:" , bpm );
for ( int i = 0 ; i < len && pos < size - 8 ; i ++ ) {
if ( i > 0 && pos < size - 1 ) buf [ pos ++ ] = ',' ;
uint8_t pitch = notes [ i ] & 0x07 ;
uint8_t octave = (( notes [ i ] >> 3 ) & 0x03 ) + 4 ;
uint8_t dur_val = DUR_VALS [( notes [ i ] >> 5 ) & 0x03 ];
if ( pitch == 0 ) pos += snprintf ( buf + pos , size - pos , "%dp" , dur_val );
else pos += snprintf ( buf + pos , size - pos , "%d%c%d" , dur_val , PITCHES [ pitch ], octave );
}
if ( pos < size ) buf [ pos ] = '\0' ;
}
2026-06-04 08:46:15 +02:00
};
2026-06-20 09:07:00 +02:00
2026-10-15 13:25:58 +02:00
// The settings, saved as /prefs.json (DataStore). What upstream's companion
// has is under its keys -- "radio", "gps", "repeat", "comp", "custom" -- so
// either firmware reads what the other wrote of it; the rest is under "solo".
// Keys are at most 15 characters (ConfigSerializer). Byte arrays and 64-bit
// masks go as hex.
class NodePrefs : public NodePrefsFields , public ConfigSerializer {
// A section: a ConfigSerializer whose structure() is one of NodePrefs's.
class Section : public ConfigSerializer {
NodePrefs * _p ;
void ( NodePrefs ::* _fn )( Section & );
protected :
void structure () override { ( _p ->* _fn )( * this ); }
public :
Section ( NodePrefs * p , void ( NodePrefs ::* fn )( Section & )) : _p ( p ), _fn ( fn ) { }
using ConfigSerializer :: def ;
template < typename T > void hex ( const char * key , T & v ) { def ( key , ( void * ) & v , sizeof ( v )); }
void sub ( const char * key , Section & s ) { def ( key , s ); }
};
// The board's own settings (Board::attachDynamicPrefs()): the LoRa front
// end's gains are fields here; any other key a board has is kept in
// `custom` as it is upstream.
class FemPrefs : public KeyValueStore {
NodePrefs * _p ;
uint8_t * field ( const char * key ) {
if ( strcmp ( key , "fem_rxgain" ) == 0 ) return & _p -> radio_fem_rxgain ;
if ( strcmp ( key , "fem_txgain" ) == 0 ) return & _p -> radio_fem_txgain ;
return nullptr ;
}
public :
FemPrefs ( NodePrefs * p ) : _p ( p ) { }
bool getByKey ( const char * key , char * value , size_t max_len ) override {
uint8_t * f = field ( key );
if ( ! f || max_len < 2 ) return false ;
value [ 0 ] = * f ? '1' : '0' ;
value [ 1 ] = 0 ;
return true ;
}
bool setByKey ( const char * key , const char * value ) override {
uint8_t * f = field ( key );
if ( ! f ) return false ;
* f = atoi ( value ) ? 1 : 0 ;
_p -> markDirty ();
return true ;
}
};
// ── upstream's sections ──
void radioKeys ( Section & s ) {
s . def ( "freq" , freq ); s . def ( "bw" , bw ); s . def ( "sf" , sf ); s . def ( "cr" , cr );
s . def ( "cad" , cad_enabled ); s . def ( "int_thr" , interference_threshold );
s . def ( "rxgain" , rx_boosted_gain );
s . def ( "fem_rxgain" , radio_fem_rxgain ); s . def ( "fem_txgain" , radio_fem_txgain );
s . def ( "tx" , tx_power_dbm ); s . def ( "af" , airtime_factor ); s . def ( "rxdelay" , rx_delay_base );
s . def ( "f_txdelay" , tx_delay_factor ); s . def ( "d_txdelay" , direct_tx_delay_factor );
s . def ( "agc_int" , agc_reset_interval );
s . def ( "hash_mode" , path_hash_mode ); s . def ( "multi_ack" , multi_acks );
}
void gpsKeys ( Section & s ) {
s . def ( "en" , gps_enabled ); s . def ( "int" , gps_interval ); s . def ( "adv_loc" , advert_loc_policy );
}
void repeatKeys ( Section & s ) { // upstream keeps "disable", the inverse
uint8_t off = client_repeat ? 0 : 1 ;
s . def ( "disable" , off );
client_repeat = off ? 0 : 1 ;
}
void compKeys ( Section & s ) {
s . def ( "auto_max" , autoadd_max_hops );
s . def ( "defs_nm" , default_scope_name , sizeof ( default_scope_name ));
s . hex ( "defs_key" , default_scope_key );
s . def ( "pin" , ble_pin ); s . def ( "buzz_q" , buzzer_quiet );
s . def ( "auto_add" , autoadd_config ); s . def ( "man_add" , manual_add_contacts );
s . def ( "tel_base" , telemetry_mode_base ); s . def ( "tel_loc" , telemetry_mode_loc );
s . def ( "tel_env" , telemetry_mode_env );
s . def ( "tz_offset" , tz_offset_hours );
}
// ── this firmware's ("solo") ──
void radioSolo ( Section & s ) {
s . def ( "rx_ps" , rx_powersave ); s . def ( "apc" , tx_apc ); s . def ( "dm_resend" , dm_resend_count );
char k [ 4 ] = "p0" ;
for ( int i = 0 ; i < USER_RADIO_PRESET_MAX ; i ++ ) {
k [ 1 ] = '0' + i ;
s . sub ( k , preset [ i ]);
}
}
void presetKeys ( Section & s ) {
for ( int i = 0 ; i < USER_RADIO_PRESET_MAX ; i ++ ) {
if ( & s != & preset [ i ]) continue ;
UserRadioPreset & p = user_radio_presets [ i ];
s . def ( "name" , p . name , sizeof ( p . name ));
s . def ( "freq" , p . freq ); s . def ( "bw" , p . bw ); s . def ( "sf" , p . sf ); s . def ( "cr" , p . cr );
}
}
void repeaterSolo ( Section & s ) {
s . def ( "skip_adv" , repeat_skip_adverts ); s . def ( "max_hops" , repeat_max_hops );
s . def ( "delay_boost" , repeat_delay_boost ); s . def ( "min_snr" , repeat_min_snr );
s . def ( "sup_dup" , repeat_suppress_dup ); s . def ( "scope_only" , repeat_scope_only );
s . def ( "extra_scopes" , repeat_extra_scope_mask );
s . def ( "use_prof" , repeater_use_profile );
s . def ( "freq" , repeater_freq ); s . def ( "bw" , repeater_bw );
s . def ( "sf" , repeater_sf ); s . def ( "cr" , repeater_cr );
}
void powerSolo ( Section & s ) {
s . def ( "ble_off" , ble_off );
s . def ( "low_mv" , low_batt_mv ); s . def ( "batt_disp" , batt_display_mode );
s . hex ( "curve" , batt_curve_mv );
}
void displaySolo ( Section & s ) {
s . def ( "bright" , display_brightness ); s . def ( "bright_pct" , display_brightness_pct );
s . def ( "auto_off" , auto_off_secs ); s . def ( "auto_lock" , auto_lock );
s . def ( "lock_compact" , lock_compact );
s . def ( "rot" , display_rotation ); s . def ( "joy_rot" , joystick_rotation );
s . def ( "eink_full" , eink_full_refresh_every );
s . def ( "text_scale" , ui_text_scale ); s . def ( "icon_scale" , ui_icon_scale );
s . def ( "pages" , home_pages_mask ); s . hex ( "dash" , dashboard_fields );
s . hex ( "order" , page_order ); s . def ( "order_set" , page_order_set );
s . def ( "ch_sort" , ch_sort ); s . def ( "flow" , flow_show );
s . def ( "kb" , keyboard_type ); s . def ( "kb_alt" , keyboard_alt_alphabet );
s . def ( "kb_main" , keyboard_main_alphabet ); s . def ( "kb_cardkb" , keyboard_cardkb_compact );
}
void clockSolo ( Section & s ) {
2026-10-29 14:09:25 +01:00
s . def ( "hide_sec" , clock_hide_seconds ); s . def ( "h12" , clock_12h ); s . def ( "dst" , dst_rule );
2026-10-15 13:25:58 +02:00
s . def ( "alarm" , alarm_on ); s . def ( "alarm_h" , alarm_hour ); s . def ( "alarm_m" , alarm_min );
s . def ( "alarm_rep" , alarm_repeat_mask );
}
void soundSolo ( Section & s ) {
s . def ( "vol" , buzzer_volume ); s . def ( "auto" , buzzer_auto );
s . def ( "quiet" , quiet_hours ); s . def ( "quiet_from" , quiet_from ); s . def ( "quiet_to" , quiet_to );
s . def ( "msg_wake" , msg_wake ); s . def ( "msg_alert" , msg_alert );
s . def ( "rt_bpm" , ringtone_bpm_idx ); s . def ( "rt_len" , ringtone_len ); s . hex ( "rt" , ringtone_notes );
s . def ( "rt2_bpm" , ringtone2_bpm_idx ); s . def ( "rt2_len" , ringtone2_len ); s . hex ( "rt2" , ringtone2_notes );
s . def ( "mel_dm" , notif_melody_dm ); s . def ( "mel_ch" , notif_melody_ch ); s . def ( "mel_ad" , notif_melody_ad );
s . def ( "adv_scope" , advert_sound_scope );
s . hex ( "ch_over" , ch_notif_override ); s . hex ( "ch_muted" , ch_notif_muted );
s . hex ( "ch_mel_set" , ch_notif_melody_set ); s . hex ( "ch_mel_2" , ch_notif_melody_2 );
s . hex ( "dm" , dm_notif ); s . hex ( "dm_mel" , dm_melody );
}
void scopeSolo ( Section & s ) { s . hex ( "ch" , ch_scope_idx ); }
void botSolo ( Section & s ) {
s . def ( "dm_en" , bot_enabled ); s . def ( "dm_trig" , bot_trigger , sizeof ( bot_trigger ));
s . def ( "dm_reply" , bot_reply_dm , sizeof ( bot_reply_dm ));
s . def ( "dm_cmd" , bot_commands_enabled ); s . def ( "dm_act" , bot_actions_dm ); s . def ( "dm_scope" , bot_dm_scope );
s . def ( "ch_en" , bot_channel_enabled ); s . def ( "ch_idx" , bot_channel_idx );
s . def ( "ch_trig" , bot_trigger_ch , sizeof ( bot_trigger_ch ));
s . def ( "ch_reply" , bot_reply_ch , sizeof ( bot_reply_ch ));
s . def ( "ch_cmd" , bot_commands_ch ); s . def ( "ch_act" , bot_actions_ch );
s . def ( "rm_en" , bot_room_enabled ); s . hex ( "rm_pfx" , bot_room_prefix );
s . def ( "rm_trig" , bot_trigger_room , sizeof ( bot_trigger_room ));
s . def ( "rm_reply" , bot_reply_room , sizeof ( bot_reply_room ));
s . def ( "rm_cmd" , bot_commands_room ); s . def ( "rm_act" , bot_actions_room );
2026-10-26 19:46:28 +01:00
s . def ( "q_start" , bot_quiet_start ); s . def ( "q_end" , bot_quiet_end ); s . def ( "act_scope" , bot_actions_scope );
2026-10-15 13:25:58 +02:00
}
void gpsSolo ( Section & s ) {
s . def ( "imperial" , units_imperial );
s . def ( "tr_delta" , trail_min_delta_idx ); s . def ( "tr_pace" , trail_show_pace );
s . def ( "tr_pause" , trail_autopause_idx ); s . def ( "tr_lowbatt" , trail_autosave_lowbatt );
s . def ( "avg" , gps_avg_idx ); s . def ( "trk_shared" , track_shared_loc );
s . def ( "adv_auto" , advert_auto_interval_sec ); s . def ( "adv_prec" , advert_loc_prec );
}
void shareSolo ( Section & s ) {
s . def ( "en" , loc_share_enabled ); s . def ( "tgt" , loc_share_target_type );
s . def ( "ch" , loc_share_channel_idx ); s . hex ( "dm" , loc_share_dm_prefix );
s . def ( "move" , loc_share_move_idx ); s . def ( "int" , loc_share_interval_idx );
s . def ( "hb" , loc_share_heartbeat_idx ); s . def ( "scope" , loc_share_scope );
s . def ( "dur" , loc_share_duration_idx );
}
void locatorSolo ( Section & s ) {
s . def ( "en" , locator_enabled ); s . def ( "has" , locator_has_target );
s . def ( "radius" , locator_radius_idx ); s . def ( "mode" , locator_mode );
s . def ( "lat" , locator_lat_1e6 ); s . def ( "lon" , locator_lon_1e6 );
s . def ( "label" , locator_label , sizeof ( locator_label ));
s . def ( "kind" , locator_target_kind ); s . hex ( "key" , locator_key );
s . def ( "beep" , locator_beeper );
}
void listsSolo ( Section & s ) {
s . def ( "dm_all" , dm_show_all ); s . def ( "room_fav" , room_fav_only );
s . hex ( "ch_fav" , ch_fav_bitmask ); s . def ( "ch_fav_only" , ch_fav_only );
s . hex ( "favs" , favourite_contacts ); s . hex ( "fav_kinds" , favourite_kinds );
2026-10-30 10:01:29 +01:00
s . def ( "fav_sort" , fav_sort ); s . def ( "expiry" , contact_expiry_idx ); s . def ( "msg_keep" , msg_keep ); s . def ( "msg_expire" , msg_expire );
2026-10-30 12:35:22 +01:00
s . hex ( "ch_no_save" , ch_no_save );
2026-10-15 13:25:58 +02:00
}
void gpioSolo ( Section & s ) {
s . def ( "m1" , gpio1_mode ); s . def ( "m2" , gpio2_mode ); s . def ( "m3" , gpio3_mode ); s . def ( "m4" , gpio4_mode );
}
void msgsSolo ( Section & s ) {
char k [ 4 ] = "m0" ;
for ( int i = 0 ; i < 10 ; i ++ ) {
k [ 1 ] = '0' + i ;
s . def ( k , custom_msgs [ i ], sizeof ( custom_msgs [ i ]));
}
}
void lockSolo ( Section & s ) { s . hex ( "hash" , lock_screen_password ); s . hex ( "salt" , lock_screen_password_salt ); }
2026-10-15 14:04:58 +02:00
#ifdef ENABLE_WIFI_INTERFACE
void wifiKeys ( Section & s ) {
s . def ( "ssid" , wifi_ssid , sizeof ( wifi_ssid ));
s . def ( "pwd" , wifi_pwd , sizeof ( wifi_pwd ));
s . def ( "enabled" , wifi_enabled );
}
#endif
2026-10-15 13:25:58 +02:00
void soloKeys ( Section & s ) {
s . sub ( "radio" , radio_solo ); s . sub ( "repeater" , repeater_solo ); s . sub ( "power" , power_solo );
s . sub ( "display" , display_solo ); s . sub ( "clock" , clock_solo ); s . sub ( "sound" , sound_solo );
s . sub ( "scope" , scope_solo ); s . sub ( "bot" , bot_solo ); s . sub ( "gps" , gps_solo );
s . sub ( "share" , share_solo ); s . sub ( "locator" , locator_solo ); s . sub ( "lists" , lists_solo );
s . sub ( "gpio" , gpio_solo ); s . sub ( "msgs" , msgs_solo ); s . sub ( "lock" , lock_solo );
}
Section radio , gps , repeat , comp ;
2026-10-15 14:04:58 +02:00
#ifdef ENABLE_WIFI_INTERFACE
Section wifi ;
#endif
2026-10-15 13:25:58 +02:00
Section solo , radio_solo , preset [ USER_RADIO_PRESET_MAX ], repeater_solo , power_solo , display_solo ,
clock_solo , sound_solo , scope_solo , bot_solo , gps_solo , share_solo , locator_solo ,
lists_solo , gpio_solo , msgs_solo , lock_solo ;
FemPrefs fem ;
DynamicConfigSerializer custom ;
protected :
void structure () override {
def ( "name" , node_name , sizeof ( node_name ));
def ( "lat" , node_lat );
def ( "lon" , node_lon );
def ( "radio" , radio );
def ( "gps" , gps );
def ( "repeat" , repeat );
def ( "comp" , comp );
def ( "custom" , custom );
2026-10-15 14:04:58 +02:00
#ifdef ENABLE_WIFI_INTERFACE
def ( "wifi" , wifi );
#endif
2026-10-15 13:25:58 +02:00
def ( "solo" , solo );
}
public :
// The node's position as the file holds it (MyMesh keeps it in `sensors`):
// DataStore sets it before a save and reads it after a load.
double node_lat = 0 , node_lon = 0 ;
NodePrefs ()
: radio ( this , & NodePrefs :: radioKeys ), gps ( this , & NodePrefs :: gpsKeys ),
repeat ( this , & NodePrefs :: repeatKeys ), comp ( this , & NodePrefs :: compKeys ),
2026-10-15 14:04:58 +02:00
#ifdef ENABLE_WIFI_INTERFACE
wifi ( this , & NodePrefs :: wifiKeys ),
#endif
2026-10-15 13:25:58 +02:00
solo ( this , & NodePrefs :: soloKeys ), radio_solo ( this , & NodePrefs :: radioSolo ),
preset { { this , & NodePrefs :: presetKeys }, { this , & NodePrefs :: presetKeys },
{ this , & NodePrefs :: presetKeys }, { this , & NodePrefs :: presetKeys } },
repeater_solo ( this , & NodePrefs :: repeaterSolo ), power_solo ( this , & NodePrefs :: powerSolo ),
display_solo ( this , & NodePrefs :: displaySolo ), clock_solo ( this , & NodePrefs :: clockSolo ),
sound_solo ( this , & NodePrefs :: soundSolo ), scope_solo ( this , & NodePrefs :: scopeSolo ),
bot_solo ( this , & NodePrefs :: botSolo ), gps_solo ( this , & NodePrefs :: gpsSolo ),
share_solo ( this , & NodePrefs :: shareSolo ), locator_solo ( this , & NodePrefs :: locatorSolo ),
lists_solo ( this , & NodePrefs :: listsSolo ), gpio_solo ( this , & NodePrefs :: gpioSolo ),
msgs_solo ( this , & NodePrefs :: msgsSolo ), lock_solo ( this , & NodePrefs :: lockSolo ),
fem ( this ), custom ( & fem ) {
clearFields ();
}
// The sections point back at this object: it isn't copied.
NodePrefs ( const NodePrefs & ) = delete ;
NodePrefs & operator = ( const NodePrefs & ) = delete ;
// Every field zero (MyMesh then sets the defaults).
void clearFields () { memset ( static_cast < NodePrefsFields *> ( this ), 0 , sizeof ( NodePrefsFields )); }
// The board's settings (upstream's getCustom()), for Board::attachDynamicPrefs().
KeyValueStore * getCustom () { return & custom ; }
2026-10-15 14:04:58 +02:00
#ifdef ENABLE_WIFI_INTERFACE
#ifndef WIFI_SSID
#define WIFI_SSID ""
#endif
// use ssid from prefs, or fallback to ssid from build flags
const char * getWifiSSID () const { return wifi_ssid [ 0 ] ? wifi_ssid : WIFI_SSID ; }
#endif
2026-10-15 13:25:58 +02:00
bool isRepeatEn () const { return client_repeat != 0 ; }
void setRepeatEn ( bool en ) { client_repeat = en ? 1 : 0 ; }
bool isDirty () const override { return ConfigSerializer :: isDirty () || custom . isDirty (); }
void clearDirty () override { ConfigSerializer :: clearDirty (); custom . clearDirty (); }
using ConfigSerializer :: markDirty ;
};
2026-09-28 21:59:40 +02:00
// Local hour `h` inside [start, end): a window with start > end wraps past
// midnight; start == end is no window. Shared by the bot's quiet hours and the
// sound's.
static inline bool hourInWindow ( int h , int start , int end ) {
if ( start == end ) return false ;
return start < end ? ( h >= start && h < end ) : ( h >= start || h < end );
}
2026-10-29 14:09:25 +01:00
// Days since 1970-01-01 of a calendar date (H. Hinnant's days_from_civil).
static inline int32_t daysFromCivil ( int y , int m , int d ) {
y -= m <= 2 ;
const int era = ( y >= 0 ? y : y - 399 ) / 400 ;
const int yoe = y - era * 400 ;
const int doy = ( 153 * ( m + ( m > 2 ? - 3 : 9 )) + 2 ) / 5 + d - 1 ;
return era * 146097 + yoe * 365 + yoe / 4 - yoe / 100 + doy - 719468 ;
}
// The day (since 1970) of the n-th Sunday of a month, n = 1..4, 5 = the last.
static inline int32_t nthSunday ( int y , int m , int n ) {
const int32_t first = daysFromCivil ( y , m , 1 );
int32_t d = first + ( 3 - first % 7 + 7 ) % 7 + ( n - 1 ) * 7 ; // 1970-01-01 was a Thursday
if ( n == 5 && d >= daysFromCivil ( m == 12 ? y + 1 : y , m == 12 ? 1 : m + 1 , 1 )) d -= 7 ;
return d ;
}
// Summer time in force at `utc`. The changes, per NodePrefs::dst_rule: Europe
// (EU / UK) last Sunday of March - last Sunday of October at 01:00 UTC; North
// America second Sunday of March - first Sunday of November; south-east
// Australia first Sunday of October - first Sunday of April; New Zealand last
// Sunday of September - first Sunday of April; those three forward at 02:00,
// back at 02:00 summer time in America and at 03:00 in the other two.
static inline bool inSummerTime ( const NodePrefs & p , uint32_t utc ) {
// Hours in local standard time, or UTC with `utc` set.
struct Rule { uint8_t on_mon , on_nth , on_hour , off_mon , off_nth , off_hour , utc ; };
static const Rule RULES [ NodePrefs :: DST_COUNT - 1 ] = {
{ 3 , 5 , 1 , 10 , 5 , 1 , 1 }, { 3 , 2 , 2 , 11 , 1 , 1 , 0 }, { 10 , 1 , 2 , 4 , 1 , 2 , 0 }, { 9 , 5 , 2 , 4 , 1 , 2 , 0 },
};
if ( p . dst_rule == NodePrefs :: DST_OFF || p . dst_rule >= NodePrefs :: DST_COUNT || utc < 1000000000UL ) return false ;
const Rule & r = RULES [ p . dst_rule - 1 ];
const int64_t std_off = r . utc ? 0 : ( int64_t ) p . tz_offset_hours * 3600 ;
time_t lt = ( time_t )(( int64_t ) utc + ( int64_t ) p . tz_offset_hours * 3600 );
const int y = gmtime ( & lt ) -> tm_year + 1900 ;
const int64_t on = ( int64_t ) nthSunday ( y , r . on_mon , r . on_nth ) * 86400 + r . on_hour * 3600 - std_off ;
const int64_t off = ( int64_t ) nthSunday ( y , r . off_mon , r . off_nth ) * 86400 + r . off_hour * 3600 - std_off ;
const int64_t t = utc ;
return on < off ? ( t >= on && t < off ) : ( t >= on || t < off ); // southern summers span New Year
}
// The offset from UTC in force at `utc`, in hours: the time zone plus summer time.
static inline int8_t tzHoursAt ( const NodePrefs & p , uint32_t utc ) {
return ( int8_t )( p . tz_offset_hours + ( inSummerTime ( p , utc ) ? 1 : 0 ));
}
static inline int8_t tzHoursAt ( const NodePrefs * p , uint32_t utc ) { return p ? tzHoursAt ( * p , utc ) : 0 ; }
2026-09-28 21:59:40 +02:00
// The local hour from a UTC time; false while the clock isn't set.
static inline bool localHour ( uint32_t utc , int8_t tz_hours , int & h ) {
if ( utc < 1000000000UL ) return false ;
h = ( int )((( int64_t ) utc + ( int64_t ) tz_hours * 3600 ) / 3600 % 24 );
return true ;
}
2026-09-28 22:35:26 +02:00
// A UTC time as the local calendar date and time; false while the clock isn't set.
static inline bool localTm ( uint32_t utc , int8_t tz_hours , struct tm & out ) {
if ( utc < 1000000000UL ) return false ;
time_t t = ( time_t )(( int64_t ) utc + ( int64_t ) tz_hours * 3600 );
out = * gmtime ( & t );
return true ;
}
2026-09-28 21:59:40 +02:00
// Settings > Sound > Quiet hours in force at `utc` (never with the clock unset).
static inline bool inQuietHours ( const NodePrefs & p , uint32_t utc ) {
int h ;
2026-10-29 14:09:25 +01:00
return p . quiet_hours && localHour ( utc , tzHoursAt ( p , utc ), h ) && hourInWindow ( h , p . quiet_from , p . quiet_to );
2026-09-28 21:59:40 +02:00
}
2026-06-21 23:09:27 +02:00
// 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
// effect on the radio, instead of a hard-coded cap (was 150-960, the SX1262
// range) that would wrongly reject legal frequencies on any other chip. SF/BW/CR
// are the chip-independent discrete LoRa sets.
static inline bool isValidRepeaterProfile ( float freq , float bw , uint8_t sf , uint8_t cr ,
float min_freq , float max_freq ) {
return freq >= min_freq && freq <= max_freq
2026-06-20 09:07:00 +02:00
&& sf >= 5 && sf <= 12
&& cr >= 5 && cr <= 8
&& bw >= 7.0f && bw <= 510.0f ;
}
// Seed a never-configured repeater profile in the same band as the companion's
// own network (see defaultRepeaterFreqForBand() above for why).
static inline void seedDefaultRepeaterProfile ( NodePrefs & prefs ) {
prefs . repeater_use_profile = 1 ;
prefs . repeater_freq = defaultRepeaterFreqForBand ( prefs . freq );
prefs . repeater_bw = LORA_BW ;
prefs . repeater_sf = LORA_SF ;
prefs . repeater_cr = LORA_CR ;
}