feat: per-contact actions, device settings, a companion CLI and your contact's QR code

- Nodes: telemetry permissions per contact, reset path, share nearby
  (L1 options menu; L2 node card with "Can ask for" switches)
- Settings > Privacy: fixed position (typed in, from a waypoint, cleared)
  and the Bluetooth PIN; Settings > System: factory reset, asked twice
- Companion CLI (CMD_RUN_CLI_COMMAND 66 / RESP_CODE_CLI_REPLY 29, as
  upstream; FIRMWARE_VER_CODE 14): upstream's command names plus a key for
  every schema setting (get / set / keys)
- Screenshots move to 0x7E; tools/screenshot.py falls back to 66 / 29
- Your contact as a QR code (meshcore://contact/add): Status > System on
  ui-new, Settings > About on the L2; Nayuki's qrcodegen made header-only

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Jakub
2026-10-10 09:59:35 +02:00
co-authored by Claude Opus 5.5
parent 6fba3dfca7
commit c5bb4752d0
22 changed files with 2521 additions and 86 deletions
@@ -47,6 +47,23 @@ inline void setMelody(NodePrefs* p, const uint8_t* pub_key, uint8_t slot) {
inline bool favourite(const ContactInfo& ci) { return (ci.flags & 0x01) != 0; }
inline bool setFavourite(const uint8_t* pub_key, bool on) { return the_mesh.setContactFavourite(pub_key, on); }
// ContactInfo::flags bits 1-3: what this contact may ask for (TELEM_PERM_*)
// where Settings > Privacy says "Allowed" -- only then do they matter.
inline uint8_t telemetry(const ContactInfo& ci) { return (ci.flags >> 1) & 0x07; }
inline bool setTelemetry(const uint8_t* pub_key, uint8_t perms) { return the_mesh.setContactTelemetry(pub_key, perms); }
inline bool telemetryPerContact(const NodePrefs* p) {
return p && (p->telemetry_mode_base == TELEM_MODE_ALLOW_FLAGS || p->telemetry_mode_loc == TELEM_MODE_ALLOW_FLAGS
|| p->telemetry_mode_env == TELEM_MODE_ALLOW_FLAGS);
}
// A path to forget: the contact has a learned one.
inline bool hasPath(const ContactInfo& ci) { return ci.out_path_len != OUT_PATH_UNKNOWN; }
inline bool resetPath(const uint8_t* pub_key) { return the_mesh.resetContactPath(pub_key); }
// Its last advert, sent again zero-hop so the nodes in range can add it.
inline bool share(const uint8_t* pub_key) {
ContactInfo* c = the_mesh.lookupContactByPubKey(pub_key, PUB_KEY_SIZE);
return c && the_mesh.shareContactZeroHop(*c);
}
// "@[nick] " -- how a reply names who it answers (raw UTF-8, over the air as is).
inline void replyPrefix(const char* nick, char* out, size_t n) { snprintf(out, n, "@[%.31s] ", nick); }
@@ -0,0 +1,47 @@
#pragma once
// Your own contact as a QR code: the link the phone app scans to add you,
// meshcore://contact/add?name=<name>&public_key=<hex>&type=1 (docs/faq.md).
// Made when it is shown (the name can change); both frontends draw it.
#include "QrCodeGen.h"
namespace contactqr {
// Version 10 (57 modules) holds the link with the longest name, low error
// correction; a short name gives a smaller code.
static const int MAX_VERSION = 10;
struct Code {
uint8_t buf[qrcodegen_BUFFER_LEN_FOR_VERSION(MAX_VERSION)];
int n = 0; // modules a side, 0: none
bool dark(int x, int y) const { return qrcodegen_getModule(buf, x, y); }
};
// The link, the name percent-encoded.
inline void link(char* out, size_t n) {
static const char DIGITS[] = "0123456789abcdef";
size_t o = snprintf(out, n, "meshcore://contact/add?name=");
for (const char* p = the_mesh.getNodeName(); *p && o + 4 < n; p++) {
unsigned char ch = (unsigned char)*p;
if (isalnum(ch) || ch == '-' || ch == '_' || ch == '.' || ch == '~') out[o++] = ch;
else { out[o++] = '%'; out[o++] = DIGITS[ch >> 4]; out[o++] = DIGITS[ch & 15]; }
}
o += snprintf(out + o, n - o, "&public_key=");
for (int i = 0; i < PUB_KEY_SIZE && o + 3 < n; i++) {
out[o++] = DIGITS[the_mesh.self_id.pub_key[i] >> 4];
out[o++] = DIGITS[the_mesh.self_id.pub_key[i] & 15];
}
snprintf(out + o, n - o, "&type=%d", ADV_TYPE_CHAT);
}
inline bool make(Code& c) {
char text[224];
link(text, sizeof(text));
uint8_t tmp[qrcodegen_BUFFER_LEN_FOR_VERSION(MAX_VERSION)];
bool ok = qrcodegen_encodeText(text, tmp, c.buf, qrcodegen_Ecc_LOW, qrcodegen_VERSION_MIN, MAX_VERSION,
qrcodegen_Mask_AUTO, true);
c.n = ok ? qrcodegen_getSize(c.buf) : 0;
return ok;
}
} // namespace contactqr
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,146 @@
#pragma once
// The companion CLI's settings keys, after MyMesh's own upstream-named
// commands (MyMeshCli.h): "get <key>", "set <key> <value>", "keys [page]".
// Each key names a row of the settings schema (SettingsSchema.h), so the CLI
// reads and writes what the Settings screens show, with the same values and
// side effects. A value is set by its label as `get` prints it ("2 h",
// "Everyone"; case doesn't matter), "on" / "off" for a switch, or a number:
// the index for a list, one of the stored values for a table (seconds, mV ...).
#include "SettingsSchema.h"
namespace settingscli {
struct Key { const char* key; uint16_t offset; uint8_t bit; };
#define K(name, f) { name, (uint16_t)offsetof(NodePrefs, f), 0 }
#define KB(name, f, mask) { name, (uint16_t)offsetof(NodePrefs, f), mask }
static const Key KEYS[] = {
K("advert.interval", advert_auto_interval_sec), K("advert.loc", advert_loc_policy),
K("telemetry.base", telemetry_mode_base), K("telemetry.loc", telemetry_mode_loc), K("telemetry.env", telemetry_mode_env),
K("autoadd", manual_add_contacts), KB("autoadd.chat", autoadd_config, 0x02), KB("autoadd.repeater", autoadd_config, 0x04),
KB("autoadd.room", autoadd_config, 0x08), KB("autoadd.sensor", autoadd_config, 0x10),
KB("autoadd.overwrite", autoadd_config, 0x01), K("autoadd.max.hops", autoadd_max_hops),
K("contacts.expiry", contact_expiry_idx), K("contacts.fav.first", fav_sort), K("dm.resend", dm_resend_count),
K("display.brightness", display_brightness), K("display.off", auto_off_secs), K("display.wake", msg_wake),
K("display.alert", msg_alert), K("lock", auto_lock), K("lock.clock", lock_compact),
K("batt.display", batt_display_mode), K("batt.shutdown", low_batt_mv), K("gps.interval", gps_interval),
K("clock.12h", clock_12h), K("clock.seconds", clock_hide_seconds), K("units.imperial", units_imperial),
K("sound.volume", buzzer_volume), K("quiet", quiet_hours), K("quiet.from", quiet_from), K("quiet.until", quiet_to),
K("melody.dm", notif_melody_dm), K("melody.channel", notif_melody_ch), K("melody.advert", notif_melody_ad),
K("advert.sound", advert_sound_scope),
K("trail.spacing", trail_min_delta_idx), K("trail.pause", trail_autopause_idx), K("trail.lowbatt", trail_autosave_lowbatt),
K("gps.avg", gps_avg_idx),
K("share.others", track_shared_loc), K("share.stop", loc_share_duration_idx), K("share.move", loc_share_move_idx),
K("share.every", loc_share_interval_idx), K("share.heartbeat", loc_share_heartbeat_idx),
K("alert", locator_enabled), K("alert.radius", locator_radius_idx), K("alert.on", locator_mode),
K("alert.beeper", locator_beeper),
};
#undef K
#undef KB
static const int KEY_COUNT = sizeof(KEYS) / sizeof(KEYS[0]);
// The schema row of a key, or -1 (this board leaves it out).
static int row(const Key& k) {
for (int i = 0; i < settings::COUNT; i++)
if (settings::ALL[i].offset == k.offset && settings::ALL[i].bit == k.bit) return i;
return -1;
}
static bool sameText(const char* a, const char* b) {
for (; *a && *b; a++, b++) if (tolower((unsigned char)*a) != tolower((unsigned char)*b)) return false;
return *a == *b;
}
// The value text into index v; false when it is none of them.
static bool parse(const NodePrefs& p, const settings::Setting& st, const char* v, int& index) {
if (!st.option) { // a switch
if (sameText(v, "on") || !strcmp(v, "1")) { index = 1; return true; }
if (sameText(v, "off") || !strcmp(v, "0")) { index = 0; return true; }
return false;
}
char buf[24];
for (int i = 0; i < st.count; i++) {
st.option((uint8_t)i, buf, sizeof(buf), p);
if (sameText(v, buf)) { index = i; return true; }
}
char* end;
double d = strtod(v, &end);
if (end == v || *end) return false;
if (!st.values) { // a list: its index
if (d < 0 || d >= st.count || d != (int)d) return false;
index = (int)d;
return true;
}
// A table: one of its stored values (one between would read back as another).
int32_t raw = (int32_t)(st.size == settings::FLOAT_X100 ? d * 100 + (d < 0 ? -0.5 : 0.5) : d);
for (int i = 0; i < st.count; i++) if (st.values[i] == raw) { index = i; return true; }
return false;
}
// The labels a setting takes, "a, b, c" into buf.
static void choices(const NodePrefs& p, const settings::Setting& st, char* out, int n) {
if (!st.option) { snprintf(out, n, "on, off"); return; }
int o = 0;
char buf[24];
for (int i = 0; i < st.count && o < n - 1; i++) {
st.option((uint8_t)i, buf, sizeof(buf), p);
o += snprintf(out + o, n - o, "%s%s", i ? ", " : "", buf);
}
}
// True when the command was a settings one (reply set either way).
static bool handle(UiCore& core, const char* cmd, char* reply, int n) {
NodePrefs* p = core.prefs();
if (!p) return false;
if (!strcmp(cmd, "keys") || !strncmp(cmd, "keys ", 5)) { // the key names, a frame's worth a page
int page = cmd[4] ? atoi(cmd + 5) : 1, pg = 1, o = 0;
bool more = false;
reply[0] = 0;
for (int k = 0; k < KEY_COUNT; k++) {
if (row(KEYS[k]) < 0) continue;
int len = (int)strlen(KEYS[k].key) + 1;
if (o + len > n - 20) { // room left for " (more: keys N)"
if (pg == page) { more = true; break; }
pg++;
o = 0;
}
if (pg == page) o += snprintf(reply + o, n - o, "%s%s", o ? " " : "", KEYS[k].key);
else o += len;
}
if (more) snprintf(reply + o, n - o, " (more: keys %d)", page + 1);
else if (pg < page || page < 1) snprintf(reply, n, "No more keys");
return true;
}
bool set = !strncmp(cmd, "set ", 4);
if (!set && strncmp(cmd, "get ", 4)) return false;
const char* name = cmd + 4;
const char* value = set ? strchr(name, ' ') : nullptr;
size_t name_len = value ? (size_t)(value - name) : strlen(name);
for (int k = 0; k < KEY_COUNT; k++) {
if (strlen(KEYS[k].key) != name_len || strncmp(KEYS[k].key, name, name_len)) continue;
int i = row(KEYS[k]);
if (i < 0) { snprintf(reply, n, "Error, not on this device"); return true; }
const settings::Setting& st = settings::ALL[i];
char buf[24];
if (!set) {
snprintf(reply, n, "> %s", settings::text(*p, st, buf, sizeof(buf), "on", "off"));
return true;
}
if (!value) { snprintf(reply, n, "Error, no value"); return true; }
int index = 0;
if (!parse(*p, st, value + 1, index)) {
char opts[120];
choices(*p, st, opts, sizeof(opts));
snprintf(reply, n, "Error, one of: %s", opts);
return true;
}
settings::set(*p, st, (uint8_t)index);
if (st.changed) st.changed(core);
the_mesh.savePrefs();
snprintf(reply, n, "OK");
return true;
}
return false; // not a settings key: MyMesh says "Unknown command"
}
} // namespace settingscli
+37
View File
@@ -143,6 +143,38 @@ public:
return true;
}
// Settings > Privacy > Position: where adverts say you are while the GPS is
// off -- the sensor manager's node_lat / node_lon, saved with the prefs as
// the app's CMD_SET_ADVERT_LATLON does. With the GPS on, each fix overwrites it.
bool fixedPosition(int32_t& lat, int32_t& lon) const {
lat = (int32_t)lround(_sensors->node_lat * 1e6);
lon = (int32_t)lround(_sensors->node_lon * 1e6);
return lat != 0 || lon != 0;
}
void setFixedPosition(int32_t lat, int32_t lon) { // 0, 0: none
_sensors->node_lat = lat / 1e6;
_sensors->node_lon = lon / 1e6;
the_mesh.savePrefs();
}
// Settings > Privacy > Bluetooth PIN: NodePrefs::ble_pin, six digits, or 0
// for a new random one each start (shown on screen). Used from the next
// start, like the app's CMD_SET_DEVICE_PIN.
static bool validBlePin(uint32_t pin) { return pin == 0 || (pin >= 100000 && pin <= 999999); }
bool setBlePin(uint32_t pin) {
if (!_prefs || !validBlePin(pin)) return false;
_prefs->ble_pin = pin;
the_mesh.savePrefs();
return true;
}
// Settings > System > Factory reset: everything erased, then a restart
// straight away -- no shutdown flush, which would write the prefs back.
// Returns only when the erase failed.
void factoryReset() {
if (the_mesh.factoryReset()) board.reboot();
}
// GPS duty cycle (NodePrefs::gps_interval, seconds; 0 = always on) to the sensor layer.
void applyGpsInterval() {
if (!_sensors || !_prefs) return;
@@ -275,6 +307,11 @@ public:
}
_host->onAdminStateChanged();
}
// The companion CLI's settings keys: the frontend answers them from the
// schema (ui-core/SettingsCli.h, included where the schema is).
bool onCliCommand(const char* command, char* reply, int n) override {
return _host->cliCommand(command, reply, n);
}
void onAdminReply(const uint8_t* pub_key, const char* text) override {
admin.onReply(pub_key, text);
_host->onAdminStateChanged();
@@ -41,6 +41,8 @@ public:
// A sound pref changed through the schema (the volume): push it to the
// speaker / buzzer.
virtual void applySoundPrefs() {}
// A companion CLI command MyMesh left over (its settings keys).
virtual bool cliCommand(const char* command, char* reply, int n) { return false; }
// Controlled power-down / restart (flush state first). See
// AbstractUITask::shutdown().