mirror of
https://github.com/MarekZegare4/MeshCore-Solo.git
synced 2026-11-03 03:11:57 +00:00
Merge dev into feature/eink-ui-scale
This commit is contained in:
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user