Merge dev into feature/eink-ui-scale

This commit is contained in:
pmow
2026-10-10 16:19:17 -10:00
26 changed files with 2836 additions and 116 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 |
| 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 |
| 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) |
| 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
> 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
> 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
+8
View File
@@ -61,6 +61,12 @@ build flag it needs.
| Contact details | yes | + compass rose, path hops | + compass rose, path hops | + compass rose, path hops | card with actions |
| Contact expiry / prune | 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 |
| 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`)
@@ -84,8 +90,10 @@ build flag it needs.
| Melodies (`PIN_BUZZER`) | Ringtones tool | same | same | wide editor | Settings › Sound |
| Status: Radio, GPS, Power, Mesh | yes, 32 min history | yes, 8 h charts | yes, 8 h charts | tiles with charts | Diagnostics app (+ noise chart) |
| Sky view (`GPS_SKYVIEW`) | Status › Sky | same | same | + side panel | GPS app: Sky, Signal, Details |
| 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 |
| 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
+97 -5
View File
@@ -10,10 +10,11 @@ board supports are shown.
| **Display** | Brightness, auto-off, wake on message, the message alert (banner, corner envelope or off), lock screen, the lock clock's look and the [PIN](./lock.md), battery as icon / % / volts, time zone, clock format and seconds; on e-ink also rotation and full refresh |
| **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 |
| **Radio** | TX power, preset, frequency, SF / BW / CR, saved presets, Auto pwr, the scope list |
| **System** | Device name, low-battery shutdown, GPS power saving, units, the battery curve, reboot |
| **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, factory reset |
| **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 |
| **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 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 |
A few notes:
@@ -31,6 +32,57 @@ A few notes:
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
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
A node you hear is added to your contacts according to **Auto-add**:
- **All**: every node heard.
- **By type**: only the types switched on below it (companions, repeaters,
rooms, sensors). With none on, nodes are added only by hand (Nearby ›
**Add contact**).
**Up to** limits how far away an added node may be: **Direct** (heard with
no repeater) or a number of hops. **Replace old** makes room when the list is
full by dropping the oldest contact that isn't a favourite; without it, new
nodes aren't added until you make room.
### Privacy
- **Advert pos** (Position in adverts): your adverts carry your position,
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
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**,
**Allowed** (the contacts you gave that permission: **Telem** in a node's
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.
- **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
At the end of **Radio**. The defaults suit almost everyone; change them only
if your mesh agrees on it.
| Setting | Does |
| --- | --- |
| **Hash size** | Bytes per repeater in a message's path: 1 (the default) fits the most hops, 2 or 3 tell repeaters apart in a big mesh |
| **Double ACK** | Sends each acknowledgement twice, for a lossy link |
| **LBT (CAD)** | Listen before talk: waits for a quiet channel before sending |
| **Interf.** | Treats the channel as busy when the signal is this far above the noise floor |
| **RX boost** | The radio's boosted receive gain: hears weaker signals for a little more power (SX126x radios) |
| **RX delay** | How long a repeated packet waits, so the best path wins; off by default |
| **Airtime** | The pause after sending, as a multiple of the time on air |
> [!NOTE]
> **Wio Tracker L2:** one list of pages:
@@ -39,7 +91,47 @@ A few notes:
> Power (battery, GPS, and the **Battery curve** as a chart whose points you
> drag), Sound (with the melody editor), Keyboard, Messages
> & contacts.
> - **Connections**: Radio, Bluetooth, WiFi, GPS.
> - **Connections**: Radio (with **Advanced** at the bottom), Privacy,
> Bluetooth, WiFi, GPS. Adding contacts is in Messages & contacts.
> - **Map & data**: Map (trail, live sharing and arrival alert options),
> 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.
+4 -4
View File
@@ -64,8 +64,8 @@ room's saved login.
## Auto-advert
**Tools › Auto-advert** sends an advert with your position every 30 seconds
to 1 hour, so others see you in their Nearby list. With it on at both ends
**Tools › Auto-advert** sends your advert every 30 seconds to 1 hour (with
your position if Settings › Privacy › **Advert pos** is on), so others see you in their Nearby list. With it on at both ends
and Settings › Sound › **AD sound** set, each device beeps when it hears the
other: a hands-free "still in range".
@@ -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 |
| **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 |
| **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
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
> strength, and the constellations) and **Details** (position, precision,
> 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
+54 -11
View File
@@ -71,8 +71,9 @@
#define CMD_SET_DEFAULT_FLOOD_SCOPE 63
#define CMD_GET_DEFAULT_FLOOD_SCOPE 64
#define CMD_SEND_RAW_PACKET 65
#define CMD_RUN_CLI_COMMAND 66 // v14+
#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
// Stats sub-types for CMD_GET_STATS
@@ -109,8 +110,9 @@
#define RESP_ALLOWED_REPEAT_FREQ 26
#define RESP_CODE_CHANNEL_DATA_RECV 27
#define RESP_CODE_DEFAULT_FLOOD_SCOPE 28
#define RESP_CODE_CLI_REPLY 29 // v14+, a reply to CMD_RUN_CLI_COMMAND
#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
#endif
@@ -288,9 +290,8 @@ bool MyMesh::getCADEnabled() const {
// RSSI-threshold interference detection relies on _noise_floor, which is only
// 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.
// _prefs.cad_enabled itself has no UI/CLI exposure yet on companion_radio
// (unlike simple_repeater's CommonCLI `cad` command) — it's wired and
// persisted for a future manual override, but always 0 today.
// _prefs.cad_enabled is the manual override: Settings > Radio's advanced
// options and the companion CLI's `cad` (MyMeshCli.h) set it.
// 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
// 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;
}
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.
//
// 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
}
} else if (cmd_frame[0] == CMD_FACTORY_RESET && memcmp(&cmd_frame[1], "reset", 5) == 0) {
if (_serial) {
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();
bool success = factoryReset(); // phone app disconnects before we can send OK frame so it's safe here
if (success) {
writeOKFrame();
#ifdef SIM_PLATFORM
@@ -3116,6 +3144,19 @@ void MyMesh::handleCmdFrame(size_t len) {
memcpy(&out_frame[i], &r->upper_freq, 4); i += 4;
}
_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) {
auto pkt = obtainNewPacket();
if (pkt) {
@@ -3484,7 +3525,8 @@ void MyMesh::loop() {
}
if (_prefs.advert_auto_interval_sec > 0 && millisHasNowPassed(_next_auto_advert_ms)) {
mesh::Packet* pkt = (sensors.node_lat != 0 || sensors.node_lon != 0)
// The position only where Privacy lets adverts carry it, and once there is one.
mesh::Packet* pkt = (_prefs.advert_loc_policy != ADVERT_LOC_NONE && (sensors.node_lat != 0 || sensors.node_lon != 0))
? createSelfAdvert(_prefs.node_name, sensors.node_lat, sensors.node_lon)
: createSelfAdvert(_prefs.node_name);
if (pkt) sendZeroHop(pkt);
@@ -3533,3 +3575,4 @@ bool MyMesh::hasPendingWork() const {
}
#include "MyMeshBot.h"
#include "MyMeshCli.h"
+21 -1
View File
@@ -9,7 +9,7 @@
class UITask;
/*------------ 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
// 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
// MyMesh then flushes and reboots by itself.
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);
@@ -402,6 +405,22 @@ public:
// "fav" list filters no longer depend on the app having starred anything.
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
// you're logged into with admin permission (see ClientACL::isAdmin()). The
// reply is a text frame (TXT_TYPE_CLI_DATA) delivered via onCommandDataRecv();
@@ -592,6 +611,7 @@ private:
}
void checkCLIRescueCmd();
bool cliRadioCommand(const char* command, char* reply, int n);
void checkSerialInterface();
bool isValidClientRepeatFreq(uint32_t f) const;
#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;
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
@@ -47,6 +47,23 @@ inline void setMelody(NodePrefs* p, const uint8_t* pub_key, uint8_t slot) {
inline bool favourite(const ContactInfo& ci) { return (ci.flags & 0x01) != 0; }
inline bool setFavourite(const uint8_t* pub_key, bool on) { return the_mesh.setContactFavourite(pub_key, on); }
// ContactInfo::flags bits 1-3: what this contact may ask for (TELEM_PERM_*)
// where Settings > Privacy says "Allowed" -- only then do they matter.
inline uint8_t telemetry(const ContactInfo& ci) { return (ci.flags >> 1) & 0x07; }
inline bool setTelemetry(const uint8_t* pub_key, uint8_t perms) { return the_mesh.setContactTelemetry(pub_key, perms); }
inline bool telemetryPerContact(const NodePrefs* p) {
return p && (p->telemetry_mode_base == TELEM_MODE_ALLOW_FLAGS || p->telemetry_mode_loc == TELEM_MODE_ALLOW_FLAGS
|| p->telemetry_mode_env == TELEM_MODE_ALLOW_FLAGS);
}
// A path to forget: the contact has a learned one.
inline bool hasPath(const ContactInfo& ci) { return ci.out_path_len != OUT_PATH_UNKNOWN; }
inline bool resetPath(const uint8_t* pub_key) { return the_mesh.resetContactPath(pub_key); }
// Its last advert, sent again zero-hop so the nodes in range can add it.
inline bool share(const uint8_t* pub_key) {
ContactInfo* c = the_mesh.lookupContactByPubKey(pub_key, PUB_KEY_SIZE);
return c && the_mesh.shareContactZeroHop(*c);
}
// "@[nick] " -- how a reply names who it answers (raw UTF-8, over the air as is).
inline void replyPrefix(const char* nick, char* out, size_t n) { snprintf(out, n, "@[%.31s] ", nick); }
@@ -0,0 +1,47 @@
#pragma once
// Your own contact as a QR code: the link the phone app scans to add you,
// meshcore://contact/add?name=<name>&public_key=<hex>&type=1 (docs/faq.md).
// Made when it is shown (the name can change); both frontends draw it.
#include "QrCodeGen.h"
namespace contactqr {
// Version 10 (57 modules) holds the link with the longest name, low error
// correction; a short name gives a smaller code.
static const int MAX_VERSION = 10;
struct Code {
uint8_t buf[qrcodegen_BUFFER_LEN_FOR_VERSION(MAX_VERSION)];
int n = 0; // modules a side, 0: none
bool dark(int x, int y) const { return qrcodegen_getModule(buf, x, y); }
};
// The link, the name percent-encoded.
inline void link(char* out, size_t n) {
static const char DIGITS[] = "0123456789abcdef";
size_t o = snprintf(out, n, "meshcore://contact/add?name=");
for (const char* p = the_mesh.getNodeName(); *p && o + 4 < n; p++) {
unsigned char ch = (unsigned char)*p;
if (isalnum(ch) || ch == '-' || ch == '_' || ch == '.' || ch == '~') out[o++] = ch;
else { out[o++] = '%'; out[o++] = DIGITS[ch >> 4]; out[o++] = DIGITS[ch & 15]; }
}
o += snprintf(out + o, n - o, "&public_key=");
for (int i = 0; i < PUB_KEY_SIZE && o + 3 < n; i++) {
out[o++] = DIGITS[the_mesh.self_id.pub_key[i] >> 4];
out[o++] = DIGITS[the_mesh.self_id.pub_key[i] & 15];
}
snprintf(out + o, n - o, "&type=%d", ADV_TYPE_CHAT);
}
inline bool make(Code& c) {
char text[224];
link(text, sizeof(text));
uint8_t tmp[qrcodegen_BUFFER_LEN_FOR_VERSION(MAX_VERSION)];
bool ok = qrcodegen_encodeText(text, tmp, c.buf, qrcodegen_Ecc_LOW, qrcodegen_VERSION_MIN, MAX_VERSION,
qrcodegen_Mask_AUTO, true);
c.n = ok ? qrcodegen_getSize(c.buf) : 0;
return ok;
}
} // namespace contactqr
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,146 @@
#pragma once
// The companion CLI's settings keys, after MyMesh's own upstream-named
// commands (MyMeshCli.h): "get <key>", "set <key> <value>", "keys [page]".
// Each key names a row of the settings schema (SettingsSchema.h), so the CLI
// reads and writes what the Settings screens show, with the same values and
// side effects. A value is set by its label as `get` prints it ("2 h",
// "Everyone"; case doesn't matter), "on" / "off" for a switch, or a number:
// the index for a list, one of the stored values for a table (seconds, mV ...).
#include "SettingsSchema.h"
namespace settingscli {
struct Key { const char* key; uint16_t offset; uint8_t bit; };
#define K(name, f) { name, (uint16_t)offsetof(NodePrefs, f), 0 }
#define KB(name, f, mask) { name, (uint16_t)offsetof(NodePrefs, f), mask }
static const Key KEYS[] = {
K("advert.interval", advert_auto_interval_sec), K("advert.loc", advert_loc_policy),
K("telemetry.base", telemetry_mode_base), K("telemetry.loc", telemetry_mode_loc), K("telemetry.env", telemetry_mode_env),
K("autoadd", manual_add_contacts), KB("autoadd.chat", autoadd_config, 0x02), KB("autoadd.repeater", autoadd_config, 0x04),
KB("autoadd.room", autoadd_config, 0x08), KB("autoadd.sensor", autoadd_config, 0x10),
KB("autoadd.overwrite", autoadd_config, 0x01), K("autoadd.max.hops", autoadd_max_hops),
K("contacts.expiry", contact_expiry_idx), K("contacts.fav.first", fav_sort), K("dm.resend", dm_resend_count),
K("display.brightness", display_brightness), K("display.off", auto_off_secs), K("display.wake", msg_wake),
K("display.alert", msg_alert), K("lock", auto_lock), K("lock.clock", lock_compact),
K("batt.display", batt_display_mode), K("batt.shutdown", low_batt_mv), K("gps.interval", gps_interval),
K("clock.12h", clock_12h), K("clock.seconds", clock_hide_seconds), K("units.imperial", units_imperial),
K("sound.volume", buzzer_volume), K("quiet", quiet_hours), K("quiet.from", quiet_from), K("quiet.until", quiet_to),
K("melody.dm", notif_melody_dm), K("melody.channel", notif_melody_ch), K("melody.advert", notif_melody_ad),
K("advert.sound", advert_sound_scope),
K("trail.spacing", trail_min_delta_idx), K("trail.pause", trail_autopause_idx), K("trail.lowbatt", trail_autosave_lowbatt),
K("gps.avg", gps_avg_idx),
K("share.others", track_shared_loc), K("share.stop", loc_share_duration_idx), K("share.move", loc_share_move_idx),
K("share.every", loc_share_interval_idx), K("share.heartbeat", loc_share_heartbeat_idx),
K("alert", locator_enabled), K("alert.radius", locator_radius_idx), K("alert.on", locator_mode),
K("alert.beeper", locator_beeper),
};
#undef K
#undef KB
static const int KEY_COUNT = sizeof(KEYS) / sizeof(KEYS[0]);
// The schema row of a key, or -1 (this board leaves it out).
static int row(const Key& k) {
for (int i = 0; i < settings::COUNT; i++)
if (settings::ALL[i].offset == k.offset && settings::ALL[i].bit == k.bit) return i;
return -1;
}
static bool sameText(const char* a, const char* b) {
for (; *a && *b; a++, b++) if (tolower((unsigned char)*a) != tolower((unsigned char)*b)) return false;
return *a == *b;
}
// The value text into index v; false when it is none of them.
static bool parse(const NodePrefs& p, const settings::Setting& st, const char* v, int& index) {
if (!st.option) { // a switch
if (sameText(v, "on") || !strcmp(v, "1")) { index = 1; return true; }
if (sameText(v, "off") || !strcmp(v, "0")) { index = 0; return true; }
return false;
}
char buf[24];
for (int i = 0; i < st.count; i++) {
st.option((uint8_t)i, buf, sizeof(buf), p);
if (sameText(v, buf)) { index = i; return true; }
}
char* end;
double d = strtod(v, &end);
if (end == v || *end) return false;
if (!st.values) { // a list: its index
if (d < 0 || d >= st.count || d != (int)d) return false;
index = (int)d;
return true;
}
// A table: one of its stored values (one between would read back as another).
int32_t raw = (int32_t)(st.size == settings::FLOAT_X100 ? d * 100 + (d < 0 ? -0.5 : 0.5) : d);
for (int i = 0; i < st.count; i++) if (st.values[i] == raw) { index = i; return true; }
return false;
}
// The labels a setting takes, "a, b, c" into buf.
static void choices(const NodePrefs& p, const settings::Setting& st, char* out, int n) {
if (!st.option) { snprintf(out, n, "on, off"); return; }
int o = 0;
char buf[24];
for (int i = 0; i < st.count && o < n - 1; i++) {
st.option((uint8_t)i, buf, sizeof(buf), p);
o += snprintf(out + o, n - o, "%s%s", i ? ", " : "", buf);
}
}
// True when the command was a settings one (reply set either way).
static bool handle(UiCore& core, const char* cmd, char* reply, int n) {
NodePrefs* p = core.prefs();
if (!p) return false;
if (!strcmp(cmd, "keys") || !strncmp(cmd, "keys ", 5)) { // the key names, a frame's worth a page
int page = cmd[4] ? atoi(cmd + 5) : 1, pg = 1, o = 0;
bool more = false;
reply[0] = 0;
for (int k = 0; k < KEY_COUNT; k++) {
if (row(KEYS[k]) < 0) continue;
int len = (int)strlen(KEYS[k].key) + 1;
if (o + len > n - 20) { // room left for " (more: keys N)"
if (pg == page) { more = true; break; }
pg++;
o = 0;
}
if (pg == page) o += snprintf(reply + o, n - o, "%s%s", o ? " " : "", KEYS[k].key);
else o += len;
}
if (more) snprintf(reply + o, n - o, " (more: keys %d)", page + 1);
else if (pg < page || page < 1) snprintf(reply, n, "No more keys");
return true;
}
bool set = !strncmp(cmd, "set ", 4);
if (!set && strncmp(cmd, "get ", 4)) return false;
const char* name = cmd + 4;
const char* value = set ? strchr(name, ' ') : nullptr;
size_t name_len = value ? (size_t)(value - name) : strlen(name);
for (int k = 0; k < KEY_COUNT; k++) {
if (strlen(KEYS[k].key) != name_len || strncmp(KEYS[k].key, name, name_len)) continue;
int i = row(KEYS[k]);
if (i < 0) { snprintf(reply, n, "Error, not on this device"); return true; }
const settings::Setting& st = settings::ALL[i];
char buf[24];
if (!set) {
snprintf(reply, n, "> %s", settings::text(*p, st, buf, sizeof(buf), "on", "off"));
return true;
}
if (!value) { snprintf(reply, n, "Error, no value"); return true; }
int index = 0;
if (!parse(*p, st, value + 1, index)) {
char opts[120];
choices(*p, st, opts, sizeof(opts));
snprintf(reply, n, "Error, one of: %s", opts);
return true;
}
settings::set(*p, st, (uint8_t)index);
if (st.changed) st.changed(core);
the_mesh.savePrefs();
snprintf(reply, n, "OK");
return true;
}
return false; // not a settings key: MyMesh says "Unknown command"
}
} // namespace settingscli
@@ -9,7 +9,9 @@
// uint8_t holding that index; with a `values` table the field (1, 2 or 4 bytes,
// `size`) holds values[index] instead -- seconds, millivolts, an hour offset,
// or {1, 0} for a switch whose field is stored inverted. count == 2 with no
// option labeller is an on/off switch.
// option labeller is an on/off switch. A `bit` mask makes it a switch over that
// one bit of a uint8_t field; size FLOAT_X100 is a float field with values in
// hundredths.
#include <stddef.h>
#include "../NodePrefs.h"
@@ -21,28 +23,33 @@ namespace settings {
// Pages (one screen each) and the sections on them, in display order.
// PG_NAV is the Map's options (its trail / live share / target tools),
// PG_ADVERT sits in Nearby's advert popup; the rest are Settings pages.
enum Page : uint8_t { PG_NAV, PG_ADVERT, PG_DEVICE, PG_MESSAGES, PG_SOUND, PG_COUNT };
// PG_PRIVACY and PG_RADIO (Radio's advanced options) are Settings pages too.
enum Page : uint8_t { PG_NAV, PG_ADVERT, PG_DEVICE, PG_MESSAGES, PG_SOUND, PG_PRIVACY, PG_RADIO, PG_COUNT };
enum Section : uint8_t { SEC_TRAIL, SEC_LIVE_SHARE, SEC_LOCATOR, SEC_ADVERT,
SEC_DISPLAY, SEC_POWER, SEC_TIME, SEC_UNITS,
SEC_MESSAGES, SEC_CONTACTS,
SEC_SOUND, SEC_SOUND_FOR, SEC_QUIET, SEC_COUNT };
SEC_MESSAGES, SEC_CONTACTS, SEC_AUTO_ADD,
SEC_SOUND, SEC_SOUND_FOR, SEC_QUIET,
SEC_PRIVACY, SEC_TELEMETRY, SEC_RADIO_ADV, SEC_COUNT };
static const char* pageTitle(uint8_t p) {
static const char* T[PG_COUNT] = { "Map options", "Advert", "Display & power", "Messages & contacts", "Sound" };
static const char* T[PG_COUNT] = { "Map options", "Advert", "Display & power", "Messages & contacts", "Sound",
"Privacy", "Advanced radio" };
return p < PG_COUNT ? T[p] : "";
}
static const char* sectionTitle(uint8_t s) {
static const char* T[SEC_COUNT] = { "TRAIL", "LIVE SHARE", "ARRIVAL ALERT", "AUTOMATIC",
"DISPLAY", "POWER", "TIME", "UNITS",
"MESSAGES", "CONTACTS",
"SOUND", "PLAYS FOR", "QUIET HOURS" };
"MESSAGES", "CONTACTS", "ADDING CONTACTS",
"SOUND", "PLAYS FOR", "QUIET HOURS",
"ADVERTS", "WHO CAN ASK FOR", "ADVANCED" };
return s < SEC_COUNT ? T[s] : "";
}
static uint8_t sectionPage(uint8_t s) {
static const uint8_t P[SEC_COUNT] = { PG_NAV, PG_NAV, PG_NAV, PG_ADVERT,
PG_DEVICE, PG_DEVICE, PG_DEVICE, PG_DEVICE,
PG_MESSAGES, PG_MESSAGES,
PG_SOUND, PG_SOUND, PG_SOUND };
PG_MESSAGES, PG_MESSAGES, PG_MESSAGES,
PG_SOUND, PG_SOUND, PG_SOUND,
PG_PRIVACY, PG_PRIVACY, PG_RADIO };
return s < SEC_COUNT ? P[s] : PG_DEVICE;
}
@@ -57,8 +64,10 @@ struct Setting {
// Side effect after a change (nullptr: none). The caller saves the prefs.
void (*changed)(UiCore& core);
const int32_t* values; // stored value per index; nullptr: the index itself
uint8_t size; // field bytes (1, 2, 4)
uint8_t size; // field bytes (1, 2, 4), or FLOAT_X100
uint8_t bit; // nonzero: a switch over this bit of a uint8_t field
};
static const uint8_t FLOAT_X100 = 0x84; // size: a float, values in hundredths
// ── Value labels ──────────────────────────────────────────────────────────────
#ifndef TRAIL_FIXED_MIN_DELTA_M
@@ -152,12 +161,40 @@ static void optAdvertScope(uint8_t v, char* b, int n, const NodePrefs&) {
snprintf(b, n, "%s", v == ADVERT_SOUND_SCOPE_ZERO_HOP ? "Direct" : "All");
}
static void optTelemetry(uint8_t v, char* b, int n, const NodePrefs&) {
static const char* const L[] = { "Nobody", "Allowed", "Everyone" }; // TELEM_MODE_*
snprintf(b, n, "%s", L[v < 3 ? v : 0]);
}
static void optAutoAdd(uint8_t v, char* b, int n, const NodePrefs&) { snprintf(b, n, "%s", v ? "By type" : "All"); }
// autoadd_max_hops: 0 = any, 1 = direct only, N = up to N-1 hops.
static const int32_t MAX_HOPS[] = { 0, 1, 2, 3, 4, 6, 9 };
static void optMaxHops(uint8_t v, char* b, int n, const NodePrefs&) {
int32_t h = MAX_HOPS[v];
if (h == 0) snprintf(b, n, "Any");
else if (h == 1) snprintf(b, n, "Direct");
else snprintf(b, n, "%ld hop%s", (long)(h - 1), h == 2 ? "" : "s");
}
static void optHashSize(uint8_t v, char* b, int n, const NodePrefs&) { snprintf(b, n, "%u byte%s", v + 1, v ? "s" : ""); }
static const int32_t INT_THRESH[] = { 0, 8, 10, 12, 14, 16, 20 };
static void optIntThresh(uint8_t v, char* b, int n, const NodePrefs&) {
if (INT_THRESH[v] == 0) snprintf(b, n, "Off"); else snprintf(b, n, "%ld dB", (long)INT_THRESH[v]);
}
static const int32_t RX_DELAY[] = { 0, 200, 500, 1000, 2000 }; // rx_delay_base, x100
static void optRxDelay(uint8_t v, char* b, int n, const NodePrefs&) {
if (RX_DELAY[v] == 0) snprintf(b, n, "Off"); else snprintf(b, n, "%ld", (long)(RX_DELAY[v] / 100));
}
static const int32_t AIRTIME[] = { 0, 50, 100, 200, 300, 500, 900 }; // airtime_factor, x100
static void optAirtime(uint8_t v, char* b, int n, const NodePrefs&) {
snprintf(b, n, "%ld.%ld x", (long)(AIRTIME[v] / 100), (long)(AIRTIME[v] % 100 / 10));
}
// ── Side effects ──────────────────────────────────────────────────────────────
static void rearmLocator(UiCore& c) { c.locator.reset(); }
static void restartShareClock(UiCore& c) { c.live_share.restartClock(); } // a new length starts over
static void applyDisplay(UiCore& c) { c.host()->applyDisplayPrefs(); }
static void applyGpsDuty(UiCore& c) { c.applyGpsInterval(); }
static void applySound(UiCore& c) { c.host()->applySoundPrefs(); }
static void applyRxGain(UiCore& c) { if (c.prefs()) radio_driver.setRxBoostedGainMode(c.prefs()->rx_boosted_gain); }
#define NP_OFF(f) (uint16_t)offsetof(NodePrefs, f)
#define NP_SIZE(f) (uint8_t)sizeof(((NodePrefs*)0)->f)
@@ -166,6 +203,8 @@ static void applySound(UiCore& c) { c.host()->applySoundPrefs(); }
#define IDX(label, hint, sec, f, count, opt, chg) { label, hint, sec, NP_OFF(f), count, opt, chg, nullptr, 1 }
#define SW(label, hint, sec, f, chg) { label, hint, sec, NP_OFF(f), 2, nullptr, chg, nullptr, 1 }
#define MAP(label, hint, sec, f, vals, opt, chg) { label, hint, sec, NP_OFF(f), COUNT_OF(vals), opt, chg, vals, NP_SIZE(f) }
#define MAPF(label, hint, sec, f, vals, opt) { label, hint, sec, NP_OFF(f), COUNT_OF(vals), opt, nullptr, vals, FLOAT_X100 }
#define BIT(label, hint, sec, f, mask) { label, hint, sec, NP_OFF(f), 2, nullptr, nullptr, nullptr, 1, mask }
static const Setting ALL[] = {
#ifndef TRAIL_FIXED_MIN_DELTA_M // else every sample past a fixed jitter gate is kept
IDX("Point spacing", "Between trail points", SEC_TRAIL, trail_min_delta_idx,
@@ -194,7 +233,7 @@ static const Setting ALL[] = {
NodePrefs::LOCATOR_MODE_COUNT, optLocMode, rearmLocator),
SW("Proximity beeper", "Ticks faster when closer", SEC_LOCATOR, locator_beeper, nullptr),
MAP("Auto-advert", "Advert with your position", SEC_ADVERT, advert_auto_interval_sec, AUTO_ADVERT,
MAP("Auto-advert", "Your advert, on a timer", SEC_ADVERT, advert_auto_interval_sec, AUTO_ADVERT,
optAutoAdvert, nullptr),
IDX("Brightness", nullptr, SEC_DISPLAY, display_brightness, 5, optBrightness, applyDisplay),
@@ -221,6 +260,15 @@ static const Setting ALL[] = {
NodePrefs::CONTACT_EXPIRY_COUNT, optExpiry, nullptr),
SW("Favourites first", "In contact and node lists", SEC_CONTACTS, fav_sort, nullptr),
// manual_add_contacts bit 0: 0 adds every node heard, 1 only the types below.
IDX("Add heard nodes", "Every node, or only these types", SEC_AUTO_ADD, manual_add_contacts, 2, optAutoAdd, nullptr),
BIT("Companions", nullptr, SEC_AUTO_ADD, autoadd_config, 0x02),
BIT("Repeaters", nullptr, SEC_AUTO_ADD, autoadd_config, 0x04),
BIT("Rooms", nullptr, SEC_AUTO_ADD, autoadd_config, 0x08),
BIT("Sensors", nullptr, SEC_AUTO_ADD, autoadd_config, 0x10),
MAP("Up to", "How far away a node may be", SEC_AUTO_ADD, autoadd_max_hops, MAX_HOPS, optMaxHops, nullptr),
BIT("Replace oldest", "When the list is full; not favourites", SEC_AUTO_ADD, autoadd_config, 0x01),
// On / Off / Auto spans two fields (soundctl::setMode): the frontend's own row.
IDX("Volume", nullptr, SEC_SOUND, buzzer_volume, 5, optVolume, applySound),
SW("Quiet hours", "Muted, screen dark; alarms ring", SEC_QUIET, quiet_hours, nullptr),
@@ -231,6 +279,23 @@ static const Setting ALL[] = {
IDX("Channels", nullptr, SEC_SOUND_FOR, notif_melody_ch, soundctl::SOUND_COUNT, optSound, nullptr),
IDX("Adverts", "A node announcing itself", SEC_SOUND_FOR, notif_melody_ad, soundctl::SOUND_COUNT, optSound, nullptr),
IDX("Advert sound for", "Direct: no repeaters", SEC_SOUND_FOR, advert_sound_scope, 2, optAdvertScope, nullptr),
SW("Position in adverts", "Your adverts carry your location", SEC_PRIVACY, advert_loc_policy, nullptr),
// TELEM_MODE_*: "Allowed" is the contacts given that permission.
IDX("Status", "Battery; the two below need it", SEC_TELEMETRY, telemetry_mode_base, 3, optTelemetry, nullptr),
IDX("Location", nullptr, SEC_TELEMETRY, telemetry_mode_loc, 3, optTelemetry, nullptr),
IDX("Sensors", nullptr, SEC_TELEMETRY, telemetry_mode_env, 3, optTelemetry, nullptr),
IDX("Path hash size", "Bytes per repeater in a path", SEC_RADIO_ADV, path_hash_mode, 3, optHashSize, nullptr),
SW("Double ACK", "Send each acknowledgement twice", SEC_RADIO_ADV, multi_acks, nullptr),
SW("Listen before talk", "Wait for a quiet channel (CAD)", SEC_RADIO_ADV, cad_enabled, nullptr),
MAP("Interference", "Busy above the noise floor by", SEC_RADIO_ADV, interference_threshold, INT_THRESH,
optIntThresh, nullptr),
#if defined(USE_SX1262) || defined(USE_SX1268) || defined(USE_LR2021)
SW("Boosted RX gain", "Hears more, uses a little more power", SEC_RADIO_ADV, rx_boosted_gain, applyRxGain),
#endif
MAPF("RX delay", "Repeaters wait to pick the best path", SEC_RADIO_ADV, rx_delay_base, RX_DELAY, optRxDelay),
MAPF("Airtime factor", "Pause after sending, times airtime", SEC_RADIO_ADV, airtime_factor, AIRTIME, optAirtime),
};
static const int COUNT = (int)(sizeof(ALL) / sizeof(ALL[0]));
@@ -249,8 +314,19 @@ static const struct { uint16_t offset; const char* text; } SHORT_LABELS[] = {
{ NP_OFF(quiet_to), " until" }, { NP_OFF(notif_melody_dm), "DM sound" },
{ NP_OFF(notif_melody_ch), "Ch sound" }, { NP_OFF(notif_melody_ad), "AD sound" },
{ NP_OFF(advert_sound_scope), "AD scope" },
{ NP_OFF(manual_add_contacts), "Auto-add" }, { NP_OFF(autoadd_max_hops), " up to" },
{ NP_OFF(advert_loc_policy), "Advert pos" }, { NP_OFF(telemetry_mode_base), "Ask status" },
{ NP_OFF(telemetry_mode_loc), "Ask loc" }, { NP_OFF(telemetry_mode_env), "Ask sens" },
{ NP_OFF(path_hash_mode), "Hash size" }, { NP_OFF(multi_acks), "Double ACK" },
{ NP_OFF(cad_enabled), "LBT (CAD)" }, { NP_OFF(interference_threshold), "Interf." },
{ NP_OFF(rx_boosted_gain), "RX boost" }, { NP_OFF(rx_delay_base), "RX delay" },
{ NP_OFF(airtime_factor), "Airtime" },
};
static const char* shortLabel(const Setting& s) {
if (s.bit) { // the auto-add types share one field
static const char* const B[] = { "Replace old", " companions", " repeaters", " rooms", " sensors" };
for (int i = 0; i < 5; i++) if (s.bit == (1 << i)) return B[i];
}
for (auto& l : SHORT_LABELS) if (l.offset == s.offset) return l.text;
return s.label;
}
@@ -262,12 +338,16 @@ static int indexOf(uint16_t offset) {
#undef IDX
#undef SW
#undef MAP
#undef MAPF
#undef BIT
#undef COUNT_OF
#undef NP_SIZE
#undef NP_OFF
static int32_t readRaw(const NodePrefs& p, const Setting& s) {
const uint8_t* f = (const uint8_t*)&p + s.offset;
if (s.bit) return (f[0] & s.bit) ? 1 : 0;
if (s.size == FLOAT_X100) { float v; memcpy(&v, f, 4); return (int32_t)(v * 100 + (v < 0 ? -0.5f : 0.5f)); }
bool sgn = false; // a table with negative values is over a signed field
for (uint8_t i = 0; s.values && i < s.count; i++) if (s.values[i] < 0) sgn = true;
if (s.size == 2) { uint16_t v; memcpy(&v, f, 2); return sgn ? (int32_t)(int16_t)v : (int32_t)v; }
@@ -276,6 +356,8 @@ static int32_t readRaw(const NodePrefs& p, const Setting& s) {
}
static void writeRaw(NodePrefs& p, const Setting& s, int32_t v) {
uint8_t* f = (uint8_t*)&p + s.offset;
if (s.bit) { f[0] = v ? (f[0] | s.bit) : (f[0] & ~s.bit); return; }
if (s.size == FLOAT_X100) { float w = v / 100.0f; memcpy(f, &w, 4); return; }
if (s.size == 2) { uint16_t w = (uint16_t)v; memcpy(f, &w, 2); }
else if (s.size == 4) { uint32_t w = (uint32_t)v; memcpy(f, &w, 4); }
else f[0] = (uint8_t)v;
+38
View File
@@ -143,6 +143,38 @@ public:
return true;
}
// Settings > Privacy > Position: where adverts say you are while the GPS is
// off -- the sensor manager's node_lat / node_lon, saved with the prefs as
// the app's CMD_SET_ADVERT_LATLON does. With the GPS on, each fix overwrites it.
bool fixedPosition(int32_t& lat, int32_t& lon) const {
lat = (int32_t)lround(_sensors->node_lat * 1e6);
lon = (int32_t)lround(_sensors->node_lon * 1e6);
return lat != 0 || lon != 0;
}
void setFixedPosition(int32_t lat, int32_t lon) { // 0, 0: none
_sensors->node_lat = lat / 1e6;
_sensors->node_lon = lon / 1e6;
the_mesh.savePrefs();
}
// Settings > Privacy > Bluetooth PIN: NodePrefs::ble_pin, six digits, or 0
// for a new random one each start (shown on screen). Used from the next
// start, like the app's CMD_SET_DEVICE_PIN.
static bool validBlePin(uint32_t pin) { return pin == 0 || (pin >= 100000 && pin <= 999999); }
bool setBlePin(uint32_t pin) {
if (!_prefs || !validBlePin(pin)) return false;
_prefs->ble_pin = pin;
the_mesh.savePrefs();
return true;
}
// Settings > System > Factory reset: everything erased, then a restart
// straight away -- no shutdown flush, which would write the prefs back.
// Returns only when the erase failed.
void factoryReset() {
if (the_mesh.factoryReset()) board.reboot();
}
// GPS duty cycle (NodePrefs::gps_interval, seconds; 0 = always on) to the sensor layer.
void applyGpsInterval() {
if (!_sensors || !_prefs) return;
@@ -152,6 +184,7 @@ public:
}
UiCoreHost* host() { return _host; }
NodePrefs* prefs() { return _prefs; }
// ════ MyMesh::Listener ════════════════════════════════════════════════════
@@ -274,6 +307,11 @@ public:
}
_host->onAdminStateChanged();
}
// The companion CLI's settings keys: the frontend answers them from the
// schema (ui-core/SettingsCli.h, included where the schema is).
bool onCliCommand(const char* command, char* reply, int n) override {
return _host->cliCommand(command, reply, n);
}
void onAdminReply(const uint8_t* pub_key, const char* text) override {
admin.onReply(pub_key, text);
_host->onAdminStateChanged();
@@ -41,6 +41,8 @@ public:
// A sound pref changed through the schema (the volume): push it to the
// speaker / buzzer.
virtual void applySoundPrefs() {}
// A companion CLI command MyMesh left over (its settings keys).
virtual bool cliCommand(const char* command, char* reply, int n) { return false; }
// Controlled power-down / restart (flush state first). See
// AbstractUITask::shutdown().
@@ -96,13 +96,19 @@ void UITask::buildRadio() {
paramRows(g, p, radioctl::currentPreset(p), p->freq, p->sf, p->bw, p->cr, onRadioDropdown, IDS, onRadioFreq,
"Higher = longer range");
groupNote(body, "Everyone you talk to needs the same settings.");
fill(&UITask::radioGroup, 1, 3); // the rest as the loop goes
fill(&UITask::radioGroup, 1, 4); // the rest as the loop goes
}
void UITask::radioGroup(int i) {
using namespace radioview;
NodePrefs* p = _prefs;
if (i == 2) { buildRadioExtras(_body); return; } // my presets, scopes (RadioExtras.h)
if (i == 3) { // the schema's advanced options, a page of their own
lv_obj_t* g = group(_body, nullptr);
listRow(g, LV_SYMBOL_SETTINGS " Advanced", "Path hash size, ACKs, listen before talk, delays",
onOpenSchemaPage, (void*)(uintptr_t)settings::PG_RADIO);
return;
}
lv_obj_t* g = group(_body, "TRANSMIT");
int o = 0;
for (int v = TX_MIN; v <= TX_MAX; v++) o += snprintf(s_opts + o, OPTS_LEN - o, v > TX_MIN ? "\n%d dBm" : "%d dBm", v);
+247 -27
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/EtaTracker.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/TrackBack.h"
#include "../ui-core/TrailProfile.h"
@@ -2399,6 +2401,8 @@ void UITask::back() {
if (_settings_page == settings::PG_NAV && s_opts_from_map) {
openMap(true);
if (s_nav_section >= 0) navToolsPopup(); // one section's options: back into Map tools
} else if (_settings_page == settings::PG_RADIO) {
showRadio();
} else {
showSettings();
}
@@ -2473,6 +2477,7 @@ static void onOpenRepeater(lv_event_t* e); // RepeaterScreen.h
static void onOpenAdminPick(lv_event_t* e); // AdminScreen.h
static void onNodeName(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.
@@ -3057,7 +3062,8 @@ void UITask::buildContacts() {
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).
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 onNearbySort(lv_event_t* e) { (void)e; s_ui->toggleNearbySort(); }
@@ -3353,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");
}
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() {
const NearbyModel::Entry& e = s_node;
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
s_nd_fav = nullptr;
_node_path_lbl = nullptr;
nodeVizBuild(info);
nodeviz::s_bearing = nodeviz::s_hops = -2; // nothing drawn yet: the first set fills both
_node_info = infoCard(info);
@@ -3389,10 +3408,31 @@ void UITask::buildNode() {
if (contact) s_nd_fav = actionRow(g, "", onNodeAction, (void*)(uintptr_t)NODE_FAV);
nodeFavText();
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,
(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();
}
@@ -3529,6 +3569,16 @@ void UITask::nodeAction(uint8_t action) {
case NODE_PIN: // to the favourites dial (Home), as from a chat's options
pinPopup(false, 0, e.pub_key);
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:
if (!tapConfirmed(_node_delete_lbl, LV_SYMBOL_TRASH " Tap again to delete")) break;
if (the_mesh.deleteContactByKey(e.pub_key)) {
@@ -3539,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 "Chart.h"
#include "ProfileView.h"
@@ -3548,6 +3597,7 @@ static lv_obj_t* switchRow(lv_obj_t* parent, const char* text, const char* sub,
#include "MapRegions.h"
#include "HomeScreen.h"
#include "ClockScreen.h"
static void onOpenSchemaPage(lv_event_t* e); // Radio > Advanced
#include "RadioScreen.h"
#include "WifiScreen.h"
#include "ChannelScreen.h"
@@ -4451,10 +4501,147 @@ void UITask::buildSchemaSettings() {
schemaRows(body, _settings_page);
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_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.
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_MESSAGES) {
lv_obj_t* c = s_sec_card[settings::SEC_CONTACTS]; // the action that goes with "Contact expiry"
@@ -4480,30 +4667,51 @@ void UITask::buildKeyboardPage(lv_obj_t* body) {
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
// with its quiet zone, built once at 5 px a module.
// A QR code as a 1-bit image into dsc / data (the palette, then the rows):
// 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 const int S = 5, SIDE = siteqr::N + 2 * siteqr::QUIET, PX = SIDE * S, STRIDE = (PX + 7) / 8;
static uint8_t data[8 + STRIDE * PX]; // the palette (white, black), then the rows
static const int S = 5, PX = (siteqr::N + 2 * siteqr::QUIET) * S;
static uint8_t data[8 + (PX + 7) / 8 * PX];
static lv_image_dsc_t dsc;
if (!dsc.data) {
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* px = 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 < siteqr::N && my < siteqr::N && siteqr::dark(mx, my))
px[y * STRIDE + x / 8] |= 0x80 >> (x % 8);
}
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 = sizeof(data);
dsc.data = data;
}
if (!dsc.data) qrImage(dsc, data, sizeof(data), siteqr::N, S, siteqr::dark);
lv_obj_t* img = lv_image_create(parent);
lv_image_set_src(img, &dsc);
return img;
}
// Your own contact's QR code (ui-core/ContactQr.h), made each time it is
// shown (the name can change), 3 px a module; null when it doesn't fit.
static lv_obj_t* contactQr(lv_obj_t* parent) {
static const int S = 3, PX = (contactqr::MAX_VERSION * 4 + 17 + 2 * siteqr::QUIET) * S;
static uint8_t data[8 + (PX + 7) / 8 * PX];
static lv_image_dsc_t dsc;
static contactqr::Code code;
if (!contactqr::make(code)) return nullptr;
qrImage(dsc, data, sizeof(data), code.n, S, [](int x, int y) { return code.dark(x, y); });
lv_obj_t* img = lv_image_create(parent);
lv_image_set_src(img, &dsc);
return img;
@@ -4515,6 +4723,13 @@ void UITask::buildAboutPage(lv_obj_t* body) {
infoRow(about, "Node", the_mesh.getNodeName());
infoRow(about, "Firmware", FIRMWARE_VERSION);
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");
lv_obj_t* man = infoCard(body);
lv_obj_set_flex_align(man, LV_FLEX_ALIGN_START, LV_FLEX_ALIGN_CENTER, LV_FLEX_ALIGN_CENTER);
@@ -4536,11 +4751,13 @@ void UITask::buildAboutPage(lv_obj_t* body) {
// (fill); a popup's list gets them all now.
static lv_obj_t* s_schema_body = nullptr;
static bool s_schema_one = false; // one section: its own page, no heading
static bool s_schema_bare = false; // no section headings
void UITask::schemaRows(lv_obj_t* body, uint8_t page) {
for (lv_obj_t*& c : s_sec_card) c = nullptr;
s_schema_body = body;
s_schema_one = page == settings::PG_NAV && s_nav_section >= 0;
s_schema_bare = s_schema_one || page == settings::PG_RADIO; // one section: the page title says it
uint8_t sec = 0xFF;
bool first = true;
for (int i = 0; i < settings::COUNT; i++) {
@@ -4557,7 +4774,7 @@ void UITask::schemaRows(lv_obj_t* body, uint8_t page) {
// The group of the section setting i starts, with its rows.
void UITask::schemaSection(int i) {
uint8_t sec = settings::ALL[i].section;
lv_obj_t* card = s_sec_card[sec] = group(s_schema_body, s_schema_one ? nullptr : settings::sectionTitle(sec));
lv_obj_t* card = s_sec_card[sec] = group(s_schema_body, s_schema_bare ? nullptr : settings::sectionTitle(sec));
if (sec == settings::SEC_SOUND) buildSoundRows(card, true); // On / Off / Auto
for (int k = i; k < settings::COUNT && settings::ALL[k].section == sec; k++) {
if (settings::ALL[k].offset == offsetof(NodePrefs, lock_compact)) continue; // ui-new's lock look; the slide card has one
@@ -4684,7 +4901,7 @@ void UITask::settingsGroup(int i) {
snprintf(sub, sizeof(sub), aa == ma ? "%s" : "%s + %s", SCRIPT[ma], SCRIPT[aa]);
}
listRow(g, LV_SYMBOL_KEYBOARD " Keyboard", sub, onOpenSchemaPage, (void*)(uintptr_t)PG_KEYBOARD);
listRow(g, LV_SYMBOL_ENVELOPE " Messages & contacts", "Resend, expiry, quick messages",
listRow(g, LV_SYMBOL_ENVELOPE " Messages & contacts", "Resend, adding, expiry, quick messages",
onOpenSchemaPage, (void*)(uintptr_t)settings::PG_MESSAGES);
break;
@@ -4696,6 +4913,8 @@ void UITask::settingsGroup(int i) {
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);
listRow(g, UI_SYMBOL_RADIO " Radio", sub, onOpenRadio, NULL);
listRow(g, LV_SYMBOL_EYE_CLOSE " Privacy", "Position, who can ask for telemetry, Bluetooth PIN",
onOpenSchemaPage, (void*)(uintptr_t)settings::PG_PRIVACY);
}
bluetoothRow(g);
wifiRow(g);
@@ -4728,13 +4947,14 @@ void UITask::settingsGroup(int i) {
schemaRow(g, SETTING(units_imperial));
}
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;
case 4:
g = group(body, nullptr);
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_TRASH " Factory reset", onFactoryReset, (void*)(uintptr_t)1, theme::FAIL);
break;
}
}
+10
View File
@@ -60,6 +60,7 @@ public:
void onViewedHistoryGrew(bool channel) override { (void)channel; _thread_dirty = true; }
void botSetGPS(bool on) override;
void applySoundPrefs() override;
bool cliCommand(const char* command, char* reply, int n) override;
// ── Navigation (called from LVGL event callbacks) ────────────────────────
void showHome();
@@ -87,6 +88,14 @@ public:
void otaAction();
void adminPick(int row);
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 applyDisplayPrefs() override;
void setSchemaValue(int idx, int v);
@@ -595,6 +604,7 @@ private:
lv_obj_t* _node_info = nullptr;
lv_obj_t* _node_ping = nullptr;
lv_obj_t* _node_delete_lbl = nullptr;
lv_obj_t* _node_path_lbl = nullptr;
bool _node_from_map = false;
// Map (SCR_MAP): view centre in fractional tile coords at zoom _map_z
@@ -22,15 +22,15 @@ public:
enum Result { IGNORED, HANDLED, ACTIVATED };
static const int MAX_SECTIONS = 8; // collapse bitmask is 8 bits
static const int MAX_ROWS = 64;
static const int MAX_SECTIONS = 16; // collapse bitmask is 16 bits
static const int MAX_ROWS = 128;
// (Re)initialise from per-section item counts. `collapsed` sets the initial
// fold state for every section (Settings opens folded, so default true).
void begin(const uint8_t* section_sizes, int section_count, bool collapsed = true) {
_count = section_count > MAX_SECTIONS ? MAX_SECTIONS : section_count;
for (int i = 0; i < _count; i++) _sizes[i] = section_sizes[i];
_collapsed = collapsed ? 0xFF : 0x00;
_collapsed = collapsed ? 0xFFFF : 0x0000;
_sel = 0; _scroll = 0;
rebuild();
}
@@ -68,7 +68,7 @@ public:
// the cursor never jumps to an unrelated row when items appear/disappear.
void toggle(int sec) {
if (sec < 0 || sec >= _count) return;
_collapsed ^= (uint8_t)(1u << sec);
_collapsed ^= (uint16_t)(1u << sec);
rebuild();
for (int i = 0; i < _vis_count; i++)
if (_rows[i].sec == sec && _rows[i].item < 0) { _sel = i; break; }
@@ -77,7 +77,7 @@ public:
private:
uint8_t _sizes[MAX_SECTIONS];
int _count = 0;
uint8_t _collapsed = 0xFF;
uint16_t _collapsed = 0xFFFF;
Row _rows[MAX_ROWS];
int _vis_count = 0;
int _sel = 0, _scroll = 0;
+53 -8
View File
@@ -21,7 +21,8 @@ class NearbyScreen : public UIScreen, protected NearbyModel {
// ── action-menu actions (matched by id, not by row index) ────────────────────
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):
// 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 _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;
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 _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() {
const Entry* e = selected();
@@ -347,6 +366,15 @@ class NearbyScreen : public UIScreen, protected NearbyModel {
else snprintf(_pin_label, sizeof(_pin_label), "Pin to dial");
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_contact && has_key) add("Delete contact", ACT_DELETE);
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();
}
// Advance the value on the menu's value rows (Sort, Fav). Both are two-state,
// so LEFT and RIGHT do the same thing here and Enter joins them.
void cycleMenuValue(int i) {
// Advance the value on the menu's value rows (Sort, Fav, Telem). Sort and Fav
// are two-state, so LEFT and RIGHT do the same there; Telem steps by `dir`.
void cycleMenuValue(int i, int dir = 1) {
if (i < 0 || i >= _menu_action_count) return;
if (_menu_actions[i] == ACT_SORT) {
_sort = (_sort == SORT_DIST) ? SORT_TIME : SORT_DIST;
@@ -375,6 +403,12 @@ class NearbyScreen : public UIScreen, protected NearbyModel {
refresh();
} else if (_menu_actions[i] == ACT_FAV) {
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;
}
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: {
const Entry* e = selected();
if (e && e->has_key) togglePinToDial(e->pub_key);
@@ -914,10 +959,10 @@ public:
if (_ping_menu.active) { handlePingMenuInput(c); return true; }
if (_menu.active) {
// 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
// user can keep tapping, and only Back closes it. Other rows swallow L/R.
// value row -- cycle Sort, Fav and Telem in place; the popup stays open so
// the user can keep tapping, and only Back closes it. Other rows swallow L/R.
if (keyIsPrev(c) || keyIsNext(c)) {
cycleMenuValue(_menu.selectedIndex());
cycleMenuValue(_menu.selectedIndex(), keyIsNext(c) ? 1 : -1);
return true;
}
auto res = _menu.handleInput(c);
@@ -10,6 +10,7 @@
#include "PopupMenu.h" // scope list management's per-row action menu
#include "InfoKit.h" // value / switch rows
#include "../ui-core/Battery.h" // the Battery curve row
#include "../GeoUtils.h" // Position typed in
class SettingsScreen : public UIScreen {
UITask* _task;
@@ -50,12 +51,14 @@ class SettingsScreen : public UIScreen {
#endif
TX_APC,
SCOPE_NAME,
SCHEMA_RADIO, // the advanced radio options
// System section
SECTION_SYSTEM,
DEVICE_NAME,
SCHEMA_SYSTEM, // power, units
BATT_CURVE,
REBOOT,
FACTORY_RESET,
// Keyboard section
SECTION_KEYBOARD,
KEYBOARD_TYPE,
@@ -65,7 +68,12 @@ class SettingsScreen : public UIScreen {
KEYBOARD_CARDKB_COMPACT,
#endif
// Contacts section
SECTION_CONTACTS, DM_FILTER, CH_FILTER, ROOM_FILTER, SCHEMA_CONTACTS, PRUNE_NOW,
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, the pairing PIN
SECTION_PRIVACY, SCHEMA_PRIVACY, OWN_POSITION, SCHEMA_TELEMETRY,
#ifdef BLE_PIN_CODE
BLE_PIN,
#endif
// Messages section
SECTION_MESSAGES,
SCHEMA_MESSAGES,
@@ -87,8 +95,8 @@ class SettingsScreen : public UIScreen {
bool _keep_place = false; // set when opening a screen from a row, so Back lands on it
AccordionList _acc;
static const int NUM_SECTIONS = 8;
static const int MAX_PER_SEC = 16;
static const int NUM_SECTIONS = 9;
static const int MAX_PER_SEC = 20;
uint8_t _sec_items[NUM_SECTIONS][MAX_PER_SEC]; // SettingItem per (section, row)
uint8_t _sec_count[NUM_SECTIONS];
uint8_t _sec_header[NUM_SECTIONS]; // the SECTION_* enum for each section
@@ -140,7 +148,8 @@ class SettingsScreen : public UIScreen {
item == SECTION_HOME_PAGES ||
item == SECTION_RADIO || item == SECTION_SYSTEM ||
item == SECTION_KEYBOARD ||
item == SECTION_CONTACTS || item == SECTION_MESSAGES;
item == SECTION_CONTACTS || item == SECTION_MESSAGES ||
item == SECTION_PRIVACY;
}
const char* sectionName(int item) const {
@@ -152,6 +161,7 @@ class SettingsScreen : public UIScreen {
if (item == SECTION_KEYBOARD) return "Keyboard";
if (item == SECTION_CONTACTS) return "Contacts";
if (item == SECTION_MESSAGES) return "Messages";
if (item == SECTION_PRIVACY) return "Privacy";
return "";
}
@@ -182,7 +192,8 @@ class SettingsScreen : public UIScreen {
static bool isSchemaGroup(int item) {
return item == SCHEMA_DISPLAY || item == SCHEMA_SOUND || item == SCHEMA_SYSTEM ||
item == SCHEMA_CONTACTS || item == SCHEMA_MESSAGES;
item == SCHEMA_CONTACTS || item == SCHEMA_MESSAGES || item == SCHEMA_RADIO || item == SCHEMA_AUTO_ADD ||
item == SCHEMA_PRIVACY || item == SCHEMA_TELEMETRY;
}
// Which schema sections a placeholder stands for.
static bool schemaIn(int group, uint8_t sec) {
@@ -192,6 +203,10 @@ class SettingsScreen : public UIScreen {
case SCHEMA_SOUND: return sec == SEC_SOUND || sec == SEC_QUIET || sec == SEC_SOUND_FOR;
case SCHEMA_SYSTEM: return sec == SEC_POWER || sec == SEC_UNITS;
case SCHEMA_CONTACTS: return sec == SEC_CONTACTS;
case SCHEMA_AUTO_ADD: return sec == SEC_AUTO_ADD;
case SCHEMA_RADIO: return sec == SEC_RADIO_ADV;
case SCHEMA_PRIVACY: return sec == SEC_PRIVACY;
case SCHEMA_TELEMETRY: return sec == SEC_TELEMETRY;
case SCHEMA_MESSAGES: return sec == SEC_MESSAGES;
}
return false;
@@ -507,6 +522,20 @@ class SettingsScreen : public UIScreen {
val("Batt curve", p && battery::validCurve(p->batt_curve_mv) ? "Custom" : "LiPo");
} else if (item == REBOOT) {
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) {
val("Type", (p && p->keyboard_type) ? "T9" : "ABC");
} else if (item == KEYBOARD_MAIN_ALPHABET) {
@@ -594,6 +623,16 @@ class SettingsScreen : public UIScreen {
PopupMenu _prune_confirm;
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) {
display.setColor(DisplayDriver::LIGHT);
drawScreenHeader(display, "Scope", -1, 0, true, _scope_action_menu.active);
@@ -643,6 +682,10 @@ public:
_scope_action_menu.active = false;
_scope_delete_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;
_lock_pass_first[0] = '\0';
if (!keep) resetList();
@@ -652,7 +695,7 @@ public:
int render(DisplayDriver& display) override {
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);
}
@@ -680,6 +723,8 @@ public:
if (_picker.menu.active) _picker.menu.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;
}
@@ -748,6 +793,40 @@ public:
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
if (_scope_rename_idx != -2) {
auto res = _kb->handleInput(c);
@@ -861,6 +940,52 @@ public:
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
if (_prune_confirm.active) {
auto res = _prune_confirm.handleInput(c);
@@ -999,6 +1124,31 @@ public:
_task->shutdown(true); // flushes prefs/RTC/contacts/trail, then reboots -- single choke point
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)) {
p->keyboard_type ^= 1;
_dirty = true;
+45 -25
View File
@@ -8,6 +8,7 @@
#include "TabBar.h"
#include "../MyMesh.h"
#include "../ui-core/SiteQr.h"
#include "../ui-core/ContactQr.h"
#include "../ui-core/Diagnostics.h"
#include "../ui-core/Battery.h"
#if ENV_INCLUDE_GPS == 1 && defined(GPS_SKYVIEW)
@@ -46,7 +47,10 @@ private:
int _scroll = 0; // blocks scrolled off the top
bool _more = false; // the last frame cut blocks off at the bottom
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
CayenneLPP _lpp;
unsigned long _lpp_at = 0;
@@ -229,7 +233,7 @@ private:
// Sensors: every reading but the battery (above) and the position (GPS).
if (!_lpp_at || millis() - _lpp_at > 5000) { // both passes (measure, draw) read one query
_lpp.reset();
sensors.querySensors(0xFF, _lpp);
{ CardKBBusLock bus_lock; sensors.querySensors(0xFF, _lpp); } // shares the CardKB's bus
_lpp_at = millis() | 1;
}
static const struct { uint8_t type; const char* name; } NAMES[] = {
@@ -296,7 +300,7 @@ private:
row(d, f, rsv, "Firmware", a);
row(d, f, rsv, "Built", FIRMWARE_BUILD_DATE);
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;
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));
@@ -320,19 +324,33 @@ private:
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
// (LIGHT is the ink on e-ink, the lit pixel elsewhere), with the address
// beside it or under it where it fits.
// A QR code as large as the screen allows, dark modules on light (LIGHT is
// the ink on e-ink, the lit pixel elsewhere), with its caption beside it or
// under it: your contact, or the site's manual.
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 bool tall = H > W;
const int side = siteqr::N + 2 * siteqr::QUIET;
const int s = (tall ? W : H) / side;
const int side = n + 2 * siteqr::QUIET;
const int s = (tall ? W : H) / side > 0 ? (tall ? W : H) / side : 1;
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 bool url = d.getTextWidth(siteqr::URL) <= tw;
const int lines = url ? 2 : 3;
const char* const* cap = wide;
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.
const int x0 = tall ? (W - px) / 2 : 0;
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); }
d.setColor(eink ? DisplayDriver::LIGHT : DisplayDriver::DARK);
const int q = x0 + siteqr::QUIET * s, r = y0 + siteqr::QUIET * s;
for (int y = 0; y < siteqr::N; y++)
for (int x = 0; x < siteqr::N; x++)
if (siteqr::dark(x, y)) d.fillRect(q + x * s, r + y * s, s, s);
for (int y = 0; y < n; y++)
for (int x = 0; x < n; x++)
if (dark(x, y)) d.fillRect(q + x * s, r + y * s, s, s);
d.setColor(DisplayDriver::LIGHT);
const int mid = tx + tw / 2;
int ty = tall ? y0 + px + lh / 2 : (H - lines * lh) / 2;
if (url) {
d.drawTextCentered(mid, ty, "Solo manual"); ty += lh;
d.drawTextCentered(mid, ty, siteqr::URL);
} else {
d.drawTextCentered(mid, ty, "Scan for"); ty += lh;
d.drawTextCentered(mid, ty, "the Solo"); ty += lh;
d.drawTextCentered(mid, ty, "manual");
for (int i = 0; i < lines; i++) {
int w = d.getTextWidth(cap[i]);
d.drawTextEllipsized(w < tw ? tx + (tw - w) / 2 : tx, ty, tw, cap[i]);
ty += lh;
}
}
@@ -386,7 +400,7 @@ public:
// Open on a given tab (Status page: Radio; the GPS shortcuts: GPS / Sky).
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; }
}
// GPS stays awake while its live tabs are on screen.
@@ -457,7 +471,10 @@ public:
}
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 (keyIsNext(c)) { _cur = (_cur + 1) % _count; _scroll = 0; 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; }
#endif
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) {
_reset_menu.beginConfirm("Reset counters?", "Reset");
return true;
+123 -6
View File
@@ -8,6 +8,20 @@
#include "../Features.h"
#include "../GeoUtils.h"
#include "target.h"
// The CardKB background poll task (see UITask::cardkbTask) shares its I2C bus
// with the environment sensors, and some screens query those sensors while
// rendering -- exactly when that task is active. Holding this lock around a
// sensor query makes the two take turns. No-op on builds without the task.
#if defined(CARDKB_I2C) && defined(NRF52_PLATFORM)
static void* g_cardkb_bus_mutex = nullptr; // SemaphoreHandle_t, set by startCardKBCapture()
struct CardKBBusLock {
CardKBBusLock() { if (g_cardkb_bus_mutex) xSemaphoreTake((SemaphoreHandle_t)g_cardkb_bus_mutex, portMAX_DELAY); }
~CardKBBusLock() { if (g_cardkb_bus_mutex) xSemaphoreGive((SemaphoreHandle_t)g_cardkb_bus_mutex); }
};
#else
struct CardKBBusLock { };
#endif
#ifdef WIFI_SSID
#include <WiFi.h>
#endif
@@ -173,6 +187,7 @@ static const telemetry::Style L1_INFO = { "\xc2\xb0", true, false, 5 };
#include "SensorPlaceholders.h"
#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/SettingsCli.h" // and their companion CLI keys
#include "SettingsScreen.h"
#include "MessagesScreen.h"
@@ -828,7 +843,7 @@ class HomeScreen : public UIScreen {
sensors_lpp.reset();
sensors_nb = 0;
sensors_lpp.addVoltage(TELEM_CHANNEL_SELF, (float)board.getBattMilliVolts() / 1000.0f);
sensors.querySensors(0xFF, sensors_lpp);
{ CardKBBusLock bus_lock; sensors.querySensors(0xFF, sensors_lpp); }
LPPReader reader (sensors_lpp.getBuffer(), sensors_lpp.getSize());
uint8_t channel, type;
while(reader.readHeader(channel, type)) {
@@ -2044,6 +2059,7 @@ void UITask::begin(DisplayDriver* display, SensorManager* sensors, NodePrefs* no
#if defined(PIN_GPIO1)
gpio_screen = new GpioScreen(this, node_prefs);
#endif
startCardKBCapture(); // after every screen: an optional task must not starve one
applyBrightness();
applyRotation();
applyFullRefreshInterval();
@@ -2726,7 +2742,11 @@ static void formatDashVal(uint8_t field, char* val, int val_len, uint16_t batt_m
{ NodePrefs* np = the_mesh.getNodePrefs(); in.gps_on = np && np->gps_enabled; } // off: no stale satellite count
#endif
if (telemetry::isSensor(field)) {
if (!lpp) { static CayenneLPP s_lpp(200); s_lpp.reset(); sensors.querySensors(0xFF, s_lpp); lpp = &s_lpp; }
if (!lpp) {
static CayenneLPP s_lpp(200); s_lpp.reset();
{ CardKBBusLock bus_lock; sensors.querySensors(0xFF, s_lpp); }
lpp = &s_lpp;
}
in.lpp = lpp->getBuffer();
in.lpp_len = lpp->getSize();
}
@@ -2893,12 +2913,100 @@ void UITask::pollCardKB() {
// Polling every loop() iteration (same as a digital button's check(), which
// has no throttle either) just shrinks that miss window down to exactly the
// render() duration instead of render()+30ms.
// First, keys the background task captured while the display blocked, in order.
while (_cardkb_q_tail != _cardkb_q_head) {
uint8_t q = _cardkb_q[_cardkb_q_tail];
_cardkb_q_tail = (uint8_t)((_cardkb_q_tail + 1) % CARDKB_Q_LEN);
handleCardKBByte(q);
}
uint8_t raw;
if (!readCardKBEdge(raw)) return;
handleCardKBByte(raw);
#endif
}
#if defined(CARDKB_I2C)
bool UITask::readCardKBEdge(uint8_t& raw) {
CARDKB_I2C.requestFrom(0x5F, 1);
if (!CARDKB_I2C.available()) return;
uint8_t raw = CARDKB_I2C.read();
if (raw == _cardkb_last_raw) return; // still held (or still released) -- no new edge
if (!CARDKB_I2C.available()) return false;
raw = CARDKB_I2C.read();
if (raw == _cardkb_last_raw) return false; // still held (or still released) -- no new edge
_cardkb_last_raw = raw;
if (raw == 0) return; // key just released, nothing to enqueue
return raw != 0; // 0 = key just released, nothing to enqueue
}
// Single producer (the poll task) / single consumer (pollCardKB() in the
// loop) ring -- byte-sized indices, so no locking needed.
void UITask::cardkbQueuePush(uint8_t raw) {
uint8_t next = (uint8_t)((_cardkb_q_head + 1) % CARDKB_Q_LEN);
if (next == _cardkb_q_tail) return; // full: drop (16 keys within one refresh)
_cardkb_q[_cardkb_q_head] = raw;
_cardkb_q_head = next;
}
#if defined(NRF52_PLATFORM)
void UITask::cardkbTask(void* self) {
UITask* t = (UITask*)self;
SemaphoreHandle_t m = (SemaphoreHandle_t)t->_cardkb_mutex;
for (;;) {
if (t->_cardkb_bg_active && xSemaphoreTake(m, portMAX_DELAY) == pdTRUE) {
if (t->_cardkb_bg_active) { // re-check: loop may have just finished
uint8_t raw;
if (t->readCardKBEdge(raw)) t->cardkbQueuePush(raw);
}
xSemaphoreGive(m);
}
vTaskDelay(ms2tick(10)); // a keypress lasts ~50+ ms, so 10 ms never misses one
}
}
#endif
#endif
// Keep sampling the CardKB while the display blocks the loop, so keys typed
// during a (long, on big panels) refresh aren't lost. Called LAST in begin():
// the heap is tight on large-panel builds, and every screen is allocated with
// `new` -- a failed allocation leaves that screen silently unopenable. Creating
// the optional poll task afterwards means a short heap costs the task (the
// CardKB is then only read between frames, as without it), never a screen.
void UITask::startCardKBCapture() {
#if defined(CARDKB_I2C) && defined(NRF52_PLATFORM)
if (!_has_cardkb) return;
SemaphoreHandle_t m = xSemaphoreCreateMutex();
if (!m) return;
// Publish the mutex BEFORE creating the task: at higher priority it starts
// running inside xTaskCreate() and reads _cardkb_mutex straight away.
_cardkb_mutex = (void*)m;
// TASK_PRIO_NORMAL (2) preempts the loop task (TASK_PRIO_LOW, 1); BLE runs higher.
if (xTaskCreate(&UITask::cardkbTask, "cardkb", 512, this, TASK_PRIO_NORMAL, NULL) == pdPASS) {
g_cardkb_bus_mutex = m; // shared with CardKBBusLock (sensor queries)
return;
}
_cardkb_mutex = nullptr; // no task -> setCardKBBackground() stays a no-op
vSemaphoreDelete(m);
#endif
}
// Hand CardKB sampling to the background task (on = loop is about to block on
// the display) or take it back (off). Taking the mutex on the way out waits for
// any in-flight background I2C read to finish before the loop touches the bus.
void UITask::setCardKBBackground(bool on) {
#if defined(CARDKB_I2C) && defined(NRF52_PLATFORM)
if (!_cardkb_mutex) return;
if (on) {
_cardkb_bg_active = true;
} else {
SemaphoreHandle_t m = (SemaphoreHandle_t)_cardkb_mutex;
xSemaphoreTake(m, portMAX_DELAY);
_cardkb_bg_active = false;
xSemaphoreGive(m);
}
#else
(void)on;
#endif
}
void UITask::handleCardKBByte(uint8_t raw) {
#if defined(CARDKB_I2C)
// Compact mode (Settings > Keyboard's "Ext. KB" row) hides the letter grid
// entirely, and is meant to guarantee joystick-free operation: while it's
@@ -3466,6 +3574,7 @@ void UITask::loop() {
if (now != ext) { ext = now; _next_refresh = 0; } }
if (_display != NULL && _display->isOn()) {
setCardKBBackground(true); // keep sampling the keyboard while the frame blocks
// Lock-screen password prompt
if (_locked && _unlock_kb && (int32_t)(millis() - _lock_wake_until) >= 0) {
cancelUnlockPrompt(); // Cancel unlock attempt on idle
@@ -3534,6 +3643,7 @@ void UITask::loop() {
_display->endFrame();
PERF_T2();
}
setCardKBBackground(false);
#if AUTO_OFF_MILLIS > 0
#ifdef KEEP_DISPLAY_ON_USB
// Opt-in: refresh the auto-off deadline while externally powered, so the
@@ -4112,6 +4222,13 @@ void UITask::applySoundPrefs() {
#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) {
#ifdef PIN_BUZZER
if (_node_prefs == NULL) return;
+23
View File
@@ -210,8 +210,30 @@ private:
// the last raw byte seen so a held key enqueues exactly one press instead
// of one per poll tick.
uint8_t _cardkb_last_raw = 0;
// Keys sampled by the background poll task while the display blocks the
// main loop; drained (in order) by the next pollCardKB().
static const uint8_t CARDKB_Q_LEN = 16;
uint8_t _cardkb_q[CARDKB_Q_LEN];
volatile uint8_t _cardkb_q_head = 0, _cardkb_q_tail = 0; // shared with the poll task
bool readCardKBEdge(uint8_t& raw); // one I2C read + debounce; true on a new keypress
void cardkbQueuePush(uint8_t raw); // add a sampled key to _cardkb_q (drops if full)
#if defined(NRF52_PLATFORM)
// nRF52 (FreeRTOS): a small higher-priority task samples the CardKB every
// 10 ms while the loop is busy drawing / sending / refreshing the e-ink
// frame -- the whole window, not just the panel's BUSY wait (on an 800x480
// panel the byte-by-byte SPI transfers alone block for the better part of a
// second). _cardkb_bg_active marks that window; the mutex guarantees the
// task and the loop never use the I2C bus at the same time. Leaves the
// display's busy-wait hook (setBusyPumpFn) to the radio pump.
void* _cardkb_mutex = nullptr; // SemaphoreHandle_t
volatile bool _cardkb_bg_active = false;
static void cardkbTask(void* self);
#endif
#endif
void pollCardKB();
void handleCardKBByte(uint8_t raw);
void setCardKBBackground(bool on); // no-op without CardKB / the poll task
void startCardKBCapture(); // end of begin(): start the poll task
// Optional magnetic "flip cover" lock: a Hall-effect or reed sensor wired to
// any free GPIO, closing (pulling active) when a magnet is near. Entirely
@@ -509,6 +531,7 @@ public:
// Settings changed through the schema (ui-core/SettingsSchema.h).
void applyDisplayPrefs() override { applyBrightness(); _next_refresh = 0; }
void applySoundPrefs() override;
bool cliCommand(const char* command, char* reply, int n) override;
void setBuzzerVolumeLevel(uint8_t level);
void applyTxPower();
void applyPowerSave(); // hardware duty-cycle RX on/off from prefs
+4 -1
View File
@@ -13,10 +13,12 @@
extends = GAT562_Mesh_Watch13
board_build.ldscript = boards/nrf52840_s140_v6_extrafs.ld
board_upload.maximum_size = 712704
; Optimise for size, not speed, as the 30S and the L1 do: the nRF52 Arduino
; core defaults to -Ofast, which bloats flash by ~200 KB on this solo build.
; The offline queue (messages held for the phone app while it's away) at 128,
; not the board's 256: on the nRF52 the 256 x 177 B left ~5 KB of heap next to
; the on-device UI (which keeps its own history), too little for a sign buffer.
build_unflags = -D OFFLINE_QUEUE_SIZE=256
build_unflags = -Ofast -D OFFLINE_QUEUE_SIZE=256
build_flags =
${GAT562_Mesh_Watch13.build_flags}
-I examples/companion_radio/ui-new
@@ -33,6 +35,7 @@ build_flags =
; GPS — re-enable (base undefines it); Serial1 pins 15/16 from variant
-D ENV_INCLUDE_GPS=1
-D GPS_BAUD_RATE=9600
-Os
; Buzzer available on IO3 (pin 21) — uncomment to enable
; -D PIN_BUZZER=21
; -D PIN_VIBRATION=36
+13 -4
View File
@@ -23,8 +23,12 @@ import os
# Mesh protocol constants
FRAME_START_OUT = 0x3C # '<' - used when sending TO device
FRAME_START_IN = 0x3E # '>' - used when receiving FROM device
CMD_GET_SCREENSHOT = 66
RESP_CODE_SCREENSHOT = 29
CMD_GET_SCREENSHOT = 0x7E
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
# display_type values (byte [5] in response frame)
@@ -129,7 +133,7 @@ def _expected_buffer_size(width, height, display_type):
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."""
start_time = time.time()
buffer_data = bytearray()
@@ -154,7 +158,7 @@ def receive_screenshot(ser, timeout=5):
print(f"Error: Device returned error code 0x{err:02x}")
return None
if resp_code != RESP_CODE_SCREENSHOT:
if resp_code != code: # 29 is now the CLI reply: only the code asked for
continue
if len(frame) < HEADER_SIZE:
@@ -365,6 +369,11 @@ def main():
time.sleep(0.1)
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:
buffer_data, width, height, display_type, disp_rotation = result
type_str = {DISPLAY_TYPE_EINK: "e-ink", DISPLAY_TYPE_RGB565: "colour"}.get(display_type, "OLED")