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
+11 -1
View File
@@ -18,6 +18,9 @@ for what you can do with it:
| Ping | Sends a ping and shows the round trip time and SNR | | Ping | Sends a ping and shows the round trip time and SNR |
| Save waypoint, Set as target | Its position as a waypoint, or as the [Locator](./navigation.md#locator) target | | Save waypoint, Set as target | Its position as a waypoint, or as the [Locator](./navigation.md#locator) target |
| Fav, Pin to dial | Favourite, or a place on the Favourites page | | Fav, Pin to dial | Favourite, or a place on the Favourites page |
| Telem | What this contact may ask for: None, Status, Stat+loc, Stat+sens, All. Shown when a [Privacy](./settings.md#privacy) setting is **Allowed** |
| Reset path | Forgets the learned route; the next message floods and finds a new one. Shown when there is a route |
| Share nearby | Sends the contact's last advert to the nodes in direct range, so they can add it |
| Admin | Remote admin, for a repeater or room (below) | | Admin | Remote admin, for a repeater or room (below) |
| Discover scan | Asks the repeaters, rooms and sensors in direct range to answer, with their signal | | Discover scan | Asks the repeaters, rooms and sensors in direct range to answer, with their signal |
@@ -32,7 +35,14 @@ back out, **Enter** opens its details and **Hold Enter** its options.
> **Wio Tracker L2:** the **Nodes** app. Filter chips at the top, the sort in > **Wio Tracker L2:** the **Nodes** app. Filter chips at the top, the sort in
> the header, and a map button that shows every node with a position. A > the header, and a map button that shows every node with a position. A
> node's card has buttons for the same actions, plus **Add** for a node that > node's card has buttons for the same actions, plus **Add** for a node that
> isn't a contact yet and **Delete**. > isn't a contact yet and **Delete**. The telemetry permissions are three
> switches under **Can ask for**.
## Your own contact
**Status › System**, **Enter** shows your contact as a QR code: the phone
app's scanner adds you from it, with your name and key. On the Wio Tracker L2
it is in **Settings › About**.
## Favourites ## Favourites
+5
View File
@@ -63,6 +63,10 @@ build flag it needs.
| Remote admin | yes | yes | yes | yes | yes | | Remote admin | yes | yes | yes | yes | yes |
| Adding contacts (auto-add by type, hops, replace oldest) | Settings › Contacts | same | same | same | Messages & contacts | | Adding contacts (auto-add by type, hops, replace oldest) | Settings › Contacts | same | same | same | Messages & contacts |
| Privacy (position in adverts, telemetry requests) | Settings › Privacy | same | same | same | Settings › Privacy | | Privacy (position in adverts, telemetry requests) | Settings › Privacy | same | same | same | Settings › Privacy |
| Per-contact actions (telemetry permissions, reset path, share nearby) | Nodes › options | same | same | same | Node card |
| Fixed position, Bluetooth PIN | Settings › Privacy | same | same | same | Settings › Privacy |
| Factory reset | Settings › System | same | same | same | Settings, at the bottom |
| Console (companion CLI, upstream commands plus settings keys) | app / USB tools | same | same | same | same |
### Navigation (`ENV_INCLUDE_GPS`) ### Navigation (`ENV_INCLUDE_GPS`)
@@ -89,6 +93,7 @@ build flag it needs.
| Advanced radio (hash size, double ACK, CAD, interference, RX boost, RX delay, airtime) | end of Settings › Radio | same | same | same | Radio › Advanced | | Advanced radio (hash size, double ACK, CAD, interference, RX boost, RX delay, airtime) | end of Settings › Radio | same | same | same | Radio › Advanced |
| Battery curve | Settings | same | same | same | Settings › Power, draggable chart | | Battery curve | Settings | same | same | same | Settings › Power, draggable chart |
| QR code of the manual | Status › System, Enter | same | same | same | Settings › About | | QR code of the manual | Status › System, Enter | same | same | same | Settings › About |
| QR code of your contact | Status › System, Enter | same | same | same | Settings › About |
### Device ### Device
+55 -5
View File
@@ -11,10 +11,10 @@ board supports are shown.
| **Sound** | Buzzer on / off / auto (quiet while the app is connected), volume, quiet hours, the melody for messages, channels and adverts | | **Sound** | Buzzer on / off / auto (quiet while the app is connected), volume, quiet hours, the melody for messages, channels and adverts |
| **Home Pages** | Order of the home pages, and which are shown | | **Home Pages** | Order of the home pages, and which are shown |
| **Radio** | TX power, preset, frequency, SF / BW / CR, saved presets, Auto pwr, the scope list, then the advanced options (below) | | **Radio** | TX power, preset, frequency, SF / BW / CR, saved presets, Auto pwr, the scope list, then the advanced options (below) |
| **System** | Device name, low-battery shutdown, GPS power saving, units, the battery curve, reboot | | **System** | Device name, low-battery shutdown, GPS power saving, units, the battery curve, reboot, factory reset |
| **Keyboard** | ABC or T9 layout, the two scripts, CardKB mode | | **Keyboard** | ABC or T9 layout, the two scripts, CardKB mode |
| **Contacts** | Show all or favourites only (DMs, channels, rooms), favourites on top, contact expiry and prune, which heard nodes are added | | **Contacts** | Show all or favourites only (DMs, channels, rooms), favourites on top, contact expiry and prune, which heard nodes are added |
| **Privacy** | Whether your adverts carry your position, and who may ask for your status, location and sensor readings | | **Privacy** | Whether your adverts carry your position and which position, who may ask for your status, location and sensor readings, the Bluetooth PIN |
| **Messages** | Automatic resend of direct messages, the ten quick messages | | **Messages** | Automatic resend of direct messages, the ten quick messages |
A few notes: A few notes:
@@ -32,6 +32,9 @@ A few notes:
doesn't follow the usual LiPo curve: **Left / Right** move a point by doesn't follow the usual LiPo curve: **Left / Right** move a point by
10 mV, **Reset** goes back to LiPo. The current voltage is shown, so a point 10 mV, **Reset** goes back to LiPo. The current voltage is shown, so a point
can be set against what the battery reads. can be set against what the battery reads.
- **Factory reset** erases contacts, channels, messages, settings and the
node's keys, then restarts as a new node. It asks twice. On the Wio Tracker
L2 the SD card is left as it is.
### Adding contacts ### Adding contacts
@@ -53,10 +56,18 @@ nodes aren't added until you make room.
including the automatic ones. Off, nodes see your name but not where you including the automatic ones. Off, nodes see your name but not where you
are. Live sharing and `{loc}` in a message still send it, because you send are. Live sharing and `{loc}` in a message still send it, because you send
those on purpose. those on purpose.
- **Position**: where your adverts say you are while the GPS is off. **Enter**
to type it in (`50.06143, 19.93658`), take it from a waypoint, or clear it.
With the GPS on, the row shows **GPS** and each fix replaces it; turning the
GPS off keeps the last fix. The phone app sets the same position.
- **Ask status / loc / sens**: who may request your telemetry: **Nobody**, - **Ask status / loc / sens**: who may request your telemetry: **Nobody**,
**Allowed** (the contacts you gave that permission, for now in the phone **Allowed** (the contacts you gave that permission: **Telem** in a node's
app) or **Everyone**. Status is the battery; a request for location or options in [Nodes](./contacts.md#nearby-nodes), or the phone app) or
**Everyone**. Status is the battery; a request for location or
sensors is answered only where status is allowed too. sensors is answered only where status is allowed too.
- **BT PIN**: the six-digit Bluetooth pairing PIN. Leave it empty for a new
random one at each start, shown on screen. A new PIN is used from the next
start; the phone pairs again after that.
### Advanced radio ### Advanced radio
@@ -84,4 +95,43 @@ if your mesh agrees on it.
> Bluetooth, WiFi, GPS. Adding contacts is in Messages & contacts. > Bluetooth, WiFi, GPS. Adding contacts is in Messages & contacts.
> - **Map & data**: Map (trail, live sharing and arrival alert options), > - **Map & data**: Map (trail, live sharing and arrival alert options),
> Storage. > Storage.
> - **System**: Name, Time, Units, Firmware update, About; Reboot and Power off. > - **System**: Name, Time, Units, Firmware update, About; Reboot, Power off
> and Factory reset. Privacy holds **Fixed position** and, under
> Bluetooth, the **Pairing PIN**.
## Console
The phone app's console and the console in the site's
[USB tools](https://solo.marekzegarek.com/#pc-tools) change settings by
text. The commands are the ones upstream MeshCore's companion firmware has,
so its documentation applies:
| Command | Does |
| --- | --- |
| `get radio`, `set radio 869.525,250,11,5` | Frequency, bandwidth, SF and CR, applied at once |
| `get tx`, `set tx 14` | TX power in dBm |
| `get af` / `set af`, `get dutycycle` / `set dutycycle` | Airtime factor, or the same as a duty cycle in % |
| `rxdelay`, `int.thresh`, `cad`, `radio.rxgain`, `path.hash.mode`, `multi.acks` | The advanced radio options, with `get` / `set` |
| `get name`, `set name …` | The device name |
| `get pin`, `set pin 123456` | The Bluetooth PIN, `0` for a random one; used from the next start |
| `get tz.offset`, `set tz.offset 2` | Time zone, in hours from UTC |
| `get lat`, `set lat …`, `get lon`, `set lon …` | The fixed position |
| `ver`, `board` | Firmware version, hardware |
| `reboot`, `poweroff` | Restart or turn off |
Every other setting has a key: `keys` lists them a page at a time (`keys 2`,
`keys 3` …). `get` shows a value as the Settings screen does, and `set`
takes it the same way, case ignored:
```
get telemetry.base > Nobody
set telemetry.base Allowed
set display.off 30 s
set advert.loc on
set autoadd.room off
```
A number works too: the position in the list (`set telemetry.base 2`), or a
stored value such as seconds or millivolts (`set display.off 60`,
`set batt.shutdown 3300`). A wrong value answers with the ones that fit.
Factory reset and the node's keys are not in the console.
+2 -2
View File
@@ -103,7 +103,7 @@ Right** between them, **Up / Down** scrolls):
| **Sky** | The satellites on a sky plot, or each one's signal as bars (**Enter** swaps them); Wio Tracker L1 | | **Sky** | The satellites on a sky plot, or each one's signal as bars (**Enter** swaps them); Wio Tracker L1 |
| **Power** | Battery level, voltage, source and trend, then the readings of any sensors | | **Power** | Battery level, voltage, source and trend, then the readings of any sensors |
| **Mesh** | Packets received and sent with a traffic line, forwarded, nodes heard, contacts; **hold Enter** resets the counters | | **Mesh** | Packets received and sent with a traffic line, forwarded, nodes heard, contacts; **hold Enter** resets the counters |
| **System** | Uptime, firmware, device, memory, the send queue, errors, and a sample of every script the font covers; **Enter** shows a QR code that opens this manual on a phone | | **System** | Uptime, firmware, device, memory, the send queue, errors, and a sample of every script the font covers; **Enter** shows your contact as a QR code, which the phone app scans to add you, and **Enter** again a QR code that opens this manual; any other key closes it |
**GPS pwr** in Settings › System turns the GPS off between fixes to save **GPS pwr** in Settings › System turns the GPS off between fixes to save
battery; anything that needs your position (the trail, live share, the battery; anything that needs your position (the trail, live share, the
@@ -116,7 +116,7 @@ locator) keeps it on.
> of the satellites and the fix), **Signal** (a bar per satellite with its > of the satellites and the fix), **Signal** (a bar per satellite with its
> strength, and the constellations) and **Details** (position, precision, > strength, and the constellations) and **Details** (position, precision,
> time to first fix and what the receiver reports). > time to first fix and what the receiver reports).
> The manual's QR code is in Settings › About. > Your contact's QR code and the manual's are in Settings › About.
## GPIO ## GPIO
+52 -10
View File
@@ -71,8 +71,9 @@
#define CMD_SET_DEFAULT_FLOOD_SCOPE 63 #define CMD_SET_DEFAULT_FLOOD_SCOPE 63
#define CMD_GET_DEFAULT_FLOOD_SCOPE 64 #define CMD_GET_DEFAULT_FLOOD_SCOPE 64
#define CMD_SEND_RAW_PACKET 65 #define CMD_SEND_RAW_PACKET 65
#define CMD_RUN_CLI_COMMAND 66 // v14+
#ifdef ENABLE_SCREENSHOT #ifdef ENABLE_SCREENSHOT
#define CMD_GET_SCREENSHOT 66 // Request screenshot from display #define CMD_GET_SCREENSHOT 0x7E // Request screenshot from display (Solo; 66 is upstream's CLI)
#endif #endif
// Stats sub-types for CMD_GET_STATS // Stats sub-types for CMD_GET_STATS
@@ -109,8 +110,9 @@
#define RESP_ALLOWED_REPEAT_FREQ 26 #define RESP_ALLOWED_REPEAT_FREQ 26
#define RESP_CODE_CHANNEL_DATA_RECV 27 #define RESP_CODE_CHANNEL_DATA_RECV 27
#define RESP_CODE_DEFAULT_FLOOD_SCOPE 28 #define RESP_CODE_DEFAULT_FLOOD_SCOPE 28
#define RESP_CODE_CLI_REPLY 29 // v14+, a reply to CMD_RUN_CLI_COMMAND
#ifdef ENABLE_SCREENSHOT #ifdef ENABLE_SCREENSHOT
#define RESP_CODE_SCREENSHOT 29 // Response with screenshot data #define RESP_CODE_SCREENSHOT 0x7E // reply to CMD_GET_SCREENSHOT (Solo; 29 is upstream's CLI reply)
#define SCREENSHOT_TYPE_RGB565 2 // display_type of a colour screen's frame #define SCREENSHOT_TYPE_RGB565 2 // display_type of a colour screen's frame
#endif #endif
@@ -288,9 +290,8 @@ bool MyMesh::getCADEnabled() const {
// RSSI-threshold interference detection relies on _noise_floor, which is only // RSSI-threshold interference detection relies on _noise_floor, which is only
// kept fresh by continuous RX — stale during RX duty-cycle sleep. Auto-enable // kept fresh by continuous RX — stale during RX duty-cycle sleep. Auto-enable
// hardware CAD (a fresh explicit scan) whenever power-save is actually active. // hardware CAD (a fresh explicit scan) whenever power-save is actually active.
// _prefs.cad_enabled itself has no UI/CLI exposure yet on companion_radio // _prefs.cad_enabled is the manual override: Settings > Radio's advanced
// (unlike simple_repeater's CommonCLI `cad` command) — it's wired and // options and the companion CLI's `cad` (MyMeshCli.h) set it.
// persisted for a future manual override, but always 0 today.
// rx_powersave is never actually applied to the radio while FEAT_RX_POWERSAVE // rx_powersave is never actually applied to the radio while FEAT_RX_POWERSAVE
// is 0 (see Features.h) -- don't let a stale persisted byte from before that // is 0 (see Features.h) -- don't let a stale persisted byte from before that
// still auto-enable CAD here for a duty-cycle mode that isn't running. // still auto-enable CAD here for a duty-cycle mode that isn't running.
@@ -487,6 +488,37 @@ bool MyMesh::setContactFavourite(const uint8_t* pub_key, bool fav) {
return true; return true;
} }
bool MyMesh::setContactTelemetry(const uint8_t* pub_key, uint8_t perms) {
ContactInfo* c = lookupContactByPubKey(pub_key, PUB_KEY_SIZE);
if (!c) return false;
c->flags = (c->flags & ~0x0E) | ((perms & 0x07) << 1);
c->lastmod = getRTCClock()->getCurrentTime();
dirty_contacts_expiry = futureMillis(LAZY_CONTACTS_WRITE_DELAY);
return true;
}
bool MyMesh::factoryReset() {
bool was_on = _serial && _serial->isEnabled();
if (_serial) {
MESH_DEBUG_PRINTLN("Factory reset: disabling serial interface to prevent reconnects (BLE/WiFi)");
_serial->disable();
}
if (_store->formatFileSystem()) return true;
if (was_on) _serial->enable(); // nothing erased: the link as it was
return false;
}
// lastmod bumped (unlike CMD_RESET_PATH, which the app sent itself) so the
// app's copy of the contact drops the old path too.
bool MyMesh::resetContactPath(const uint8_t* pub_key) {
ContactInfo* c = lookupContactByPubKey(pub_key, PUB_KEY_SIZE);
if (!c) return false;
c->out_path_len = OUT_PATH_UNKNOWN;
c->lastmod = getRTCClock()->getCurrentTime();
dirty_contacts_expiry = futureMillis(LAZY_CONTACTS_WRITE_DELAY);
return true;
}
// Shared staleness test for the Settings > Contacts "Prune now" sweep. // Shared staleness test for the Settings > Contacts "Prune now" sweep.
// //
// Caveats on lastmod, deliberately accepted: it's "last touched", not purely // Caveats on lastmod, deliberately accepted: it's "last touched", not purely
@@ -3017,11 +3049,7 @@ void MyMesh::handleCmdFrame(size_t len) {
writeErrFrame(ERR_CODE_ILLEGAL_ARG); // invalid stats sub-type writeErrFrame(ERR_CODE_ILLEGAL_ARG); // invalid stats sub-type
} }
} else if (cmd_frame[0] == CMD_FACTORY_RESET && memcmp(&cmd_frame[1], "reset", 5) == 0) { } else if (cmd_frame[0] == CMD_FACTORY_RESET && memcmp(&cmd_frame[1], "reset", 5) == 0) {
if (_serial) { bool success = factoryReset(); // phone app disconnects before we can send OK frame so it's safe here
MESH_DEBUG_PRINTLN("Factory reset: disabling serial interface to prevent reconnects (BLE/WiFi)");
_serial->disable(); // Phone app disconnects before we can send OK frame so it's safe here
}
bool success = _store->formatFileSystem();
if (success) { if (success) {
writeOKFrame(); writeOKFrame();
#ifdef SIM_PLATFORM #ifdef SIM_PLATFORM
@@ -3116,6 +3144,19 @@ void MyMesh::handleCmdFrame(size_t len) {
memcpy(&out_frame[i], &r->upper_freq, 4); i += 4; memcpy(&out_frame[i], &r->upper_freq, 4); i += 4;
} }
_serial->writeFrame(out_frame, i); _serial->writeFrame(out_frame, i);
} else if (cmd_frame[0] == CMD_RUN_CLI_COMMAND && len >= 3) { // v14+, MyMeshCli.h
char text[MAX_FRAME_SIZE + 1];
memcpy(text, &cmd_frame[1], len - 1);
text[len - 1] = 0;
char* reply = (char*)&out_frame[1];
const int n = MAX_FRAME_SIZE - 1;
reply[0] = 0;
if (!handleCliCommand(text, reply, n)) {
size_t at = strlen(reply); // after an "xx|" prefix, if it had one
snprintf(reply + at, n - at, "Unknown command");
}
out_frame[0] = RESP_CODE_CLI_REPLY;
_serial->writeFrame(out_frame, 1 + strlen(reply));
} else if (cmd_frame[0] == CMD_SEND_RAW_PACKET && len >= 4) { } else if (cmd_frame[0] == CMD_SEND_RAW_PACKET && len >= 4) {
auto pkt = obtainNewPacket(); auto pkt = obtainNewPacket();
if (pkt) { if (pkt) {
@@ -3534,3 +3575,4 @@ bool MyMesh::hasPendingWork() const {
} }
#include "MyMeshBot.h" #include "MyMeshBot.h"
#include "MyMeshCli.h"
+21 -1
View File
@@ -9,7 +9,7 @@
class UITask; class UITask;
/*------------ Frame Protocol --------------*/ /*------------ Frame Protocol --------------*/
#define FIRMWARE_VER_CODE 13 #define FIRMWARE_VER_CODE 14 // 14: CMD_RUN_CLI_COMMAND, as upstream
// Fallback only -- every real build (local or CI) goes through build.sh, which // Fallback only -- every real build (local or CI) goes through build.sh, which
// always injects its own FIRMWARE_BUILD_DATE (today's date at build time). // always injects its own FIRMWARE_BUILD_DATE (today's date at build time).
@@ -188,6 +188,9 @@ public:
// its state and restarts. Returns false if it doesn't handle it, and // its state and restarts. Returns false if it doesn't handle it, and
// MyMesh then flushes and reboots by itself. // MyMesh then flushes and reboots by itself.
virtual bool requestShutdown(bool restart) { return false; } virtual bool requestShutdown(bool restart) { return false; }
// A companion CLI command MyMesh has no answer for (its settings keys):
// the reply into `reply` (n bytes), false when unknown.
virtual bool onCliCommand(const char* command, char* reply, int n) { return false; }
}; };
MyMesh(mesh::Radio &radio, mesh::RNG &rng, mesh::RTCClock &rtc, SimpleMeshTables &tables, DataStore& store); MyMesh(mesh::Radio &radio, mesh::RNG &rng, mesh::RTCClock &rtc, SimpleMeshTables &tables, DataStore& store);
@@ -402,6 +405,22 @@ public:
// "fav" list filters no longer depend on the app having starred anything. // "fav" list filters no longer depend on the app having starred anything.
bool setContactFavourite(const uint8_t* pub_key, bool fav); bool setContactFavourite(const uint8_t* pub_key, bool fav);
// On-device per-contact telemetry permissions (Nodes): ContactInfo::flags
// bits 1-3 = status / location / sensors, read where Settings > Privacy says
// "Allowed" (onContactRequest). The same bits the app's per-contact toggles write.
bool setContactTelemetry(const uint8_t* pub_key, uint8_t perms);
// On-device path reset (Nodes): forget the learned route; the next message
// floods and learns a new one, as CMD_RESET_PATH does.
bool resetContactPath(const uint8_t* pub_key);
// Factory reset (CMD_FACTORY_RESET, Settings on the device): the serial link
// off, then every file and setting erased, keys too. The caller reboots.
bool factoryReset();
// Companion CLI (MyMeshCli.h): CMD_RUN_CLI_COMMAND's text in, the reply out.
bool handleCliCommand(const char* command, char* reply, int n);
// On-device "remote admin" (Tools > Admin): send a CLI command to a node // On-device "remote admin" (Tools > Admin): send a CLI command to a node
// you're logged into with admin permission (see ClientACL::isAdmin()). The // you're logged into with admin permission (see ClientACL::isAdmin()). The
// reply is a text frame (TXT_TYPE_CLI_DATA) delivered via onCommandDataRecv(); // reply is a text frame (TXT_TYPE_CLI_DATA) delivered via onCommandDataRecv();
@@ -592,6 +611,7 @@ private:
} }
void checkCLIRescueCmd(); void checkCLIRescueCmd();
bool cliRadioCommand(const char* command, char* reply, int n);
void checkSerialInterface(); void checkSerialInterface();
bool isValidClientRepeatFreq(uint32_t f) const; bool isValidClientRepeatFreq(uint32_t f) const;
#ifdef ENABLE_SCREENSHOT #ifdef ENABLE_SCREENSHOT
+201
View File
@@ -0,0 +1,201 @@
#pragma once
// Companion CLI (CMD_RUN_CLI_COMMAND) -- not part of upstream MyMesh.cpp.
// Included at the bottom of MyMesh.cpp after all class definitions.
//
// The commands upstream's companion CLI has (meshcore-dev MyMesh::handleCommand
// and CommonRadioPrefs::handleCommand), with the same names and replies, so
// the phone app's console works as it does on upstream firmware. What isn't
// one of those goes to the listener: the frontend's settings keys
// (ui-core/SettingsCli.h), the same rows its Settings screen shows.
// No factory reset or key export here: those stay on the device.
static bool cliOnOff(const char* v) { return strncmp(v, "on", 2) == 0 || strcmp(v, "1") == 0; }
bool MyMesh::handleCliCommand(const char* command, char* reply, int n) {
while (*command == ' ') command++;
if (strlen(command) > 4 && command[2] == '|') { // optional "xx|" prefix, reflected back
memcpy(reply, command, 3);
reply += 3;
n -= 3;
*reply = 0;
command += 3;
}
if (cliRadioCommand(command, reply, n)) return true;
if (strcmp(command, "reboot") == 0 || strcmp(command, "poweroff") == 0 || strcmp(command, "shutdown") == 0) {
bool restart = command[0] == 'r';
if (!(_listener && _listener->requestShutdown(restart))) {
flushDirtyContacts();
savePrefs();
if (restart) board.reboot(); else board.powerOff();
}
return true;
}
if (memcmp(command, "set name ", 9) == 0) {
if (command[9] && strlen(&command[9]) < sizeof(_prefs.node_name)) {
StrHelper::strncpy(_prefs.node_name, &command[9], sizeof(_prefs.node_name));
savePrefs();
snprintf(reply, n, "OK");
} else {
snprintf(reply, n, "Error, bad name");
}
return true;
}
if (strcmp(command, "get name") == 0) { snprintf(reply, n, "> %s", _prefs.node_name); return true; }
if (memcmp(command, "set pin ", 8) == 0) {
char* end;
uint32_t pin = (uint32_t)strtoul(&command[8], &end, 10);
if (end == &command[8] || *end || (pin != 0 && (pin < 100000 || pin > 999999))) {
snprintf(reply, n, "Error, 6 digits or 0");
return true;
}
_prefs.ble_pin = pin;
savePrefs();
if (pin) snprintf(reply, n, "> pin is now %06lu", (unsigned long)pin);
else snprintf(reply, n, "> pin is now random each start");
return true;
}
if (strcmp(command, "get pin") == 0) {
if (_prefs.ble_pin) snprintf(reply, n, "> %06lu", (unsigned long)_prefs.ble_pin);
else snprintf(reply, n, "> 0 (random each start)");
return true;
}
if (strcmp(command, "board") == 0) { snprintf(reply, n, "%s", board.getManufacturerName()); return true; }
if (strcmp(command, "ver") == 0) {
snprintf(reply, n, "%s (Build: %s)", FIRMWARE_VERSION, FIRMWARE_BUILD_DATE);
return true;
}
if (strcmp(command, "get tz.offset") == 0) { snprintf(reply, n, "> %d", _prefs.tz_offset_hours); return true; }
if (memcmp(command, "set tz.offset ", 14) == 0) {
int tz = atoi(&command[14]);
if (tz < -12 || tz > 14) snprintf(reply, n, "Error, must be from -12 to +14");
else { _prefs.tz_offset_hours = (int8_t)tz; savePrefs(); snprintf(reply, n, "OK"); }
return true;
}
// The position adverts carry while the GPS is off (Settings > Privacy > Position).
if (strcmp(command, "get lat") == 0) { snprintf(reply, n, "> %.6f", sensors.node_lat); return true; }
if (strcmp(command, "get lon") == 0) { snprintf(reply, n, "> %.6f", sensors.node_lon); return true; }
if (memcmp(command, "set lat ", 8) == 0 || memcmp(command, "set lon ", 8) == 0) {
char* end;
double v = strtod(&command[8], &end);
bool lat = command[6] == 't';
if (end == &command[8] || *end || (lat ? (v < -90 || v > 90) : (v < -180 || v > 180))) { snprintf(reply, n, "Error, bad coordinate"); return true; }
(lat ? sensors.node_lat : sensors.node_lon) = v;
savePrefs();
snprintf(reply, n, "OK");
return true;
}
return _listener && _listener->onCliCommand(command, reply, n);
}
// CommonRadioPrefs::handleCommand's keys, over this firmware's NodePrefs.
bool MyMesh::cliRadioCommand(const char* command, char* reply, int n) {
if (strcmp(command, "get radio") == 0) {
char freq[16], bw[16];
strcpy(freq, StrHelper::ftoa(_prefs.freq));
strcpy(bw, StrHelper::ftoa3(_prefs.bw));
snprintf(reply, n, "> %s,%s,%d,%d", freq, bw, (int)_prefs.sf, (int)_prefs.cr);
return true;
}
if (memcmp(command, "set radio ", 10) == 0) {
char* p = (char*)&command[10]; // "freq,bw,sf,cr" (strtof: no float scanf on every libc)
float freq = strtof(p, &p);
float bw = *p == ',' ? strtof(p + 1, &p) : 0;
int sf = *p == ',' ? (int)strtol(p + 1, &p, 10) : 0;
int cr = *p == ',' ? (int)strtol(p + 1, &p, 10) : 0;
if (freq >= 150.0f && freq <= 2500.0f && sf >= 5 && sf <= 12 && cr >= 5 && cr <= 8 && bw >= 7.0f && bw <= 500.0f) {
_prefs.freq = freq; _prefs.bw = bw; _prefs.sf = sf; _prefs.cr = cr;
savePrefs();
applyRepeaterRadio(); // at once, as CMD_SET_RADIO_PARAMS does
snprintf(reply, n, "OK");
} else {
snprintf(reply, n, "Error, invalid radio params");
}
return true;
}
if (strcmp(command, "get freq") == 0) { snprintf(reply, n, "> %s", StrHelper::ftoa(_prefs.freq)); return true; }
if (strcmp(command, "get af") == 0) { snprintf(reply, n, "> %s", StrHelper::ftoa(_prefs.airtime_factor)); return true; }
if (memcmp(command, "set af ", 7) == 0) {
char* end;
float af = strtof(&command[7], &end);
if (end == &command[7] || af < 0 || af > 9) snprintf(reply, n, "ERROR: af must be 0-9");
else { _prefs.airtime_factor = af; savePrefs(); snprintf(reply, n, "OK"); }
return true;
}
if (strcmp(command, "get dutycycle") == 0) {
float dc = 100.0f / (_prefs.airtime_factor + 1.0f);
snprintf(reply, n, "> %d.%d%%", (int)dc, (int)((dc - (int)dc) * 10.0f + 0.5f));
return true;
}
if (memcmp(command, "set dutycycle ", 14) == 0) {
float dc = atof(&command[14]);
if (dc < 1 || dc > 100) { snprintf(reply, n, "ERROR: dutycycle must be 1-100"); return true; }
_prefs.airtime_factor = 100.0f / dc - 1.0f;
savePrefs();
float a = 100.0f / (_prefs.airtime_factor + 1.0f);
snprintf(reply, n, "OK - %d.%d%%", (int)a, (int)((a - (int)a) * 10.0f + 0.5f));
return true;
}
if (strcmp(command, "get int.thresh") == 0) { snprintf(reply, n, "> %d", (int)_prefs.interference_threshold); return true; }
if (memcmp(command, "set int.thresh ", 15) == 0) {
_prefs.interference_threshold = (uint8_t)constrain(atoi(&command[15]), 0, 255);
savePrefs();
snprintf(reply, n, "OK");
return true;
}
if (strcmp(command, "get cad") == 0) { snprintf(reply, n, "> %s", _prefs.cad_enabled ? "on" : "off"); return true; }
if (memcmp(command, "set cad ", 8) == 0) {
_prefs.cad_enabled = cliOnOff(&command[8]) ? 1 : 0;
savePrefs();
snprintf(reply, n, "OK");
return true;
}
if (strcmp(command, "get radio.rxgain") == 0) { snprintf(reply, n, "> %s", _prefs.rx_boosted_gain ? "on" : "off"); return true; }
if (memcmp(command, "set radio.rxgain ", 17) == 0) {
#if defined(USE_SX1262) || defined(USE_SX1268) || defined(USE_LR2021)
_prefs.rx_boosted_gain = cliOnOff(&command[17]) ? 1 : 0;
savePrefs();
snprintf(reply, n, radio_driver.setRxBoostedGainMode(_prefs.rx_boosted_gain) ? "OK" : "Error: unsupported");
#else
snprintf(reply, n, "Error: unsupported");
#endif
return true;
}
if (memcmp(command, "get tx", 6) == 0 && (command[6] == 0 || command[6] == ' ')) {
snprintf(reply, n, "> %d", (int)_prefs.tx_power_dbm);
return true;
}
if (memcmp(command, "set tx ", 7) == 0) {
int p = atoi(&command[7]);
if (p < -9 || p > MAX_LORA_TX_POWER) { snprintf(reply, n, "Error, must be -9 to %d", MAX_LORA_TX_POWER); return true; }
radio_driver.setTxPower(p);
_prefs.tx_power_dbm = radio_driver.getTxPower(); // what the radio applied (see CMD_SET_RADIO_TX_POWER)
savePrefs();
snprintf(reply, n, "OK");
return true;
}
if (strcmp(command, "get rxdelay") == 0) { snprintf(reply, n, "> %s", StrHelper::ftoa(_prefs.rx_delay_base)); return true; }
if (memcmp(command, "set rxdelay ", 12) == 0) {
float d = atof(&command[12]);
if (d < 0 || d > 20.0f) snprintf(reply, n, "Error, must be 0-20");
else { _prefs.rx_delay_base = d; savePrefs(); snprintf(reply, n, "OK"); }
return true;
}
if (strcmp(command, "get path.hash.mode") == 0) { snprintf(reply, n, "> %d", (int)_prefs.path_hash_mode); return true; }
if (memcmp(command, "set path.hash.mode ", 19) == 0) {
int m = atoi(&command[19]);
if (m < 0 || m > 2) snprintf(reply, n, "Error, must be 0,1, or 2");
else { _prefs.path_hash_mode = (uint8_t)m; savePrefs(); snprintf(reply, n, "OK"); }
return true;
}
if (strcmp(command, "get multi.acks") == 0) { snprintf(reply, n, "> %d", (int)_prefs.multi_acks); return true; }
if (memcmp(command, "set multi.acks ", 15) == 0) {
_prefs.multi_acks = (uint8_t)constrain(atoi(&command[15]), 0, 1);
savePrefs();
snprintf(reply, n, "OK");
return true;
}
return false;
}
+13
View File
@@ -694,4 +694,17 @@ extern "C" EMSCRIPTEN_KEEPALIVE int sim_test_login_first_repeater(const char* pa
uint32_t est_timeout; uint32_t est_timeout;
return the_mesh.sendRoomLogin(ci, password, est_timeout) ? 1 : 0; return the_mesh.sendRoomLogin(ci, password, est_timeout) ? 1 : 0;
} }
// The companion CLI (CMD_RUN_CLI_COMMAND) without a transport: the text in,
// the reply out, as the app's console gets it.
extern "C" EMSCRIPTEN_KEEPALIVE const char* sim_cli(const char* command) {
static char reply[MAX_FRAME_SIZE];
reply[0] = 0;
if (!g_sim_ready) return reply;
if (!the_mesh.handleCliCommand(command, reply, sizeof(reply))) {
size_t at = strlen(reply);
snprintf(reply + at, sizeof(reply) - at, "Unknown command");
}
return reply;
}
#endif #endif
@@ -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 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); } 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). // "@[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); } 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; 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. // GPS duty cycle (NodePrefs::gps_interval, seconds; 0 = always on) to the sensor layer.
void applyGpsInterval() { void applyGpsInterval() {
if (!_sensors || !_prefs) return; if (!_sensors || !_prefs) return;
@@ -275,6 +307,11 @@ public:
} }
_host->onAdminStateChanged(); _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 { void onAdminReply(const uint8_t* pub_key, const char* text) override {
admin.onReply(pub_key, text); admin.onReply(pub_key, text);
_host->onAdminStateChanged(); _host->onAdminStateChanged();
@@ -41,6 +41,8 @@ public:
// A sound pref changed through the schema (the volume): push it to the // A sound pref changed through the schema (the volume): push it to the
// speaker / buzzer. // speaker / buzzer.
virtual void applySoundPrefs() {} 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 // Controlled power-down / restart (flush state first). See
// AbstractUITask::shutdown(). // AbstractUITask::shutdown().
+239 -26
View File
@@ -72,6 +72,8 @@ static bool s_chats_stale = false; // Messages: a refresh waits for a held row
#include "../ui-core/NearbyModel.h" #include "../ui-core/NearbyModel.h"
#include "../ui-core/EtaTracker.h" #include "../ui-core/EtaTracker.h"
#include "../ui-core/SettingsSchema.h" #include "../ui-core/SettingsSchema.h"
#include "../ui-core/SettingsCli.h" // the companion CLI's settings keys
#include "../ui-core/ContactQr.h"
#include "../ui-core/GpsAverager.h" #include "../ui-core/GpsAverager.h"
#include "../ui-core/TrackBack.h" #include "../ui-core/TrackBack.h"
#include "../ui-core/TrailProfile.h" #include "../ui-core/TrailProfile.h"
@@ -2475,6 +2477,7 @@ static void onOpenRepeater(lv_event_t* e); // RepeaterScreen.h
static void onOpenAdminPick(lv_event_t* e); // AdminScreen.h static void onOpenAdminPick(lv_event_t* e); // AdminScreen.h
static void onNodeName(lv_event_t* e); static void onNodeName(lv_event_t* e);
static void onPowerRow(lv_event_t* e); static void onPowerRow(lv_event_t* e);
static void onFactoryReset(lv_event_t* e) { s_ui->factoryResetStep((int)(uintptr_t)lv_event_get_user_data(e)); }
// Home itself (pages, clock, minimap, apps) is HomeScreen.h. // Home itself (pages, clock, minimap, apps) is HomeScreen.h.
@@ -3059,7 +3062,8 @@ void UITask::buildContacts() {
static NearbyModel::Entry s_node; // the node open in SCR_NODE (a copy: the list re-sorts) static NearbyModel::Entry s_node; // the node open in SCR_NODE (a copy: the list re-sorts)
// Its info card's values (UITask::_node_info is the card). // Its info card's values (UITask::_node_info is the card).
static lv_obj_t *s_nd_type, *s_nd_status, *s_nd_dist, *s_nd_pos, *s_nd_heard, *s_nd_signal, *s_nd_id; static lv_obj_t *s_nd_type, *s_nd_status, *s_nd_dist, *s_nd_pos, *s_nd_heard, *s_nd_signal, *s_nd_id;
enum : uint8_t { NODE_MSG, NODE_PING, NODE_FAV, NODE_ADD, NODE_DELETE, NODE_NAV, NODE_ADMIN, NODE_WAYPOINT, NODE_PIN }; enum : uint8_t { NODE_MSG, NODE_PING, NODE_FAV, NODE_ADD, NODE_DELETE, NODE_NAV, NODE_ADMIN, NODE_WAYPOINT, NODE_PIN,
NODE_RESET_PATH, NODE_SHARE };
static void onNearbyChip(lv_event_t* e) { s_ui->setNearbyFilter((uint8_t)(uintptr_t)lv_event_get_user_data(e)); } static void onNearbyChip(lv_event_t* e) { s_ui->setNearbyFilter((uint8_t)(uintptr_t)lv_event_get_user_data(e)); }
static void onNearbySort(lv_event_t* e) { (void)e; s_ui->toggleNearbySort(); } static void onNearbySort(lv_event_t* e) { (void)e; s_ui->toggleNearbySort(); }
@@ -3355,12 +3359,25 @@ static void nodeFavText() {
if (s_nd_fav) lv_label_set_text(s_nd_fav, s_node.fav ? UI_SYMBOL_STAR " Remove from favourites" : UI_SYMBOL_STAR " Add to favourites"); if (s_nd_fav) lv_label_set_text(s_nd_fav, s_node.fav ? UI_SYMBOL_STAR " Remove from favourites" : UI_SYMBOL_STAR " Add to favourites");
} }
static lv_obj_t* switchRow(lv_obj_t* parent, const char* text, const char* sub, uint8_t* pref); // below
// One of the node's "Can ask for" switches; user data = its TELEM_PERM_* bit.
static void onNodeTelemSwitch(lv_event_t* e) {
ContactInfo* ci = the_mesh.lookupContactByPubKey(s_node.pub_key, PUB_KEY_SIZE);
if (!ci) return;
uint8_t bit = (uint8_t)(uintptr_t)lv_event_get_user_data(e);
uint8_t perms = contactctl::telemetry(*ci);
if (lv_obj_has_state((lv_obj_t*)lv_event_get_target(e), LV_STATE_CHECKED)) perms |= bit; else perms &= ~bit;
contactctl::setTelemetry(s_node.pub_key, perms);
}
void UITask::buildNode() { void UITask::buildNode() {
const NearbyModel::Entry& e = s_node; const NearbyModel::Entry& e = s_node;
lv_obj_t* body = newScreen(e.name[0] ? e.name : "(unknown)", true); lv_obj_t* body = newScreen(e.name[0] ? e.name : "(unknown)", true);
lv_obj_t* info = scrollList(body); // the info, scrolling above the actions at the bottom lv_obj_t* info = scrollList(body); // the info, scrolling above the actions at the bottom
s_nd_fav = nullptr; s_nd_fav = nullptr;
_node_path_lbl = nullptr;
nodeVizBuild(info); nodeVizBuild(info);
nodeviz::s_bearing = nodeviz::s_hops = -2; // nothing drawn yet: the first set fills both nodeviz::s_bearing = nodeviz::s_hops = -2; // nothing drawn yet: the first set fills both
_node_info = infoCard(info); _node_info = infoCard(info);
@@ -3391,10 +3408,31 @@ void UITask::buildNode() {
if (contact) s_nd_fav = actionRow(g, "", onNodeAction, (void*)(uintptr_t)NODE_FAV); if (contact) s_nd_fav = actionRow(g, "", onNodeAction, (void*)(uintptr_t)NODE_FAV);
nodeFavText(); nodeFavText();
if (contact && e.has_key) actionRow(g, UI_SYMBOL_TACK " Pin to Home", onNodeAction, (void*)(uintptr_t)NODE_PIN); if (contact && e.has_key) actionRow(g, UI_SYMBOL_TACK " Pin to Home", onNodeAction, (void*)(uintptr_t)NODE_PIN);
ContactInfo* ci = (contact && e.has_key) ? the_mesh.lookupContactByPubKey(e.pub_key, PUB_KEY_SIZE) : nullptr;
if (ci && contactctl::hasPath(*ci))
_node_path_lbl = actionRow(g, LV_SYMBOL_REFRESH " Reset path", onNodeAction, (void*)(uintptr_t)NODE_RESET_PATH);
if (ci) actionRow(g, LV_SYMBOL_UPLOAD " Share with nodes in range", onNodeAction, (void*)(uintptr_t)NODE_SHARE);
if (contact) _node_delete_lbl = actionRow(g, LV_SYMBOL_TRASH " Delete contact", onNodeAction, if (contact) _node_delete_lbl = actionRow(g, LV_SYMBOL_TRASH " Delete contact", onNodeAction,
(void*)(uintptr_t)NODE_DELETE, theme::FAIL); (void*)(uintptr_t)NODE_DELETE, theme::FAIL);
} }
// Where Settings > Privacy says "Allowed", what this contact may ask for.
ContactInfo* ci = (contact && e.has_key) ? the_mesh.lookupContactByPubKey(e.pub_key, PUB_KEY_SIZE) : nullptr;
if (ci && contactctl::telemetryPerContact(_prefs)) {
lv_obj_t* g = group(info, "Can ask for");
static const struct { const char* text; const char* sub; uint8_t bit; } T[] = {
{ "Status", "Battery; the two below need it", TELEM_PERM_BASE },
{ "Location", nullptr, TELEM_PERM_LOCATION },
{ "Sensors", nullptr, TELEM_PERM_ENVIRONMENT },
};
uint8_t perms = contactctl::telemetry(*ci);
for (const auto& t : T) {
lv_obj_t* sw = switchRow(g, t.text, t.sub, nullptr);
if (perms & t.bit) lv_obj_add_state(sw, LV_STATE_CHECKED);
lv_obj_add_event_cb(sw, onNodeTelemSwitch, LV_EVENT_VALUE_CHANGED, (void*)(uintptr_t)t.bit);
}
}
refreshNode(); refreshNode();
} }
@@ -3531,6 +3569,16 @@ void UITask::nodeAction(uint8_t action) {
case NODE_PIN: // to the favourites dial (Home), as from a chat's options case NODE_PIN: // to the favourites dial (Home), as from a chat's options
pinPopup(false, 0, e.pub_key); pinPopup(false, 0, e.pub_key);
break; break;
case NODE_RESET_PATH: // the row goes, the chain above turns to flood
if (contactctl::resetPath(e.pub_key) && _node_path_lbl) {
lv_obj_add_flag(lv_obj_get_parent(lv_obj_get_parent(_node_path_lbl)), LV_OBJ_FLAG_HIDDEN); // label < text box < row
_node_path_lbl = nullptr;
refreshNode();
}
break;
case NODE_SHARE:
showToast(contactctl::share(e.pub_key) ? "Sent to nodes in range" : "No advert saved to send");
break;
case NODE_DELETE: case NODE_DELETE:
if (!tapConfirmed(_node_delete_lbl, LV_SYMBOL_TRASH " Tap again to delete")) break; if (!tapConfirmed(_node_delete_lbl, LV_SYMBOL_TRASH " Tap again to delete")) break;
if (the_mesh.deleteContactByKey(e.pub_key)) { if (the_mesh.deleteContactByKey(e.pub_key)) {
@@ -3541,7 +3589,6 @@ void UITask::nodeAction(uint8_t action) {
} }
} }
static lv_obj_t* switchRow(lv_obj_t* parent, const char* text, const char* sub, uint8_t* pref); // below
#include "MapScreen.h" #include "MapScreen.h"
#include "Chart.h" #include "Chart.h"
#include "ProfileView.h" #include "ProfileView.h"
@@ -4454,10 +4501,147 @@ void UITask::buildSchemaSettings() {
schemaRows(body, _settings_page); schemaRows(body, _settings_page);
if (_settings_page == settings::PG_SOUND) then(&UITask::schemaExtras, settings::PG_SOUND); // after its groups if (_settings_page == settings::PG_SOUND) then(&UITask::schemaExtras, settings::PG_SOUND); // after its groups
if (_settings_page == settings::PG_MESSAGES) then(&UITask::schemaExtras, settings::PG_MESSAGES); if (_settings_page == settings::PG_MESSAGES) then(&UITask::schemaExtras, settings::PG_MESSAGES);
if (_settings_page == settings::PG_PRIVACY) then(&UITask::schemaExtras, settings::PG_PRIVACY);
}
// ── Settings > Privacy: position, Bluetooth PIN; factory reset ─────────────────
static void onOwnPos(lv_event_t* e) { (void)e; s_ui->ownPositionPopup(); }
static void onOwnPosPick(lv_event_t* e) { s_ui->ownPositionPick((int)(uintptr_t)lv_event_get_user_data(e)); }
static void onOwnPosKb(lv_event_t* e) { s_ui->ownPositionDone(lv_event_get_code(e) == LV_EVENT_READY); }
static void onBlePin(lv_event_t* e) { (void)e; s_ui->blePinPopup(); }
static void onBlePinKb(lv_event_t* e) { s_ui->blePinDone(lv_event_get_code(e) == LV_EVENT_READY); }
enum : int { OWNPOS_TYPE, OWNPOS_WAYPOINTS, OWNPOS_CLEAR, OWNPOS_WP0 = 100 };
void UITask::ownPositionPopup() {
if (_core->gpsEnabled()) { showToast("The GPS sets your position while it's on", 2500); return; }
int32_t lat, lon;
bool have = _core->fixedPosition(lat, lon);
lv_obj_t* g = group(navPopupPanel("Fixed position", false), nullptr);
listRow(g, "Type coordinates", "Latitude, longitude in degrees", onOwnPosPick, (void*)(uintptr_t)OWNPOS_TYPE);
if (_core->waypoints.count() > 0) {
char sub[24];
snprintf(sub, sizeof(sub), "%d saved", _core->waypoints.count());
listRow(g, "From a waypoint", sub, onOwnPosPick, (void*)(uintptr_t)OWNPOS_WAYPOINTS);
}
if (have) actionRow(g, "Clear", onOwnPosPick, (void*)(uintptr_t)OWNPOS_CLEAR);
}
void UITask::ownPositionPick(int code) {
const WaypointModel& wp = _core->waypoints;
if (code == OWNPOS_TYPE) {
int32_t lat, lon;
char cur[32] = "";
if (_core->fixedPosition(lat, lon)) snprintf(cur, sizeof(cur), "%.5f, %.5f", lat / 1e6, lon / 1e6);
TextEntry t = { "Fixed position", onOwnPosKb };
t.text = cur;
t.hint = "50.06143, 19.93658";
t.max_bytes = 31;
t.accepted = "0123456789.,- ";
t.symbols = true;
navTextEntry(t);
return;
}
if (code == OWNPOS_WAYPOINTS) {
lv_obj_t* list = scrollList(navPopupPanel("From a waypoint", true));
for (int i = 0; i < wp.count(); i++) {
const Waypoint& w = wp.at(i);
char title[WAYPOINT_LABEL_LEN + 8], sub[40];
snprintf(title, sizeof(title), UI_SYMBOL_FLAG " %s", w.label[0] ? w.label : "(unnamed)");
snprintf(sub, sizeof(sub), "%.5f, %.5f", w.lat_1e6 / 1e6, w.lon_1e6 / 1e6);
listRow(list, title, sub, onOwnPosPick, (void*)(uintptr_t)(OWNPOS_WP0 + i));
}
return;
}
if (code == OWNPOS_CLEAR) _core->setFixedPosition(0, 0);
else if (code >= OWNPOS_WP0 && code - OWNPOS_WP0 < wp.count())
_core->setFixedPosition(wp.at(code - OWNPOS_WP0).lat_1e6, wp.at(code - OWNPOS_WP0).lon_1e6);
navClosePopup();
if (_screen == SCR_SETTINGS_NAV) rebuildSchemaSettings();
}
void UITask::ownPositionDone(bool ok) {
if (ok && _nav_ta) {
int32_t lat, lon;
if (!geo::parseLatLon(lv_textarea_get_text(_nav_ta), lat, lon) || (!lat && !lon)) {
showToast("Type it as latitude, longitude");
return; // the field stays, to fix it
}
_core->setFixedPosition(lat, lon);
}
navClosePopup();
if (ok && _screen == SCR_SETTINGS_NAV) rebuildSchemaSettings();
}
void UITask::blePinPopup() {
if (!_prefs) return;
char cur[8] = "";
if (_prefs->ble_pin) snprintf(cur, sizeof(cur), "%06lu", (unsigned long)_prefs->ble_pin);
TextEntry t = { "Bluetooth PIN", onBlePinKb };
t.text = cur;
t.hint = "6 digits, empty for a random one";
t.max_bytes = 6;
t.accepted = "0123456789";
t.symbols = true;
navTextEntry(t);
}
void UITask::blePinDone(bool ok) {
if (ok && _nav_ta) {
const char* t = lv_textarea_get_text(_nav_ta);
if ((t[0] && strlen(t) != 6) || !_core->setBlePin((uint32_t)strtoul(t, nullptr, 10))) {
showToast("Six digits, not starting with 0");
return;
}
showToast("Used from the next start", 2000);
}
navClosePopup();
if (ok && _screen == SCR_SETTINGS_NAV) rebuildSchemaSettings();
}
void UITask::factoryResetStep(int stage) {
if (stage == 1)
confirmBody(navPopupPanel("Factory reset?", false),
"Erases contacts, channels, messages, settings and this node's keys. The SD card is left as it is.",
"Continue", onFactoryReset, 2);
else if (stage == 2)
confirmBody(navPopupPanel("Erase everything?", false),
"This can't be undone. The device restarts as a new node.",
LV_SYMBOL_TRASH " Erase", onFactoryReset, 3);
else {
navClosePopup();
_core->factoryReset(); // restarts; back here only if the erase failed
showToast("Erase failed");
}
}
// The companion CLI's settings keys (ui-core/SettingsCli.h); a Settings page
// open on the screen is built again with the new value.
bool UITask::cliCommand(const char* command, char* reply, int n) {
if (!settingscli::handle(*_core, command, reply, n)) return false;
if (_screen == SCR_SETTINGS_NAV) rebuildSchemaSettings();
return true;
} }
// What a page adds to its schema groups, once they are there. // What a page adds to its schema groups, once they are there.
void UITask::schemaExtras(int page) { void UITask::schemaExtras(int page) {
if (page == settings::PG_PRIVACY) {
if (lv_obj_t* c = s_sec_card[settings::SEC_PRIVACY]) { // with "Position in adverts"
char sub[40];
int32_t lat, lon;
if (_core->gpsEnabled()) snprintf(sub, sizeof(sub), "From the GPS while it's on");
else if (_core->fixedPosition(lat, lon)) snprintf(sub, sizeof(sub), "%.5f, %.5f", lat / 1e6, lon / 1e6);
else snprintf(sub, sizeof(sub), "Not set");
listRow(c, "Fixed position", sub, onOwnPos, NULL);
}
#ifdef BLE_PIN_CODE
char pin[40];
if (_prefs && _prefs->ble_pin) snprintf(pin, sizeof(pin), "%06lu", (unsigned long)_prefs->ble_pin);
else snprintf(pin, sizeof(pin), "Random each start, shown on screen");
listRow(group(_body, "BLUETOOTH"), "Pairing PIN", pin, onBlePin, NULL);
#endif
}
if (page == settings::PG_SOUND) buildSoundRows(_body, false); // the melodies if (page == settings::PG_SOUND) buildSoundRows(_body, false); // the melodies
if (page == settings::PG_MESSAGES) { if (page == settings::PG_MESSAGES) {
lv_obj_t* c = s_sec_card[settings::SEC_CONTACTS]; // the action that goes with "Contact expiry" lv_obj_t* c = s_sec_card[settings::SEC_CONTACTS]; // the action that goes with "Contact expiry"
@@ -4483,30 +4667,51 @@ void UITask::buildKeyboardPage(lv_obj_t* body) {
groupNote(body, "Hold a letter for accents and other variants."); groupNote(body, "Hold a letter for accents and other variants.");
} }
// The Solo site's QR code (ui-core/SiteQr.h): a 1-bit image, black on white // A QR code as a 1-bit image into dsc / data (the palette, then the rows):
// with its quiet zone, built once at 5 px a module. // black on white with its quiet zone, s px a module. The image cache forgets
// an older picture at the same place.
template <class Dark>
static void qrImage(lv_image_dsc_t& dsc, uint8_t* data, size_t size, int n, int s, Dark dark) {
const int side = n + 2 * siteqr::QUIET, px = side * s, stride = (px + 7) / 8;
memset(data, 0, size);
const lv_color32_t pal[2] = { lv_color32_make(0xFF, 0xFF, 0xFF, 0xFF), lv_color32_make(0, 0, 0, 0xFF) };
memcpy(data, pal, sizeof(pal));
uint8_t* rows = data + 8;
for (int y = 0; y < px; y++)
for (int x = 0; x < px; x++) {
int mx = x / s - siteqr::QUIET, my = y / s - siteqr::QUIET;
if (mx >= 0 && my >= 0 && mx < n && my < n && dark(mx, my)) rows[y * stride + x / 8] |= 0x80 >> (x % 8);
}
lv_image_cache_drop(&dsc);
dsc.header.magic = LV_IMAGE_HEADER_MAGIC;
dsc.header.cf = LV_COLOR_FORMAT_I1;
dsc.header.w = px;
dsc.header.h = px;
dsc.header.stride = stride;
dsc.data_size = 8 + stride * px;
dsc.data = data;
}
// The Solo site's QR code (ui-core/SiteQr.h), built once at 5 px a module.
static lv_obj_t* siteQr(lv_obj_t* parent) { static lv_obj_t* siteQr(lv_obj_t* parent) {
static const int S = 5, SIDE = siteqr::N + 2 * siteqr::QUIET, PX = SIDE * S, STRIDE = (PX + 7) / 8; static const int S = 5, PX = (siteqr::N + 2 * siteqr::QUIET) * S;
static uint8_t data[8 + STRIDE * PX]; // the palette (white, black), then the rows static uint8_t data[8 + (PX + 7) / 8 * PX];
static lv_image_dsc_t dsc; static lv_image_dsc_t dsc;
if (!dsc.data) { if (!dsc.data) qrImage(dsc, data, sizeof(data), siteqr::N, S, siteqr::dark);
const lv_color32_t pal[2] = { lv_color32_make(0xFF, 0xFF, 0xFF, 0xFF), lv_color32_make(0, 0, 0, 0xFF) }; lv_obj_t* img = lv_image_create(parent);
memcpy(data, pal, sizeof(pal)); lv_image_set_src(img, &dsc);
uint8_t* px = data + 8; return img;
for (int y = 0; y < PX; y++) }
for (int x = 0; x < PX; x++) {
int mx = x / S - siteqr::QUIET, my = y / S - siteqr::QUIET; // Your own contact's QR code (ui-core/ContactQr.h), made each time it is
if (mx >= 0 && my >= 0 && mx < siteqr::N && my < siteqr::N && siteqr::dark(mx, my)) // shown (the name can change), 3 px a module; null when it doesn't fit.
px[y * STRIDE + x / 8] |= 0x80 >> (x % 8); static lv_obj_t* contactQr(lv_obj_t* parent) {
} static const int S = 3, PX = (contactqr::MAX_VERSION * 4 + 17 + 2 * siteqr::QUIET) * S;
dsc.header.magic = LV_IMAGE_HEADER_MAGIC; static uint8_t data[8 + (PX + 7) / 8 * PX];
dsc.header.cf = LV_COLOR_FORMAT_I1; static lv_image_dsc_t dsc;
dsc.header.w = PX; static contactqr::Code code;
dsc.header.h = PX; if (!contactqr::make(code)) return nullptr;
dsc.header.stride = STRIDE; qrImage(dsc, data, sizeof(data), code.n, S, [](int x, int y) { return code.dark(x, y); });
dsc.data_size = sizeof(data);
dsc.data = data;
}
lv_obj_t* img = lv_image_create(parent); lv_obj_t* img = lv_image_create(parent);
lv_image_set_src(img, &dsc); lv_image_set_src(img, &dsc);
return img; return img;
@@ -4518,6 +4723,13 @@ void UITask::buildAboutPage(lv_obj_t* body) {
infoRow(about, "Node", the_mesh.getNodeName()); infoRow(about, "Node", the_mesh.getNodeName());
infoRow(about, "Firmware", FIRMWARE_VERSION); infoRow(about, "Firmware", FIRMWARE_VERSION);
if (!strstr(FIRMWARE_VERSION, FIRMWARE_BUILD_DATE)) infoRow(about, "Built", FIRMWARE_BUILD_DATE); if (!strstr(FIRMWARE_VERSION, FIRMWARE_BUILD_DATE)) infoRow(about, "Built", FIRMWARE_BUILD_DATE);
sectionTitle(body, "MY CONTACT"); // the phone app scans it to add you
lv_obj_t* me = infoCard(body);
lv_obj_set_flex_align(me, LV_FLEX_ALIGN_START, LV_FLEX_ALIGN_CENTER, LV_FLEX_ALIGN_CENTER);
lv_obj_set_style_pad_ver(me, theme::PAD, 0);
lv_obj_set_style_pad_row(me, 6, 0);
contactQr(me);
label(me, "Scan with the MeshCore app to add this node", THEME_FONT_SMALL, theme::TEXT_MUTED);
sectionTitle(body, "MANUAL"); sectionTitle(body, "MANUAL");
lv_obj_t* man = infoCard(body); lv_obj_t* man = infoCard(body);
lv_obj_set_flex_align(man, LV_FLEX_ALIGN_START, LV_FLEX_ALIGN_CENTER, LV_FLEX_ALIGN_CENTER); lv_obj_set_flex_align(man, LV_FLEX_ALIGN_START, LV_FLEX_ALIGN_CENTER, LV_FLEX_ALIGN_CENTER);
@@ -4701,7 +4913,7 @@ void UITask::settingsGroup(int i) {
if (pi >= 0) radioctl::presetAt(_prefs, pi, pn, f, b, sf, cr); if (pi >= 0) radioctl::presetAt(_prefs, pi, pn, f, b, sf, cr);
snprintf(sub, sizeof(sub), "%s - %.3f MHz, %d dBm", pn, _prefs->freq, _prefs->tx_power_dbm); snprintf(sub, sizeof(sub), "%s - %.3f MHz, %d dBm", pn, _prefs->freq, _prefs->tx_power_dbm);
listRow(g, UI_SYMBOL_RADIO " Radio", sub, onOpenRadio, NULL); listRow(g, UI_SYMBOL_RADIO " Radio", sub, onOpenRadio, NULL);
listRow(g, LV_SYMBOL_EYE_CLOSE " Privacy", "Position in adverts, who can ask for telemetry", listRow(g, LV_SYMBOL_EYE_CLOSE " Privacy", "Position, who can ask for telemetry, Bluetooth PIN",
onOpenSchemaPage, (void*)(uintptr_t)settings::PG_PRIVACY); onOpenSchemaPage, (void*)(uintptr_t)settings::PG_PRIVACY);
} }
bluetoothRow(g); bluetoothRow(g);
@@ -4735,13 +4947,14 @@ void UITask::settingsGroup(int i) {
schemaRow(g, SETTING(units_imperial)); schemaRow(g, SETTING(units_imperial));
} }
listRow(g, LV_SYMBOL_DOWNLOAD " Firmware update", FIRMWARE_VERSION, onOpenOta, NULL); listRow(g, LV_SYMBOL_DOWNLOAD " Firmware update", FIRMWARE_VERSION, onOpenOta, NULL);
listRow(g, LV_SYMBOL_LIST " About", "Node, firmware, manual, credits", onOpenSchemaPage, (void*)(uintptr_t)PG_ABOUT); listRow(g, LV_SYMBOL_LIST " About", "Node, your contact QR, manual, credits", onOpenSchemaPage, (void*)(uintptr_t)PG_ABOUT);
break; break;
case 4: case 4:
g = group(body, nullptr); g = group(body, nullptr);
actionRow(g, LV_SYMBOL_REFRESH " Reboot", onPowerRow, (void*)(uintptr_t)1); actionRow(g, LV_SYMBOL_REFRESH " Reboot", onPowerRow, (void*)(uintptr_t)1);
actionRow(g, LV_SYMBOL_POWER " Power off", onPowerRow, (void*)(uintptr_t)0, theme::FAIL); actionRow(g, LV_SYMBOL_POWER " Power off", onPowerRow, (void*)(uintptr_t)0, theme::FAIL);
actionRow(g, LV_SYMBOL_TRASH " Factory reset", onFactoryReset, (void*)(uintptr_t)1, theme::FAIL);
break; break;
} }
} }
+10
View File
@@ -60,6 +60,7 @@ public:
void onViewedHistoryGrew(bool channel) override { (void)channel; _thread_dirty = true; } void onViewedHistoryGrew(bool channel) override { (void)channel; _thread_dirty = true; }
void botSetGPS(bool on) override; void botSetGPS(bool on) override;
void applySoundPrefs() override; void applySoundPrefs() override;
bool cliCommand(const char* command, char* reply, int n) override;
// ── Navigation (called from LVGL event callbacks) ──────────────────────── // ── Navigation (called from LVGL event callbacks) ────────────────────────
void showHome(); void showHome();
@@ -87,6 +88,14 @@ public:
void otaAction(); void otaAction();
void adminPick(int row); void adminPick(int row);
void pruneContacts(); void pruneContacts();
// Settings > Privacy: the position adverts carry with the GPS off, the
// Bluetooth PIN; Settings: factory reset, asked twice (stage 1, 2; 3 erases).
void ownPositionPopup();
void ownPositionPick(int code);
void ownPositionDone(bool ok);
void blePinPopup();
void blePinDone(bool ok);
void factoryResetStep(int stage);
void setBrightnessPct(uint8_t pct, bool save); void setBrightnessPct(uint8_t pct, bool save);
void applyDisplayPrefs() override; void applyDisplayPrefs() override;
void setSchemaValue(int idx, int v); void setSchemaValue(int idx, int v);
@@ -595,6 +604,7 @@ private:
lv_obj_t* _node_info = nullptr; lv_obj_t* _node_info = nullptr;
lv_obj_t* _node_ping = nullptr; lv_obj_t* _node_ping = nullptr;
lv_obj_t* _node_delete_lbl = nullptr; lv_obj_t* _node_delete_lbl = nullptr;
lv_obj_t* _node_path_lbl = nullptr;
bool _node_from_map = false; bool _node_from_map = false;
// Map (SCR_MAP): view centre in fractional tile coords at zoom _map_z // Map (SCR_MAP): view centre in fractional tile coords at zoom _map_z
+53 -8
View File
@@ -21,7 +21,8 @@ class NearbyScreen : public UIScreen, protected NearbyModel {
// ── action-menu actions (matched by id, not by row index) ──────────────────── // ── action-menu actions (matched by id, not by row index) ────────────────────
enum Action : uint8_t { ACT_NAV, ACT_PING, ACT_WAYPOINT, ACT_LOCATOR, enum Action : uint8_t { ACT_NAV, ACT_PING, ACT_WAYPOINT, ACT_LOCATOR,
ACT_ADD, ACT_DELETE, ACT_FAV, ACT_PIN, ACT_ADMIN, ACT_SORT, ACT_SCAN, ACT_MAP }; ACT_ADD, ACT_DELETE, ACT_FAV, ACT_PIN, ACT_ADMIN, ACT_SORT, ACT_SCAN, ACT_MAP,
ACT_TELEM, ACT_RESET_PATH, ACT_SHARE };
// Set by UITask::pickAdminTarget() (Tools > Admin, which is remote-only): // Set by UITask::pickAdminTarget() (Tools > Admin, which is remote-only):
// while true, ENTER on an eligible row (a stored repeater/room contact) hands // while true, ENTER on an eligible row (a stored repeater/room contact) hands
@@ -55,7 +56,7 @@ class NearbyScreen : public UIScreen, protected NearbyModel {
PopupMenu _ping_menu; // ping (special: read-only result rows) PopupMenu _ping_menu; // ping (special: read-only result rows)
PopupMenu _confirm; // delete-contact confirmation (destructive → 2-step) PopupMenu _confirm; // delete-contact confirmation (destructive → 2-step)
Action _menu_actions[12]; // parallel to _menu rows — stable action ids Action _menu_actions[16]; // parallel to _menu rows — stable action ids
int _menu_action_count; int _menu_action_count;
char _sort_label[16]; // dynamic label for the Sort row char _sort_label[16]; // dynamic label for the Sort row
@@ -303,6 +304,24 @@ class NearbyScreen : public UIScreen, protected NearbyModel {
char _fav_label[12]; // "Fav: ON" / "Fav: OFF" -- rewritten in place by L/R char _fav_label[12]; // "Fav: ON" / "Fav: OFF" -- rewritten in place by L/R
char _pin_label[24]; // "Pin to dial" / "Unpin (slot N)" -- _menu stores the pointer char _pin_label[24]; // "Pin to dial" / "Unpin (slot N)" -- _menu stores the pointer
char _telem_label[20]; // "Telem: ..." -- rewritten in place by L/R
// What a contact may ask for, as one cycled value: status first, since
// location and sensors go unanswered without it (MyMesh::onContactRequest).
static uint8_t telemPerms(int step) {
static const uint8_t STEPS[5] = {
0, TELEM_PERM_BASE, TELEM_PERM_BASE | TELEM_PERM_LOCATION, TELEM_PERM_BASE | TELEM_PERM_ENVIRONMENT,
TELEM_PERM_BASE | TELEM_PERM_LOCATION | TELEM_PERM_ENVIRONMENT };
return STEPS[step];
}
static int telemStep(uint8_t perms) {
for (int i = 4; i > 0; i--) if (perms == telemPerms(i)) return i;
return 0;
}
void buildTelemLabel(uint8_t perms) {
static const char* const NAMES[5] = { "None", "Status", "Stat+loc", "Stat+sens", "All" };
snprintf(_telem_label, sizeof(_telem_label), "Telem: %s", NAMES[telemStep(perms)]);
}
void openActionMenu() { void openActionMenu() {
const Entry* e = selected(); const Entry* e = selected();
@@ -347,6 +366,15 @@ class NearbyScreen : public UIScreen, protected NearbyModel {
else snprintf(_pin_label, sizeof(_pin_label), "Pin to dial"); else snprintf(_pin_label, sizeof(_pin_label), "Pin to dial");
add(_pin_label, ACT_PIN); add(_pin_label, ACT_PIN);
} }
// A saved contact (not a scan row): its telemetry permissions -- only where
// Privacy says "Allowed" do they count -- its path and its advert.
ContactInfo* ci = (is_contact && has_key) ? the_mesh.lookupContactByPubKey(e->pub_key, PUB_KEY_SIZE) : nullptr;
if (ci && contactctl::telemetryPerContact(_task->getNodePrefs())) {
buildTelemLabel(contactctl::telemetry(*ci));
addValue(_telem_label, ACT_TELEM);
}
if (ci && contactctl::hasPath(*ci)) add("Reset path", ACT_RESET_PATH);
if (ci) add("Share nearby", ACT_SHARE);
if (is_admin_target) add("Admin", ACT_ADMIN); if (is_admin_target) add("Admin", ACT_ADMIN);
if (is_contact && has_key) add("Delete contact", ACT_DELETE); if (is_contact && has_key) add("Delete contact", ACT_DELETE);
if (stored) addValue(_sort_label, ACT_SORT); // sort is meaningless for live-scan rows if (stored) addValue(_sort_label, ACT_SORT); // sort is meaningless for live-scan rows
@@ -365,9 +393,9 @@ class NearbyScreen : public UIScreen, protected NearbyModel {
refreshKeepingSelection(); refreshKeepingSelection();
} }
// Advance the value on the menu's value rows (Sort, Fav). Both are two-state, // Advance the value on the menu's value rows (Sort, Fav, Telem). Sort and Fav
// so LEFT and RIGHT do the same thing here and Enter joins them. // are two-state, so LEFT and RIGHT do the same there; Telem steps by `dir`.
void cycleMenuValue(int i) { void cycleMenuValue(int i, int dir = 1) {
if (i < 0 || i >= _menu_action_count) return; if (i < 0 || i >= _menu_action_count) return;
if (_menu_actions[i] == ACT_SORT) { if (_menu_actions[i] == ACT_SORT) {
_sort = (_sort == SORT_DIST) ? SORT_TIME : SORT_DIST; _sort = (_sort == SORT_DIST) ? SORT_TIME : SORT_DIST;
@@ -375,6 +403,12 @@ class NearbyScreen : public UIScreen, protected NearbyModel {
refresh(); refresh();
} else if (_menu_actions[i] == ACT_FAV) { } else if (_menu_actions[i] == ACT_FAV) {
toggleFavSelected(); toggleFavSelected();
} else if (_menu_actions[i] == ACT_TELEM) {
const Entry* e = selected();
ContactInfo* ci = e ? the_mesh.lookupContactByPubKey(e->pub_key, PUB_KEY_SIZE) : nullptr;
if (!ci) return;
uint8_t next = telemPerms((telemStep(contactctl::telemetry(*ci)) + dir + 5) % 5);
if (contactctl::setTelemetry(e->pub_key, next)) buildTelemLabel(next);
} }
} }
@@ -416,6 +450,17 @@ class NearbyScreen : public UIScreen, protected NearbyModel {
break; break;
} }
case ACT_FAV: break; // value rows -- see cycleMenuValue() case ACT_FAV: break; // value rows -- see cycleMenuValue()
case ACT_TELEM: break;
case ACT_RESET_PATH: {
const Entry* e = selected();
if (e && contactctl::resetPath(e->pub_key)) _task->showAlert("Path reset", 800);
break;
}
case ACT_SHARE: {
const Entry* e = selected();
_task->showAlert(e && contactctl::share(e->pub_key) ? "Shared nearby" : "No advert saved", 1000);
break;
}
case ACT_PIN: { case ACT_PIN: {
const Entry* e = selected(); const Entry* e = selected();
if (e && e->has_key) togglePinToDial(e->pub_key); if (e && e->has_key) togglePinToDial(e->pub_key);
@@ -914,10 +959,10 @@ public:
if (_ping_menu.active) { handlePingMenuInput(c); return true; } if (_ping_menu.active) { handlePingMenuInput(c); return true; }
if (_menu.active) { if (_menu.active) {
// LEFT/RIGHT -- and Enter, which PopupMenu reports as VALUE_NEXT on a // LEFT/RIGHT -- and Enter, which PopupMenu reports as VALUE_NEXT on a
// value row -- cycle Sort and Fav in place; the popup stays open so the // value row -- cycle Sort, Fav and Telem in place; the popup stays open so
// user can keep tapping, and only Back closes it. Other rows swallow L/R. // the user can keep tapping, and only Back closes it. Other rows swallow L/R.
if (keyIsPrev(c) || keyIsNext(c)) { if (keyIsPrev(c) || keyIsNext(c)) {
cycleMenuValue(_menu.selectedIndex()); cycleMenuValue(_menu.selectedIndex(), keyIsNext(c) ? 1 : -1);
return true; return true;
} }
auto res = _menu.handleInput(c); auto res = _menu.handleInput(c);
@@ -10,6 +10,7 @@
#include "PopupMenu.h" // scope list management's per-row action menu #include "PopupMenu.h" // scope list management's per-row action menu
#include "InfoKit.h" // value / switch rows #include "InfoKit.h" // value / switch rows
#include "../ui-core/Battery.h" // the Battery curve row #include "../ui-core/Battery.h" // the Battery curve row
#include "../GeoUtils.h" // Position typed in
class SettingsScreen : public UIScreen { class SettingsScreen : public UIScreen {
UITask* _task; UITask* _task;
@@ -53,6 +54,7 @@ class SettingsScreen : public UIScreen {
SCHEMA_SYSTEM, // power, units SCHEMA_SYSTEM, // power, units
BATT_CURVE, BATT_CURVE,
REBOOT, REBOOT,
FACTORY_RESET,
// Keyboard section // Keyboard section
SECTION_KEYBOARD, SECTION_KEYBOARD,
KEYBOARD_TYPE, KEYBOARD_TYPE,
@@ -63,8 +65,11 @@ class SettingsScreen : public UIScreen {
#endif #endif
// Contacts section // Contacts section
SECTION_CONTACTS, DM_FILTER, CH_FILTER, ROOM_FILTER, SCHEMA_CONTACTS, PRUNE_NOW, SCHEMA_AUTO_ADD, SECTION_CONTACTS, DM_FILTER, CH_FILTER, ROOM_FILTER, SCHEMA_CONTACTS, PRUNE_NOW, SCHEMA_AUTO_ADD,
// Privacy section: what adverts carry, who may ask for telemetry // Privacy section: what adverts carry, who may ask for telemetry, the pairing PIN
SECTION_PRIVACY, SCHEMA_PRIVACY, SECTION_PRIVACY, SCHEMA_PRIVACY, OWN_POSITION, SCHEMA_TELEMETRY,
#ifdef BLE_PIN_CODE
BLE_PIN,
#endif
// Messages section // Messages section
SECTION_MESSAGES, SECTION_MESSAGES,
SCHEMA_MESSAGES, SCHEMA_MESSAGES,
@@ -184,7 +189,7 @@ class SettingsScreen : public UIScreen {
static bool isSchemaGroup(int item) { static bool isSchemaGroup(int item) {
return item == SCHEMA_DISPLAY || item == SCHEMA_SOUND || item == SCHEMA_SYSTEM || return item == SCHEMA_DISPLAY || item == SCHEMA_SOUND || item == SCHEMA_SYSTEM ||
item == SCHEMA_CONTACTS || item == SCHEMA_MESSAGES || item == SCHEMA_RADIO || item == SCHEMA_AUTO_ADD || item == SCHEMA_CONTACTS || item == SCHEMA_MESSAGES || item == SCHEMA_RADIO || item == SCHEMA_AUTO_ADD ||
item == SCHEMA_PRIVACY; item == SCHEMA_PRIVACY || item == SCHEMA_TELEMETRY;
} }
// Which schema sections a placeholder stands for. // Which schema sections a placeholder stands for.
static bool schemaIn(int group, uint8_t sec) { static bool schemaIn(int group, uint8_t sec) {
@@ -196,7 +201,8 @@ class SettingsScreen : public UIScreen {
case SCHEMA_CONTACTS: return sec == SEC_CONTACTS; case SCHEMA_CONTACTS: return sec == SEC_CONTACTS;
case SCHEMA_AUTO_ADD: return sec == SEC_AUTO_ADD; case SCHEMA_AUTO_ADD: return sec == SEC_AUTO_ADD;
case SCHEMA_RADIO: return sec == SEC_RADIO_ADV; case SCHEMA_RADIO: return sec == SEC_RADIO_ADV;
case SCHEMA_PRIVACY: return sec == SEC_PRIVACY || sec == SEC_TELEMETRY; case SCHEMA_PRIVACY: return sec == SEC_PRIVACY;
case SCHEMA_TELEMETRY: return sec == SEC_TELEMETRY;
case SCHEMA_MESSAGES: return sec == SEC_MESSAGES; case SCHEMA_MESSAGES: return sec == SEC_MESSAGES;
} }
return false; return false;
@@ -512,6 +518,20 @@ class SettingsScreen : public UIScreen {
val("Batt curve", p && battery::validCurve(p->batt_curve_mv) ? "Custom" : "LiPo"); val("Batt curve", p && battery::validCurve(p->batt_curve_mv) ? "Custom" : "LiPo");
} else if (item == REBOOT) { } else if (item == REBOOT) {
display.print("Reboot"); // action row: Enter reboots this device display.print("Reboot"); // action row: Enter reboots this device
} else if (item == FACTORY_RESET) {
display.print("Factory reset"); // action row: asks twice, then erases everything
} else if (item == OWN_POSITION) {
int32_t lat, lon;
if (_task->core().gpsEnabled()) val("Position", "GPS");
else if (_task->core().fixedPosition(lat, lon)) {
snprintf(buf, sizeof(buf), "%.4f,%.4f", lat / 1e6, lon / 1e6);
val("Position", buf);
} else val("Position", "None");
#ifdef BLE_PIN_CODE
} else if (item == BLE_PIN) {
if (p && p->ble_pin) snprintf(buf, sizeof(buf), "%06lu", (unsigned long)p->ble_pin);
val("BT PIN", p && p->ble_pin ? buf : "Random");
#endif
} else if (item == KEYBOARD_TYPE) { } else if (item == KEYBOARD_TYPE) {
val("Type", (p && p->keyboard_type) ? "T9" : "ABC"); val("Type", (p && p->keyboard_type) ? "T9" : "ABC");
} else if (item == KEYBOARD_MAIN_ALPHABET) { } else if (item == KEYBOARD_MAIN_ALPHABET) {
@@ -587,6 +607,16 @@ class SettingsScreen : public UIScreen {
PopupMenu _prune_confirm; PopupMenu _prune_confirm;
char _prune_confirm_title[40]; char _prune_confirm_title[40];
// Privacy > Position: Type in / From waypoint / Clear, then the waypoints;
// the keyboard for typing one in. Privacy > BT PIN: the number pad.
PopupMenu _pos_menu;
bool _pos_pick_wp = false; // _pos_menu lists the waypoints
bool _edit_pos = false;
bool _edit_ble_pin = false;
// System > Factory reset: asked twice (1, 2), 0 = not asking.
PopupMenu _reset_confirm;
uint8_t _reset_stage = 0;
int renderScopeMgmt(DisplayDriver& display) { int renderScopeMgmt(DisplayDriver& display) {
display.setColor(DisplayDriver::LIGHT); display.setColor(DisplayDriver::LIGHT);
drawScreenHeader(display, "Scope", -1, 0, true, _scope_action_menu.active); drawScreenHeader(display, "Scope", -1, 0, true, _scope_action_menu.active);
@@ -636,6 +666,10 @@ public:
_scope_action_menu.active = false; _scope_action_menu.active = false;
_scope_delete_confirm_active = false; _scope_delete_confirm_active = false;
_prune_confirm.active = false; _prune_confirm.active = false;
_pos_menu.active = false;
_reset_confirm.active = false;
_reset_stage = 0;
_edit_pos = _edit_ble_pin = false;
_edit_lock_pass = false; _edit_lock_pass = false;
_lock_pass_first[0] = '\0'; _lock_pass_first[0] = '\0';
if (!keep) resetList(); if (!keep) resetList();
@@ -645,7 +679,7 @@ public:
int render(DisplayDriver& display) override { int render(DisplayDriver& display) override {
display.setTextSize(1); display.setTextSize(1);
if (_edit_slot >= 0 || _edit_name || _edit_lock_pass || _scope_rename_idx != -2 || _picker.saving) { if (_edit_slot >= 0 || _edit_name || _edit_lock_pass || _edit_pos || _edit_ble_pin || _scope_rename_idx != -2 || _picker.saving) {
return _kb->render(display); return _kb->render(display);
} }
@@ -673,6 +707,8 @@ public:
if (_picker.menu.active) _picker.menu.render(display); if (_picker.menu.active) _picker.menu.render(display);
if (_prune_confirm.active) _prune_confirm.render(display); if (_prune_confirm.active) _prune_confirm.render(display);
if (_pos_menu.active) _pos_menu.render(display);
if (_reset_confirm.active) _reset_confirm.render(display);
return (mq_delay > 0 && mq_delay < 2000) ? mq_delay : 2000; return (mq_delay > 0 && mq_delay < 2000) ? mq_delay : 2000;
} }
@@ -741,6 +777,40 @@ public:
return true; return true;
} }
// Privacy > Position typed in: "lat, lon"
if (_edit_pos) {
auto res = _kb->handleInput(c);
if (res == KeyboardWidget::DONE) {
int32_t lat, lon;
if (geo::parseLatLon(_kb->buf, lat, lon) && (lat || lon)) {
_task->core().setFixedPosition(lat, lon);
_edit_pos = false;
} else {
_kb->prompt = "Lat, lon?"; // stays open to fix it
}
} else if (res == KeyboardWidget::CANCELLED) {
_edit_pos = false;
}
return true;
}
// Privacy > BT PIN: six digits, or nothing for a random one
if (_edit_ble_pin) {
auto res = _kb->handleInput(c);
if (res == KeyboardWidget::DONE) {
uint32_t pin = (uint32_t)strtoul(_kb->buf, nullptr, 10);
if ((_kb->buf[0] && strlen(_kb->buf) != 6) || !_task->core().setBlePin(pin)) {
_kb->prompt = "6 digits";
} else {
_edit_ble_pin = false;
_task->showAlert("From next start", 1200);
}
} else if (res == KeyboardWidget::CANCELLED) {
_edit_ble_pin = false;
}
return true;
}
// Keyboard editing mode for adding/renaming a scope-list entry // Keyboard editing mode for adding/renaming a scope-list entry
if (_scope_rename_idx != -2) { if (_scope_rename_idx != -2) {
auto res = _kb->handleInput(c); auto res = _kb->handleInput(c);
@@ -854,6 +924,52 @@ public:
return true; return true;
} }
// Privacy > Position menu, and its waypoint list
if (_pos_menu.active) {
auto res = _pos_menu.handleInput(c);
if (res == PopupMenu::SELECTED) {
int sel = _pos_menu.selectedIndex();
const WaypointModel& wp = _task->core().waypoints;
if (_pos_pick_wp) {
if (sel < wp.count()) _task->core().setFixedPosition(wp.at(sel).lat_1e6, wp.at(sel).lon_1e6);
_pos_pick_wp = false;
} else if (sel == 0) { // Type in
int32_t lat, lon;
char cur[32] = "";
if (_task->core().fixedPosition(lat, lon)) snprintf(cur, sizeof(cur), "%.5f, %.5f", lat / 1e6, lon / 1e6);
_edit_pos = true;
_kb->begin(cur, 31);
_kb->prompt = "Lat, lon";
_kb->clearPlaceholders();
} else if (sel == 1 && wp.count() > 0) { // From waypoint
_pos_menu.begin("Waypoint", 4);
for (int i = 0; i < wp.count() && i < 24; i++) _pos_menu.addItem(wp.at(i).label[0] ? wp.at(i).label : "(unnamed)");
_pos_pick_wp = true;
} else { // Clear
_task->core().setFixedPosition(0, 0);
}
} else if (res != PopupMenu::NONE) {
_pos_pick_wp = false;
}
return true;
}
// System > Factory reset: the second yes erases
if (_reset_confirm.active) {
auto res = _reset_confirm.handleInput(c);
if (res == PopupMenu::SELECTED && _reset_confirm.selectedIndex() == 0) {
if (_reset_stage == 1) {
_reset_stage = 2;
_reset_confirm.beginConfirm("Keys too. Sure?", "Erase");
return true;
}
_task->core().factoryReset(); // restarts; back here only if the erase failed
_task->showAlert("Erase failed", 1400);
}
if (res != PopupMenu::NONE) _reset_stage = 0;
return true;
}
// Contacts > "Prune now" confirm // Contacts > "Prune now" confirm
if (_prune_confirm.active) { if (_prune_confirm.active) {
auto res = _prune_confirm.handleInput(c); auto res = _prune_confirm.handleInput(c);
@@ -992,6 +1108,31 @@ public:
_task->shutdown(true); // flushes prefs/RTC/contacts/trail, then reboots -- single choke point _task->shutdown(true); // flushes prefs/RTC/contacts/trail, then reboots -- single choke point
return true; return true;
} }
if (_selected == FACTORY_RESET && enter) {
_reset_stage = 1;
_reset_confirm.beginConfirm("Erase everything?", "Erase");
return true;
}
if (_selected == OWN_POSITION && enter) {
if (_task->core().gpsEnabled()) { _task->showAlert("GPS sets it", 1200); return true; }
int32_t lat, lon;
bool have = _task->core().fixedPosition(lat, lon);
bool wps = _task->core().waypoints.count() > 0;
_pos_menu.begin("Position", 3);
_pos_menu.addItem("Type in");
if (wps) _pos_menu.addItem("From waypoint");
if (have) _pos_menu.addItem("Clear");
_pos_pick_wp = false;
return true;
}
#ifdef BLE_PIN_CODE
if (_selected == BLE_PIN && enter) {
_edit_ble_pin = true;
_kb->beginPin("", 6, false, "6 digits, or none");
_kb->clearPlaceholders();
return true;
}
#endif
if (_selected == KEYBOARD_TYPE && p && (left || right || enter)) { if (_selected == KEYBOARD_TYPE && p && (left || right || enter)) {
p->keyboard_type ^= 1; p->keyboard_type ^= 1;
_dirty = true; _dirty = true;
+44 -24
View File
@@ -8,6 +8,7 @@
#include "TabBar.h" #include "TabBar.h"
#include "../MyMesh.h" #include "../MyMesh.h"
#include "../ui-core/SiteQr.h" #include "../ui-core/SiteQr.h"
#include "../ui-core/ContactQr.h"
#include "../ui-core/Diagnostics.h" #include "../ui-core/Diagnostics.h"
#include "../ui-core/Battery.h" #include "../ui-core/Battery.h"
#if ENV_INCLUDE_GPS == 1 && defined(GPS_SKYVIEW) #if ENV_INCLUDE_GPS == 1 && defined(GPS_SKYVIEW)
@@ -46,7 +47,10 @@ private:
int _scroll = 0; // blocks scrolled off the top int _scroll = 0; // blocks scrolled off the top
bool _more = false; // the last frame cut blocks off at the bottom bool _more = false; // the last frame cut blocks off at the bottom
bool _signal = false; // Sky: the bars instead of the plot bool _signal = false; // Sky: the bars instead of the plot
bool _qr = false; // System: the site's QR code, full screen // System: a QR code full screen -- your contact, then (Enter) the manual.
enum : uint8_t { QR_NONE, QR_CONTACT, QR_MANUAL };
uint8_t _qr = QR_NONE;
contactqr::Code _contact_qr;
PopupMenu _reset_menu; // Mesh, Hold Enter -> confirm PopupMenu _reset_menu; // Mesh, Hold Enter -> confirm
CayenneLPP _lpp; CayenneLPP _lpp;
unsigned long _lpp_at = 0; unsigned long _lpp_at = 0;
@@ -296,7 +300,7 @@ private:
row(d, f, rsv, "Firmware", a); row(d, f, rsv, "Firmware", a);
row(d, f, rsv, "Built", FIRMWARE_BUILD_DATE); row(d, f, rsv, "Built", FIRMWARE_BUILD_DATE);
row(d, f, rsv, "Device", board.getManufacturerName()); row(d, f, rsv, "Device", board.getManufacturerName());
row(d, f, rsv, "Manual", "Enter: QR code"); row(d, f, rsv, "QR codes", "Enter: you, manual");
uint32_t heap_free, heap_total; uint32_t heap_free, heap_total;
DeviceDiag::getHeapStats(heap_free, heap_total); DeviceDiag::getHeapStats(heap_free, heap_total);
if (heap_total) snprintf(a, sizeof(a), "%lu of %lu KB", (unsigned long)(heap_free / 1024), (unsigned long)(heap_total / 1024)); if (heap_total) snprintf(a, sizeof(a), "%lu of %lu KB", (unsigned long)(heap_free / 1024), (unsigned long)(heap_total / 1024));
@@ -320,19 +324,33 @@ private:
if (f.place(d.lineStep())) d.drawTextEllipsized(1, f.at, d.width() - rsv - 3, lines[i]); if (f.place(d.lineStep())) d.drawTextEllipsized(1, f.at, d.width() - rsv - 3, lines[i]);
} }
// The site's QR code as large as the screen allows, dark modules on light // A QR code as large as the screen allows, dark modules on light (LIGHT is
// (LIGHT is the ink on e-ink, the lit pixel elsewhere), with the address // the ink on e-ink, the lit pixel elsewhere), with its caption beside it or
// beside it or under it where it fits. // under it: your contact, or the site's manual.
void renderQr(DisplayDriver& d) { void renderQr(DisplayDriver& d) {
static const char* const MANUAL[] = { "Solo manual", siteqr::URL, nullptr };
static const char* const MANUAL_NARROW[] = { "Scan for", "the Solo", "manual" };
if (_qr == QR_CONTACT && _contact_qr.n) {
const char* const you[] = { "Scan to add", the_mesh.getNodeName(), nullptr };
renderQr(d, _contact_qr.n, [&](int x, int y) { return _contact_qr.dark(x, y); }, you, you);
} else {
renderQr(d, siteqr::N, siteqr::dark, MANUAL, MANUAL_NARROW);
}
}
// Caption: up to three lines, `wide` when every line fits beside / under
// the code, else `narrow` (lines cut to fit).
template <class Dark>
void renderQr(DisplayDriver& d, int n, Dark dark, const char* const* wide, const char* const* narrow) {
const int W = d.width(), H = d.height(), lh = d.getLineHeight(); const int W = d.width(), H = d.height(), lh = d.getLineHeight();
const bool tall = H > W; const bool tall = H > W;
const int side = siteqr::N + 2 * siteqr::QUIET; const int side = n + 2 * siteqr::QUIET;
const int s = (tall ? W : H) / side; const int s = (tall ? W : H) / side > 0 ? (tall ? W : H) / side : 1;
const int px = side * s; const int px = side * s;
// The text: "Solo manual" and the address if that fits on one line.
const int tx = tall ? 0 : px + 2, tw = W - tx; const int tx = tall ? 0 : px + 2, tw = W - tx;
const bool url = d.getTextWidth(siteqr::URL) <= tw; const char* const* cap = wide;
const int lines = url ? 2 : 3; for (int i = 0; i < 3 && wide[i]; i++) if (d.getTextWidth(wide[i]) > tw) cap = narrow;
int lines = 0;
while (lines < 3 && cap[lines]) lines++;
// Portrait: the code and the text under it, centred together. // Portrait: the code and the text under it, centred together.
const int x0 = tall ? (W - px) / 2 : 0; const int x0 = tall ? (W - px) / 2 : 0;
const int y0 = tall ? (H - px - lh / 2 - lines * lh) / 2 : (H - px) / 2; const int y0 = tall ? (H - px - lh / 2 - lines * lh) / 2 : (H - px) / 2;
@@ -340,19 +358,15 @@ private:
if (!eink) { d.setColor(DisplayDriver::LIGHT); d.fillRect(x0, y0, px, px); } if (!eink) { d.setColor(DisplayDriver::LIGHT); d.fillRect(x0, y0, px, px); }
d.setColor(eink ? DisplayDriver::LIGHT : DisplayDriver::DARK); d.setColor(eink ? DisplayDriver::LIGHT : DisplayDriver::DARK);
const int q = x0 + siteqr::QUIET * s, r = y0 + siteqr::QUIET * s; const int q = x0 + siteqr::QUIET * s, r = y0 + siteqr::QUIET * s;
for (int y = 0; y < siteqr::N; y++) for (int y = 0; y < n; y++)
for (int x = 0; x < siteqr::N; x++) for (int x = 0; x < n; x++)
if (siteqr::dark(x, y)) d.fillRect(q + x * s, r + y * s, s, s); if (dark(x, y)) d.fillRect(q + x * s, r + y * s, s, s);
d.setColor(DisplayDriver::LIGHT); d.setColor(DisplayDriver::LIGHT);
const int mid = tx + tw / 2;
int ty = tall ? y0 + px + lh / 2 : (H - lines * lh) / 2; int ty = tall ? y0 + px + lh / 2 : (H - lines * lh) / 2;
if (url) { for (int i = 0; i < lines; i++) {
d.drawTextCentered(mid, ty, "Solo manual"); ty += lh; int w = d.getTextWidth(cap[i]);
d.drawTextCentered(mid, ty, siteqr::URL); d.drawTextEllipsized(w < tw ? tx + (tw - w) / 2 : tx, ty, tw, cap[i]);
} else { ty += lh;
d.drawTextCentered(mid, ty, "Scan for"); ty += lh;
d.drawTextCentered(mid, ty, "the Solo"); ty += lh;
d.drawTextCentered(mid, ty, "manual");
} }
} }
@@ -386,7 +400,7 @@ public:
// Open on a given tab (Status page: Radio; the GPS shortcuts: GPS / Sky). // Open on a given tab (Status page: Radio; the GPS shortcuts: GPS / Sky).
void showTab(uint8_t t) { void showTab(uint8_t t) {
_qr = false; _qr = QR_NONE;
for (uint8_t i = 0; i < _count; i++) if (_tabs[i] == t) { _cur = i; _scroll = 0; } for (uint8_t i = 0; i < _count; i++) if (_tabs[i] == t) { _cur = i; _scroll = 0; }
} }
// GPS stays awake while its live tabs are on screen. // GPS stays awake while its live tabs are on screen.
@@ -457,7 +471,10 @@ public:
} }
return true; return true;
} }
if (_qr) { _qr = false; return true; } if (_qr) { // Enter: the other code; anything else closes
_qr = c == KEY_ENTER && _qr == QR_CONTACT ? QR_MANUAL : c == KEY_ENTER && _qr == QR_MANUAL ? QR_CONTACT : QR_NONE;
return true;
}
if (keyIsPrev(c)) { _cur = (_cur + _count - 1) % _count; _scroll = 0; return true; } if (keyIsPrev(c)) { _cur = (_cur + _count - 1) % _count; _scroll = 0; return true; }
if (keyIsNext(c)) { _cur = (_cur + 1) % _count; _scroll = 0; return true; } if (keyIsNext(c)) { _cur = (_cur + 1) % _count; _scroll = 0; return true; }
if (c == KEY_UP) { if (_scroll > 0) _scroll--; return true; } if (c == KEY_UP) { if (_scroll > 0) _scroll--; return true; }
@@ -466,7 +483,10 @@ public:
if (c == KEY_ENTER && tab() == TAB_GPS) { _task->toggleGPS(); return true; } if (c == KEY_ENTER && tab() == TAB_GPS) { _task->toggleGPS(); return true; }
#endif #endif
if (c == KEY_ENTER && tab() == TAB_SKY) { _signal = !_signal; return true; } if (c == KEY_ENTER && tab() == TAB_SKY) { _signal = !_signal; return true; }
if (c == KEY_ENTER && tab() == TAB_SYSTEM) { _qr = true; return true; } if (c == KEY_ENTER && tab() == TAB_SYSTEM) {
_qr = contactqr::make(_contact_qr) ? QR_CONTACT : QR_MANUAL;
return true;
}
if (c == KEY_CONTEXT_MENU && tab() == TAB_MESH) { if (c == KEY_CONTEXT_MENU && tab() == TAB_MESH) {
_reset_menu.beginConfirm("Reset counters?", "Reset"); _reset_menu.beginConfirm("Reset counters?", "Reset");
return true; return true;
@@ -173,6 +173,7 @@ static const telemetry::Style L1_INFO = { "\xc2\xb0", true, false, 5 };
#include "SensorPlaceholders.h" #include "SensorPlaceholders.h"
#include "../ui-core/UiCore.h" // shared UI Core: history rings + unread models (MessagesScreen views them) #include "../ui-core/UiCore.h" // shared UI Core: history rings + unread models (MessagesScreen views them)
#include "../ui-core/SettingsSchema.h" // the settings both frontends share (SettingsScreen.h) #include "../ui-core/SettingsSchema.h" // the settings both frontends share (SettingsScreen.h)
#include "../ui-core/SettingsCli.h" // and their companion CLI keys
#include "SettingsScreen.h" #include "SettingsScreen.h"
#include "MessagesScreen.h" #include "MessagesScreen.h"
@@ -4053,6 +4054,13 @@ void UITask::applySoundPrefs() {
#endif #endif
} }
// The companion CLI's settings keys (ui-core/SettingsCli.h).
bool UITask::cliCommand(const char* command, char* reply, int n) {
if (!settingscli::handle(*_core, command, reply, n)) return false;
_next_refresh = 0; // a screen showing the value draws it anew
return true;
}
void UITask::setBuzzerVolumeLevel(uint8_t level) { void UITask::setBuzzerVolumeLevel(uint8_t level) {
#ifdef PIN_BUZZER #ifdef PIN_BUZZER
if (_node_prefs == NULL) return; if (_node_prefs == NULL) return;
+1
View File
@@ -508,6 +508,7 @@ public:
// Settings changed through the schema (ui-core/SettingsSchema.h). // Settings changed through the schema (ui-core/SettingsSchema.h).
void applyDisplayPrefs() override { applyBrightness(); _next_refresh = 0; } void applyDisplayPrefs() override { applyBrightness(); _next_refresh = 0; }
void applySoundPrefs() override; void applySoundPrefs() override;
bool cliCommand(const char* command, char* reply, int n) override;
void setBuzzerVolumeLevel(uint8_t level); void setBuzzerVolumeLevel(uint8_t level);
void applyTxPower(); void applyTxPower();
void applyPowerSave(); // hardware duty-cycle RX on/off from prefs void applyPowerSave(); // hardware duty-cycle RX on/off from prefs
+13 -4
View File
@@ -23,8 +23,12 @@ import os
# Mesh protocol constants # Mesh protocol constants
FRAME_START_OUT = 0x3C # '<' - used when sending TO device FRAME_START_OUT = 0x3C # '<' - used when sending TO device
FRAME_START_IN = 0x3E # '>' - used when receiving FROM device FRAME_START_IN = 0x3E # '>' - used when receiving FROM device
CMD_GET_SCREENSHOT = 66 CMD_GET_SCREENSHOT = 0x7E
RESP_CODE_SCREENSHOT = 29 RESP_CODE_SCREENSHOT = 0x7E
# Firmware before v2.0 used 66 / 29, which upstream gave to its CLI since:
# asked with the new code, such firmware answers an error, and we ask again.
CMD_GET_SCREENSHOT_OLD = 66
RESP_CODE_SCREENSHOT_OLD = 29
RESP_CODE_ERR = 1 RESP_CODE_ERR = 1
# display_type values (byte [5] in response frame) # display_type values (byte [5] in response frame)
@@ -129,7 +133,7 @@ def _expected_buffer_size(width, height, display_type):
return (width * height) // 8 return (width * height) // 8
def receive_screenshot(ser, timeout=5): def receive_screenshot(ser, timeout=5, code=RESP_CODE_SCREENSHOT):
"""Receive a multi-chunk screenshot response. Returns (buf, w, h, type, rot) or None.""" """Receive a multi-chunk screenshot response. Returns (buf, w, h, type, rot) or None."""
start_time = time.time() start_time = time.time()
buffer_data = bytearray() buffer_data = bytearray()
@@ -154,7 +158,7 @@ def receive_screenshot(ser, timeout=5):
print(f"Error: Device returned error code 0x{err:02x}") print(f"Error: Device returned error code 0x{err:02x}")
return None return None
if resp_code != RESP_CODE_SCREENSHOT: if resp_code != code: # 29 is now the CLI reply: only the code asked for
continue continue
if len(frame) < HEADER_SIZE: if len(frame) < HEADER_SIZE:
@@ -365,6 +369,11 @@ def main():
time.sleep(0.1) time.sleep(0.1)
result = receive_screenshot(ser) result = receive_screenshot(ser)
if result is None: # older firmware: the old code
ser.reset_input_buffer()
send_command(ser, CMD_GET_SCREENSHOT_OLD)
time.sleep(0.1)
result = receive_screenshot(ser, code=RESP_CODE_SCREENSHOT_OLD)
if result: if result:
buffer_data, width, height, display_type, disp_rotation = result buffer_data, width, height, display_type, disp_rotation = result
type_str = {DISPLAY_TYPE_EINK: "e-ink", DISPLAY_TYPE_RGB565: "colour"}.get(display_type, "OLED") type_str = {DISPLAY_TYPE_EINK: "e-ink", DISPLAY_TYPE_RGB565: "colour"}.get(display_type, "OLED")