#include "UITask.h" #include "target.h" #include #include #include #include #include #ifdef DISPLAY_REDRAW_ON_CHANGE #include #endif #ifndef USER_BTN_PRESSED #define USER_BTN_PRESSED LOW #endif #ifdef ESP_PLATFORM #include #include // defines WITH_WEBCONFIG on ESP32 #endif #ifdef DISPLAY_TOUCH_TOGGLE #define TOUCH_POLL_MILLIS 50 #endif // Wrap-safe deadline test. `millis() >= deadline` fires early for the whole // interval before a rollover, because the deadline has already wrapped to a // small value while millis() is still near UINT32_MAX; the signed difference // stays correct across it. static inline bool millisReached(unsigned long now, unsigned long deadline) { return (int32_t)((uint32_t)now - (uint32_t)deadline) >= 0; } // Applies `display.flip` when it changes, forcing a complete repaint because // the panel's existing contents are now the wrong way up. void UITask::applyDisplayFlip() { #ifdef WITH_MQTT_BRIDGE if (_observer_prefs == NULL || _observer_prefs->display_flip == _flip_seen) return; _flip_seen = _observer_prefs->display_flip; _display->setFlipped(_flip_seen != 0); // Logged unconditionally: this is persisted config, so it survives a reflash // and is otherwise invisible when someone is chasing a wrong orientation. mesh::usbConsolePort().printf("Display: flip %s\n", _flip_seen ? "on (rotated 180)" : "off"); #ifdef DISPLAY_REDRAW_ON_CHANGE _frame_valid = false; #endif #ifdef DISPLAY_ACTIVITY_DASHBOARD _rows_valid = false; #endif _next_refresh = 0; #endif } #define BOOT_SCREEN_MILLIS 4000 // 4 seconds #define POWEROFF_DELAY 3000 // 'meshcore', 128x13px static const uint8_t meshcore_logo [] PROGMEM = { 0x3c, 0x01, 0xe3, 0xff, 0xc7, 0xff, 0x8f, 0x03, 0x87, 0xfe, 0x1f, 0xfe, 0x1f, 0xfe, 0x1f, 0xfe, 0x3c, 0x03, 0xe3, 0xff, 0xc7, 0xff, 0x8e, 0x03, 0x8f, 0xfe, 0x3f, 0xfe, 0x1f, 0xff, 0x1f, 0xfe, 0x3e, 0x03, 0xc3, 0xff, 0x8f, 0xff, 0x0e, 0x07, 0x8f, 0xfe, 0x7f, 0xfe, 0x1f, 0xff, 0x1f, 0xfc, 0x3e, 0x07, 0xc7, 0x80, 0x0e, 0x00, 0x0e, 0x07, 0x9e, 0x00, 0x78, 0x0e, 0x3c, 0x0f, 0x1c, 0x00, 0x3e, 0x0f, 0xc7, 0x80, 0x1e, 0x00, 0x0e, 0x07, 0x1e, 0x00, 0x70, 0x0e, 0x38, 0x0f, 0x3c, 0x00, 0x7f, 0x0f, 0xc7, 0xfe, 0x1f, 0xfc, 0x1f, 0xff, 0x1c, 0x00, 0x70, 0x0e, 0x38, 0x0e, 0x3f, 0xf8, 0x7f, 0x1f, 0xc7, 0xfe, 0x0f, 0xff, 0x1f, 0xff, 0x1c, 0x00, 0xf0, 0x0e, 0x38, 0x0e, 0x3f, 0xf8, 0x7f, 0x3f, 0xc7, 0xfe, 0x0f, 0xff, 0x1f, 0xff, 0x1c, 0x00, 0xf0, 0x1e, 0x3f, 0xfe, 0x3f, 0xf0, 0x77, 0x3b, 0x87, 0x00, 0x00, 0x07, 0x1c, 0x0f, 0x3c, 0x00, 0xe0, 0x1c, 0x7f, 0xfc, 0x38, 0x00, 0x77, 0xfb, 0x8f, 0x00, 0x00, 0x07, 0x1c, 0x0f, 0x3c, 0x00, 0xe0, 0x1c, 0x7f, 0xf8, 0x38, 0x00, 0x73, 0xf3, 0x8f, 0xff, 0x0f, 0xff, 0x1c, 0x0e, 0x3f, 0xf8, 0xff, 0xfc, 0x70, 0x78, 0x7f, 0xf8, 0xe3, 0xe3, 0x8f, 0xff, 0x1f, 0xfe, 0x3c, 0x0e, 0x3f, 0xf8, 0xff, 0xfc, 0x70, 0x3c, 0x7f, 0xf8, 0xe3, 0xe3, 0x8f, 0xff, 0x1f, 0xfc, 0x3c, 0x0e, 0x1f, 0xf8, 0xff, 0xf8, 0x70, 0x3c, 0x7f, 0xf8, }; void UITask::begin(NodePrefs* node_prefs, const char* build_date, const char* firmware_version) { _prevBtnState = HIGH; _started_at = millis(); _node_prefs = node_prefs; #ifdef DISPLAY_ACTIVITY_DASHBOARD ObserverDashboard::applyDarkPalette(); // retunes UIColor for this target only #endif _display->servicePower(board.isExternalPowered() || board.isUsbHostConnected()); applyDisplayFlip(); #ifdef DISPLAY_TOUCH_TOGGLE _touch.begin(); #endif #ifdef DISPLAY_REDRAW_ON_CHANGE _frame_valid = false; #endif #if defined(PIN_USER_BTN) && defined(DISPLAY_CLASS) user_btn.begin(); #endif // strip off dash and commit hash by changing dash to null terminator // e.g: v1.2.3-abcdef -> v1.2.3 char *version = strdup(firmware_version); char *dash = strchr(version, '-'); if(dash){ *dash = 0; } // v1.2.3 (1 Jan 2025) snprintf(_version_info, sizeof(_version_info), "%s (%s)", version, build_date); free(version); } void UITask::renderCurrScreen() { char tmp[80]; #ifdef DISPLAY_ACTIVITY_DASHBOARD _rows_valid = false; #endif if ((uint32_t)(millis() - _started_at) < BOOT_SCREEN_MILLIS) { // boot screen // meshcore logo _display->setColor(UIColor::corp_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(UIColor::primary_txt); _display->setTextSize(1); _display->drawTextCentered(_display->width() / 2, 22, website); // version info _display->setTextSize(1); _display->drawTextCentered(_display->width() / 2, 35, _version_info); // node type const char* node_type = "< Repeater >"; _display->drawTextCentered(_display->width() / 2, 48, node_type); } else if (_powering_off_at > 0) { // meshcore logo _display->setColor(UIColor::corp_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(UIColor::primary_txt); _display->setTextSize(1); _display->drawTextCentered(_display->width()/ 2, 22, website); // Powering off const char* poweroff_string = "Turning OFF"; uint16_t poffWidth = _display->getTextWidth(poweroff_string); _display->setCursor((_display->width() - poffWidth) / 2, 48); _display->drawTextCentered(_display->width()/2, 48, poweroff_string); } else { // home screen #ifdef WITH_WEBCONFIG if (WebConfigServer::isRebootPending()) { // save confirmed on-device: show ground truth even if the browser // lost its connection before the confirmation reached it _display->setTextSize(1); _display->setColor(UIColor::corp_blue); _display->setCursor(0, 14); _display->print("Config saved!"); _display->setColor(UIColor::primary_txt); _display->setCursor(0, 30); _display->print("Rebooting..."); return; } char wc_ssid[33], wc_ip[16]; if (WebConfigServer::getSetupInfo(wc_ssid, sizeof(wc_ssid), wc_ip, sizeof(wc_ip))) { // setup portal active: show join instructions instead of the home screen if (mesh::ui::drawWiFiSetupQr(*_display, wc_ssid, wc_ip)) { return; } _display->setTextSize(1); _display->setColor(UIColor::corp_blue); _display->setCursor(0, 0); #ifdef DISPLAY_ACTIVITY_DASHBOARD _display->print("Observer WiFi Setup"); #else _display->print("WebUI WiFi Setup"); #endif _display->setColor(UIColor::primary_txt); _display->setCursor(0, 14); _display->print("Join WiFi:"); _display->setColor(UIColor::warning_txt); _display->setCursor(6, 24); _display->print(wc_ssid); _display->setColor(UIColor::primary_txt); _display->setCursor(0, 40); _display->print("Then browse to:"); _display->setColor(UIColor::warning_txt); _display->setCursor(6, 50); _display->print(wc_ip); return; } #endif #ifdef DISPLAY_ACTIVITY_DASHBOARD renderDashboard(); return; #endif // Reserve a full measured font-height for each row on OLED/TFT/e-paper. mesh::ui::BoundedTextRows rows(*_display, {0, 0, _display->width(), _display->height()}); _display->setTextSize(1); _display->setColor(UIColor::primary_txt); rows.draw(_node_prefs->node_name, false); // freq / sf sprintf(tmp, "FREQ: %06.3f SF%d", _node_prefs->freq, _node_prefs->sf); rows.draw(tmp, false); // bw / cr sprintf(tmp, "BW: %03.2f CR: %d", _node_prefs->bw, _node_prefs->cr); rows.draw(tmp, false); #ifdef WITH_MQTT_BRIDGE // Display IP address for MQTT bridge devices if (WiFi.status() == WL_CONNECTED) { IPAddress ip = WiFi.localIP(); _display->setColor(UIColor::primary_txt); snprintf(tmp, sizeof(tmp), "IP: %d.%d.%d.%d", ip[0], ip[1], ip[2], ip[3]); rows.draw(tmp, false); } else #endif { snprintf(tmp, sizeof(tmp), "BAT: %.2fV", _board->getBattMilliVolts() / 1000.0f); rows.draw(tmp, false); } // Keep power-saving state visible even when the MQTT IP replaces battery. snprintf(tmp, sizeof(tmp), "PowerSaving: %s", _node_prefs->powersaving_enabled ? "ON" : "off"); rows.draw(tmp, false); } } #ifdef DISPLAY_REDRAW_ON_CHANGE uint32_t UITask::getFrameSignature() { uint32_t signature = DisplayFrameSignature::INITIAL; char tmp[80]; if ((uint32_t)(millis() - _started_at) < BOOT_SCREEN_MILLIS) { signature = DisplayFrameSignature::append(signature, "boot"); return DisplayFrameSignature::append(signature, _version_info); } if (_powering_off_at > 0) { return DisplayFrameSignature::append(signature, "powering-off"); } #ifdef WITH_WEBCONFIG if (WebConfigServer::isRebootPending()) { return DisplayFrameSignature::append(signature, "rebooting"); } char wc_ssid[33], wc_ip[16]; if (WebConfigServer::getSetupInfo(wc_ssid, sizeof(wc_ssid), wc_ip, sizeof(wc_ip))) { signature = DisplayFrameSignature::append(signature, "setup"); signature = DisplayFrameSignature::append(signature, wc_ssid); return DisplayFrameSignature::append(signature, wc_ip); } #endif signature = DisplayFrameSignature::append(signature, "home"); signature = DisplayFrameSignature::append(signature, _node_prefs->node_name); snprintf(tmp, sizeof(tmp), "FREQ: %06.3f SF%d", _node_prefs->freq, _node_prefs->sf); signature = DisplayFrameSignature::append(signature, tmp); snprintf(tmp, sizeof(tmp), "BW: %03.2f CR: %d", _node_prefs->bw, _node_prefs->cr); signature = DisplayFrameSignature::append(signature, tmp); #if defined(WITH_MQTT_BRIDGE) && !defined(DISPLAY_ACTIVITY_DASHBOARD) if (WiFi.status() == WL_CONNECTED) { IPAddress ip = WiFi.localIP(); snprintf(tmp, sizeof(tmp), "IP: %d.%d.%d.%d", ip[0], ip[1], ip[2], ip[3]); signature = DisplayFrameSignature::append(signature, tmp); } else { signature = DisplayFrameSignature::append(signature, "wifi-disconnected"); } #endif #ifndef DISPLAY_ACTIVITY_DASHBOARD #ifdef WITH_MQTT_BRIDGE if (WiFi.status() != WL_CONNECTED) { snprintf(tmp, sizeof(tmp), "BAT: %.2fV", _board->getBattMilliVolts() / 1000.0f); signature = DisplayFrameSignature::append(signature, tmp); } #else snprintf(tmp, sizeof(tmp), "BAT: %.2fV", _board->getBattMilliVolts() / 1000.0f); signature = DisplayFrameSignature::append(signature, tmp); #endif signature = DisplayFrameSignature::append( signature, _node_prefs->powersaving_enabled ? "powersaving-on" : "powersaving-off"); #endif return signature; } #endif #ifdef DISPLAY_ACTIVITY_DASHBOARD #define ACTIVITY_REFRESH_MILLIS 5000 bool UITask::buildDashboardContext(ObserverDashboard::Context* ctx) { if (_node_prefs == NULL) return false; ctx->node_name = _node_prefs->node_name; ctx->role_label = "REPEATER"; ctx->freq = _node_prefs->freq; ctx->sf = _node_prefs->sf; ctx->bw = _node_prefs->bw; #ifdef WITH_MQTT_BRIDGE ctx->link_up = (WiFi.status() == WL_CONNECTED); #else ctx->link_up = false; #endif return true; } void UITask::renderDashboard() { ObserverDashboard::Context ctx; if (!buildDashboardContext(&ctx)) return; RadioActivitySnapshot snap; if (_activity) { _activity->snapshot(millis(), &snap); } else { memset(&snap, 0, sizeof(snap)); } const ObserverDashboard::Layout& layout = ObserverDashboard::activeLayout(); ObserverDashboard::drawFull(*_display, layout, ctx, snap); ObserverDashboard::allRowSignatures(layout, ctx, snap, _row_signatures); _rows_valid = true; _next_activity = millis() + ACTIVITY_REFRESH_MILLIS; } // Repaints just the analytics rows whose contents moved. No startFrame(), so // the header, the radio strip and the rest of the panel are never cleared. void UITask::updateActivityRows() { if (!_rows_valid || _activity == NULL) return; // not showing the dashboard ObserverDashboard::Context ctx; if (!buildDashboardContext(&ctx)) return; RadioActivitySnapshot snap; _activity->snapshot(millis(), &snap); ObserverDashboard::drawChangedRows(*_display, ObserverDashboard::activeLayout(), ctx, snap, _row_signatures); } #endif #ifdef DISPLAY_TOUCH_TOGGLE void UITask::toggleDisplay(const char* source) { _display->wake(mesh::ui::DisplayWake::Button); #ifdef DISPLAY_TOUCH_DEBUG mesh::usbConsolePort().printf("Display: %s -> %s\n", source, _display->isOn() ? "on" : "off"); #else (void)source; #endif #ifdef DISPLAY_REDRAW_ON_CHANGE _frame_valid = false; // wake draws one complete current frame #endif #ifdef DISPLAY_ACTIVITY_DASHBOARD _rows_valid = false; #endif _next_refresh = 0; // redraw at once rather than showing the stale frame } #endif void UITask::loop() { if (_display->servicePower(board.isExternalPowered() || board.isUsbHostConnected())) { _next_refresh = 0; #ifdef DISPLAY_REDRAW_ON_CHANGE _frame_valid = false; #endif #ifdef DISPLAY_ACTIVITY_DASHBOARD _rows_valid = false; #endif } #if defined(PIN_USER_BTN) && defined(DISPLAY_CLASS) int ev = user_btn.check(); // Multiclick stays enabled on the R8 observer so triple-click can toggle // WebConfig. Collapse a completed double-click into one display action // instead of silently consuming both presses. if (ev == BUTTON_EVENT_CLICK || ev == BUTTON_EVENT_DOUBLE_CLICK) { #ifdef DISPLAY_TOUCH_TOGGLE toggleDisplay("button"); // same action as tapping the panel #else if (_display->isOn()) { // TODO: any action ? } else { _display->wake(mesh::ui::DisplayWake::Button); #ifdef DISPLAY_REDRAW_ON_CHANGE _frame_valid = false; #endif #ifdef DISPLAY_ACTIVITY_DASHBOARD _rows_valid = false; #endif } _display->wake(mesh::ui::DisplayWake::Button); #endif } else if (ev == BUTTON_EVENT_LONG_PRESS) { _display->wake(mesh::ui::DisplayWake::Button); mesh::usbConsolePort().printf("Powering Off\r\n"); _powering_off_at = millis() + POWEROFF_DELAY; #ifdef DISPLAY_REDRAW_ON_CHANGE _frame_valid = false; #endif #ifdef DISPLAY_ACTIVITY_DASHBOARD _rows_valid = false; #endif _next_refresh = 0; #ifdef WITH_WEBCONFIG } else if (ev == BUTTON_EVENT_TRIPLE_CLICK) { // Preserve the fork's persistent WebUI toggle. R8 observer repeaters keep // multiclick enabled specifically for this action. WebConfigServer::requestToggleFromButton(); _display->wake(mesh::ui::DisplayWake::Button); #endif } #endif #ifdef DISPLAY_TOUCH_TOGGLE { unsigned long now = millis(); if (millisReached(now, _next_touch)) { _next_touch = now + TOUCH_POLL_MILLIS; if (_powering_off_at == 0 && _touch.checkTap(now)) toggleDisplay("touch"); } } #endif if (_display->isOn()) { // Apply a live orientation change before drawing, including the first frame // after a preference was changed while the panel was blanked. applyDisplayFlip(); if (millisReached(millis(), _next_refresh)) { bool redraw = true; #ifdef DISPLAY_REDRAW_ON_CHANGE uint32_t frame_signature = getFrameSignature(); redraw = !_frame_valid || frame_signature != _last_frame_signature; #endif if (redraw) { _display->startFrame(); renderCurrScreen(); _display->endFrame(); #ifdef DISPLAY_REDRAW_ON_CHANGE _last_frame_signature = frame_signature; _frame_valid = true; #endif } #ifdef DISPLAY_ACTIVITY_DASHBOARD else if (millisReached(millis(), _next_activity)) { updateActivityRows(); _next_activity = millis() + ACTIVITY_REFRESH_MILLIS; } #endif _next_refresh = millis() + 1000; // check for visible changes every second } } if (_powering_off_at > 0) { // power off timer armed #ifdef LED_PIN digitalWrite(LED_PIN, LED_STATE_ON); // switch on the led until poweroff #endif if (millisReached(millis(), _powering_off_at)) { _board->powerOff(); // should not return } } }