feat: app-only settings on the device: privacy, adding contacts, advanced radio

- Settings schema gains Privacy (position in adverts, who can ask for
  status / location / sensors), Adding contacts (all or by type, max
  hops, replace oldest) and Advanced radio (path hash size, double ACK,
  listen before talk, interference threshold, RX boost, RX delay,
  airtime factor); both UIs build their rows from it
- Auto-advert carries position only when Position in adverts is on
- ui-new accordion holds 16 sections and 128 rows
- docs: settings, tools, feature matrix

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Jakub
2026-10-10 00:47:03 +02:00
co-authored by Claude Opus 5.5
parent a0a76fbc60
commit 6fba3dfca7
10 changed files with 181 additions and 30 deletions
@@ -9,7 +9,9 @@
// uint8_t holding that index; with a `values` table the field (1, 2 or 4 bytes,
// `size`) holds values[index] instead -- seconds, millivolts, an hour offset,
// or {1, 0} for a switch whose field is stored inverted. count == 2 with no
// option labeller is an on/off switch.
// option labeller is an on/off switch. A `bit` mask makes it a switch over that
// one bit of a uint8_t field; size FLOAT_X100 is a float field with values in
// hundredths.
#include <stddef.h>
#include "../NodePrefs.h"
@@ -21,28 +23,33 @@ namespace settings {
// Pages (one screen each) and the sections on them, in display order.
// PG_NAV is the Map's options (its trail / live share / target tools),
// PG_ADVERT sits in Nearby's advert popup; the rest are Settings pages.
enum Page : uint8_t { PG_NAV, PG_ADVERT, PG_DEVICE, PG_MESSAGES, PG_SOUND, PG_COUNT };
// PG_PRIVACY and PG_RADIO (Radio's advanced options) are Settings pages too.
enum Page : uint8_t { PG_NAV, PG_ADVERT, PG_DEVICE, PG_MESSAGES, PG_SOUND, PG_PRIVACY, PG_RADIO, PG_COUNT };
enum Section : uint8_t { SEC_TRAIL, SEC_LIVE_SHARE, SEC_LOCATOR, SEC_ADVERT,
SEC_DISPLAY, SEC_POWER, SEC_TIME, SEC_UNITS,
SEC_MESSAGES, SEC_CONTACTS,
SEC_SOUND, SEC_SOUND_FOR, SEC_QUIET, SEC_COUNT };
SEC_MESSAGES, SEC_CONTACTS, SEC_AUTO_ADD,
SEC_SOUND, SEC_SOUND_FOR, SEC_QUIET,
SEC_PRIVACY, SEC_TELEMETRY, SEC_RADIO_ADV, SEC_COUNT };
static const char* pageTitle(uint8_t p) {
static const char* T[PG_COUNT] = { "Map options", "Advert", "Display & power", "Messages & contacts", "Sound" };
static const char* T[PG_COUNT] = { "Map options", "Advert", "Display & power", "Messages & contacts", "Sound",
"Privacy", "Advanced radio" };
return p < PG_COUNT ? T[p] : "";
}
static const char* sectionTitle(uint8_t s) {
static const char* T[SEC_COUNT] = { "TRAIL", "LIVE SHARE", "ARRIVAL ALERT", "AUTOMATIC",
"DISPLAY", "POWER", "TIME", "UNITS",
"MESSAGES", "CONTACTS",
"SOUND", "PLAYS FOR", "QUIET HOURS" };
"MESSAGES", "CONTACTS", "ADDING CONTACTS",
"SOUND", "PLAYS FOR", "QUIET HOURS",
"ADVERTS", "WHO CAN ASK FOR", "ADVANCED" };
return s < SEC_COUNT ? T[s] : "";
}
static uint8_t sectionPage(uint8_t s) {
static const uint8_t P[SEC_COUNT] = { PG_NAV, PG_NAV, PG_NAV, PG_ADVERT,
PG_DEVICE, PG_DEVICE, PG_DEVICE, PG_DEVICE,
PG_MESSAGES, PG_MESSAGES,
PG_SOUND, PG_SOUND, PG_SOUND };
PG_MESSAGES, PG_MESSAGES, PG_MESSAGES,
PG_SOUND, PG_SOUND, PG_SOUND,
PG_PRIVACY, PG_PRIVACY, PG_RADIO };
return s < SEC_COUNT ? P[s] : PG_DEVICE;
}
@@ -57,8 +64,10 @@ struct Setting {
// Side effect after a change (nullptr: none). The caller saves the prefs.
void (*changed)(UiCore& core);
const int32_t* values; // stored value per index; nullptr: the index itself
uint8_t size; // field bytes (1, 2, 4)
uint8_t size; // field bytes (1, 2, 4), or FLOAT_X100
uint8_t bit; // nonzero: a switch over this bit of a uint8_t field
};
static const uint8_t FLOAT_X100 = 0x84; // size: a float, values in hundredths
// ── Value labels ──────────────────────────────────────────────────────────────
#ifndef TRAIL_FIXED_MIN_DELTA_M
@@ -152,12 +161,40 @@ static void optAdvertScope(uint8_t v, char* b, int n, const NodePrefs&) {
snprintf(b, n, "%s", v == ADVERT_SOUND_SCOPE_ZERO_HOP ? "Direct" : "All");
}
static void optTelemetry(uint8_t v, char* b, int n, const NodePrefs&) {
static const char* const L[] = { "Nobody", "Allowed", "Everyone" }; // TELEM_MODE_*
snprintf(b, n, "%s", L[v < 3 ? v : 0]);
}
static void optAutoAdd(uint8_t v, char* b, int n, const NodePrefs&) { snprintf(b, n, "%s", v ? "By type" : "All"); }
// autoadd_max_hops: 0 = any, 1 = direct only, N = up to N-1 hops.
static const int32_t MAX_HOPS[] = { 0, 1, 2, 3, 4, 6, 9 };
static void optMaxHops(uint8_t v, char* b, int n, const NodePrefs&) {
int32_t h = MAX_HOPS[v];
if (h == 0) snprintf(b, n, "Any");
else if (h == 1) snprintf(b, n, "Direct");
else snprintf(b, n, "%ld hop%s", (long)(h - 1), h == 2 ? "" : "s");
}
static void optHashSize(uint8_t v, char* b, int n, const NodePrefs&) { snprintf(b, n, "%u byte%s", v + 1, v ? "s" : ""); }
static const int32_t INT_THRESH[] = { 0, 8, 10, 12, 14, 16, 20 };
static void optIntThresh(uint8_t v, char* b, int n, const NodePrefs&) {
if (INT_THRESH[v] == 0) snprintf(b, n, "Off"); else snprintf(b, n, "%ld dB", (long)INT_THRESH[v]);
}
static const int32_t RX_DELAY[] = { 0, 200, 500, 1000, 2000 }; // rx_delay_base, x100
static void optRxDelay(uint8_t v, char* b, int n, const NodePrefs&) {
if (RX_DELAY[v] == 0) snprintf(b, n, "Off"); else snprintf(b, n, "%ld", (long)(RX_DELAY[v] / 100));
}
static const int32_t AIRTIME[] = { 0, 50, 100, 200, 300, 500, 900 }; // airtime_factor, x100
static void optAirtime(uint8_t v, char* b, int n, const NodePrefs&) {
snprintf(b, n, "%ld.%ld x", (long)(AIRTIME[v] / 100), (long)(AIRTIME[v] % 100 / 10));
}
// ── Side effects ──────────────────────────────────────────────────────────────
static void rearmLocator(UiCore& c) { c.locator.reset(); }
static void restartShareClock(UiCore& c) { c.live_share.restartClock(); } // a new length starts over
static void applyDisplay(UiCore& c) { c.host()->applyDisplayPrefs(); }
static void applyGpsDuty(UiCore& c) { c.applyGpsInterval(); }
static void applySound(UiCore& c) { c.host()->applySoundPrefs(); }
static void applyRxGain(UiCore& c) { if (c.prefs()) radio_driver.setRxBoostedGainMode(c.prefs()->rx_boosted_gain); }
#define NP_OFF(f) (uint16_t)offsetof(NodePrefs, f)
#define NP_SIZE(f) (uint8_t)sizeof(((NodePrefs*)0)->f)
@@ -166,6 +203,8 @@ static void applySound(UiCore& c) { c.host()->applySoundPrefs(); }
#define IDX(label, hint, sec, f, count, opt, chg) { label, hint, sec, NP_OFF(f), count, opt, chg, nullptr, 1 }
#define SW(label, hint, sec, f, chg) { label, hint, sec, NP_OFF(f), 2, nullptr, chg, nullptr, 1 }
#define MAP(label, hint, sec, f, vals, opt, chg) { label, hint, sec, NP_OFF(f), COUNT_OF(vals), opt, chg, vals, NP_SIZE(f) }
#define MAPF(label, hint, sec, f, vals, opt) { label, hint, sec, NP_OFF(f), COUNT_OF(vals), opt, nullptr, vals, FLOAT_X100 }
#define BIT(label, hint, sec, f, mask) { label, hint, sec, NP_OFF(f), 2, nullptr, nullptr, nullptr, 1, mask }
static const Setting ALL[] = {
#ifndef TRAIL_FIXED_MIN_DELTA_M // else every sample past a fixed jitter gate is kept
IDX("Point spacing", "Between trail points", SEC_TRAIL, trail_min_delta_idx,
@@ -194,7 +233,7 @@ static const Setting ALL[] = {
NodePrefs::LOCATOR_MODE_COUNT, optLocMode, rearmLocator),
SW("Proximity beeper", "Ticks faster when closer", SEC_LOCATOR, locator_beeper, nullptr),
MAP("Auto-advert", "Advert with your position", SEC_ADVERT, advert_auto_interval_sec, AUTO_ADVERT,
MAP("Auto-advert", "Your advert, on a timer", SEC_ADVERT, advert_auto_interval_sec, AUTO_ADVERT,
optAutoAdvert, nullptr),
IDX("Brightness", nullptr, SEC_DISPLAY, display_brightness, 5, optBrightness, applyDisplay),
@@ -221,6 +260,15 @@ static const Setting ALL[] = {
NodePrefs::CONTACT_EXPIRY_COUNT, optExpiry, nullptr),
SW("Favourites first", "In contact and node lists", SEC_CONTACTS, fav_sort, nullptr),
// manual_add_contacts bit 0: 0 adds every node heard, 1 only the types below.
IDX("Add heard nodes", "Every node, or only these types", SEC_AUTO_ADD, manual_add_contacts, 2, optAutoAdd, nullptr),
BIT("Companions", nullptr, SEC_AUTO_ADD, autoadd_config, 0x02),
BIT("Repeaters", nullptr, SEC_AUTO_ADD, autoadd_config, 0x04),
BIT("Rooms", nullptr, SEC_AUTO_ADD, autoadd_config, 0x08),
BIT("Sensors", nullptr, SEC_AUTO_ADD, autoadd_config, 0x10),
MAP("Up to", "How far away a node may be", SEC_AUTO_ADD, autoadd_max_hops, MAX_HOPS, optMaxHops, nullptr),
BIT("Replace oldest", "When the list is full; not favourites", SEC_AUTO_ADD, autoadd_config, 0x01),
// On / Off / Auto spans two fields (soundctl::setMode): the frontend's own row.
IDX("Volume", nullptr, SEC_SOUND, buzzer_volume, 5, optVolume, applySound),
SW("Quiet hours", "Muted, screen dark; alarms ring", SEC_QUIET, quiet_hours, nullptr),
@@ -231,6 +279,23 @@ static const Setting ALL[] = {
IDX("Channels", nullptr, SEC_SOUND_FOR, notif_melody_ch, soundctl::SOUND_COUNT, optSound, nullptr),
IDX("Adverts", "A node announcing itself", SEC_SOUND_FOR, notif_melody_ad, soundctl::SOUND_COUNT, optSound, nullptr),
IDX("Advert sound for", "Direct: no repeaters", SEC_SOUND_FOR, advert_sound_scope, 2, optAdvertScope, nullptr),
SW("Position in adverts", "Your adverts carry your location", SEC_PRIVACY, advert_loc_policy, nullptr),
// TELEM_MODE_*: "Allowed" is the contacts given that permission.
IDX("Status", "Battery; the two below need it", SEC_TELEMETRY, telemetry_mode_base, 3, optTelemetry, nullptr),
IDX("Location", nullptr, SEC_TELEMETRY, telemetry_mode_loc, 3, optTelemetry, nullptr),
IDX("Sensors", nullptr, SEC_TELEMETRY, telemetry_mode_env, 3, optTelemetry, nullptr),
IDX("Path hash size", "Bytes per repeater in a path", SEC_RADIO_ADV, path_hash_mode, 3, optHashSize, nullptr),
SW("Double ACK", "Send each acknowledgement twice", SEC_RADIO_ADV, multi_acks, nullptr),
SW("Listen before talk", "Wait for a quiet channel (CAD)", SEC_RADIO_ADV, cad_enabled, nullptr),
MAP("Interference", "Busy above the noise floor by", SEC_RADIO_ADV, interference_threshold, INT_THRESH,
optIntThresh, nullptr),
#if defined(USE_SX1262) || defined(USE_SX1268) || defined(USE_LR2021)
SW("Boosted RX gain", "Hears more, uses a little more power", SEC_RADIO_ADV, rx_boosted_gain, applyRxGain),
#endif
MAPF("RX delay", "Repeaters wait to pick the best path", SEC_RADIO_ADV, rx_delay_base, RX_DELAY, optRxDelay),
MAPF("Airtime factor", "Pause after sending, times airtime", SEC_RADIO_ADV, airtime_factor, AIRTIME, optAirtime),
};
static const int COUNT = (int)(sizeof(ALL) / sizeof(ALL[0]));
@@ -249,8 +314,19 @@ static const struct { uint16_t offset; const char* text; } SHORT_LABELS[] = {
{ NP_OFF(quiet_to), " until" }, { NP_OFF(notif_melody_dm), "DM sound" },
{ NP_OFF(notif_melody_ch), "Ch sound" }, { NP_OFF(notif_melody_ad), "AD sound" },
{ NP_OFF(advert_sound_scope), "AD scope" },
{ NP_OFF(manual_add_contacts), "Auto-add" }, { NP_OFF(autoadd_max_hops), " up to" },
{ NP_OFF(advert_loc_policy), "Advert pos" }, { NP_OFF(telemetry_mode_base), "Ask status" },
{ NP_OFF(telemetry_mode_loc), "Ask loc" }, { NP_OFF(telemetry_mode_env), "Ask sens" },
{ NP_OFF(path_hash_mode), "Hash size" }, { NP_OFF(multi_acks), "Double ACK" },
{ NP_OFF(cad_enabled), "LBT (CAD)" }, { NP_OFF(interference_threshold), "Interf." },
{ NP_OFF(rx_boosted_gain), "RX boost" }, { NP_OFF(rx_delay_base), "RX delay" },
{ NP_OFF(airtime_factor), "Airtime" },
};
static const char* shortLabel(const Setting& s) {
if (s.bit) { // the auto-add types share one field
static const char* const B[] = { "Replace old", " companions", " repeaters", " rooms", " sensors" };
for (int i = 0; i < 5; i++) if (s.bit == (1 << i)) return B[i];
}
for (auto& l : SHORT_LABELS) if (l.offset == s.offset) return l.text;
return s.label;
}
@@ -262,12 +338,16 @@ static int indexOf(uint16_t offset) {
#undef IDX
#undef SW
#undef MAP
#undef MAPF
#undef BIT
#undef COUNT_OF
#undef NP_SIZE
#undef NP_OFF
static int32_t readRaw(const NodePrefs& p, const Setting& s) {
const uint8_t* f = (const uint8_t*)&p + s.offset;
if (s.bit) return (f[0] & s.bit) ? 1 : 0;
if (s.size == FLOAT_X100) { float v; memcpy(&v, f, 4); return (int32_t)(v * 100 + (v < 0 ? -0.5f : 0.5f)); }
bool sgn = false; // a table with negative values is over a signed field
for (uint8_t i = 0; s.values && i < s.count; i++) if (s.values[i] < 0) sgn = true;
if (s.size == 2) { uint16_t v; memcpy(&v, f, 2); return sgn ? (int32_t)(int16_t)v : (int32_t)v; }
@@ -276,6 +356,8 @@ static int32_t readRaw(const NodePrefs& p, const Setting& s) {
}
static void writeRaw(NodePrefs& p, const Setting& s, int32_t v) {
uint8_t* f = (uint8_t*)&p + s.offset;
if (s.bit) { f[0] = v ? (f[0] | s.bit) : (f[0] & ~s.bit); return; }
if (s.size == FLOAT_X100) { float w = v / 100.0f; memcpy(f, &w, 4); return; }
if (s.size == 2) { uint16_t w = (uint16_t)v; memcpy(f, &w, 2); }
else if (s.size == 4) { uint32_t w = (uint32_t)v; memcpy(f, &w, 4); }
else f[0] = (uint8_t)v;
@@ -152,6 +152,7 @@ public:
}
UiCoreHost* host() { return _host; }
NodePrefs* prefs() { return _prefs; }
// ════ MyMesh::Listener ════════════════════════════════════════════════════