diff --git a/.devcontainer/devcontainer.json b/.devcontainer/devcontainer.json index 3a1c2f0548..ecabfa383d 100644 --- a/.devcontainer/devcontainer.json +++ b/.devcontainer/devcontainer.json @@ -13,8 +13,8 @@ "--privileged", "--network=host", "--device=/dev/bus/usb", - // arch linux tty* is owned by uucp (986) - "--group-add=986", + // arch linux tty* is owned by uucp (984) + "--group-add=984", // debian tty* is owned by dialout (20) "--group-add=20" ], diff --git a/.gitignore b/.gitignore index a0ad5f6ea9..5e9cfde243 100644 --- a/.gitignore +++ b/.gitignore @@ -17,3 +17,4 @@ compile_commands.json .venv/ venv/ platformio.local.ini +.devcontainer/devcontainer-lock.json \ No newline at end of file diff --git a/.vscode/extensions.json b/.vscode/extensions.json index 8057bc70a7..5c5735c63b 100644 --- a/.vscode/extensions.json +++ b/.vscode/extensions.json @@ -1,4 +1,6 @@ { + // See http://go.microsoft.com/fwlink/?LinkId=827846 + // for the documentation about the extensions.json format "recommendations": [ "pioarduino.pioarduino-ide", "platformio.platformio-ide" diff --git a/examples/companion_radio/AbstractUITask.h b/examples/companion_radio/AbstractUITask.h index 0eee45aef3..71cd0fd392 100644 --- a/examples/companion_radio/AbstractUITask.h +++ b/examples/companion_radio/AbstractUITask.h @@ -36,9 +36,13 @@ class AbstractUITask { void setHasConnection(bool connected) { _connected = connected; } bool hasConnection() const { return _connected; } uint16_t getBattMilliVolts() const { return _board->getBattMilliVolts(); } + bool isExternalPowered() const { return _board->isExternalPowered(); } bool isSerialEnabled() const { return _serial->isEnabled(); } void enableSerial() { _serial->enable(); } void disableSerial() { _serial->disable(); } + bool isPairingAllowed() const { return _serial->isPairingAllowed(); } + void setPairingAllowed(bool a) { _serial->setPairingAllowed(a); } + virtual void onClientActivity(const char* text) { } virtual void msgRead(int msgcount) = 0; virtual void newMsg(uint8_t path_len, const char* from_name, const char* text, int msgcount) = 0; virtual void notify(UIEventType t = UIEventType::none) = 0; diff --git a/examples/companion_radio/DataStore.cpp b/examples/companion_radio/DataStore.cpp index bf2f36c3d9..75912f9ee0 100644 --- a/examples/companion_radio/DataStore.cpp +++ b/examples/companion_radio/DataStore.cpp @@ -233,6 +233,7 @@ void DataStore::loadPrefsInt(const char *filename, NodePrefs& _prefs, double& no file.read((uint8_t *)&_prefs.rx_boosted_gain, sizeof(_prefs.rx_boosted_gain)); // 89 file.read((uint8_t *)_prefs.default_scope_name, sizeof(_prefs.default_scope_name)); // 90 file.read((uint8_t *)_prefs.default_scope_key, sizeof(_prefs.default_scope_key)); // 121 + file.read((uint8_t *)&_prefs.pairing_locked, sizeof(_prefs.pairing_locked)); // 137 file.close(); } @@ -273,6 +274,7 @@ void DataStore::savePrefs(const NodePrefs& _prefs, double node_lat, double node_ file.write((uint8_t *)&_prefs.rx_boosted_gain, sizeof(_prefs.rx_boosted_gain)); // 89 file.write((uint8_t *)_prefs.default_scope_name, sizeof(_prefs.default_scope_name)); // 90 file.write((uint8_t *)_prefs.default_scope_key, sizeof(_prefs.default_scope_key)); // 121 + file.write((uint8_t *)&_prefs.pairing_locked, sizeof(_prefs.pairing_locked)); // 137 file.close(); } diff --git a/examples/companion_radio/MyMesh.cpp b/examples/companion_radio/MyMesh.cpp index 5fb9bf9d37..d5ffca5346 100644 --- a/examples/companion_radio/MyMesh.cpp +++ b/examples/companion_radio/MyMesh.cpp @@ -365,6 +365,14 @@ void MyMesh::onDiscoveredContact(ContactInfo &contact, bool is_new, uint8_t path #endif } +#ifdef DISPLAY_CLASS + if (_ui && is_new) { + char act[40]; + snprintf(act, sizeof(act), "New: %s", contact.name); + _ui->onClientActivity(act); + } +#endif + // add inbound-path to mem cache if (path && mesh::Packet::isValidPathLen(path_len)) { // check path is valid AdvertPath* p = advert_paths; @@ -940,19 +948,21 @@ void MyMesh::begin(bool has_display) { _prefs.gps_interval = constrain(_prefs.gps_interval, 0, 86400); // Max 24 hours #ifdef BLE_PIN_CODE // 123456 by default - if (_prefs.ble_pin == 0) { + if (_prefs.ble_pin != 0) { + _active_ble_pin = _prefs.ble_pin; // pin configured via app + } else if (BLE_PIN_CODE != 123456) { + _active_ble_pin = BLE_PIN_CODE; // pin configured at build time + } else { #ifdef DISPLAY_CLASS - if (has_display && BLE_PIN_CODE == 123456) { + if (has_display) { StdRNG rng; - _active_ble_pin = rng.nextInt(100000, 999999); // random pin each session + _active_ble_pin = rng.nextInt(100000, 999999); // generated each session, shown on screen } else { - _active_ble_pin = BLE_PIN_CODE; // otherwise static pin + _active_ble_pin = BLE_PIN_CODE; } #else - _active_ble_pin = BLE_PIN_CODE; // otherwise static pin + _active_ble_pin = BLE_PIN_CODE; #endif - } else { - _active_ble_pin = _prefs.ble_pin; } #else _active_ble_pin = 0; @@ -2230,19 +2240,22 @@ void MyMesh::loop() { #endif } -bool MyMesh::advert() { +bool MyMesh::advert(bool flood) { mesh::Packet* pkt; if (_prefs.advert_loc_policy == ADVERT_LOC_NONE) { pkt = createSelfAdvert(_prefs.node_name); } else { pkt = createSelfAdvert(_prefs.node_name, sensors.node_lat, sensors.node_lon); } - if (pkt) { - sendZeroHop(pkt); - return true; + if (!pkt) return false; + if (flood) { + TransportKey default_scope; + memcpy(&default_scope.key, _prefs.default_scope_key, sizeof(default_scope.key)); + sendFloodScoped(default_scope, pkt, 0); } else { - return false; + sendZeroHop(pkt); } + return true; } // To check if there is pending work diff --git a/examples/companion_radio/MyMesh.h b/examples/companion_radio/MyMesh.h index f4190f30ac..8d4dbc7ec4 100644 --- a/examples/companion_radio/MyMesh.h +++ b/examples/companion_radio/MyMesh.h @@ -97,7 +97,7 @@ class MyMesh : public BaseChatMesh, public DataStoreHost { void loop(); void handleCmdFrame(size_t len); - bool advert(); + bool advert(bool flood = false); void enterCLIRescue(); int getRecentlyHeard(AdvertPath dest[], int max_num); diff --git a/examples/companion_radio/NodePrefs.h b/examples/companion_radio/NodePrefs.h index 48c381ceaf..f6966f2bda 100644 --- a/examples/companion_radio/NodePrefs.h +++ b/examples/companion_radio/NodePrefs.h @@ -34,4 +34,5 @@ struct NodePrefs { // persisted to file uint8_t autoadd_max_hops; // 0 = no limit, 1 = direct (0 hops), N = up to N-1 hops (max 64) char default_scope_name[31]; uint8_t default_scope_key[16]; + uint8_t pairing_locked; // 0 = BLE pairing allowed; 1 = BLE pairing locked (no new pairs allowed) }; \ No newline at end of file diff --git a/examples/companion_radio/main.cpp b/examples/companion_radio/main.cpp index ef9b6bfca4..092be1a988 100644 --- a/examples/companion_radio/main.cpp +++ b/examples/companion_radio/main.cpp @@ -162,6 +162,7 @@ void setup() { serial_interface.begin(Serial); #endif the_mesh.startInterface(serial_interface); + serial_interface.setPairingAllowed(!the_mesh.getNodePrefs()->pairing_locked); #elif defined(RP2040_PLATFORM) LittleFS.begin(); store.begin(); diff --git a/examples/companion_radio/ui-new/UITask.cpp b/examples/companion_radio/ui-new/UITask.cpp index 7c84201941..66124f9387 100644 --- a/examples/companion_radio/ui-new/UITask.cpp +++ b/examples/companion_radio/ui-new/UITask.cpp @@ -31,14 +31,155 @@ #include "icons.h" +#ifndef BATT_MIN_MILLIVOLTS + #define BATT_MIN_MILLIVOLTS 3000 +#endif +#ifndef BATT_MAX_MILLIVOLTS + #define BATT_MAX_MILLIVOLTS 4200 +#endif + +#define UI_TXT_OFS 10 +#define UI_MARGIN 5 +#define UI_HUD_TEXT 14 +#define UI_HUD_SEP 20 +#define UI_TITLE_Y 36 +#define UI_TITLE_RULE 40 +#define UI_BODY_Y0 56 +#define UI_ROW_H 16 +#define UI_ICON_Y 46 +#define UI_ICON_LABEL 100 +#define UI_FOOTER_SEP 112 +#define UI_FOOTER_DOT 118 +#define UI_FOOTER_Y 124 + +static int uiOfs(DisplayDriver& d) { return d.isEink() ? UI_TXT_OFS : 0; } + +static void uiText(DisplayDriver& d, int x, int gy, const char* s) { + d.setCursor(x, gy - uiOfs(d)); + d.print(s); +} +static void uiTextCentered(DisplayDriver& d, int mx, int gy, const char* s) { + d.drawTextCentered(mx, gy - uiOfs(d), s); +} +static void uiTextRight(DisplayDriver& d, int ax, int gy, const char* s) { + d.drawTextRightAlign(ax, gy - uiOfs(d), s); +} +static void uiTextEllip(DisplayDriver& d, int x, int gy, int maxw, const char* s) { + d.drawTextEllipsized(x, gy - uiOfs(d), maxw, s); +} + +static void uiAbbrevName(char* dst, const char* src) { + int n = strlen(src); + if (n <= 13) { + strcpy(dst, src); + } else { + memcpy(dst, src, 5); + dst[5] = '.'; dst[6] = '.'; dst[7] = '.'; + memcpy(dst + 8, src + n - 5, 5); + dst[13] = 0; + } +} + +static int uiBatteryPercent(uint16_t mv) { + // Convert millivolts to percentage + int p = ((int)mv - BATT_MIN_MILLIVOLTS) * 100 / (BATT_MAX_MILLIVOLTS - BATT_MIN_MILLIVOLTS); + if (p < 0) p = 0; + if (p > 100) p = 100; + return p; +} + +static void uiFillRoundRect(DisplayDriver& d, int x, int y, int w, int h, DisplayDriver::Color c) { + d.setColor(c); + d.fillRect(x, y, w, h); + d.setColor(DisplayDriver::DARK); + d.fillRect(x, y, 1, 1); + d.fillRect(x + w - 1, y, 1, 1); + d.fillRect(x, y + h - 1, 1, 1); + d.fillRect(x + w - 1, y + h - 1, 1, 1); + d.setColor(c); +} + +static void uiSectionTitle(DisplayDriver& d, const char* t) { + d.setTextSize(1); + d.setColor(DisplayDriver::LIGHT); + uiTextCentered(d, d.width() / 2, UI_TITLE_Y, t); + int w = d.getTextWidth(t); + d.fillRect((d.width() - w) / 2, UI_TITLE_RULE, w, 1); +} + +static void uiRow(DisplayDriver& d, int y, const char* label, const char* value) { + d.setTextSize(1); + d.setColor(DisplayDriver::LIGHT); + uiText(d, UI_MARGIN, y, label); + uiTextRight(d, d.width() - UI_MARGIN, y, value); +} + +static void uiBattery(DisplayDriver& d, uint16_t mv, bool charging) { + int pct = uiBatteryPercent(mv); + int bw = 22, bh = 11; + int bx = d.width() - bw - 3, by = 3; + d.setColor(DisplayDriver::LIGHT); + d.drawRect(bx, by, bw, bh); + d.fillRect(bx + bw, by + 3, 2, bh - 6); + int fw = (pct * (bw - 4)) / 100; + d.fillRect(bx + 2, by + 2, fw, bh - 4); + if (charging) { + d.drawXbm(bx - 11, by + 1, charging_icon, 8, 8); + } +} + +static void uiWrapText(DisplayDriver& d, int x, int gy, int maxw, int lineH, int maxLines, const char* s) { + int ofs = uiOfs(d); + char line[96]; + line[0] = 0; + int ll = 0, lines = 0; + char word[64]; + const char* p = s; + while (*p && lines < maxLines) { + while (*p == ' ') p++; + int wl = 0; + while (*p && *p != ' ' && wl < (int)sizeof(word) - 1) word[wl++] = *p++; + word[wl] = 0; + if (wl == 0) break; + char trial[128]; + if (ll == 0) { + strcpy(trial, word); + } else { + snprintf(trial, sizeof(trial), "%s %s", line, word); + } + if (d.getTextWidth(trial) <= maxw) { + strcpy(line, trial); + ll = strlen(line); + } else if (ll > 0) { + d.setCursor(x, gy - ofs); + d.print(line); + gy += lineH; + lines++; + strcpy(line, word); + ll = wl; + } else { + d.setCursor(x, gy - ofs); + d.print(word); + gy += lineH; + lines++; + line[0] = 0; + ll = 0; + } + } + if (ll > 0 && lines < maxLines) { + d.setCursor(x, gy - ofs); + d.print(line); + } +} + class SplashScreen : public UIScreen { UITask* _task; + unsigned long _start; unsigned long dismiss_after; char _version_info[12]; public: SplashScreen(UITask* task) : _task(task) { - // strip off dash and commit hash by changing dash to null terminator // e.g: v1.2.3-abcdef -> v1.2.3 const char *ver = FIRMWARE_VERSION; const char *dash = strchr(ver, '-'); @@ -48,32 +189,34 @@ class SplashScreen : public UIScreen { memcpy(_version_info, ver, len); _version_info[len] = 0; - dismiss_after = millis() + BOOT_SCREEN_MILLIS; + _start = millis(); + dismiss_after = _start + BOOT_SCREEN_MILLIS; } int render(DisplayDriver& display) override { + int W = display.width(); + // meshcore logo display.setColor(DisplayDriver::BLUE); - int logoWidth = 128; - display.drawXbm((display.width() - logoWidth) / 2, 3, meshcore_logo, logoWidth, 13); - - // meshcore website - const char* website = "https://meshcore.io"; - display.setColor(DisplayDriver::LIGHT); - display.setTextSize(1); - uint16_t websiteWidth = display.getTextWidth(website); - display.setCursor((display.width() - websiteWidth) / 2, 22); - display.print(website); + display.drawXbm((W - 128) / 2, 30, meshcore_logo, 128, 13); - // version info + int aw = 50; display.setColor(DisplayDriver::LIGHT); - display.setTextSize(1); - display.drawTextCentered(display.width()/2, 35, _version_info); + display.fillRect((W - aw) / 2, 58, aw, 1); display.setTextSize(1); - display.drawTextCentered(display.width()/2, 48, FIRMWARE_BUILD_DATE); - - return 1000; + uiTextCentered(display, W / 2, 74, "COMPANION"); + uiTextCentered(display, W / 2, 90, _version_info); + uiTextCentered(display, W / 2, 102, FIRMWARE_BUILD_DATE); + + int bx = 28, bw = W - 56; + display.drawRect(bx, 110, bw, 7); + float pf = (float)(millis() - _start) / (float)BOOT_SCREEN_MILLIS; + if (pf < 0) pf = 0; + if (pf > 1) pf = 1; + display.fillRect(bx + 2, 112, (int)((bw - 4) * pf), 3); + + return 400; } void poll() override { @@ -96,6 +239,7 @@ class HomeScreen : public UIScreen { #if UI_SENSORS_PAGE == 1 SENSORS, #endif + ACTIVITY, SHUTDOWN, Count // keep as last }; @@ -108,45 +252,35 @@ class HomeScreen : public UIScreen { bool _shutdown_init; AdvertPath recent[UI_RECENT_LIST_SIZE]; + void drawHud(DisplayDriver& d) { + char nm[sizeof(_node_prefs->node_name)]; + char shortnm[16]; + d.translateUTF8ToBlocks(nm, _node_prefs->node_name, sizeof(nm)); + uiAbbrevName(shortnm, nm); + d.setTextSize(1); + d.setColor(DisplayDriver::LIGHT); + uiText(d, UI_MARGIN, UI_HUD_TEXT, shortnm); + uiBattery(d, _task->getBattMilliVolts(), _task->isExternalPowered()); + d.setColor(DisplayDriver::LIGHT); + d.fillRect(0, UI_HUD_SEP, d.width(), 1); + } - void renderBatteryIndicator(DisplayDriver& display, uint16_t batteryMilliVolts) { - // Convert millivolts to percentage -#ifndef BATT_MIN_MILLIVOLTS - #define BATT_MIN_MILLIVOLTS 3000 -#endif -#ifndef BATT_MAX_MILLIVOLTS - #define BATT_MAX_MILLIVOLTS 4200 -#endif - const int minMilliVolts = BATT_MIN_MILLIVOLTS; - const int maxMilliVolts = BATT_MAX_MILLIVOLTS; - int batteryPercentage = ((batteryMilliVolts - minMilliVolts) * 100) / (maxMilliVolts - minMilliVolts); - if (batteryPercentage < 0) batteryPercentage = 0; // Clamp to 0% - if (batteryPercentage > 100) batteryPercentage = 100; // Clamp to 100% - - // battery icon - int iconWidth = 24; - int iconHeight = 10; - int iconX = display.width() - iconWidth - 5; // Position the icon near the top-right corner - int iconY = 0; - display.setColor(DisplayDriver::GREEN); - - // battery outline - display.drawRect(iconX, iconY, iconWidth, iconHeight); - - // battery "cap" - display.fillRect(iconX + iconWidth, iconY + (iconHeight / 4), 3, iconHeight / 2); - - // fill the battery based on the percentage - int fillWidth = (batteryPercentage * (iconWidth - 4)) / 100; - display.fillRect(iconX + 2, iconY + 2, fillWidth, iconHeight - 4); - - // show muted icon if buzzer is muted -#ifdef PIN_BUZZER - if (_task->isBuzzerQuiet()) { - display.setColor(DisplayDriver::RED); - display.drawXbm(iconX - 9, iconY + 1, muted_icon, 8, 8); + void drawFooter(DisplayDriver& d, const char* hint) { + d.setColor(DisplayDriver::LIGHT); + d.fillRect(0, UI_FOOTER_SEP, d.width(), 1); + int n = HomePage::Count; + int spacing = 7, x = UI_MARGIN; + for (int i = 0; i < n; i++, x += spacing) { + if (i == _page) { + d.fillRect(x - 1, UI_FOOTER_DOT - 1, 4, 4); + } else { + d.fillRect(x, UI_FOOTER_DOT, 2, 2); + } + } + if (hint && hint[0]) { + d.setTextSize(1); + uiTextRight(d, d.width() - UI_MARGIN, UI_FOOTER_Y, hint); } -#endif } CayenneLPP sensors_lpp; @@ -167,7 +301,7 @@ class HomeScreen : public UIScreen { reader.skipData(type); sensors_nb ++; } - sensors_scroll = sensors_nb > UI_RECENT_LIST_SIZE; + sensors_scroll = sensors_nb > 4; #if AUTO_OFF_MILLIS > 0 next_sensors_refresh = millis() + 5000; // refresh sensor values every 5 sec #else @@ -188,151 +322,141 @@ class HomeScreen : public UIScreen { } int render(DisplayDriver& display) override { - char tmp[80]; - // node name - display.setTextSize(1); - display.setColor(DisplayDriver::GREEN); - char filtered_name[sizeof(_node_prefs->node_name)]; - display.translateUTF8ToBlocks(filtered_name, _node_prefs->node_name, sizeof(filtered_name)); - display.setCursor(0, 0); - display.print(filtered_name); - - // battery voltage - renderBatteryIndicator(display, _task->getBattMilliVolts()); - - // curr page indicator - int y = 14; - int x = display.width() / 2 - 5 * (HomePage::Count-1); - for (uint8_t i = 0; i < HomePage::Count; i++, x += 10) { - if (i == _page) { - display.fillRect(x-1, y-1, 3, 3); - } else { - display.fillRect(x, y, 1, 1); - } + int W = display.width(); + + if (_page == HomePage::SHUTDOWN && _shutdown_init) { + display.setColor(DisplayDriver::LIGHT); + display.setTextSize(2); + uiTextCentered(display, W / 2, 70, "Hibernating"); + return 60000; } + char v[48]; + drawHud(display); + const char* hint = ""; + if (_page == HomePage::FIRST) { - display.setColor(DisplayDriver::YELLOW); - display.setTextSize(2); - sprintf(tmp, "MSG: %d", _task->getMsgCount()); - display.drawTextCentered(display.width() / 2, 20, tmp); - - #ifdef WIFI_SSID - IPAddress ip = WiFi.localIP(); - snprintf(tmp, sizeof(tmp), "IP: %d.%d.%d.%d", ip[0], ip[1], ip[2], ip[3]); - display.setTextSize(1); - display.drawTextCentered(display.width() / 2, 54, tmp); - #endif - if (_task->hasConnection()) { - display.setColor(DisplayDriver::GREEN); - display.setTextSize(1); - display.drawTextCentered(display.width() / 2, 43, "< Connected >"); - - } else if (the_mesh.getBLEPin() != 0) { // BT pin - display.setColor(DisplayDriver::RED); - display.setTextSize(2); - sprintf(tmp, "Pin:%d", the_mesh.getBLEPin()); - display.drawTextCentered(display.width() / 2, 43, tmp); + uiSectionTitle(display, "STATUS"); + sprintf(v, "%d", _task->getMsgCount()); + uiRow(display, UI_BODY_Y0, "Messages", v); + const char* bt; + if (_task->isSerialEnabled() && _task->isPairingAllowed() && !_task->hasConnection() && the_mesh.getBLEPin() != 0) { + sprintf(v, "PIN %d", the_mesh.getBLEPin()); + bt = v; + } else if (_task->hasConnection()) { + bt = "linked"; + } else if (!_task->isSerialEnabled()) { + bt = "off"; + } else { + bt = "ready"; + } + uiRow(display, UI_BODY_Y0 + UI_ROW_H, "Bluetooth", bt); + char b2[16]; + int pct = uiBatteryPercent(_task->getBattMilliVolts()); + if (_task->isExternalPowered()) { + sprintf(b2, "%d%% charging", pct); + } else { + sprintf(b2, "%d%%", pct); } + uiRow(display, UI_BODY_Y0 + UI_ROW_H * 2, "Battery", b2); +#ifdef WIFI_SSID + IPAddress ip = WiFi.localIP(); + snprintf(v, sizeof(v), "%d.%d.%d.%d", ip[0], ip[1], ip[2], ip[3]); + uiRow(display, UI_BODY_Y0 + UI_ROW_H * 3, "IP", v); +#endif } else if (_page == HomePage::RECENT) { + uiSectionTitle(display, "RECENTLY HEARD"); the_mesh.getRecentlyHeard(recent, UI_RECENT_LIST_SIZE); - display.setColor(DisplayDriver::GREEN); - int y = 20; - for (int i = 0; i < UI_RECENT_LIST_SIZE; i++, y += 11) { + display.setColor(DisplayDriver::LIGHT); + int y = UI_BODY_Y0, shown = 0; + for (int i = 0; i < UI_RECENT_LIST_SIZE && shown < 4; i++) { auto a = &recent[i]; if (a->name[0] == 0) continue; // empty slot int secs = _rtc->getCurrentTime() - a->recv_timestamp; if (secs < 60) { - sprintf(tmp, "%ds", secs); + sprintf(v, "%ds", secs); } else if (secs < 60*60) { - sprintf(tmp, "%dm", secs / 60); + sprintf(v, "%dm", secs / 60); } else { - sprintf(tmp, "%dh", secs / (60*60)); + sprintf(v, "%dh", secs / (60*60)); } - - int timestamp_width = display.getTextWidth(tmp); - int max_name_width = display.width() - timestamp_width - 1; - - char filtered_recent_name[sizeof(a->name)]; - display.translateUTF8ToBlocks(filtered_recent_name, a->name, sizeof(filtered_recent_name)); - display.drawTextEllipsized(0, y, max_name_width, filtered_recent_name); - display.setCursor(display.width() - timestamp_width - 1, y); - display.print(tmp); + int tw = display.getTextWidth(v); + char nm[sizeof(a->name)]; + display.translateUTF8ToBlocks(nm, a->name, sizeof(nm)); + uiTextEllip(display, UI_MARGIN, y, W - tw - UI_MARGIN - 6, nm); + uiTextRight(display, W - UI_MARGIN, y, v); + y += UI_ROW_H; + shown++; + } + if (shown == 0) { + uiTextCentered(display, W / 2, UI_BODY_Y0 + UI_ROW_H, "no nodes yet"); } } else if (_page == HomePage::RADIO) { - display.setColor(DisplayDriver::YELLOW); - display.setTextSize(1); - // freq / sf - display.setCursor(0, 20); - sprintf(tmp, "FQ: %06.3f SF: %d", _node_prefs->freq, _node_prefs->sf); - display.print(tmp); - - display.setCursor(0, 31); - sprintf(tmp, "BW: %03.2f CR: %d", _node_prefs->bw, _node_prefs->cr); - display.print(tmp); - - // tx power, noise floor - display.setCursor(0, 42); - sprintf(tmp, "TX: %ddBm", _node_prefs->tx_power_dbm); - display.print(tmp); - display.setCursor(0, 53); - sprintf(tmp, "Noise floor: %d", radio_driver.getNoiseFloor()); - display.print(tmp); + uiSectionTitle(display, "RADIO"); + sprintf(v, "%.3f", _node_prefs->freq); + uiRow(display, UI_BODY_Y0, "Freq MHz", v); + sprintf(v, "%d / %.0f", _node_prefs->sf, _node_prefs->bw); + uiRow(display, UI_BODY_Y0 + UI_ROW_H, "SF / BW", v); + sprintf(v, "%d / %ddBm", _node_prefs->cr, _node_prefs->tx_power_dbm); + uiRow(display, UI_BODY_Y0 + UI_ROW_H * 2, "CR / TX", v); + sprintf(v, "%d", radio_driver.getNoiseFloor()); + uiRow(display, UI_BODY_Y0 + UI_ROW_H * 3, "Noise", v); } else if (_page == HomePage::BLUETOOTH) { - display.setColor(DisplayDriver::GREEN); - display.drawXbm((display.width() - 32) / 2, 18, - _task->isSerialEnabled() ? bluetooth_on : bluetooth_off, - 32, 32); + uiSectionTitle(display, "BLUETOOTH"); + display.setColor(DisplayDriver::LIGHT); + display.drawXbm((W - 32) / 2, UI_ICON_Y, + _task->isSerialEnabled() ? bluetooth_on : bluetooth_off, 32, 32); display.setTextSize(1); - display.drawTextCentered(display.width() / 2, 64 - 11, "toggle: " PRESS_LABEL); + if (_task->isSerialEnabled() && _task->isPairingAllowed() && !_task->hasConnection() && the_mesh.getBLEPin() != 0) { + char pinbuf[20]; + snprintf(pinbuf, sizeof(pinbuf), "PIN %u", (unsigned)the_mesh.getBLEPin()); + uiTextCentered(display, W / 2, UI_ICON_LABEL, pinbuf); + } else { + uiTextCentered(display, W / 2, UI_ICON_LABEL, + _task->isSerialEnabled() + ? (_task->hasConnection() ? "linked" + : (_task->isPairingAllowed() ? "on - pairing open" : "on - pairing locked")) + : "off"); + } + hint = "hold = options"; } else if (_page == HomePage::ADVERT) { - display.setColor(DisplayDriver::GREEN); - display.drawXbm((display.width() - 32) / 2, 18, advert_icon, 32, 32); - display.drawTextCentered(display.width() / 2, 64 - 11, "advert: " PRESS_LABEL); + uiSectionTitle(display, "ADVERT"); + display.setColor(DisplayDriver::LIGHT); + display.drawXbm((W - 32) / 2, UI_ICON_Y, advert_icon, 32, 32); + display.setTextSize(1); + uiTextCentered(display, W / 2, UI_ICON_LABEL, "Broadcast presence"); + hint = "hold = options"; #if ENV_INCLUDE_GPS == 1 } else if (_page == HomePage::GPS) { + uiSectionTitle(display, "GPS"); LocationProvider* nmea = sensors.getLocationProvider(); - char buf[50]; - int y = 18; bool gps_state = _task->getGPSState(); #ifdef PIN_GPS_SWITCH bool hw_gps_state = digitalRead(PIN_GPS_SWITCH); if (gps_state != hw_gps_state) { - strcpy(buf, gps_state ? "gps off(hw)" : "gps off(sw)"); + uiRow(display, UI_BODY_Y0, "Power", gps_state ? "on (hw off)" : "off (hw)"); } else { - strcpy(buf, gps_state ? "gps on" : "gps off"); + uiRow(display, UI_BODY_Y0, "Power", gps_state ? "on" : "off"); } #else - strcpy(buf, gps_state ? "gps on" : "gps off"); + uiRow(display, UI_BODY_Y0, "Power", gps_state ? "on" : "off"); #endif - display.drawTextLeftAlign(0, y, buf); if (nmea == NULL) { - y = y + 12; - display.drawTextLeftAlign(0, y, "Can't access GPS"); + uiTextCentered(display, W / 2, UI_BODY_Y0 + UI_ROW_H * 2, "GPS unavailable"); } else { - strcpy(buf, nmea->isValid()?"fix":"no fix"); - display.drawTextRightAlign(display.width()-1, y, buf); - y = y + 12; - display.drawTextLeftAlign(0, y, "sat"); - sprintf(buf, "%d", nmea->satellitesCount()); - display.drawTextRightAlign(display.width()-1, y, buf); - y = y + 12; - display.drawTextLeftAlign(0, y, "pos"); - sprintf(buf, "%.4f %.4f", - nmea->getLatitude()/1000000., nmea->getLongitude()/1000000.); - display.drawTextRightAlign(display.width()-1, y, buf); - y = y + 12; - display.drawTextLeftAlign(0, y, "alt"); - sprintf(buf, "%.2f", nmea->getAltitude()/1000.); - display.drawTextRightAlign(display.width()-1, y, buf); - y = y + 12; + sprintf(v, "%s / %d", nmea->isValid() ? "yes" : "no", nmea->satellitesCount()); + uiRow(display, UI_BODY_Y0 + UI_ROW_H, "Fix / Sat", v); + sprintf(v, "%.4f", sensors.node_lat); + uiRow(display, UI_BODY_Y0 + UI_ROW_H * 2, "Lat", v); + sprintf(v, "%.4f", sensors.node_lon); + uiRow(display, UI_BODY_Y0 + UI_ROW_H * 3, "Lon", v); } + hint = "hold = options"; #endif #if UI_SENSORS_PAGE == 1 } else if (_page == HomePage::SENSORS) { - int y = 18; + uiSectionTitle(display, "SENSORS"); refresh_sensors(); - char buf[30]; char name[30]; LPPReader r(sensors_lpp.getBuffer(), sensors_lpp.getSize()); @@ -342,75 +466,100 @@ class HomeScreen : public UIScreen { r.skipData(type); } - for (int i = 0; i < (sensors_scroll?UI_RECENT_LIST_SIZE:sensors_nb); i++) { + if (sensors_nb == 0) { + uiTextCentered(display, W / 2, UI_BODY_Y0 + UI_ROW_H, "no sensors"); + } + + int y = UI_BODY_Y0; + int rows = sensors_scroll ? 4 : sensors_nb; + for (int i = 0; i < rows; i++) { uint8_t channel, type; if (!r.readHeader(channel, type)) { // reached end, reset r.reset(); r.readHeader(channel, type); } - - display.setCursor(0, y); - float v; + float val; switch (type) { case LPP_GPS: // GPS float lat, lon, alt; r.readGPS(lat, lon, alt); - strcpy(name, "gps"); sprintf(buf, "%.4f %.4f", lat, lon); + strcpy(name, "gps"); sprintf(v, "%.4f %.4f", lat, lon); break; case LPP_VOLTAGE: - r.readVoltage(v); - strcpy(name, "voltage"); sprintf(buf, "%6.2f", v); + r.readVoltage(val); + strcpy(name, "voltage"); sprintf(v, "%.2f", val); break; case LPP_CURRENT: - r.readCurrent(v); - strcpy(name, "current"); sprintf(buf, "%.3f", v); + r.readCurrent(val); + strcpy(name, "current"); sprintf(v, "%.3f", val); break; case LPP_TEMPERATURE: - r.readTemperature(v); - strcpy(name, "temperature"); sprintf(buf, "%.2f", v); + r.readTemperature(val); + strcpy(name, "temperature"); sprintf(v, "%.2f", val); break; case LPP_RELATIVE_HUMIDITY: - r.readRelativeHumidity(v); - strcpy(name, "humidity"); sprintf(buf, "%.2f", v); + r.readRelativeHumidity(val); + strcpy(name, "humidity"); sprintf(v, "%.2f", val); break; case LPP_BAROMETRIC_PRESSURE: - r.readPressure(v); - strcpy(name, "pressure"); sprintf(buf, "%.2f", v); + r.readPressure(val); + strcpy(name, "pressure"); sprintf(v, "%.2f", val); break; case LPP_ALTITUDE: - r.readAltitude(v); - strcpy(name, "altitude"); sprintf(buf, "%.0f", v); + r.readAltitude(val); + strcpy(name, "altitude"); sprintf(v, "%.0f", val); break; case LPP_POWER: - r.readPower(v); - strcpy(name, "power"); sprintf(buf, "%6.2f", v); + r.readPower(val); + strcpy(name, "power"); sprintf(v, "%.2f", val); break; default: r.skipData(type); - strcpy(name, "unk"); sprintf(buf, ""); + strcpy(name, "unk"); v[0] = 0; } - display.setCursor(0, y); - display.print(name); - display.setCursor( - display.width()-display.getTextWidth(buf)-1, y - ); - display.print(buf); - y = y + 12; + uiRow(display, y, name, v); + y += UI_ROW_H; } - if (sensors_scroll) sensors_scroll_offset = (sensors_scroll_offset+1)%sensors_nb; + if (sensors_scroll) sensors_scroll_offset = (sensors_scroll_offset + 1) % sensors_nb; else sensors_scroll_offset = 0; #endif - } else if (_page == HomePage::SHUTDOWN) { - display.setColor(DisplayDriver::GREEN); - display.setTextSize(1); - if (_shutdown_init) { - display.drawTextCentered(display.width() / 2, 34, "hibernating..."); + } else if (_page == HomePage::ACTIVITY) { + uiSectionTitle(display, "ACTIVITY"); + int count = _task->getActivityCount(); + if (count == 0) { + uiTextCentered(display, W / 2, UI_BODY_Y0 + UI_ROW_H, "nothing yet"); } else { - display.drawXbm((display.width() - 32) / 2, 18, power_icon, 32, 32); - display.drawTextCentered(display.width() / 2, 64 - 11, "hibernate:" PRESS_LABEL); + int y = UI_BODY_Y0; + int shown = count < 4 ? count : 4; + for (int i = 0; i < shown; i++) { + int secs = _rtc->getCurrentTime() - _task->getActivityTime(i); + if (secs < 0) secs = 0; + if (secs < 60) { + sprintf(v, "%ds", secs); + } else if (secs < 60*60) { + sprintf(v, "%dm", secs / 60); + } else { + sprintf(v, "%dh", secs / (60*60)); + } + int tw = display.getTextWidth(v); + char line[40]; + display.translateUTF8ToBlocks(line, _task->getActivityText(i), sizeof(line)); + uiTextEllip(display, UI_MARGIN, y, W - tw - UI_MARGIN - 6, line); + uiTextRight(display, W - UI_MARGIN, y, v); + y += UI_ROW_H; + } } + } else if (_page == HomePage::SHUTDOWN) { + uiSectionTitle(display, "POWER"); + display.setColor(DisplayDriver::LIGHT); + display.drawXbm((W - 32) / 2, UI_ICON_Y, power_icon, 32, 32); + display.setTextSize(1); + uiTextCentered(display, W / 2, UI_ICON_LABEL, "Hibernate device"); + hint = "hold = sleep"; } - return 5000; // next render after 5000 ms + + drawFooter(display, hint); + return 10000; // next render after 10000 ms } bool handleInput(char c) override { @@ -420,38 +569,25 @@ class HomeScreen : public UIScreen { } if (c == KEY_NEXT || c == KEY_RIGHT) { _page = (_page + 1) % HomePage::Count; - if (_page == HomePage::RECENT) { - _task->showAlert("Recent adverts", 800); - } return true; } if (c == KEY_ENTER && _page == HomePage::BLUETOOTH) { - if (_task->isSerialEnabled()) { // toggle Bluetooth on/off - _task->disableSerial(); - } else { - _task->enableSerial(); - } + _task->openMenu(UITask::MENU_BLE); return true; } if (c == KEY_ENTER && _page == HomePage::ADVERT) { - _task->notify(UIEventType::ack); - if (the_mesh.advert()) { - _task->showAlert("Advert sent!", 1000); - } else { - _task->showAlert("Advert failed..", 1000); - } + _task->openMenu(UITask::MENU_ADVERT); return true; } #if ENV_INCLUDE_GPS == 1 if (c == KEY_ENTER && _page == HomePage::GPS) { - _task->toggleGPS(); + _task->openMenu(UITask::MENU_GPS); return true; } #endif #if UI_SENSORS_PAGE == 1 if (c == KEY_ENTER && _page == HomePage::SENSORS) { - _task->toggleGPS(); - next_sensors_refresh=0; + next_sensors_refresh = 0; return true; } #endif @@ -487,23 +623,25 @@ class MsgPreviewScreen : public UIScreen { auto p = &unread[head]; p->timestamp = _rtc->getCurrentTime(); if (path_len == 0xFF) { - sprintf(p->origin, "(D) %s:", from_name); + sprintf(p->origin, "(D) %s", from_name); } else { - sprintf(p->origin, "(%d) %s:", (uint32_t) path_len, from_name); + sprintf(p->origin, "(%d) %s", (uint32_t) path_len, from_name); } StrHelper::strncpy(p->msg, msg, sizeof(p->msg)); } int render(DisplayDriver& display) override { - char tmp[16]; - display.setCursor(0, 0); - display.setTextSize(1); - display.setColor(DisplayDriver::GREEN); - sprintf(tmp, "Unread: %d", num_unread); - display.print(tmp); - + int W = display.width(); + char tmp[24]; auto p = &unread[head]; + display.setTextSize(1); + display.setColor(DisplayDriver::LIGHT); + uiText(display, UI_MARGIN, UI_HUD_TEXT, "INBOX"); + sprintf(tmp, "%d unread", num_unread); + uiTextRight(display, W - UI_MARGIN, UI_HUD_TEXT, tmp); + display.fillRect(0, UI_HUD_SEP, W, 1); + int secs = _rtc->getCurrentTime() - p->timestamp; if (secs < 60) { sprintf(tmp, "%ds", secs); @@ -512,22 +650,20 @@ class MsgPreviewScreen : public UIScreen { } else { sprintf(tmp, "%dh", secs / (60*60)); } - display.setCursor(display.width() - display.getTextWidth(tmp) - 2, 0); - display.print(tmp); - display.drawRect(0, 11, display.width(), 1); // horiz line - - display.setCursor(0, 14); - display.setColor(DisplayDriver::YELLOW); + int tw = display.getTextWidth(tmp); char filtered_origin[sizeof(p->origin)]; display.translateUTF8ToBlocks(filtered_origin, p->origin, sizeof(filtered_origin)); - display.print(filtered_origin); - - display.setCursor(0, 25); display.setColor(DisplayDriver::LIGHT); + uiTextEllip(display, UI_MARGIN, 38, W - tw - UI_MARGIN - 6, filtered_origin); + uiTextRight(display, W - UI_MARGIN, 38, tmp); + char filtered_msg[sizeof(p->msg)]; display.translateUTF8ToBlocks(filtered_msg, p->msg, sizeof(filtered_msg)); - display.printWordWrap(filtered_msg, display.width()); + uiWrapText(display, UI_MARGIN, 58, W - UI_MARGIN * 2, UI_ROW_H, 4, filtered_msg); + + display.fillRect(0, UI_FOOTER_SEP, W, 1); + uiTextCentered(display, W / 2, UI_FOOTER_Y, "hold = clear all"); #if AUTO_OFF_MILLIS==0 // probably e-ink return 10000; // 10 s @@ -554,6 +690,208 @@ class MsgPreviewScreen : public UIScreen { } }; +class MenuScreen : public UIScreen { + UITask* _task; + UITask::MenuKind _kind; + int _sel; + int _count; + + void buildLabels(const char* out[]) { + if (_kind == UITask::MENU_ADVERT) { + out[0] = "Zero-hop advert"; + out[1] = "Flood advert"; + out[2] = "Back"; + } else if (_kind == UITask::MENU_GPS) { + out[0] = _task->getGPSState() ? "Disable GPS" : "Enable GPS"; + out[1] = "Resync GPS"; + out[2] = "Back"; + } else { + out[0] = _task->isSerialEnabled() ? "Disable BLE" : "Enable BLE"; + out[1] = _task->isPairingAllowed() ? "Lock pairing" : "Unlock pairing"; + out[2] = "Back"; + } + } + + const char* title() { + if (_kind == UITask::MENU_ADVERT) return "ADVERTS"; + if (_kind == UITask::MENU_GPS) return "GPS OPTIONS"; + return "BLE OPTIONS"; + } + +public: + MenuScreen(UITask* task) : _task(task), _kind(UITask::MENU_ADVERT), _sel(0), _count(3) { } + + void configure(UITask::MenuKind kind) { + _kind = kind; + _sel = 0; + _count = 3; + } + + int render(DisplayDriver& d) override { + int W = d.width(); + uiSectionTitle(d, title()); + + const char* labels[4]; + buildLabels(labels); + + int y = UI_BODY_Y0; + for (int i = 0; i < _count; i++) { + if (i == _sel) { + uiFillRoundRect(d, 2, y - 12, W - 4, 16, DisplayDriver::LIGHT); + d.setColor(DisplayDriver::DARK); + } else { + d.setColor(DisplayDriver::LIGHT); + } + d.setTextSize(1); + uiText(d, UI_MARGIN + 2, y, labels[i]); + y += UI_ROW_H; + } + + d.setColor(DisplayDriver::LIGHT); + d.fillRect(0, UI_FOOTER_SEP, W, 1); + uiTextRight(d, W - UI_MARGIN, UI_FOOTER_Y, "click=move hold=ok"); + return 10000; + } + + bool handleInput(char c) override { + if (c == KEY_NEXT || c == KEY_RIGHT) { + _sel = (_sel + 1) % _count; + return true; + } + if (c == KEY_PREV || c == KEY_LEFT) { + _sel = (_sel + _count - 1) % _count; + return true; + } + if (c == KEY_ENTER) { + if (_sel == _count - 1) { + _task->gotoHomeScreen(); + } else { + _task->menuSelect(_kind, _sel); + } + return true; + } + if (c == KEY_SELECT) { + _task->gotoHomeScreen(); + return true; + } + return false; + } +}; + +void UITask::openMenu(MenuKind kind) { + ((MenuScreen*) menu)->configure(kind); + setCurrScreen(menu); +} + +void UITask::menuSelect(MenuKind kind, int item) { + if (kind == MENU_ADVERT) { + bool flood = (item == 1); + notify(UIEventType::ack); + if (the_mesh.advert(flood)) { + showAlert(flood ? "Flood advert sent" : "Zero-hop sent", 1000); + onClientActivity(flood ? "Sent flood advert" : "Sent zero-hop advert"); + } else { + showAlert("Advert failed", 1000); + } + gotoHomeScreen(); + } else if (kind == MENU_GPS) { + if (item == 0) { + toggleGPS(); + } else if (item == 1) { + resyncGPS(); + } + _next_refresh = 0; + } else { + if (item == 0) { + if (isSerialEnabled()) disableSerial(); else enableSerial(); + showAlert(isSerialEnabled() ? "BLE enabled" : "BLE disabled", 900); + onClientActivity(isSerialEnabled() ? "BLE enabled" : "BLE disabled"); + } else if (item == 1) { + setPairingAllowed(!isPairingAllowed()); + _node_prefs->pairing_locked = isPairingAllowed() ? 0 : 1; + the_mesh.savePrefs(); + showAlert(isPairingAllowed() ? "Pairing open" : "Pairing locked", 900); + onClientActivity(isPairingAllowed() ? "Pairing unlocked" : "Pairing locked"); + } + _next_refresh = 0; + } +} + +void UITask::resyncGPS() { + if (_sensors != NULL) { + _sensors->setSettingValue("gps", "0"); + _sensors->setSettingValue("gps", "1"); + _node_prefs->gps_enabled = 1; + the_mesh.savePrefs(); + notify(UIEventType::ack); + onClientActivity("GPS resync"); + showAlert("GPS resync...", 900); + _next_refresh = 0; + } +} + +void UITask::onClientActivity(const char* text) { + _activity_head = (_activity_head + 1) % UI_ACTIVITY_SIZE; + if (_activity_count < UI_ACTIVITY_SIZE) _activity_count++; + _activity[_activity_head].ts = rtc_clock.getCurrentTime(); + StrHelper::strncpy(_activity[_activity_head].text, text, sizeof(_activity[_activity_head].text)); +} + +const char* UITask::getActivityText(int i) const { + if (i < 0 || i >= _activity_count) return ""; + int idx = (_activity_head - i + UI_ACTIVITY_SIZE) % UI_ACTIVITY_SIZE; + return _activity[idx].text; +} + +uint32_t UITask::getActivityTime(int i) const { + if (i < 0 || i >= _activity_count) return 0; + int idx = (_activity_head - i + UI_ACTIVITY_SIZE) % UI_ACTIVITY_SIZE; + return _activity[idx].ts; +} + +void UITask::drawHibernation() { + if (_display == NULL) return; + DisplayDriver& d = *_display; + d.fullRefresh(); + d.startFrame(); + int W = d.width(); + + uiBattery(d, getBattMilliVolts(), false); + char buf[44]; + sprintf(buf, "%d%%", uiBatteryPercent(getBattMilliVolts())); + d.setTextSize(1); + d.setColor(DisplayDriver::LIGHT); + uiTextRight(d, W - 28, UI_HUD_TEXT, buf); + + d.setColor(DisplayDriver::BLUE); + d.drawXbm((W - 128) / 2, 46, meshcore_logo, 128, 13); + + d.setColor(DisplayDriver::LIGHT); + d.setTextSize(1); + uint32_t t = rtc_clock.getCurrentTime(); + int hh = (t / 3600) % 24, mm = (t / 60) % 60; + snprintf(buf, sizeof(buf), "Asleep at %02d:%02d UTC", hh, mm); + uiTextCentered(d, W / 2, 76, buf); + + if (getGPSState()) { + if (sensors.node_lat != 0 || sensors.node_lon != 0) { + snprintf(buf, sizeof(buf), "%.4f, %.4f", sensors.node_lat, sensors.node_lon); + } else { + strcpy(buf, "location unknown"); + } + uiTextCentered(d, W / 2, 92, buf); + } + + if (_low_batt_shutdown) { + d.setColor(DisplayDriver::RED); + uiTextCentered(d, W / 2, 104, "Battery was almost empty"); + d.setColor(DisplayDriver::LIGHT); + } + + uiTextCentered(d, W / 2, 116, "press button to wake"); + d.endFrame(); +} + void UITask::begin(DisplayDriver* display, SensorManager* sensors, NodePrefs* node_prefs) { _display = display; _sensors = sensors; @@ -588,6 +926,7 @@ void UITask::begin(DisplayDriver* display, SensorManager* sensors, NodePrefs* no splash = new SplashScreen(this); home = new HomeScreen(this, &rtc_clock, sensors, node_prefs); msg_preview = new MsgPreviewScreen(this, &rtc_clock); + menu = new MenuScreen(this); setCurrScreen(splash); } @@ -697,6 +1036,7 @@ void UITask::shutdown(bool restart){ if (restart) { _board->reboot(); } else { + drawHibernation(); _display->turnOff(); radio_driver.powerOff(); _board->powerOff(); @@ -713,6 +1053,20 @@ bool UITask::isButtonPressed() const { void UITask::loop() { char c = 0; + + bool conn = hasConnection(); + if (conn != _prev_conn) { + onClientActivity(conn ? "App connected" : "App disconnected"); + _prev_conn = conn; + } + + bool charging = isExternalPowered(); + if (charging != _prev_charging) { + _prev_charging = charging; + if (_display != NULL && !_display->isOn()) _display->turnOn(); + _next_refresh = 0; + } + #if UI_HAS_JOYSTICK int ev = user_btn.check(); if (ev == BUTTON_EVENT_CLICK) { @@ -795,13 +1149,15 @@ void UITask::loop() { int delay_millis = curr->render(*_display); if (millis() < _alert_expiry) { // render alert popup _display->setTextSize(1); - int y = _display->height() / 3; - int p = _display->height() / 32; + int tw = _display->getTextWidth(_alert); + int bw = tw + 16; + if (bw > _display->width() - 8) bw = _display->width() - 8; + int bh = 26; + int bx = (_display->width() - bw) / 2; + int by = (_display->height() - bh) / 2; + uiFillRoundRect(*_display, bx, by, bw, bh, DisplayDriver::LIGHT); _display->setColor(DisplayDriver::DARK); - _display->fillRect(p, y, _display->width() - p*2, y); - _display->setColor(DisplayDriver::LIGHT); // draw box border - _display->drawRect(p, y, _display->width() - p*2, y); - _display->drawTextCentered(_display->width() / 2, y + p*3, _alert); + uiTextCentered(*_display, _display->width() / 2, by + bh / 2 + 5, _alert); _next_refresh = _alert_expiry; // will need refresh when alert is dismissed } else { _next_refresh = millis() + delay_millis; @@ -834,14 +1190,19 @@ void UITask::loop() { if (milliVolts > 0 && milliVolts < AUTO_SHUTDOWN_MILLIVOLTS) { if(!board.isExternalPowered()) { if (_display != NULL) { + int W = _display->width(); _display->startFrame(); - _display->setTextSize(2); _display->setColor(DisplayDriver::RED); - _display->drawTextCentered(_display->width() / 2, 20, "Low Battery."); - _display->drawTextCentered(_display->width() / 2, 40, "Shutting Down!"); + _display->setTextSize(2); + uiTextCentered(*_display, W / 2, 56, "LOW"); + uiTextCentered(*_display, W / 2, 82, "BATTERY"); + _display->setColor(DisplayDriver::LIGHT); // draw box border + _display->setTextSize(1); + uiTextCentered(*_display, W / 2, 104, "shutting down"); _display->endFrame(); if (_display->isEink() == false) { delay(3000); } } + _low_batt_shutdown = true; shutdown(); } } @@ -857,7 +1218,7 @@ char UITask::checkDisplayOn(char c) { c = 0; } _auto_off = millis() + AUTO_OFF_MILLIS; // extend auto-off timer - _next_refresh = 0; // trigger refresh + _next_refresh = 0; } return c; } @@ -898,7 +1259,7 @@ bool UITask::getGPSState() { void UITask::toggleGPS() { if (_sensors != NULL) { - // toggle GPS on/off + // toggle GPS on/off int num = _sensors->getNumSettings(); for (int i = 0; i < num; i++) { if (strcmp(_sensors->getSettingName(i), "gps") == 0) { @@ -921,7 +1282,7 @@ void UITask::toggleGPS() { } void UITask::toggleBuzzer() { - // Toggle buzzer quiet mode + // Toggle buzzer quiet mode #ifdef PIN_BUZZER if (buzzer.isQuiet()) { buzzer.quiet(false); diff --git a/examples/companion_radio/ui-new/UITask.h b/examples/companion_radio/ui-new/UITask.h index a77ad6e7ec..0565c2cf76 100644 --- a/examples/companion_radio/ui-new/UITask.h +++ b/examples/companion_radio/ui-new/UITask.h @@ -51,8 +51,23 @@ class UITask : public AbstractUITask { UIScreen* splash; UIScreen* home; UIScreen* msg_preview; + UIScreen* menu; UIScreen* curr; + struct Activity { + uint32_t ts; + char text[40]; + }; + #define UI_ACTIVITY_SIZE 6 + Activity _activity[UI_ACTIVITY_SIZE]; + int _activity_head = -1; + int _activity_count = 0; + bool _prev_conn = false; + bool _prev_charging = false; + bool _low_batt_shutdown = false; + + void drawHibernation(); + void userLedHandler(); // Button action handlers @@ -78,6 +93,16 @@ class UITask : public AbstractUITask { bool hasDisplay() const { return _display != NULL; } bool isButtonPressed() const; + enum MenuKind { MENU_ADVERT, MENU_BLE, MENU_GPS }; + void openMenu(MenuKind kind); + void menuSelect(MenuKind kind, int item); + void resyncGPS(); + + void onClientActivity(const char* text) override; + int getActivityCount() const { return _activity_count; } + const char* getActivityText(int i) const; + uint32_t getActivityTime(int i) const; + bool isBuzzerQuiet() { #ifdef PIN_BUZZER return buzzer.isQuiet(); diff --git a/examples/companion_radio/ui-new/icons.h b/examples/companion_radio/ui-new/icons.h index cbe237902d..3e61072323 100644 --- a/examples/companion_radio/ui-new/icons.h +++ b/examples/companion_radio/ui-new/icons.h @@ -119,4 +119,8 @@ static const uint8_t advert_icon[] = { static const uint8_t muted_icon[] = { 0x20, 0x6a, 0xea, 0xe4, 0xe4, 0xea, 0x6a, 0x20 +}; + +static const uint8_t charging_icon[] = { + 0x0C, 0x18, 0x30, 0x7E, 0x0C, 0x18, 0x30, 0x60 }; \ No newline at end of file diff --git a/src/helpers/ArduinoSerialInterface.cpp b/src/helpers/ArduinoSerialInterface.cpp index a01fa5866f..253b40f327 100644 --- a/src/helpers/ArduinoSerialInterface.cpp +++ b/src/helpers/ArduinoSerialInterface.cpp @@ -13,8 +13,12 @@ void ArduinoSerialInterface::disable() { _isEnabled = false; } -bool ArduinoSerialInterface::isConnected() const { +bool ArduinoSerialInterface::isConnected() const { +#if defined(NRF52_PLATFORM) + return (bool)Serial; +#else return true; // no way of knowing, so assume yes +#endif } bool ArduinoSerialInterface::isWriteBusy() const { diff --git a/src/helpers/BaseSerialInterface.h b/src/helpers/BaseSerialInterface.h index e9a3f2ab46..70bd7c5c93 100644 --- a/src/helpers/BaseSerialInterface.h +++ b/src/helpers/BaseSerialInterface.h @@ -18,4 +18,7 @@ class BaseSerialInterface { virtual bool isWriteBusy() const = 0; virtual size_t writeFrame(const uint8_t src[], size_t len) = 0; virtual size_t checkRecvFrame(uint8_t dest[]) = 0; + + virtual void setPairingAllowed(bool allowed) { } + virtual bool isPairingAllowed() const { return true; } }; diff --git a/src/helpers/nrf52/SerialBLEInterface.cpp b/src/helpers/nrf52/SerialBLEInterface.cpp index 75a4e3b064..c15cf60171 100644 --- a/src/helpers/nrf52/SerialBLEInterface.cpp +++ b/src/helpers/nrf52/SerialBLEInterface.cpp @@ -79,6 +79,10 @@ bool SerialBLEInterface::onPairingPasskey(uint16_t connection_handle, uint8_t co (void)connection_handle; (void)passkey; BLE_DEBUG_PRINTLN("SerialBLEInterface: pairing passkey request match=%d", match_request); + if (instance && !instance->_allowPairing) { + BLE_DEBUG_PRINTLN("SerialBLEInterface: pairing locked, rejecting new device"); + return false; + } return true; } diff --git a/src/helpers/nrf52/SerialBLEInterface.h b/src/helpers/nrf52/SerialBLEInterface.h index de1030548f..f1d4535ea3 100644 --- a/src/helpers/nrf52/SerialBLEInterface.h +++ b/src/helpers/nrf52/SerialBLEInterface.h @@ -11,6 +11,7 @@ class SerialBLEInterface : public BaseSerialInterface { BLEDfu bledfu; BLEUart bleuart; bool _isEnabled; + bool _allowPairing; bool _isDeviceConnected; uint16_t _conn_handle; unsigned long _last_health_check; @@ -45,6 +46,7 @@ class SerialBLEInterface : public BaseSerialInterface { public: SerialBLEInterface() { _isEnabled = false; + _allowPairing = true; _isDeviceConnected = false; _conn_handle = BLE_CONN_HANDLE_INVALID; _last_health_check = 0; @@ -69,6 +71,22 @@ class SerialBLEInterface : public BaseSerialInterface { bool isWriteBusy() const override; size_t writeFrame(const uint8_t src[], size_t len) override; size_t checkRecvFrame(uint8_t dest[]) override; + void setPairingAllowed(bool allowed) override { + _allowPairing = allowed; + // stop Bluefruit's blue advertising/conn LED while pairing is locked + Bluefruit.autoConnLed(allowed); + if (!allowed) { +#ifdef LED_BLUE + pinMode(LED_BLUE, OUTPUT); + #ifdef LED_STATE_ON + digitalWrite(LED_BLUE, !LED_STATE_ON); + #else + digitalWrite(LED_BLUE, HIGH); + #endif +#endif + } + } + bool isPairingAllowed() const override { return _allowPairing; } }; #if BLE_DEBUG_LOGGING && ARDUINO diff --git a/src/helpers/ui/DisplayDriver.h b/src/helpers/ui/DisplayDriver.h index dcc5fe0318..2743cc26ee 100644 --- a/src/helpers/ui/DisplayDriver.h +++ b/src/helpers/ui/DisplayDriver.h @@ -18,6 +18,7 @@ class DisplayDriver { virtual void turnOn() = 0; virtual void turnOff() = 0; virtual void clear() = 0; + virtual void fullRefresh() { } // force a clean full-panel refresh, no-op by default virtual void startFrame(Color bkg = DARK) = 0; virtual void setTextSize(int sz) = 0; virtual void setColor(Color c) = 0; diff --git a/src/helpers/ui/GxEPDDisplay.cpp b/src/helpers/ui/GxEPDDisplay.cpp index ad47754bf9..a2d1aa9753 100644 --- a/src/helpers/ui/GxEPDDisplay.cpp +++ b/src/helpers/ui/GxEPDDisplay.cpp @@ -61,6 +61,17 @@ void GxEPDDisplay::clear() { display_crc.reset(); } +void GxEPDDisplay::fullRefresh() { + if (!_init) begin(); + display.setFullWindow(); + display.fillScreen(GxEPD_WHITE); + display.display(false); + display.setPartialWindow(0, 0, display.width(), display.height()); + display_crc.reset(); + last_display_crc_value = 0; + _force_full = true; +} + void GxEPDDisplay::startFrame(Color bkg) { display.fillScreen(GxEPD_WHITE); display.setTextColor(_curr_color = GxEPD_BLACK); @@ -171,6 +182,14 @@ uint16_t GxEPDDisplay::getTextWidth(const char* str) { } void GxEPDDisplay::endFrame() { + if (_force_full) { + display.setFullWindow(); + display.display(false); + display.setPartialWindow(0, 0, display.width(), display.height()); + last_display_crc_value = display_crc.finalize(); + _force_full = false; + return; + } uint32_t crc = display_crc.finalize(); if (crc != last_display_crc_value) { display.display(true); diff --git a/src/helpers/ui/GxEPDDisplay.h b/src/helpers/ui/GxEPDDisplay.h index 219b607644..f85408e5e7 100644 --- a/src/helpers/ui/GxEPDDisplay.h +++ b/src/helpers/ui/GxEPDDisplay.h @@ -33,6 +33,7 @@ class GxEPDDisplay : public DisplayDriver { #endif bool _init = false; bool _isOn = false; + bool _force_full = false; uint16_t _curr_color; CRC32 display_crc; int last_display_crc_value = 0; @@ -51,6 +52,7 @@ class GxEPDDisplay : public DisplayDriver { void turnOn() override; void turnOff() override; void clear() override; + void fullRefresh() override; void startFrame(Color bkg = DARK) override; void setTextSize(int sz) override; void setColor(Color c) override;