mirror of
https://github.com/MarekZegare4/MeshCore-Solo.git
synced 2026-11-03 03:11:57 +00:00
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:
@@ -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
|
||||
@@ -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().
|
||||
|
||||
Reference in New Issue
Block a user