Files
HaloKeymind/examples/simple_room_server/UITask.cpp
T
mikecarper a814a00ce7 Add configurable battery and USB display behavior
Persist separate display modes and timeouts in seconds for battery and USB power, expose four controls in WebConfig and CLI, and apply one shared policy across display-equipped roles.

Detect R8 external power from a USB host or calibrated battery voltage above 4.21 V, with cached sampling and hysteresis. Add policy, persistence, browser, and R8 power-detection regression coverage.
2026-09-10 21:03:15 -07:00

468 lines
16 KiB
C++

#include "UITask.h"
#include "target.h"
#include <Arduino.h>
#include <helpers/UsbLogging.h>
#include <helpers/CommonCLI.h>
#include <helpers/ui/WiFiSetupQrDisplay.h>
#ifdef DISPLAY_REDRAW_ON_CHANGE
#include <helpers/ui/DisplayFrameSignature.h>
#endif
#ifndef USER_BTN_PRESSED
#define USER_BTN_PRESSED LOW
#endif
#ifdef ESP_PLATFORM
#include <WiFi.h>
#include <helpers/esp32/WebConfigServer.h> // 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
#ifdef DISPLAY_TOUCH_TOGGLE
#define POWEROFF_DELAY 3000
#endif
// '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
#if defined(PIN_USER_BTN) && defined(DISPLAY_CLASS) \
&& (defined(DISPLAY_TOUCH_TOGGLE) \
|| (defined(MOMENTARY_BUTTON_WAKE_FROM_SLEEP) \
&& MOMENTARY_BUTTON_WAKE_FROM_SLEEP))
user_btn.begin();
#endif
#ifdef DISPLAY_REDRAW_ON_CHANGE
_frame_valid = false;
#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);
uint16_t websiteWidth = _display->getTextWidth(website);
_display->setCursor((_display->width() - websiteWidth) / 2, 22);
_display->print(website);
// version info
_display->setTextSize(1);
uint16_t versionWidth = _display->getTextWidth(_version_info);
_display->setCursor((_display->width() - versionWidth) / 2, 35);
_display->print(_version_info);
// node type
const char* node_type = "< Room Server >";
uint16_t typeWidth = _display->getTextWidth(node_type);
_display->setCursor((_display->width() - typeWidth) / 2, 48);
_display->print(node_type);
#ifdef DISPLAY_TOUCH_TOGGLE
} else if (_powering_off_at > 0) {
_display->setColor(UIColor::corp_blue);
_display->drawXbm(0, 3, meshcore_logo, 128, 13);
_display->setTextSize(1);
_display->setColor(UIColor::primary_txt);
_display->drawTextCentered(_display->width() / 2, 48, "Turning OFF");
#endif
} 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
// node name
_display->setCursor(0, 0);
_display->setTextSize(1);
_display->setColor(UIColor::primary_txt);
_display->print(_node_prefs->node_name);
// freq / sf
_display->setCursor(0, 20);
sprintf(tmp, "FREQ: %06.3f SF%d", _node_prefs->freq, _node_prefs->sf);
_display->print(tmp);
// bw / cr
_display->setCursor(0, 30);
sprintf(tmp, "BW: %03.2f CR: %d", _node_prefs->bw, _node_prefs->cr);
_display->print(tmp);
#ifdef WITH_MQTT_BRIDGE
// Display IP address for MQTT bridge devices
if (WiFi.status() == WL_CONNECTED) {
IPAddress ip = WiFi.localIP();
_display->setCursor(0, 40);
_display->setColor(UIColor::primary_txt);
snprintf(tmp, sizeof(tmp), "IP: %d.%d.%d.%d", ip[0], ip[1], ip[2], ip[3]);
_display->print(tmp);
}
#endif
}
}
#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);
}
#ifdef DISPLAY_TOUCH_TOGGLE
if (_powering_off_at > 0) {
return DisplayFrameSignature::append(signature, "powering-off");
}
#endif
#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
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 = "ROOM SERVER";
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_TOUCH_TOGGLE)
int ev = user_btn.check();
if (ev == BUTTON_EVENT_CLICK) {
toggleDisplay("button");
} 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;
}
#elif defined(PIN_USER_BTN) \
&& defined(DISPLAY_CLASS) \
&& defined(MOMENTARY_BUTTON_WAKE_FROM_SLEEP) \
&& MOMENTARY_BUTTON_WAKE_FROM_SLEEP
// Sleep-capable targets must use the shared debounced button state
// machine. Raw 200 ms polling can go back to sleep after the GPIO edge and
// miss both a short press and its release.
int ev = user_btn.check();
if (ev != BUTTON_EVENT_NONE) {
if (!_display->isOn()) {
_display->wake(mesh::ui::DisplayWake::Button);
#ifdef DISPLAY_REDRAW_ON_CHANGE
_frame_valid = false;
#endif
#ifdef DISPLAY_ACTIVITY_DASHBOARD
_rows_valid = false;
#endif
_next_refresh = 0;
}
_display->wake(mesh::ui::DisplayWake::Button);
}
#elif defined(PIN_USER_BTN)
if (millisReached(millis(), _next_read)) {
int btnState = digitalRead(PIN_USER_BTN);
if (btnState != _prevBtnState) {
if (btnState == USER_BTN_PRESSED) { // pressed?
#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
}
_prevBtnState = btnState;
}
_next_read = millis() + 200; // 5 reads per second
}
#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
}
}
#ifdef DISPLAY_TOUCH_TOGGLE
if (_powering_off_at > 0 && millisReached(millis(), _powering_off_at)) {
_board->powerOff(); // current board path performs the full radio/GPS shutdown
}
#endif
}