Files
MeshCore/examples/companion_radio/ui-tiny/UITask.cpp
T
mikecarperandR0ck f57dffa2be Add saved Companion sound and vibration controls
Default fresh settings to sound off and vibration on, and keep existing saved choices. Apply changes immediately across all three UIs and make custom notification patterns respect the same mute controls.

Keep the Wio Mini Companion within its existing flash boundary by storing its LED switch in a reserved preference byte and shortening CLI help without removing commands. Add coverage for live output changes, persistence, and storage failures.

Adapted from https://github.com/meshcore-dev/MeshCore/pull/3603.

Co-authored-by: R0ck <develop@hectospark.co.uk>
2026-10-20 09:33:43 -07:00

1197 lines
38 KiB
C++

#include "UITask.h"
#if UI_SMALL_MESSAGE_FONT == 1
#include <helpers/ui/SmallMessageText.h>
#endif
#include <helpers/TxtDataHelpers.h>
#include <helpers/MorseLoopPolicy.h>
#include <helpers/ui/BluetoothPairingUiPolicy.h>
#include <helpers/ui/RadioProfileDisplayPage.h>
#include <helpers/ui/RadioProfileSystemStatus.h>
#include "../MyMesh.h"
#include <RadioProfiles.h>
#include "target.h"
#include "u8g2_icons.h"
#if defined(ENABLE_WIFI_INTERFACE) || defined(WIFI_SSID)
#include <WiFi.h>
#endif
#ifndef AUTO_OFF_MILLIS
#define AUTO_OFF_MILLIS 15000 // 15 seconds
#endif
#ifndef BLE_PAIRING_DISPLAY_MILLIS
#define BLE_PAIRING_DISPLAY_MILLIS 120000UL
#endif
#define BOOT_SCREEN_MILLIS 4000 // 4 seconds
#ifdef PIN_STATUS_LED
#define LED_ON_MILLIS 20
#define LED_ON_MSG_MILLIS 200
#define LED_CYCLE_MILLIS 4000
#endif
#define LONG_PRESS_MILLIS 1200
namespace {
constexpr unsigned SCREEN_SENSOR_BUFFER_BYTES = 200;
mesh::ui::RadioProfileSystemStatus radioProfileSystemStatus(
const CompanionNodePrefs& prefs) {
mesh::ui::RadioProfileSystemStatus status;
const auto* radio = the_mesh.getProfileRadio();
const mesh::RadioProfiles* profiles = radio ? radio->profiles() : NULL;
status.public_key = the_mesh.self_id.pub_key;
status.powersaving_enabled = prefs.powersaving_enabled != 0;
#if ENV_INCLUDE_GPS == 1
status.gps_enabled = prefs.gps_enabled != 0;
#endif
status.fem_enabled = board.canControlLoRaFemLna()
&& board.isLoRaFemLnaEnabled();
status.rx_boosted_gain = prefs.rx_boosted_gain != 0;
status.rx_powersaving_enabled = prefs.rx_powersaving_enabled != 0;
status.cad_enabled = prefs.cad_enabled != 0;
status.dual_radio_enabled = profiles != NULL && profiles->enabled();
if (status.dual_radio_enabled) {
status.secondary_temporary = profiles->secondary_temporary;
status.secondary_mode = profiles->secondary.mode;
status.cross = profiles->cross;
}
status.noise_floor_1 = radio_driver.getNoiseFloorDbm(0);
status.noise_floor_1_seconds =
radio_driver.getNoiseFloorCalibrationSecondsRemaining(0);
if (status.dual_radio_enabled) {
status.noise_floor_2 = radio_driver.getNoiseFloorDbm(1);
status.noise_floor_2_seconds =
radio_driver.getNoiseFloorCalibrationSecondsRemaining(1);
}
return status;
}
} // namespace
#ifndef UI_RECENT_LIST_SIZE
#define UI_RECENT_LIST_SIZE 4
#endif
#if UI_HAS_JOYSTICK
#define PRESS_LABEL "press Enter"
#else
#define PRESS_LABEL "long press"
#endif
class SplashScreen : public UIScreen {
UITask* _task;
unsigned long dismiss_after;
unsigned long version_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, '-');
int len = dash ? dash - ver : strlen(ver);
if (len >= sizeof(_version_info)) len = sizeof(_version_info) - 1;
memcpy(_version_info, ver, len);
_version_info[len] = 0;
version_after = millis() + BOOT_SCREEN_MILLIS / 2;
dismiss_after = millis() + BOOT_SCREEN_MILLIS;
}
int render(DisplayDriver& display) override {
if (millis() < version_after) {
// meshcore logo
display.setColor(UIColor::corp_blue);
int logoWidth = 72;
display.drawXbm(0, 0, meshcore_logo, 72, 36);
} else {
// meshcore website
const char* website = "meshcore.io";
display.setColor(UIColor::primary_txt);
display.setTextSize(1);
uint16_t websiteWidth = display.getTextWidth(website);
display.setCursor((display.width() - websiteWidth) / 2, 9);
display.print(website);
// version info
display.setTextSize(1);
display.drawTextCentered(display.width()/2, 18, _version_info);
display.setColor(UIColor::secondary_txt);
display.setTextSize(1);
display.drawTextCentered(display.width()/2, 27, FIRMWARE_BUILD_DATE);
}
return 1000;
}
void poll() override {
if (millis() >= dismiss_after) {
_task->gotoHomeScreen();
}
}
};
class HomeScreen : public UIScreen {
enum HomePage {
FIRST,
RECENT,
RADIO,
// Only visible when the secondary radio profile is configured.
RADIO2,
RADIO_STATUS,
BLUETOOTH,
ADVERT,
#if ENV_INCLUDE_GPS == 1
GPS,
#endif
#if UI_SENSORS_PAGE == 1
SENSORS,
#endif
SHUTDOWN,
Count // keep as last
};
UITask* _task;
mesh::RTCClock* _rtc;
SensorManager* _sensors;
CompanionNodePrefs* _node_prefs;
uint8_t _page;
bool _shutdown_init;
AdvertPath recent[UI_RECENT_LIST_SIZE];
CayenneLPP sensors_lpp;
int sensors_nb = 0;
bool sensors_scroll = false;
int sensors_scroll_offset = 0;
uint32_t last_sensors_refresh = 0;
uint32_t _radio_profile_page_started_at = 0;
uint8_t _radio_status_page = 0;
bool _dual_radio_enabled_seen = false;
void refresh_sensors() {
#if AUTO_OFF_MILLIS > 0
const uint32_t refresh_interval = 5000;
#else
const uint32_t refresh_interval = 60000;
#endif
// Empty data requests an initial/forced sample. Elapsed time also handles
// returning to this page after more than half a millis() cycle.
if (sensors_lpp.getSize() == 0
|| static_cast<uint32_t>(millis() - last_sensors_refresh) >= refresh_interval) {
sensors_lpp.reset();
sensors_nb = 0;
sensors_lpp.addVoltage(TELEM_CHANNEL_SELF, (float)board.getBattMilliVolts() / 1000.0f);
sensors.querySensors(0xFF, sensors_lpp);
LPPReader reader (sensors_lpp.getBuffer(), sensors_lpp.getSize());
uint8_t channel, type;
while(reader.readHeader(channel, type)) {
reader.skipData(type);
sensors_nb ++;
}
sensors_scroll = sensors_nb > UI_RECENT_LIST_SIZE;
last_sensors_refresh = millis();
}
}
void resetRadioProfileDisplayPage() {
_radio_profile_page_started_at = millis();
_radio_status_page = 0;
_dual_radio_enabled_seen = the_mesh.isDualRadioActive();
}
bool isPageVisible(uint8_t page) const {
return the_mesh.isDualRadioActive()
|| (page != HomePage::RADIO2 && page != HomePage::RADIO_STATUS);
}
void movePage(int direction) {
do {
_page = (_page + HomePage::Count + direction) % HomePage::Count;
} while (!isPageVisible(_page));
if (_page == HomePage::RADIO || _page == HomePage::RADIO_STATUS) {
resetRadioProfileDisplayPage();
}
}
int radioProfileRefreshMillis() const {
const uint32_t elapsed = millis() - _radio_profile_page_started_at;
const uint32_t remaining = mesh::ui::RADIO_PROFILE_DISPLAY_PAGE_MILLIS
- elapsed % mesh::ui::RADIO_PROFILE_DISPLAY_PAGE_MILLIS;
return remaining < 5000 ? static_cast<int>(remaining) : 5000;
}
public:
HomeScreen(UITask* task, mesh::RTCClock* rtc, SensorManager* sensors, CompanionNodePrefs* node_prefs)
: _task(task), _rtc(rtc), _sensors(sensors), _node_prefs(node_prefs), _page(0),
_shutdown_init(false), sensors_lpp(SCREEN_SENSOR_BUFFER_BYTES) {
resetRadioProfileDisplayPage();
}
void showFirstPage() { _page = HomePage::FIRST; }
void poll() override {
if (_dual_radio_enabled_seen != the_mesh.isDualRadioActive()) {
resetRadioProfileDisplayPage();
}
if (!isPageVisible(_page)) {
_page = HomePage::RADIO;
resetRadioProfileDisplayPage();
}
if (_shutdown_init && !_task->isButtonPressed()) { // must wait for USR button to be released
_task->shutdown();
}
}
int render(DisplayDriver& display) override {
char tmp[80];
if (_page == HomePage::FIRST) {
// // 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);
display.setColor(UIColor::primary_txt);
display.setTextSize(2);
sprintf(tmp, "MSG: %d", _task->getMsgCount());
display.setCursor(0, 10);
display.print(tmp);
sprintf(tmp, "BATT: %.2fV", _task->getCachedBattMV() / 1000.0f);
display.setCursor(0, 19);
display.print(tmp);
#ifdef ENABLE_WIFI_INTERFACE
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
const bool bluetooth_connected = _task->hasBluetoothConnection();
const uint32_t bluetooth_pin = the_mesh.getBLEPin();
if (bluetooth_connected) {
display.setColor(UIColor::warning_txt);
display.setTextSize(1);
display.drawTextCentered(display.width() / 2, display.height()-8, "< Connected >");
} else if (mesh::ui::shouldDisplayBluetoothPairingPin(
_task->isBluetoothEnabled(), bluetooth_connected,
bluetooth_pin)) { // BT pin
display.setColor(UIColor::warning_txt);
display.setTextSize(2);
sprintf(tmp, "Pin:%u", (unsigned int)bluetooth_pin);
display.drawTextCentered(display.width() / 2, display.height()-8, tmp);
}
} else if (_page == HomePage::RECENT) {
the_mesh.getRecentlyHeard(recent, UI_RECENT_LIST_SIZE);
display.setColor(UIColor::primary_txt);
int y = 8;
for (int i = 0; i < UI_RECENT_LIST_SIZE; i++, y += 11) {
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);
} else if (secs < 60*60) {
sprintf(tmp, "%dm", secs / 60);
} else {
sprintf(tmp, "%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);
}
} else if (_page == HomePage::RADIO || _page == HomePage::RADIO2
|| _page == HomePage::RADIO_STATUS) {
display.setColor(UIColor::primary_txt);
display.setTextSize(1);
const auto* radio = the_mesh.getProfileRadio();
const mesh::RadioProfiles* profiles = radio ? radio->profiles() : NULL;
const bool dual_radio = profiles != NULL && profiles->enabled();
const uint8_t status_pages = mesh::ui::radioProfileSystemStatusPageCount(
display, dual_radio, 8);
uint8_t status_page_index = 0;
const bool status_page = dual_radio
? _page == HomePage::RADIO_STATUS
: mesh::ui::showRadioProfileSystemStatusPage(
false, status_pages,
millis() - _radio_profile_page_started_at,
&status_page_index);
if (status_page) {
if (dual_radio && status_pages != 0) {
status_page_index = _radio_status_page % status_pages;
}
mesh::ui::drawRadioProfileSystemStatusPage(display,
radioProfileSystemStatus(*_node_prefs), status_page_index, 8);
return dual_radio ? 5000 : radioProfileRefreshMillis();
}
const bool secondary_page = dual_radio && _page == HomePage::RADIO2;
const uint8_t profile = dual_radio && secondary_page ? 1 : 0;
float freq = _node_prefs->freq;
float bw = _node_prefs->bw;
uint8_t sf = _node_prefs->sf;
uint8_t cr = _node_prefs->cr;
const char* profile_tag = "";
if (dual_radio) {
const auto& params = profiles->params(profile);
freq = params.freq;
bw = params.bw;
sf = params.sf;
cr = params.cr;
profile_tag = mesh::ui::radioProfileDisplayTag(profile,
profile == 1 ? profiles->secondary_temporary
: profiles->primary_temporary);
}
// frequency and spreading factor
display.setCursor(0, 8);
if (dual_radio) {
snprintf(tmp, sizeof(tmp), "%s F:%06.3f", profile_tag, freq);
} else {
snprintf(tmp, sizeof(tmp), "FQ %06.3f", freq);
}
display.print(tmp);
snprintf(tmp, sizeof(tmp), "S:%d", sf);
display.drawTextRightAlign(display.width(), 8, tmp);
// bandwidth and coding rate
display.setCursor(0, 17);
if (dual_radio) {
snprintf(tmp, sizeof(tmp), "%s B:%03.2f", profile_tag, bw);
} else {
snprintf(tmp, sizeof(tmp), "BW %03.2f", bw);
}
display.print(tmp);
snprintf(tmp, sizeof(tmp), "C:%d", cr);
display.drawTextRightAlign(display.width(), 17, tmp);
// tx power and noise floor
display.setCursor(0, 26);
const char* noise_label = dual_radio ? (profile == 0 ? "N1" : "N2") : "NF";
const float noise_floor = radio_driver.getNoiseFloorDbm(profile);
if (noise_floor == 0.0f) {
const float seconds = radio_driver
.getNoiseFloorCalibrationSecondsRemaining(profile);
if (seconds > 0.0f) {
snprintf(tmp, sizeof(tmp), "%s:%.1fs", noise_label, seconds);
} else {
snprintf(tmp, sizeof(tmp), "%s:WAIT", noise_label);
}
} else {
snprintf(tmp, sizeof(tmp), "%s:%.1f", noise_label, noise_floor);
}
display.print(tmp);
snprintf(tmp, sizeof(tmp), "TX:%d", _node_prefs->tx_power_dbm);
display.drawTextRightAlign(display.width(), 26, tmp);
} else if (_page == HomePage::BLUETOOTH) {
display.setColor(UIColor::corp_blue);
display.drawXbm((display.width() - 32) / 2, 8,
_task->isBluetoothEnabled() ? bluetooth_on : bluetooth_off,
32, 32);
display.setTextSize(1);
// display.drawTextCentered(display.width() / 2, 40 - 11, "toggle: " PRESS_LABEL);
} else if (_page == HomePage::ADVERT) {
display.setColor(UIColor::corp_blue);
display.drawXbm((display.width() - 32) / 2, 8, advert_icon, 32, 32);
// display.drawTextCentered(display.width() / 2, 40 - 11, "advert: " PRESS_LABEL);
#if ENV_INCLUDE_GPS == 1
} else if (_page == HomePage::GPS) {
LocationProvider* nmea = sensors.getLocationProvider();
char buf[50];
int y = 8;
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)");
} else {
strcpy(buf, gps_state ? "gps on" : "gps off");
}
#else
strcpy(buf, gps_state ? "gps on" : "gps off");
#endif
display.setColor(UIColor::primary_txt);
display.drawTextLeftAlign(0, y, buf);
if (nmea == NULL) {
// y = y + 8;
display.setColor(UIColor::warning_txt);
display.drawTextLeftAlign(0, y, "Can't access GPS");
} else {
if (!gps_state || !nmea->isValid()) {
strcpy(buf, "no fix");
} else {
sprintf(buf, "%d sat", nmea->satellitesCount());
}
display.setColor(UIColor::primary_txt);
display.drawTextRightAlign(display.width()-1, y, buf);
y = y + 8;
sprintf(buf, "lat %.4f",
nmea->getLatitude()/1000000.);
display.drawTextLeftAlign(0, y, buf);
y = y + 8;
sprintf(buf, "lon %.4f",
nmea->getLongitude()/1000000.);
display.drawTextLeftAlign(0, y, buf);
y = y + 8;
sprintf(buf, "alt %.1f", nmea->getAltitude()/1000.);
display.drawTextLeftAlign(0, y, buf);
}
#endif
#if UI_SENSORS_PAGE == 1
} else if (_page == HomePage::SENSORS) {
int y = 8;
refresh_sensors();
char buf[30];
char name[30];
LPPReader r(sensors_lpp.getBuffer(), sensors_lpp.getSize());
for (int i = 0; i < sensors_scroll_offset; i++) {
uint8_t channel, type;
r.readHeader(channel, type);
r.skipData(type);
}
for (int i = 0; i < (sensors_scroll?UI_RECENT_LIST_SIZE:sensors_nb); 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;
switch (type) {
case LPP_GPS: // GPS
float lat, lon, alt;
r.readGPS(lat, lon, alt);
strcpy(name, "gps"); sprintf(buf, "%.4f %.4f", lat, lon);
break;
case LPP_VOLTAGE:
r.readVoltage(v);
strcpy(name, "voltage"); sprintf(buf, "%6.2f", v);
break;
case LPP_CURRENT:
r.readCurrent(v);
strcpy(name, "current"); sprintf(buf, "%.3f", v);
break;
case LPP_TEMPERATURE:
r.readTemperature(v);
strcpy(name, "temperature"); sprintf(buf, "%.2f", v);
break;
case LPP_RELATIVE_HUMIDITY:
r.readRelativeHumidity(v);
strcpy(name, "humidity"); sprintf(buf, "%.2f", v);
break;
case LPP_BAROMETRIC_PRESSURE:
r.readPressure(v);
strcpy(name, "pressure"); sprintf(buf, "%.2f", v);
break;
case LPP_ALTITUDE:
r.readAltitude(v);
strcpy(name, "altitude"); sprintf(buf, "%.0f", v);
break;
case LPP_POWER:
r.readPower(v);
strcpy(name, "power"); sprintf(buf, "%6.2f", v);
break;
default:
r.skipData(type);
strcpy(name, "unk"); sprintf(buf, "");
}
display.setColor(UIColor::secondary_txt);
display.setCursor(0, y);
display.print(name);
display.setColor(UIColor::primary_txt);
display.setCursor(
display.width()-display.getTextWidth(buf)-1, y
);
display.print(buf);
y = y + 12;
}
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(UIColor::secondary_txt);
display.setTextSize(1);
if (_shutdown_init) {
display.drawTextCentered(display.width() / 2, 20, "hibernating...");
} else {
display.drawXbm((display.width() - 32) / 2, 8, power_icon, 32, 32);
// display.drawTextCentered(display.width() / 2, 40 - 11, "hibernate:" PRESS_LABEL);
}
}
return _page == HomePage::RADIO && !the_mesh.isDualRadioActive()
? radioProfileRefreshMillis() : 5000;
}
bool handleInput(char c) override {
if (c == KEY_LEFT || c == KEY_PREV) {
movePage(-1);
return true;
}
if (c == KEY_NEXT || c == KEY_RIGHT) {
movePage(1);
if (_page == HomePage::RECENT) {
_task->showAlert("Recent adverts", 800);
}
return true;
}
if (c == KEY_ENTER && _page == HomePage::RADIO_STATUS) {
_radio_status_page++;
return true;
}
if (c == KEY_ENTER && _page == HomePage::BLUETOOTH) {
if (_task->isBluetoothEnabled()) { // toggle Bluetooth on/off
_task->disableBluetooth();
} else {
_task->enableBluetooth();
}
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);
}
return true;
}
#if ENV_INCLUDE_GPS == 1
if (c == KEY_ENTER && _page == HomePage::GPS) {
_task->toggleGPS();
return true;
}
#endif
#if UI_SENSORS_PAGE == 1
if (c == KEY_ENTER && _page == HomePage::SENSORS) {
_task->toggleGPS();
sensors_lpp.reset();
return true;
}
#endif
if (c == KEY_ENTER && _page == HomePage::SHUTDOWN) {
_shutdown_init = true; // need to wait for button to be released
return true;
}
return false;
}
};
#if UI_SMALL_MESSAGE_FONT == 1
class TinyMessageScreen : public UIScreen {
UITask* _task;
char _origin[62] = {};
char _message[161] = {};
public:
explicit TinyMessageScreen(UITask* task) : _task(task) {}
void setMessage(uint8_t path_len, const char* from, const char* message) {
if (path_len == 0xFF) {
snprintf(_origin, sizeof(_origin), "%s [direct]:", from);
} else {
snprintf(_origin, sizeof(_origin), "%s [%uh]:", from,
(unsigned int)path_len);
}
StrHelper::strncpy(_message, message, sizeof(_message));
}
int render(DisplayDriver& display) override {
// The 72x40 interface reserves its top 8px for the scrolling status bar.
mesh::ui::drawSmallMessageBody(display, _origin, _message, 10);
return 1000;
}
bool handleInput(char c) override {
if (c == KEY_NEXT || c == KEY_PREV || c == KEY_LEFT || c == KEY_RIGHT
|| c == KEY_ENTER) {
_task->gotoHomeScreen();
return true;
}
return false;
}
};
#endif
#if defined(MESH_COMPANION_SCREEN_STARTUP_BYTES)
// This UI allocates three small screens, one sensor buffer, and no history
// table. Check the concrete objects instead of borrowing ui-new's allowance.
static_assert(sizeof(SplashScreen) + sizeof(HomeScreen)
#if UI_SMALL_MESSAGE_FONT == 1
+ sizeof(TinyMessageScreen)
#endif
+ SCREEN_SENSOR_BUFFER_BYTES + 4 * 16
<= MESH_COMPANION_SCREEN_STARTUP_BYTES,
"Increase MESH_COMPANION_SCREEN_STARTUP_BYTES and its RAM budget");
#endif
void UITask::begin(DisplayDriver* display, SensorManager* sensors, CompanionNodePrefs* node_prefs) {
_display = display;
_sensors = sensors;
if (_display != NULL) {
_display->setRotationDegrees(node_prefs->display_rotation_degrees);
}
_cached_batt_mv = getBattMilliVolts();
#if defined(PIN_USER_BTN)
user_btn.begin();
#endif
#if defined(PIN_USER_BTN_ANA)
analog_btn.begin();
#endif
_node_prefs = node_prefs;
if (_display != NULL) {
_display->servicePower(_board->isExternalPowered() || _board->isUsbHostConnected(), hasConnection());
}
_statusBar.begin(_display->width());
#ifdef PIN_BUZZER
buzzer.begin();
buzzer.quiet(_node_prefs->buzzer_quiet);
buzzer.startup();
#endif
#if defined(PIN_VIBRATION) || defined(HAS_DRV2605)
vibration.begin();
vibration.quiet(_node_prefs->vibe_quiet);
#endif
ui_started_at = millis();
_startup_complete = false;
next_batt_chck = _next_refresh = ui_started_at;
_alert[0] = 0;
_alert_expiry = 0;
splash = new SplashScreen(this);
home = new HomeScreen(this, &rtc_clock, sensors, node_prefs);
#if UI_SMALL_MESSAGE_FONT == 1
msg_preview = new TinyMessageScreen(this);
#endif
setCurrScreen(splash);
}
void UITask::showAlert(const char* text, int duration_millis) {
strcpy(_alert, text);
_alert_expiry = millis() + duration_millis;
}
void UITask::notify(UIEventType t) {
#if defined(PIN_BUZZER)
if (_notification_outputs & mesh::notify::Sound) switch(t){
case UIEventType::contactMessage:
// gemini's pick
buzzer.play("MsgRcv3:d=4,o=6,b=200:32e,32g,32b,16c7");
break;
case UIEventType::channelMessage:
buzzer.play("kerplop:d=16,o=6,b=120:32g#,32c#");
break;
case UIEventType::ack:
buzzer.play("ack:d=32,o=8,b=120:c");
break;
case UIEventType::roomMessage:
case UIEventType::newContactMessage:
case UIEventType::none:
default:
break;
}
#endif
#if defined(PIN_VIBRATION) || defined(HAS_DRV2605)
// Trigger vibration for all UI events except none
if ((_notification_outputs & mesh::notify::Vibration) && t != UIEventType::none) {
vibration.trigger();
}
#endif
}
void UITask::msgRead(int msgcount) {
_msgcount = msgcount;
if (msgcount == 0) {
#if UI_SMALL_MESSAGE_FONT == 1
if (curr == msg_preview
&& static_cast<int32_t>(millis() - _msg_preview_until) < 0) return;
_deferred_msg_preview = false;
#endif
gotoHomeScreen();
}
}
void UITask::newMsg(uint8_t path_len, const char* from_name, const char* text,
int msgcount, int channel_idx,
const char* channel_name, int queue_index) {
(void)queue_index;
(void)channel_idx;
(void)channel_name;
_msgcount = msgcount;
#if UI_SMALL_MESSAGE_FONT == 1
static_cast<TinyMessageScreen*>(msg_preview)->setMessage(path_len, from_name, text);
if (isPairingScreenActive()) _deferred_msg_preview = true;
else setCurrScreen(msg_preview);
_msg_preview_until = millis() + 15000UL;
#endif
if (_display != NULL) {
if (shouldWakeDisplayForMessage()) {
_display->wake(mesh::ui::DisplayWake::Message);
}
if (_display->isOn()) _next_refresh = millis();
}
}
void UITask::userLedHandler() {
if (!mesh::leds::enabled()) return;
if (_notification_led_owned || !(_notification_outputs & mesh::notify::Led)) return;
#ifdef PIN_STATUS_LED
const uint32_t cur_time = millis();
// Phase age also survives long notification-owned or disabled intervals.
if (static_cast<uint32_t>(cur_time - last_led_change) > led_phase_millis) {
if (led_state == 0) {
led_state = 1;
if (_msgcount > 0) {
led_phase_millis = LED_ON_MSG_MILLIS;
} else {
led_phase_millis = LED_ON_MILLIS;
}
} else {
led_state = 0;
led_phase_millis = LED_CYCLE_MILLIS - led_phase_millis;
}
last_led_change = cur_time;
digitalWrite(PIN_STATUS_LED, led_state == LED_STATE_ON);
}
#endif
}
void UITask::setCurrScreen(UIScreen* c) {
curr = c;
_next_refresh = millis();
}
bool UITask::isPairingScreenActive() const {
return mesh::ui::isBluetoothPairingPromptActive(
isBluetoothEnabled(), hasBluetoothConnection(),
static_cast<uint32_t>(_pairing_screen_until),
static_cast<uint32_t>(millis()));
}
void UITask::showPairingPin() {
if (!isBluetoothEnabled()) return;
const unsigned long now = millis();
_pairing_screen_until = now + BLE_PAIRING_DISPLAY_MILLIS;
static_cast<HomeScreen*>(home)->showFirstPage();
setCurrScreen(home);
if (_display != NULL) {
_display->servicePower(_board->isExternalPowered() || _board->isUsbHostConnected(), hasConnection(), true);
_next_refresh = millis();
}
}
void UITask::finishPairingScreen(bool timed_out) {
_pairing_screen_until = 0;
_next_refresh = millis();
#if UI_SMALL_MESSAGE_FONT == 1
if (_deferred_msg_preview) {
_deferred_msg_preview = false;
setCurrScreen(msg_preview);
}
#endif
if (_display == NULL) return;
if (timed_out) {
_display->dismiss();
}
}
void UITask::servicePairingState() {
if (_interfaceManager->takePairingRequest()) {
showPairingPin();
}
if (_pairing_screen_until != 0) {
const bool timed_out =
static_cast<int32_t>(millis() - _pairing_screen_until) >= 0;
if (!isPairingScreenActive()) {
finishPairingScreen(timed_out);
}
}
if (_display != NULL && _display->servicePower(
_board->isExternalPowered() || _board->isUsbHostConnected(),
hasConnection(), isPairingScreenActive())) _next_refresh = millis();
}
/*
hardware-agnostic pre-shutdown activity should be done here
*/
void UITask::shutdown(bool restart){
if (!prepareForShutdown()) return;
#ifdef PIN_BUZZER
/* note: we have a choice here -
we can do a blocking buzzer.loop() with non-deterministic consequences
or we can set a flag and delay the shutdown for a couple of seconds
while a non-blocking buzzer.loop() plays out in UITask::loop()
*/
buzzer.shutdown();
uint32_t buzzer_timer = millis(); // fail-safe shutdown
while (buzzer.isPlaying() && (millis() - 2500) < buzzer_timer)
buzzer.loop();
#endif // PIN_BUZZER
if (restart) {
_board->reboot();
} else {
// Power off board including radio, display, GPS and components
_board->powerOff();
}
}
bool UITask::isButtonPressed() const {
#ifdef PIN_USER_BTN
return user_btn.isPressed();
#else
return false;
#endif
}
void UITask::syncAlertPreferences() {
if (!_node_prefs) return;
#ifdef PIN_BUZZER
if (buzzer.isQuiet() != bool(_node_prefs->buzzer_quiet))
buzzer.quiet(_node_prefs->buzzer_quiet);
#endif
#if defined(PIN_VIBRATION) || defined(HAS_DRV2605)
if (vibration.isQuiet() != bool(_node_prefs->vibe_quiet))
vibration.quiet(_node_prefs->vibe_quiet);
#endif
}
void UITask::loop() {
syncAlertPreferences();
// Observe startup expiry even while display/Morse/input defers rendering.
if (!_startup_complete && static_cast<uint32_t>(millis() - ui_started_at) >= 8000u) {
_startup_complete = true;
}
const bool morse_timing = mesh::morseTimingActive(&radio_driver);
servicePairingState();
// Keep already-due work due across long display-off/Morse intervals,
// without moving a future refresh or triggering any display/ADC work.
const uint32_t refresh_now = millis();
if (static_cast<int32_t>(refresh_now - _next_refresh) >= 0) {
_next_refresh = refresh_now;
}
#ifndef AUTO_SHUTDOWN_MILLIVOLTS
if (static_cast<int32_t>(refresh_now - next_batt_chck) >= 0) {
next_batt_chck = refresh_now;
}
#endif
// Retire the popup even while sleep, Morse or input prevents drawing.
// Its deadline may be zero after wrapping; empty text means inactive.
if (_alert[0] && static_cast<int32_t>(millis() - _alert_expiry) >= 0) {
_alert[0] = 0;
_next_refresh = millis();
}
char c = 0;
#if UI_HAS_JOYSTICK
int ev = user_btn.check();
if (ev == BUTTON_EVENT_CLICK) {
c = checkDisplayOn(KEY_ENTER);
} else if (ev == BUTTON_EVENT_LONG_PRESS) {
c = handleLongPress(KEY_ENTER); // REVISIT: could be mapped to different key code
}
ev = joystick_left.check();
if (ev == BUTTON_EVENT_CLICK) {
c = checkDisplayOn(KEY_LEFT);
} else if (ev == BUTTON_EVENT_LONG_PRESS) {
c = handleLongPress(KEY_LEFT);
}
ev = joystick_right.check();
if (ev == BUTTON_EVENT_CLICK) {
c = checkDisplayOn(KEY_RIGHT);
} else if (ev == BUTTON_EVENT_LONG_PRESS) {
c = handleLongPress(KEY_RIGHT);
}
ev = back_btn.check();
if (ev == BUTTON_EVENT_TRIPLE_CLICK) {
c = handleTripleClick(KEY_SELECT);
}
#elif defined(PIN_USER_BTN)
int ev = user_btn.check();
if (ev == BUTTON_EVENT_CLICK) {
c = checkDisplayOn(KEY_NEXT);
} else if (ev == BUTTON_EVENT_LONG_PRESS) {
c = handleLongPress(KEY_ENTER);
} else if (ev == BUTTON_EVENT_DOUBLE_CLICK) {
c = handleDoubleClick(KEY_PREV);
} else if (ev == BUTTON_EVENT_TRIPLE_CLICK) {
c = handleTripleClick(KEY_SELECT);
}
#endif
#if defined(PIN_USER_BTN_ANA)
if (abs(millis() - _analogue_pin_read_millis) > 10) {
int ev = analog_btn.check();
if (ev == BUTTON_EVENT_CLICK) {
c = checkDisplayOn(KEY_NEXT);
} else if (ev == BUTTON_EVENT_LONG_PRESS) {
c = handleLongPress(KEY_ENTER);
} else if (ev == BUTTON_EVENT_DOUBLE_CLICK) {
c = handleDoubleClick(KEY_PREV);
} else if (ev == BUTTON_EVENT_TRIPLE_CLICK) {
c = handleTripleClick(KEY_SELECT);
}
_analogue_pin_read_millis = millis();
}
#endif
#if defined(BACKLIGHT_BTN)
// First-poll state must not collide with a completed poll at timestamp zero.
enum class BacklightButtonState : uint8_t { Unseen, Released, Pressed };
static BacklightButtonState button_state = BacklightButtonState::Unseen;
const uint32_t backlight_now = millis();
if (button_state == BacklightButtonState::Unseen ? backlight_now > 0
: static_cast<uint32_t>(backlight_now - last_backlight_btn_check) > 300) {
const bool pressed = digitalRead(PIN_BUTTON2) == LOW;
if (pressed && button_state != BacklightButtonState::Pressed && _display != NULL)
_display->wake(mesh::ui::DisplayWake::Button);
button_state = pressed ? BacklightButtonState::Pressed : BacklightButtonState::Released;
last_backlight_btn_check = millis();
}
#endif
#if defined(HAS_TORCH)
ev = back_btn.check();
if (ev == BUTTON_EVENT_CLICK && c == 0) {
c = checkDisplayOn(KEY_PREV);
} else if (ev == BUTTON_EVENT_DOUBLE_CLICK) {
board.toggleTorch();
c = 0;
}
#endif
if (isPairingScreenActive()) {
static_cast<HomeScreen*>(home)->showFirstPage();
if (curr != home) setCurrScreen(home);
c = 0;
}
if (c != 0 && curr) {
_display->wake(mesh::ui::DisplayWake::Button);
curr->handleInput(c);
_next_refresh = millis(); // trigger refresh
}
userLedHandler();
#ifdef PIN_BUZZER
if (buzzer.isPlaying()) buzzer.loop();
#endif
if (!morse_timing && curr) curr->poll();
if (!morse_timing && _display != NULL && _display->isOn()) {
_statusBar.update(*_display,
_node_prefs->node_name,
_cached_batt_mv,
isBuzzerQuiet(),
getGPSState(),
isBluetoothEnabled());
bool status_dirty = _statusBar.needsRedraw();
bool content_dirty = (static_cast<int32_t>(millis() - _next_refresh) >= 0 && curr);
if (status_dirty || content_dirty) {
_display->startFrame();
_statusBar.render(*_display);
if (curr) {
int delay_millis = curr->render(*_display);
if (content_dirty) {
_next_refresh = millis() + delay_millis;
}
}
if (!isPairingScreenActive() && _alert[0] && static_cast<int32_t>(millis() - _alert_expiry) < 0) { // render alert popup
_display->setTextSize(1);
int y = _display->height() / 3;
int p = _display->height() / 32;
_display->setColor(UIColor::popup_bkg);
_display->fillRect(p, y, _display->width() - p*2, y);
_display->setColor(UIColor::popup_txt); // draw box border
_display->drawRect(p, y, _display->width() - p*2, y);
_display->drawTextCentered(_display->width() / 2, y + p*3, _alert);
_next_refresh = _alert_expiry; // will need refresh when alert is dismissed
}
_display->endFrame();
}
#if AUTO_OFF_MILLIS > 0
#if MOMENTARY_BUTTON_WAKE_HOLD_MS > 0 && defined(PIN_USER_BTN)
if (user_btn.isWakeHoldActive()) _display->wake(mesh::ui::DisplayWake::Button);
#endif
#endif
}
#if defined(PIN_VIBRATION) || defined(HAS_DRV2605)
vibration.loop();
#endif
#ifdef AUTO_SHUTDOWN_MILLIVOLTS
if (static_cast<int32_t>(millis() - next_batt_chck) >= 0) {
_cached_batt_mv = getBattMilliVolts();
if (_cached_batt_mv > 0 && _cached_batt_mv < AUTO_SHUTDOWN_MILLIVOLTS) {
if(!board.isExternalPowered()) {
if (_display != NULL) {
_display->startFrame();
_display->setTextSize(2);
_display->drawTextCentered(_display->width() / 2, 6, "Low battery!");
_display->setTextSize(1);
_display->drawTextCentered(_display->width() / 2, 18, "Shutting down!");
_display->endFrame();
if (_display->isEink() == false) { delay(3000); }
}
shutdown();
}
}
next_batt_chck = millis() + 8000;
}
#else
if (!morse_timing && _display != NULL && _display->isOn() && static_cast<int32_t>(millis() - next_batt_chck) >= 0) {
_cached_batt_mv = getBattMilliVolts();
next_batt_chck = millis() + 8000;
}
#endif
}
char UITask::checkDisplayOn(char c) {
the_mesh.notificationButton();
if (_display != NULL) {
const bool was_on = _display->isOn();
_display->wake(mesh::ui::DisplayWake::Button);
if (!was_on) {
c = 0;
}
_next_refresh = millis(); // trigger refresh
}
return c;
}
char UITask::handleLongPress(char c) {
if (!_startup_complete && static_cast<uint32_t>(millis() - ui_started_at) < 8000u) { // early-boot CLI/rescue only
the_mesh.enterCLIRescue();
c = 0; // consume event
}
return c;
}
char UITask::handleDoubleClick(char c) {
MESH_DEBUG_PRINTLN("UITask: double-click triggered");
checkDisplayOn(c);
return c;
}
char UITask::handleTripleClick(char c) {
MESH_DEBUG_PRINTLN("UITask: triple click triggered");
checkDisplayOn(c);
toggleBuzzer();
c = 0;
return c;
}
bool UITask::getGPSState() {
if (_sensors != NULL) {
int num = _sensors->getNumSettings();
for (int i = 0; i < num; i++) {
if (strcmp(_sensors->getSettingName(i), "gps") == 0) {
return !strcmp(_sensors->getSettingValue(i), "1");
}
}
}
return false;
}
void UITask::toggleGPS() {
if (_sensors != NULL) {
// toggle GPS on/off
int num = _sensors->getNumSettings();
for (int i = 0; i < num; i++) {
if (strcmp(_sensors->getSettingName(i), "gps") == 0) {
if (strcmp(_sensors->getSettingValue(i), "1") == 0) {
_sensors->setSettingValue("gps", "0");
_node_prefs->gps_enabled = 0;
notify(UIEventType::ack);
} else {
_sensors->setSettingValue("gps", "1");
_node_prefs->gps_enabled = 1;
notify(UIEventType::ack);
}
the_mesh.savePrefs();
showAlert(_node_prefs->gps_enabled ? "GPS: Enabled" : "GPS: Disabled", 800);
_next_refresh = millis();
break;
}
}
}
}
void UITask::toggleBuzzer() {
// Toggle buzzer quiet mode
#ifdef PIN_BUZZER
if (buzzer.isQuiet()) {
buzzer.quiet(false);
notify(UIEventType::ack);
} else {
buzzer.quiet(true);
}
_node_prefs->buzzer_quiet = buzzer.isQuiet();
the_mesh.setNotificationOutputMute(mesh::notify::Sound, _node_prefs->buzzer_quiet);
the_mesh.savePrefs();
showAlert(buzzer.isQuiet() ? "Buzzer: OFF" : "Buzzer: ON", 800);
_next_refresh = millis(); // trigger refresh
#endif
}
uint8_t UITask::notificationCapabilities() const {
uint8_t bits = 0;
#ifdef PIN_BUZZER
bits |= mesh::notify::Sound;
#endif
#if defined(PIN_VIBRATION) || defined(HAS_DRV2605)
bits |= mesh::notify::Vibration;
#endif
#ifdef MESHCORE_HAS_REAL_DISPLAY
if (_display) bits |= mesh::notify::Screen;
#endif
return bits;
}
void UITask::notificationMelody(const char* text) {
#ifdef PIN_BUZZER
if (text) buzzer.playNotification(text); else buzzer.stopNotification();
#endif
}
void UITask::notificationScreen(int8_t mode) {
if (!_display) return;
_display->notificationPower(mode);
_next_refresh = millis();
}
void UITask::notificationVibration(bool on) {
#if defined(PIN_VIBRATION) || defined(HAS_DRV2605)
vibration.pulse(on);
#endif
}