2025-03-04 23:09:43 +11:00
#include "UITask.h"
2026-09-26 00:27:52 +02:00
#include "../ui-core/SoundNotifier.h"
#include "../ui-core/SoundControl.h"
2025-03-04 23:09:43 +11:00
#include <helpers/TxtDataHelpers.h>
2025-08-16 20:04:54 +10:00
#include "../MyMesh.h"
2026-05-12 14:16:37 +02:00
#include "../MsgExpand.h"
2026-05-24 20:41:25 +02:00
#include "../Features.h"
2026-06-03 08:20:59 +02:00
#include "../GeoUtils.h"
2025-08-08 20:01:31 +10:00
#include "target.h"
2025-10-27 17:58:29 +01:00
#ifdef WIFI_SSID
#include <WiFi.h>
#endif
2026-09-03 00:46:47 +02:00
#ifdef SIM_PLATFORM
#include <sys/select.h>
#include <unistd.h>
#ifdef __EMSCRIPTEN__
#include <emscripten.h>
// The single UITask instance is a file-scope global in
// examples/companion_radio/main.cpp (`UITask ui_task(...)`, only under
// `#ifdef DISPLAY_CLASS`, which the sim build always defines) -- not
// reachable from here by name, so UITask::begin() stashes `this` here
// (see below) the same way every other single-instance sim glue point
// does. Declared up here (rather than next to its use near enqueueKey(),
// further down this file) since UITask::begin() -- also further down,
// but earlier in the file -- needs it too.
static UITask * g_sim_ui_task_for_js = nullptr ;
#endif
#endif
2025-03-04 23:09:43 +11:00
2025-09-03 18:17:37 +02:00
#ifndef AUTO_OFF_MILLIS
#define AUTO_OFF_MILLIS 15000 // 15 seconds
#endif
2026-08-06 20:08:31 +02:00
// Upstream MeshCore version, shown on the splash screen. Most variants set it in
// their platformio.ini; the ones that don't used to fail to compile this file
// outright rather than fall back, which quietly made every ui-new env on those
// boards unbuildable (heltec v3/v4, thinknode m1/m5, mesh pocket, techo).
#ifndef MESHCORE_VERSION
2026-08-29 19:23:08 +02:00
#define MESHCORE_VERSION "1.17.1"
2026-08-06 20:08:31 +02:00
#endif
2025-06-07 15:57:22 -07:00
#define BOOT_SCREEN_MILLIS 3000 // 3 seconds
2025-03-04 23:09:43 +11:00
2025-04-24 21:54:37 -04:00
#ifdef PIN_STATUS_LED
#define LED_ON_MILLIS 20
#define LED_ON_MSG_MILLIS 200
#define LED_CYCLE_MILLIS 4000
#endif
2025-08-08 20:01:31 +10:00
#define LONG_PRESS_MILLIS 1200
2025-03-10 22:42:52 +01:00
2025-08-16 18:13:50 +02:00
#ifndef UI_RECENT_LIST_SIZE
#define UI_RECENT_LIST_SIZE 4
#endif
2026-09-03 10:12:31 +02:00
// The sim's D-pad + dedicated OK/Enter key behaves like a joystick board
// (a short Enter press opens Settings/Tools/Messages -- see the
// KEY_ENTER && _page==... handlers below; holding it separately reaches
// KEY_CONTEXT_MENU via handleLongPress(), same as a real joystick board's
// long-press) -- without SIM_PLATFORM here, this would fall to the
// touchscreen-board wording below, which describes a different, and for
// this input method simply wrong, interaction.
#if UI_HAS_JOYSTICK || defined(SIM_PLATFORM)
2025-10-16 17:33:22 +11:00
#define PRESS_LABEL "press Enter"
#else
#define PRESS_LABEL "long press"
#endif
2025-08-08 20:01:31 +10:00
#include "icons.h"
2026-06-25 19:04:21 +02:00
#include "GfxUtils.h" // gfx::drawLine — connects trail points on the Home map preview
2025-08-08 20:01:31 +10:00
2026-06-15 18:17:34 +02:00
// Blinking status indicators: on for the first half of a 4 s cycle, but e-ink
// can't repaint fast enough to blink, so it shows them steadily.
static inline bool blinkOn () {
return Features :: BLINK_INDICATORS ? (( millis () % 4000 ) < 2000 ) : true ;
}
2025-08-08 20:01:31 +10:00
class SplashScreen : public UIScreen {
UITask * _task ;
unsigned long dismiss_after ;
char _version_info [ 12 ];
2026-05-29 12:46:29 +02:00
char _solo_ver [ 12 ];
2025-08-08 20:01:31 +10:00
public :
SplashScreen ( UITask * task ) : _task ( task ) {
2026-05-24 00:35:07 +02:00
// MeshCore upstream version shown large (e.g. "1.15")
strncpy ( _version_info , MESHCORE_VERSION , sizeof ( _version_info ) - 1 );
_version_info [ sizeof ( _version_info ) - 1 ] = '\0' ;
2026-06-25 19:04:21 +02:00
// Solo firmware version: strip the commit-hash suffix build.sh always
// appends as the LAST dash-segment (v1.15-solo.1-abcdef -> v1.15-solo.1).
// Must be the last dash, not the first: a tag like v1.21-rc1 has a dash
// of its own before the commit hash gets appended.
2025-08-08 20:01:31 +10:00
const char * ver = FIRMWARE_VERSION ;
2026-06-25 19:04:21 +02:00
const char * dash = strrchr ( ver , '-' );
2026-05-24 00:35:07 +02:00
int plen = dash ? ( int )( dash - ver ) : ( int ) strlen ( ver );
2026-05-29 12:46:29 +02:00
if ( plen >= ( int ) sizeof ( _solo_ver )) plen = sizeof ( _solo_ver ) - 1 ;
memcpy ( _solo_ver , ver , plen );
_solo_ver [ plen ] = '\0' ;
2026-05-16 00:33:13 +02:00
2025-08-08 20:01:31 +10:00
dismiss_after = millis () + BOOT_SCREEN_MILLIS ;
}
int render ( DisplayDriver & display ) override {
2026-05-22 18:05:09 +02:00
display . setTextSize ( 1 );
const int lh = display . getLineHeight ();
const int step = display . lineStep ();
2025-08-08 20:01:31 +10:00
// meshcore logo
2026-05-11 15:56:30 +02:00
display . setColor ( DisplayDriver :: LIGHT );
2026-05-22 23:53:16 +02:00
int logoWidth = 128 ;
2026-05-22 18:05:09 +02:00
int logo_y = 3 ;
display . drawXbm (( display . width () - logoWidth ) / 2 , logo_y , meshcore_logo , logoWidth , 13 );
2025-08-08 20:01:31 +10:00
2026-05-22 18:05:09 +02:00
// version info at sz2
int ver_y = logo_y + 13 + 2 ;
2025-08-08 20:01:31 +10:00
display . setTextSize ( 2 );
2026-05-22 18:34:20 +02:00
int lh2 = display . getLineHeight ();
2026-05-22 18:05:09 +02:00
display . drawTextCentered ( display . width () / 2 , ver_y , _version_info );
2025-08-08 20:01:31 +10:00
2026-05-22 18:34:20 +02:00
// build date at sz1, below sz2 version
int date_y = ver_y + lh2 + 2 ;
2025-08-08 20:01:31 +10:00
display . setTextSize ( 1 );
2026-05-22 18:05:09 +02:00
display . drawTextCentered ( display . width () / 2 , date_y , FIRMWARE_BUILD_DATE );
2025-08-08 20:01:31 +10:00
2026-05-29 12:25:34 +02:00
#ifdef FIRMWARE_SOLO_BUILD
2026-05-29 12:46:29 +02:00
int solo_y = date_y + step ;
display . fillRect ( 0 , solo_y - 1 , display . width (), lh + 2 );
2026-05-11 15:56:30 +02:00
display . setColor ( DisplayDriver :: DARK );
2026-05-29 12:46:29 +02:00
char solo_label [ 24 ];
if ( _solo_ver [ 0 ])
2026-06-12 09:46:45 +02:00
snprintf ( solo_label , sizeof ( solo_label ), "Solo %s" , _solo_ver );
2026-05-16 00:33:13 +02:00
else
2026-06-12 09:46:45 +02:00
snprintf ( solo_label , sizeof ( solo_label ), "Solo" );
2026-05-29 12:46:29 +02:00
display . drawTextCentered ( display . width () / 2 , solo_y , solo_label );
2026-05-11 15:56:30 +02:00
display . setColor ( DisplayDriver :: LIGHT );
2026-05-10 22:58:46 +02:00
#endif
2025-08-08 20:01:31 +10:00
return 1000 ;
}
void poll () override {
if ( millis () >= dismiss_after ) {
_task -> gotoHomeScreen ();
}
}
};
2026-05-11 16:52:24 +02:00
static const int QUICK_MSGS_MAX = 10 ;
2026-05-12 09:51:30 +02:00
2026-06-29 18:20:47 +02:00
// ── Screen fragments — included into THIS translation unit only ───────────────
// These headers are not standalone: they are compiled solely as part of
// UITask.cpp, in the order below. Two consequences a new screen must respect:
// • Order matters. A `static inline` helper (drawList, msgReplyBody, geo::…)
// or a shared scratch buffer (FullscreenMsgView's s_wrap_*) is only visible
// to fragments included *after* the one that defines it. Add new screens
// after their dependencies.
// • Single-TU only. Some fragments define external-linkage symbols at file
// scope (e.g. NearbyScreen::FILTER_LABELS), so including any of them from a
// second .cpp is a duplicate-symbol link error. Keep them UITask-internal;
// anything genuinely shareable belongs in a real header (icons.h, GeoUtils.h).
2026-05-13 23:54:26 +02:00
#include "FullscreenMsgView.h"
2026-09-26 00:46:34 +02:00
#include "../ui-core/MessageText.h"
2026-05-14 00:12:07 +02:00
#include "SensorPlaceholders.h"
2026-05-17 00:12:40 +02:00
#include "SettingsScreen.h"
2026-09-24 23:00:46 +02:00
#include "../ui-core/UiCore.h" // shared UI Core: history rings + unread models (MessagesScreen views them)
2026-07-12 19:40:53 +02:00
#include "MessagesScreen.h"
2026-05-10 18:45:35 +02:00
2026-05-12 14:45:02 +02:00
// ── Custom screens (separate files to ease upstream merges) ───────────────────
#include "RingtoneEditorScreen.h"
#include "BotScreen.h"
2026-07-12 19:40:53 +02:00
#include "AdminScreen.h"
2026-09-25 00:53:23 +02:00
#include "../ui-core/NearbyModel.h"
2026-09-25 19:20:42 +02:00
#include "../ui-core/RadioControl.h"
2026-05-14 11:28:36 +02:00
#include "NearbyScreen.h"
#include "DashboardConfigScreen.h"
2026-05-14 16:08:33 +02:00
#include "AutoAdvertScreen.h"
2026-06-25 19:04:21 +02:00
#include "LiveShareScreen.h"
#include "LocatorScreen.h"
2026-05-25 09:10:40 +02:00
#include "TrailScreen.h"
2026-06-03 14:07:53 +02:00
#include "CompassScreen.h"
2026-06-18 22:34:52 +02:00
#include "DiagnosticsScreen.h"
2026-06-20 09:07:00 +02:00
#include "RepeaterScreen.h"
2026-07-21 20:30:03 +02:00
#if defined(PIN_GPIO1)
#include "GpioScreen.h"
#endif
2026-05-12 14:45:02 +02:00
#include "ToolsScreen.h"
2026-06-30 01:54:01 +02:00
#include "ClockToolsScreen.h" // Alarm / Timer / Stopwatch (Clock page › Enter)
2026-05-12 09:40:37 +02:00
2026-09-25 23:21:38 +02:00
#include "../ui-core/Battery.h"
2026-05-28 22:57:50 +02:00
2026-09-25 23:21:38 +02:00
// Voltage -> battery %, from the Core (top bar and dashboard Batt% alike).
static int battMvToPercent ( int mv , int low_mv ) { return battery :: percent ( mv , low_mv ); }
2026-05-28 22:57:50 +02:00
2026-06-02 23:32:27 +02:00
// Render the time starting at top_y; returns the y just below the time block
// so the caller can flow the date / dashboard rows beneath it.
//
// On a tall portrait panel (e-ink in portrait — height > width) HH and MM are
// stacked on two lines in the huge built-in font (size 4, ~56 px tall) so the
// digits fill the narrow width. On wide panels (OLED, landscape e-ink) the
// classic single-line "HH:MM" at size 2 is kept.
static int drawClockTime ( DisplayDriver & d , int top_y , const struct tm * ti ,
2026-09-05 22:01:14 +02:00
bool h12 , bool show_sec , bool alignCenter = false ) {
2026-06-02 23:32:27 +02:00
const bool tall = d . height () > d . width (); // true only on portrait e-ink
if ( tall ) {
int hh = ti -> tm_hour ;
const char * ap = nullptr ;
if ( h12 ) { ap = ( hh < 12 ) ? "AM" : "PM" ; hh %= 12 ; if ( hh == 0 ) hh = 12 ; }
const int cx = d . width () / 2 ;
char hbuf [ 4 ], mbuf [ 4 ];
snprintf ( hbuf , sizeof ( hbuf ), "%02d" , hh );
snprintf ( mbuf , sizeof ( mbuf ), "%02d" , ti -> tm_min );
int y = top_y ;
d . setTextSize ( 4 );
const int lhb = d . getLineHeight ();
// The built-in GFX font advances 6 px per char but the glyph is only 5 px
// wide, so getTextWidth() over-reports by one trailing blank column and
// drawTextCentered() would bias the digits ~half a column to the left.
// Centre on the visible width (minus that trailing column) instead.
const int trail = d . getCharWidth () / 6 ; // one built-in column at this size
auto drawBig = [ & ]( const char * s , int yy ) {
2026-09-05 22:01:14 +02:00
if ( alignCenter ) {
int w = ( int ) d . getTextWidth ( s ) - trail ;
d . setCursor ( cx - w / 2 , yy );
d . print ( s );
} else {
d . setCursor ( 0 , yy );
d . print ( s );
}
2026-06-02 23:32:27 +02:00
};
drawBig ( hbuf , y ); y += lhb + 2 ;
drawBig ( mbuf , y ); y += lhb + 2 ;
2026-09-05 22:01:14 +02:00
if ( ap ) {
d . setTextSize ( 2 );
if ( alignCenter ) {
d . drawTextCentered ( cx , y , ap );
} else {
d . setCursor ( 0 , y );
d . print ( ap );
}
y += d . getLineHeight () + 1 ;
}
2026-06-02 23:32:27 +02:00
d . setTextSize ( 1 );
return y ;
}
// Wide layout: single inline line at size 2.
char buf [ 16 ];
d . setTextSize ( 2 );
const int lh2 = d . getLineHeight ();
if ( h12 ) {
int hh = ti -> tm_hour % 12 ; if ( hh == 0 ) hh = 12 ;
const char * ap = ( ti -> tm_hour < 12 ) ? "AM" : "PM" ;
if ( show_sec ) snprintf ( buf , sizeof ( buf ), "%d:%02d:%02d%s" , hh , ti -> tm_min , ti -> tm_sec , ap );
else snprintf ( buf , sizeof ( buf ), "%d:%02d %s" , hh , ti -> tm_min , ap );
} else {
if ( show_sec ) snprintf ( buf , sizeof ( buf ), "%02d:%02d:%02d" , ti -> tm_hour , ti -> tm_min , ti -> tm_sec );
else snprintf ( buf , sizeof ( buf ), "%02d:%02d" , ti -> tm_hour , ti -> tm_min );
}
2026-09-05 22:01:14 +02:00
if ( alignCenter ) {
d . drawTextCentered ( d . width () / 2 , top_y , buf );
} else {
d . setCursor ( 0 , top_y );
d . print ( buf );
}
2026-06-02 23:32:27 +02:00
d . setTextSize ( 1 );
return top_y + lh2 + 2 ;
}
2026-05-12 09:40:37 +02:00
// ── HomeScreen ────────────────────────────────────────────────────────────────
2026-09-05 20:40:13 +02:00
// Forward declaration to be able to call formatDashVal from HomeScreen::render()
static void formatDashVal ( uint8_t field , char * val , int val_len , uint16_t batt_mv ,
2026-09-23 13:45:07 +02:00
uint16_t low_batt_mv , int unread , bool unread_overflow , bool imperial , CayenneLPP * lpp = nullptr );
2026-09-10 20:12:29 +02:00
// Altitude (baro or GPS) respects Settings > System > Units, same as every
// other distance in the UI -- unlike geo::fmtDist, never switches to km/mi
// regardless of magnitude, since altitude is always discussed in the small unit.
static void fmtAlt ( char * buf , int n , float meters , bool imperial ) {
if ( imperial ) snprintf ( buf , n , "%.0fft" , meters * 3.28084f );
else snprintf ( buf , n , "%.0fm" , meters );
}
2026-09-05 20:40:13 +02:00
2025-08-08 20:01:31 +10:00
class HomeScreen : public UIScreen {
enum HomePage {
2026-05-10 15:18:35 +02:00
CLOCK ,
2026-05-24 23:00:27 +02:00
FAVOURITES ,
2025-08-08 20:01:31 +10:00
RECENT ,
RADIO ,
BLUETOOTH ,
ADVERT ,
2025-09-28 09:43:28 +02:00
#if ENV_INCLUDE_GPS == 1
2025-09-23 10:39:43 +02:00
GPS ,
#endif
2025-09-09 16:32:41 +02:00
#if UI_SENSORS_PAGE == 1
2025-09-05 15:20:52 +02:00
SENSORS ,
2025-09-09 16:32:41 +02:00
#endif
2026-05-10 15:18:35 +02:00
SETTINGS ,
2026-06-25 19:04:21 +02:00
MAP ,
2026-05-12 09:40:37 +02:00
TOOLS ,
2026-05-10 18:45:35 +02:00
QUICK_MSG ,
2025-08-08 20:01:31 +10:00
SHUTDOWN ,
2026-09-05 20:40:13 +02:00
Count , // keep before LOCK — navigable-page count
LOCK // lock screen: full clock + dashboard view, not a navigable page
2025-08-08 20:01:31 +10:00
};
2026-05-24 23:00:27 +02:00
// Selected slot on the Favourites page (0..FAVOURITES_COUNT - 1).
uint8_t _fav_sel = 0 ;
2026-08-31 15:02:01 +02:00
// Slot payload (pubkey prefix, or channel index in byte 0 — see the slot's
// kind), or nullptr when the slot is empty.
const uint8_t * favSlotPrefix ( int slot ) const {
NodePrefs * p = _task -> getNodePrefs ();
if ( ! p || _task -> isFavouriteSlotEmpty ( slot )) return nullptr ;
return p -> favourite_contacts [ slot ];
2026-05-25 08:08:12 +02:00
}
2026-08-31 15:02:01 +02:00
// Unpin/Replace menu for a filled tile. Choosing what to pin is the Messages
// screen's job (see UITask::pickFavouriteTarget) -- the dial doesn't carry a
// second browser for contacts, rooms and channels.
PopupMenu _tile_menu ;
2026-05-25 08:08:12 +02:00
int _pin_target_slot = - 1 ;
2025-08-08 20:01:31 +10:00
UITask * _task ;
mesh :: RTCClock * _rtc ;
SensorManager * _sensors ;
NodePrefs * _node_prefs ;
uint8_t _page ;
2026-09-05 20:40:13 +02:00
uint8_t _prev_page ; // home page restored when the device unlocks
2025-08-08 20:01:31 +10:00
bool _shutdown_init ;
2026-05-12 09:51:30 +02:00
int pageBit ( int page ) const {
2026-05-24 23:00:27 +02:00
if ( page == CLOCK ) return NodePrefs :: HPB_CLOCK ;
if ( page == FAVOURITES ) return NodePrefs :: HPB_FAVOURITES ;
2026-05-24 22:26:56 +02:00
if ( page == RECENT ) return NodePrefs :: HPB_RECENT ;
if ( page == RADIO ) return NodePrefs :: HPB_RADIO ;
if ( page == BLUETOOTH ) return NodePrefs :: HPB_BLUETOOTH ;
if ( page == ADVERT ) return NodePrefs :: HPB_ADVERT ;
2026-05-12 09:51:30 +02:00
#if ENV_INCLUDE_GPS == 1
2026-05-24 22:26:56 +02:00
if ( page == GPS ) return NodePrefs :: HPB_GPS ;
2026-05-12 09:51:30 +02:00
#endif
#if UI_SENSORS_PAGE == 1
2026-05-24 22:26:56 +02:00
if ( page == SENSORS ) return NodePrefs :: HPB_SENSORS ;
2026-05-12 09:51:30 +02:00
#endif
2026-05-24 22:26:56 +02:00
if ( page == TOOLS ) return NodePrefs :: HPB_TOOLS ;
if ( page == SHUTDOWN ) return NodePrefs :: HPB_SHUTDOWN ;
2026-07-02 10:34:38 +02:00
if ( page == MAP ) return NodePrefs :: HPB_MAP ;
2026-05-24 22:26:56 +02:00
return - 1 ; // SETTINGS, QUICK_MSG always visible (no mask bit)
2026-05-12 09:51:30 +02:00
}
2026-05-24 22:26:56 +02:00
// Maps page_order bit-index back to the HomePage enum value for this build.
2026-05-22 23:33:06 +02:00
// Returns -1 if the page is not compiled in.
int bitToPage ( int bit ) const {
switch ( bit ) {
2026-05-24 23:00:27 +02:00
case NodePrefs :: HPB_CLOCK : return CLOCK ;
case NodePrefs :: HPB_FAVOURITES : return FAVOURITES ;
2026-05-24 22:26:56 +02:00
case NodePrefs :: HPB_RECENT : return RECENT ;
case NodePrefs :: HPB_RADIO : return RADIO ;
case NodePrefs :: HPB_BLUETOOTH : return BLUETOOTH ;
case NodePrefs :: HPB_ADVERT : return ADVERT ;
2026-05-22 23:33:06 +02:00
#if ENV_INCLUDE_GPS == 1
2026-05-24 22:26:56 +02:00
case NodePrefs :: HPB_GPS : return GPS ;
2026-05-22 23:33:06 +02:00
#endif
#if UI_SENSORS_PAGE == 1
2026-05-24 22:26:56 +02:00
case NodePrefs :: HPB_SENSORS : return SENSORS ;
2026-05-22 23:33:06 +02:00
#endif
2026-05-24 22:26:56 +02:00
case NodePrefs :: HPB_TOOLS : return TOOLS ;
case NodePrefs :: HPB_SHUTDOWN : return SHUTDOWN ;
case NodePrefs :: HPB_SETTINGS : return SETTINGS ;
case NodePrefs :: HPB_QUICK_MSG : return QUICK_MSG ;
2026-07-02 10:34:38 +02:00
case NodePrefs :: HPB_MAP : return MAP ;
2026-05-22 23:33:06 +02:00
default : return - 1 ;
}
}
2026-05-12 09:51:30 +02:00
bool isPageVisible ( int page ) const {
2026-07-07 01:53:02 +02:00
if ( page == RECENT ) return false ; // Recent adverts folded into Nearby Nodes; page retired
2026-05-12 09:51:30 +02:00
int bit = pageBit ( page );
if ( bit < 0 ) return true ;
2026-05-23 20:03:45 +02:00
uint16_t mask = ( _node_prefs && _node_prefs -> home_pages_mask ) ? _node_prefs -> home_pages_mask : NodePrefs :: HP_ALL ;
2026-05-12 09:51:30 +02:00
return ( mask >> bit ) & 1 ;
}
2026-05-22 23:33:06 +02:00
// Build ordered list of all visible pages, respecting page_order when set.
// Returns count; out[] receives HomePage enum values.
int buildVisibleOrder ( int * out ) const {
int n = 0 ;
2026-05-24 19:53:05 +02:00
bool custom = _node_prefs && _node_prefs -> page_order_set == NodePrefs :: PAGE_ORDER_MAGIC ;
2026-05-22 23:33:06 +02:00
if ( custom ) {
2026-05-24 23:00:27 +02:00
for ( int i = 0 ; i < NodePrefs :: PAGE_ORDER_LEN ; i ++ ) {
2026-05-22 23:33:06 +02:00
uint8_t v = _node_prefs -> page_order [ i ];
2026-05-24 22:26:56 +02:00
if ( v < 1 || v > NodePrefs :: HPB_COUNT ) break ;
2026-05-22 23:33:06 +02:00
int pg = bitToPage ( v - 1 );
if ( pg >= 0 && pg < ( int ) Count && isPageVisible ( pg )) out [ n ++ ] = pg ;
}
2026-05-23 18:11:24 +02:00
// Append any visible page missing from page_order (handles corrupted/migrated prefs)
for ( int pg = 0 ; pg < ( int ) Count ; pg ++ ) {
if ( ! isPageVisible ( pg )) continue ;
bool found = false ;
for ( int i = 0 ; i < n ; i ++ ) if ( out [ i ] == pg ) { found = true ; break ; }
if ( ! found ) out [ n ++ ] = pg ;
}
2026-05-22 23:33:06 +02:00
} else {
for ( int pg = 0 ; pg < ( int ) Count ; pg ++ )
if ( isPageVisible ( pg )) out [ n ++ ] = pg ;
2026-05-12 09:51:30 +02:00
}
2026-05-22 23:33:06 +02:00
return n ;
}
int navPage ( int from , int dir ) const {
2026-05-25 12:59:32 +02:00
int order [( int ) Count ]; int n = buildVisibleOrder ( order );
2026-05-22 23:33:06 +02:00
if ( n == 0 ) return from ;
int cur = 0 ;
for ( int i = 0 ; i < n ; i ++ ) if ( order [ i ] == from ) { cur = i ; break ; }
return order [(( cur + dir ) % n + n ) % n ];
2026-05-12 09:51:30 +02:00
}
2025-09-16 17:17:15 -07:00
2026-09-08 11:04:14 +02:00
// reserve_left: how much width from x=0 must stay clear of status icons --
// the node name on every other page (name_min below), or the LOCK page's
// own clock (see the LOCK branch in render(), which passes its actual
// footprint here so the icon row sheds low-priority icons instead of
// drawing over the clock). -1 = use the normal name reserve.
int renderBatteryIndicator ( DisplayDriver & display , uint16_t batteryMilliVolts , int reserve_left = - 1 ) {
2026-05-28 22:57:50 +02:00
int low_mv = _node_prefs ? ( int ) _node_prefs -> low_batt_mv : 0 ;
int pct = battMvToPercent (( int ) batteryMilliVolts , low_mv );
2025-08-08 20:01:31 +10:00
2026-09-26 01:00:31 +02:00
uint8_t mode = battery :: mode ( _node_prefs ? _node_prefs -> batt_display_mode : 0 );
2026-05-10 15:18:35 +02:00
display . setTextSize ( 1 );
2026-05-11 15:56:30 +02:00
display . setColor ( DisplayDriver :: LIGHT );
2025-08-08 20:01:31 +10:00
2026-05-22 23:13:27 +02:00
const int lh = display . getLineHeight ();
const int cw = display . getCharWidth ();
const int ind = cw + 2 ; // single-char indicator width
2026-07-17 21:02:33 +02:00
const int ind_h = display . isSingleFont () ? lh - 2 : lh ;
2026-05-23 14:45:24 +02:00
const int ind_gap = display . isLandscape () ? 3 : 1 ; // gap between indicator boxes
2026-05-22 18:34:20 +02:00
2026-05-10 15:18:35 +02:00
int battLeftX ;
2026-09-26 01:00:31 +02:00
if ( mode == battery :: PERCENT ) {
2026-05-10 15:18:35 +02:00
char buf [ 6 ];
2026-05-24 20:35:49 +02:00
snprintf ( buf , sizeof ( buf ), "%d%%" , pct );
2026-05-10 15:18:35 +02:00
battLeftX = display . width () - display . getTextWidth ( buf ) - 1 ;
display . setCursor ( battLeftX , 0 );
display . print ( buf );
2026-09-26 01:00:31 +02:00
} else if ( mode == battery :: VOLTAGE ) {
2026-05-10 15:18:35 +02:00
char buf [ 8 ];
2026-05-24 20:35:49 +02:00
snprintf ( buf , sizeof ( buf ), "%u.%02uV" , batteryMilliVolts / 1000 , ( batteryMilliVolts % 1000 ) / 10 );
2026-05-10 15:18:35 +02:00
battLeftX = display . width () - display . getTextWidth ( buf ) - 1 ;
display . setCursor ( battLeftX , 0 );
display . print ( buf );
2026-07-15 15:36:39 +02:00
} else { // icon — scales with lh, same box height as the status icons beside it (ind_h)
const int iconH = ind_h ;
2026-05-22 23:13:27 +02:00
const int iconW = lh * 2 ;
2026-05-23 14:45:24 +02:00
const int bm = display . isLandscape () ? 3 : 2 ; // inner margin: 3px on landscape e-ink, 2px on OLED/portrait
2026-05-22 18:34:20 +02:00
battLeftX = display . width () - iconW - 3 ;
display . drawRect ( battLeftX , 0 , iconW , iconH );
2026-07-15 15:36:39 +02:00
// Nub height/2, vertically centred by remaining-space/2 rather than a flat
// iconH/4 margin — the flat form only centres when iconH is a multiple of
// 4 (true for the old built-in font's lh=8, false for misc-fixed's 7/9),
// so it visibly drifted off-centre once the box height changed.
const int nub_h = iconH / 2 ;
display . fillRect ( battLeftX + iconW , ( iconH - nub_h ) / 2 , 2 , nub_h );
2026-05-22 23:13:27 +02:00
int fillW = ( pct * ( iconW - 2 * bm )) / 100 ;
display . fillRect ( battLeftX + bm , bm , fillW , iconH - 2 * bm );
2026-05-10 15:18:35 +02:00
}
2025-08-08 20:01:31 +10:00
2026-07-07 10:30:48 +02:00
// Secondary status icons, laid out right→left in PRIORITY order so a crowded
// bar sheds its least-important cues instead of crushing the node name. Once
// an icon won't fit above the reserved name area, every lower-priority icon
// after it is dropped too (the list is ordered high→low). A blinking icon
// still reserves its slot while off, so the name width doesn't flicker.
//
// Priority: BT > GPS fix > alarm > mute > auto-advert > trail > live-share >
// repeater. Battery (drawn above) is always rightmost. The background modes
// (advert / trail / live-share / repeater) stay outside any BT gate — they
// keep running with Bluetooth off, so their cue must not vanish with it.
2026-06-25 19:04:21 +02:00
LocationProvider * loc = _sensors ? _sensors -> getLocationProvider () : nullptr ;
2026-07-07 10:30:48 +02:00
bool gps_on = loc && _node_prefs && _node_prefs -> gps_enabled ;
2026-08-07 22:14:19 +02:00
// Blinks while GPS is napping between duty-cycle wakes -- same convention
// the background-mode icons below already use for "running, but not busy
// right this instant".
bool gps_napping = _sensors && _sensors -> isGpsDutySleeping ();
2026-07-07 10:30:48 +02:00
bool mute_on = false ;
#ifdef PIN_BUZZER
mute_on = _task -> isBuzzerQuiet ();
#endif
struct Sicon { bool active ; const MiniIcon * icon ; bool boxed ; bool blink ; };
const Sicon icons [] = {
{ _task -> isSerialEnabled (), & ICON_BLUETOOTH , _task -> isSerialEnabled () && _task -> isBLEConnected (), false },
2026-08-07 22:14:19 +02:00
{ gps_on , & ICON_GPS , gps_on && loc -> isValid (), gps_napping },
2026-07-07 10:30:48 +02:00
{ _node_prefs && _node_prefs -> alarm_on , & ICON_ALARM , true , false },
{ mute_on , & ICON_MUTE , true , false },
{ _node_prefs && _node_prefs -> advert_auto_interval_sec > 0 , & ICON_ADVERT , true , true },
{ _task -> trail (). isActive (), & ICON_TRAIL , true , true },
{ _node_prefs && _node_prefs -> loc_share_enabled , & ICON_MAP_CONTACT , true , true },
{ _node_prefs && _node_prefs -> client_repeat , & ICON_REPEATER , true , true },
};
int x = battLeftX ;
2026-09-08 11:04:14 +02:00
const int name_min = ( reserve_left >= 0 ) ? reserve_left : display . getCharWidth () * 5 ;
2026-07-07 10:30:48 +02:00
for ( const Sicon & s : icons ) {
if ( ! s . active ) continue ;
int ix = x - ind - ind_gap ;
if ( ix < name_min ) break ; // out of room — drop this + all lower priority
if ( ! s . blink || blinkOn ()) {
if ( s . boxed ) drawBoxedIcon ( display , ix , ind , ind_h , * s . icon );
else drawSlotIcon ( display , ix , ind , ind_h , * s . icon );
}
x = ix ;
2026-06-25 19:04:21 +02:00
}
2026-07-07 10:30:48 +02:00
return x ;
2025-08-08 20:01:31 +10:00
}
2025-09-09 16:32:41 +02:00
CayenneLPP sensors_lpp ;
int sensors_nb = 0 ;
int sensors_scroll_offset = 0 ;
2025-09-05 15:20:52 +02:00
int next_sensors_refresh = 0 ;
2026-04-28 21:26:56 -07:00
2025-09-05 15:20:52 +02:00
void refresh_sensors () {
if ( millis () > next_sensors_refresh ) {
2025-09-09 16:32:41 +02:00
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 ++ ;
}
2025-09-05 15:20:52 +02:00
#if AUTO_OFF_MILLIS > 0
next_sensors_refresh = millis () + 5000 ; // refresh sensor values every 5 sec
#else
next_sensors_refresh = millis () + 60000 ; // refresh sensor values every 1 min
#endif
}
}
2025-08-08 20:01:31 +10:00
public :
HomeScreen ( UITask * task , mesh :: RTCClock * rtc , SensorManager * sensors , NodePrefs * node_prefs )
2026-04-28 21:26:56 -07:00
: _task ( task ), _rtc ( rtc ), _sensors ( sensors ), _node_prefs ( node_prefs ), _page ( 0 ),
2026-09-05 20:40:13 +02:00
_prev_page ( 0 ), _shutdown_init ( false ), sensors_lpp ( 200 ) { }
// Flips home screen between LOCK page and the page that was showing before locking
void setLocked ( bool locked ) {
if ( locked ) {
if ( _page != LOCK ) _prev_page = _page ;
_page = LOCK ;
} else {
_page = _prev_page ;
}
}
2025-08-08 20:01:31 +10:00
void poll () override {
if ( _shutdown_init && ! _task -> isButtonPressed ()) { // must wait for USR button to be released
2026-09-03 22:20:55 +02:00
// _shutdown_init is never cleared elsewhere -- on real hardware
// that's harmless because _board->powerOff() halts the MCU, so
// there is no next poll() tick to matter. In the sim, powerOff()
// is a deliberate no-op (no real hardware to power off, see
// SimMainBoard.h), so without this the instance keeps running and
// this branch re-fires shutdown() -> _display->turnOff() on every
// single tick forever, repeatedly blacking out its canvas -- which
// fights with a freshly reset instance's own boot splash trying to
// render onto that same (simInstanceTag-keyed) canvas element.
_shutdown_init = false ;
2025-08-08 20:01:31 +10:00
_task -> shutdown ();
}
}
2026-06-25 19:04:21 +02:00
// Compact map preview for the Home "Map" page: own position, the GPS trail,
// and live-tracked contacts (◆) folded into one auto-scaled box. A simplified
// cousin of TrailScreen's map (no grid/labels, no break markers) so the home
// carousel stays light. Returns false (and draws nothing) when there's
// nothing to show.
bool drawMapPreview ( DisplayDriver & display , int ax , int ay , int aw , int ah ) {
if ( aw < 8 || ah < 8 ) return false ;
bool init = false ;
int32_t mnla = 0 , mxla = 0 , mnlo = 0 , mxlo = 0 ;
auto fold = [ & ]( int32_t la , int32_t lo ) {
if ( ! init ) { mnla = mxla = la ; mnlo = mxlo = lo ; init = true ; }
else { if ( la < mnla ) mnla = la ; if ( la > mxla ) mxla = la ;
if ( lo < mnlo ) mnlo = lo ; if ( lo > mxlo ) mxlo = lo ; }
};
TrailStore & tr = _task -> trail ();
if ( ! tr . empty ()) { int32_t a , b , c , d ; tr . boundingBox ( a , b , c , d ); fold ( a , b ); fold ( c , d ); }
LiveTrackStore & lt = _task -> liveTrack ();
uint32_t now = rtc_clock . getCurrentTime ();
for ( int i = 0 ; i < LiveTrackStore :: CAPACITY ; i ++ )
if ( lt . isActive ( i , now )) fold ( lt . slotAt ( i ). lat_1e6 , lt . slotAt ( i ). lon_1e6 );
int32_t mla , mlo ;
bool have_gps = _task -> currentLocation ( mla , mlo );
if ( have_gps ) fold ( mla , mlo );
int32_t tla , tlo ;
bool have_tgt = _task -> activeTargetPos ( tla , tlo ); // active Locator/Nav target
if ( have_tgt ) fold ( tla , tlo );
if ( ! init ) return false ;
// North marker — top-right, the same mini-icon as the full Trail map.
display . setColor ( DisplayDriver :: LIGHT );
{
const int ns = miniIconScale ( display );
miniIconDrawTop ( display , ax + aw - ICON_MAP_NORTH . w * ns - 1 , ay + 1 , ICON_MAP_NORTH );
}
int cx = ax + aw / 2 , cy = ay + ah / 2 ;
// Degenerate: one coincident point — just centre the markers.
if ( mnla == mxla && mnlo == mxlo ) {
for ( int i = 0 ; i < LiveTrackStore :: CAPACITY ; i ++ )
if ( lt . isActive ( i , now )) { miniIconDrawCentered ( display , cx , cy , ICON_MAP_CONTACT ); break ; }
if ( have_gps || ! tr . empty ()) miniIconDrawCentered ( display , cx , cy , ICON_MAP_CURRENT );
if ( have_tgt ) miniIconDrawCentered ( display , cx , cy , ICON_MAP_TARGET ); // highlight on top
return true ;
}
float avg_lat_rad = (( mnla + mxla ) / 2.0e6 f ) * ( float ) M_PI / 180.0f ;
float lon_scale = cosf ( avg_lat_rad ); if ( lon_scale < 0.05f ) lon_scale = 0.05f ;
float lat_span = ( float )( mxla - mnla );
float lon_span = ( float )( mxlo - mnlo ) * lon_scale ;
float slat = ( float ) ah / ( lat_span > 0 ? lat_span : 1.0f );
float slon = ( float ) aw / ( lon_span > 0 ? lon_span : 1.0f );
float scale = ( slat < slon ) ? slat : slon ;
int off_x = ax + ( aw - ( int )( lon_span * scale )) / 2 ;
int off_y = ay + ( ah - ( int )( lat_span * scale )) / 2 ;
auto project = [ & ]( int32_t la , int32_t lo , int & px , int & py ) {
px = off_x + ( int )(( float )( lo - mnlo ) * lon_scale * scale );
py = off_y + ( int )(( float )( mxla - la ) * scale );
};
// Trail as a connected line, matching the full Trail map (shared helper —
// see gfx::drawTrail); no break marker here, just a silent gap.
gfx :: drawTrail ( display , tr , project , []( int , int , int , int ) {});
for ( int i = 0 ; i < LiveTrackStore :: CAPACITY ; i ++ ) {
if ( ! lt . isActive ( i , now )) continue ;
int px , py ; project ( lt . slotAt ( i ). lat_1e6 , lt . slotAt ( i ). lon_1e6 , px , py );
miniIconDrawCentered ( display , px , py , ICON_MAP_CONTACT );
}
if ( have_gps ) { int px , py ; project ( mla , mlo , px , py ); miniIconDrawCentered ( display , px , py , ICON_MAP_CURRENT ); }
// Active target flag drawn last so it stays legible even atop a contact/own dot.
if ( have_tgt ) { int px , py ; project ( tla , tlo , px , py ); miniIconDrawCentered ( display , px , py , ICON_MAP_TARGET ); }
// Bottom-left scale reference, always shown — distance to the active
// target (or else the nearest live-tracked contact) now lives on the
// status line below instead (see statusDistanceKm() / render()), so this
// corner is free for it.
{
display . setColor ( DisplayDriver :: LIGHT );
int ty = ay + ah - display . getLineHeight ();
static const float M_PER_1E6 = 0.11132f ; // metres per 1e-6° lat
float ppm = scale / M_PER_1E6 ; // pixels per metre
if ( ppm > 0.0f ) {
bool imp = _task -> useImperial ();
static const float MET_M [] = { 5 , 10 , 25 , 50 , 100 , 250 , 500 , 1000 , 2000 , 5000 , 10000 , 25000 , 50000 };
static const char * MET_L [] = { "5m" , "10m" , "25m" , "50m" , "100m" , "250m" , "500m" , "1km" , "2km" , "5km" , "10km" , "25km" , "50km" };
static const float IMP_M [] = { 4.572f , 15.24f , 30.48f , 76.2f , 152.4f , 402.34f , 804.67f , 1609.34f , 4828.0f , 16093.4f , 80467.2f };
static const char * IMP_L [] = { "15ft" , "50ft" , "100ft" , "250ft" , "500ft" , "1/4mi" , "1/2mi" , "1mi" , "3mi" , "10mi" , "50mi" };
const float * M = imp ? IMP_M : MET_M ;
const char * const * L = imp ? IMP_L : MET_L ;
int N = imp ? ( int )( sizeof ( IMP_M ) / sizeof ( IMP_M [ 0 ])) : ( int )( sizeof ( MET_M ) / sizeof ( MET_M [ 0 ]));
float target = 8.0f / ppm ; // short reference tick, not 1/3 of the width
int sel = 0 ;
for ( int i = N - 1 ; i >= 0 ; i -- ) if ( M [ i ] <= target ) { sel = i ; break ; }
int barpx = ( int )( M [ sel ] * ppm + 0.5f );
if ( barpx < 5 ) barpx = 5 ;
if ( barpx > aw / 4 ) barpx = aw / 4 ;
int bx = ax + 1 , mid = ty + display . getLineHeight () / 2 ;
display . fillRect ( bx , mid , barpx , 1 ); // single tick, on the text baseline
display . setCursor ( bx + barpx + 2 , ty );
display . print ( L [ sel ]);
}
}
return true ;
}
// Distance shown on the MAP status line. The active Locator/Nav target
// takes priority — that's what the flag on the mini-map is pointing at,
// and it's the only way a waypoint target ever gets a distance readout
// here (a waypoint isn't a live-tracked contact). Falls back to the
// nearest live-tracked ([LOC]-sharing) contact when no target is set.
// -1 when we don't have a fix or nothing to measure against.
float statusDistanceKm () {
int32_t mla , mlo ;
if ( ! _task -> currentLocation ( mla , mlo )) return - 1.0f ;
int32_t tla , tlo ;
if ( _task -> activeTargetPos ( tla , tlo )) return geo :: haversineKm ( mla , mlo , tla , tlo );
LiveTrackStore & lt = _task -> liveTrack ();
uint32_t now = rtc_clock . getCurrentTime ();
float nearest_km = - 1.0f ;
for ( int i = 0 ; i < LiveTrackStore :: CAPACITY ; i ++ ) {
if ( ! lt . isActive ( i , now )) continue ;
float d = geo :: haversineKm ( mla , mlo , lt . slotAt ( i ). lat_1e6 , lt . slotAt ( i ). lon_1e6 );
if ( nearest_km < 0.0f || d < nearest_km ) nearest_km = d ;
}
return nearest_km ;
}
// Small 5x5 glyph shown in the page-indicator row for each HomePage.
static const MiniIcon * pageIcon ( int page ) {
switch ( page ) {
case CLOCK : return & ICON_PG_CLOCK ;
case FAVOURITES : return & ICON_PG_STAR ;
case RECENT : return & ICON_PG_RECENT ;
case RADIO : return & ICON_PG_RADIO ;
case BLUETOOTH : return & ICON_PG_BT ;
case ADVERT : return & ICON_PG_ADVERT ;
#if ENV_INCLUDE_GPS == 1
case GPS : return & ICON_PG_GPS ;
#endif
#if UI_SENSORS_PAGE == 1
case SENSORS : return & ICON_PG_SENSORS ;
#endif
case SETTINGS : return & ICON_PG_SETTINGS ;
case MAP : return & ICON_PG_MAP ;
case TOOLS : return & ICON_PG_TOOLS ;
case QUICK_MSG : return & ICON_PG_MSG ;
case SHUTDOWN : return & ICON_PG_POWER ;
}
return nullptr ;
}
2025-08-08 20:01:31 +10:00
int render ( DisplayDriver & display ) override {
char tmp [ 80 ];
2026-08-30 17:17:55 +02:00
int mq_delay = 0 ; // >0 while a selected row's name is marquee-scrolling
2026-05-22 18:05:09 +02:00
display . setTextSize ( 1 );
const int lh = display . getLineHeight (); // line height at sz1
const int step = display . lineStep (); // lh + 2
2026-06-25 19:04:21 +02:00
// Page-indicator row: small (5px) page icons replace the old dots. Centre and
// gap scale with the font so the band clears the header above and content
// below (identical to the old lh+4 / +6 dots layout at 1x).
const int pg_half = ( 5 * miniIconScale ( display ) + 1 ) / 2 ;
const int dots_y = lh + pg_half + 1 ; // icon-row centre, below the header
const int content_y = dots_y + pg_half + 3 ; // first content row, below the icons
2026-05-22 18:05:09 +02:00
2026-09-05 22:05:15 +02:00
// Title bar displaying node name (except on lock screen), status icons and battery.
// Hidden on fullscreen pages (CLOCK).
2026-05-14 11:28:36 +02:00
if ( _page != CLOCK ) {
display . setColor ( DisplayDriver :: LIGHT );
2026-09-08 11:04:14 +02:00
int lock_reserve = - 1 ;
if ( _page == LOCK ) {
// The lock screen's own clock shares row 0 with this title bar --
// reserve its real footprint instead of the usual name_min, so the
// (already priority-ordered) status icons shed low-priority ones as
// needed and never draw over it. Built-in font is fixed-width, so a
// worst-case digit string measures this without the actual time.
bool tall = display . height () > display . width ();
display . setTextSize ( tall ? 4 : 2 );
lock_reserve = display . getTextWidth ( tall ? "88" : "88:88" );
display . setTextSize ( 1 );
}
int rightEdge = renderBatteryIndicator ( display , _task -> getBattMilliVolts (), lock_reserve );
2026-05-14 18:13:18 +02:00
display . setColor ( DisplayDriver :: LIGHT );
2026-09-05 22:05:15 +02:00
if ( _page != LOCK ) {
char filtered_name [ sizeof ( _node_prefs -> node_name )];
display . translateUTF8ToBlocks ( filtered_name , _node_prefs -> node_name , sizeof ( filtered_name ));
// Only show the live-power readout when APC is actually controlling power —
// not merely when the pref is set. While repeating APC is suppressed and
// power is pinned to the ceiling, so apcActive() is false and the name bar
// drops the readout (matching the "--" lock in Settings).
if ( the_mesh . apcActive ()) {
char pwr_buf [ 8 ];
snprintf ( pwr_buf , sizeof ( pwr_buf ), "%ddB" , ( int ) radio_driver . getTxPower ());
int pwr_w = display . getTextWidth ( pwr_buf );
display . drawTextEllipsized ( 0 , 0 , rightEdge - 2 - pwr_w - 2 , filtered_name );
display . drawTextRightAlign ( rightEdge - 2 , 0 , pwr_buf );
} else {
display . drawTextEllipsized ( 0 , 0 , rightEdge - 2 , filtered_name );
}
2026-06-10 23:30:43 +02:00
}
2026-05-14 11:28:36 +02:00
}
2025-08-08 20:01:31 +10:00
2026-05-12 09:51:30 +02:00
// ensure current page is visible (e.g. after settings change)
2026-05-12 09:57:21 +02:00
if ( ! isPageVisible ( _page )) _page = navPage ( _page , + 1 );
2026-05-12 09:51:30 +02:00
2026-06-25 19:04:21 +02:00
// curr page indicator — a row of small page icons, one per visible page, with
2026-09-05 20:40:13 +02:00
// the current page underlined. Hidden on CLOCK and LOCK (full screen used for
// the clock/dashboard).
if ( _page != CLOCK && _page != LOCK ) {
2026-05-25 12:59:32 +02:00
int order [( int ) Count ]; int n = buildVisibleOrder ( order );
2026-05-22 23:33:06 +02:00
int curr_vis = 0 ;
for ( int i = 0 ; i < n ; i ++ ) if ( order [ i ] == _page ) { curr_vis = i ; break ; }
2026-06-25 19:04:21 +02:00
const int s = miniIconScale ( display );
const int icon_w = 5 * s ;
int pitch = icon_w + 5 * s ; // comfortable spacing
if ( n > 1 ) { // shrink to fit if many pages
int fit = ( display . width () - icon_w ) / ( n - 1 );
if ( fit < pitch ) pitch = fit ;
}
int x = display . width () / 2 - pitch * ( n - 1 ) / 2 ;
2026-05-22 23:33:06 +02:00
for ( int i = 0 ; i < n ; i ++ ) {
2026-06-25 19:04:21 +02:00
const MiniIcon * ic = pageIcon ( order [ i ]);
if ( ic ) miniIconDrawCentered ( display , x , dots_y , * ic );
if ( i == curr_vis ) // underline the current page
display . fillRect ( x - icon_w / 2 , dots_y + pg_half + 1 , icon_w , s );
x += pitch ;
2025-08-08 20:01:31 +10:00
}
}
2026-05-10 15:18:35 +02:00
if ( _page == HomePage :: CLOCK ) {
uint32_t unix_ts = _rtc -> getCurrentTime ();
if ( unix_ts < 1000000000UL ) {
display . setColor ( DisplayDriver :: LIGHT );
2026-05-11 15:56:30 +02:00
display . setTextSize ( 1 );
2026-05-22 18:05:09 +02:00
int mid_y = display . height () / 2 - step ;
display . drawTextCentered ( display . width () / 2 , mid_y , "! No time sync" );
display . drawTextCentered ( display . width () / 2 , mid_y + step , "Enable GPS or" );
display . drawTextCentered ( display . width () / 2 , mid_y + step * 2 , "connect app" );
2026-05-10 15:18:35 +02:00
} else {
int8_t tz = _node_prefs ? _node_prefs -> tz_offset_hours : 0 ;
unix_ts += ( int32_t ) tz * 3600 ;
time_t t = ( time_t ) unix_ts ;
struct tm * ti = gmtime ( & t );
char buf [ 24 ];
2026-05-11 15:56:30 +02:00
display . setColor ( DisplayDriver :: LIGHT );
2026-05-24 20:41:25 +02:00
bool show_sec = ! Features :: IS_EINK && ( ! _node_prefs || ! _node_prefs -> clock_hide_seconds );
2026-05-21 21:04:43 +02:00
bool h12 = _node_prefs && _node_prefs -> clock_12h ;
2026-09-05 22:01:14 +02:00
int date_y = drawClockTime ( display , 0 , ti , h12 , show_sec , true );
2026-05-10 15:18:35 +02:00
display . setTextSize ( 1 );
2026-05-14 11:28:36 +02:00
static const char * wd [] = { "Sun" , "Mon" , "Tue" , "Wed" , "Thu" , "Fri" , "Sat" };
static const char * mo [] = { "Jan" , "Feb" , "Mar" , "Apr" , "May" , "Jun" , "Jul" , "Aug" , "Sep" , "Oct" , "Nov" , "Dec" };
2026-05-24 20:35:49 +02:00
snprintf ( buf , sizeof ( buf ), "%s %d %s %d" , wd [ ti -> tm_wday ], ti -> tm_mday , mo [ ti -> tm_mon ], 1900 + ti -> tm_year );
2026-05-22 18:34:20 +02:00
display . drawTextCentered ( display . width () / 2 , date_y , buf );
2026-05-10 15:18:35 +02:00
2026-06-30 01:54:01 +02:00
// Alarm armed: a small bell in the top-left corner. The status bar (and
// its bell) is hidden on this page, so signal the armed alarm here. Just
// the glyph — no time text — so it stays clear of the centred clock
// digits (which can reach the corner when seconds are shown), matching
// the icon-only status-bar indicator. The exact time is in Clock Tools.
if ( _node_prefs && _node_prefs -> alarm_on ) {
display . setColor ( DisplayDriver :: LIGHT );
miniIconDrawTop ( display , 0 , 0 , ICON_ALARM );
}
2026-05-22 18:34:20 +02:00
int sep_y = date_y + lh + 1 ;
2026-05-22 23:13:27 +02:00
int dash0 = sep_y + display . sepH () + 2 ;
display . fillRect ( 0 , sep_y , display . width (), display . sepH ());
2026-05-14 11:28:36 +02:00
// dashboard data fields
if ( _node_prefs ) {
refresh_sensors ();
2026-05-22 18:05:09 +02:00
const int FIELD_Y [ 3 ] = { dash0 , dash0 + step , dash0 + step * 2 };
2026-05-14 11:28:36 +02:00
for ( int fi = 0 ; fi < 3 ; fi ++ ) {
uint8_t field = _node_prefs -> dashboard_fields [ fi ];
if ( field == DASH_NONE ) continue ;
char label [ 10 ], val [ 20 ];
label [ 0 ] = '\0' ;
val [ 0 ] = '\0' ;
2026-05-27 22:55:50 +02:00
if ( field == DASH_BATT_V ) {
2026-05-14 11:28:36 +02:00
strcpy ( label , "Batt" );
uint16_t mv = _task -> getBattMilliVolts ();
if ( mv > 0 ) snprintf ( val , sizeof ( val ), "%u.%02uV" , mv / 1000 , ( mv % 1000 ) / 10 );
else strcpy ( val , "--" );
2026-05-27 22:55:50 +02:00
} else if ( field == DASH_BATT_PCT ) {
strcpy ( label , "Batt" );
uint16_t mv = _task -> getBattMilliVolts ();
2026-05-28 22:57:50 +02:00
if ( mv > 0 ) snprintf ( val , sizeof ( val ), "%d%%" ,
battMvToPercent ( mv , _node_prefs -> low_batt_mv ));
else strcpy ( val , "--" );
2026-05-14 11:28:36 +02:00
} else if ( field == DASH_GPS ) {
strcpy ( label , "GPS" );
#if ENV_INCLUDE_GPS == 1
LocationProvider * loc = sensors . getLocationProvider ();
if ( loc && loc -> isValid ())
snprintf ( val , sizeof ( val ), "%.3f %.3f" ,
loc -> getLatitude () / 1000000.0f , loc -> getLongitude () / 1000000.0f );
else
strcpy ( val , "no fix" );
#else
strcpy ( val , "--" );
2026-09-08 11:04:14 +02:00
#endif
} else if ( field == DASH_SATS ) {
strcpy ( label , "Sats" );
#if ENV_INCLUDE_GPS == 1
LocationProvider * loc = sensors . getLocationProvider ();
if ( loc ) snprintf ( val , sizeof ( val ), "%ld" , loc -> satellitesCount ());
else strcpy ( val , "--" );
#else
strcpy ( val , "--" );
2026-09-10 20:12:29 +02:00
#endif
} else if ( field == DASH_ALT_GPS ) {
strcpy ( label , "AltG" );
#if ENV_INCLUDE_GPS == 1
LocationProvider * loc = sensors . getLocationProvider ();
if ( loc && loc -> isValid ())
fmtAlt ( val , sizeof ( val ), loc -> getAltitude () / 1000.0f , _node_prefs && _node_prefs -> units_imperial );
else
strcpy ( val , "no fix" );
#else
strcpy ( val , "--" );
2026-05-14 11:28:36 +02:00
#endif
} else if ( field == DASH_NODES ) {
strcpy ( label , "Nodes" );
snprintf ( val , sizeof ( val ), "%d" , the_mesh . getNumContacts ());
2026-05-14 18:20:49 +02:00
} else if ( field == DASH_MSGS ) {
strcpy ( label , "Msgs" );
int unread = _task -> getDMUnreadTotal () + _task -> getChannelUnreadCount () + _task -> getRoomUnreadCount ();
2026-09-23 13:45:07 +02:00
// "+" when the true total is understated -- at least one unread
// message has already been evicted off a ring before ever being
// seen (see UITask::getAnyUnreadOverflow()).
snprintf ( val , sizeof ( val ), _task -> getAnyUnreadOverflow () ? "%d+" : "%d" , unread );
2026-05-14 11:28:36 +02:00
} else {
uint8_t lpp_type = 0 ;
switch ( field ) {
case DASH_TEMP : strcpy ( label , "Temp" ); lpp_type = LPP_TEMPERATURE ; break ;
case DASH_HUM : strcpy ( label , "Hum" ); lpp_type = LPP_RELATIVE_HUMIDITY ; break ;
case DASH_PRES : strcpy ( label , "Pres" ); lpp_type = LPP_BAROMETRIC_PRESSURE ; break ;
case DASH_ALT : strcpy ( label , "Alt" ); lpp_type = LPP_ALTITUDE ; break ;
case DASH_LUX : strcpy ( label , "Lux" ); lpp_type = LPP_LUMINOSITY ; break ;
case DASH_CO2 : strcpy ( label , "CO2" ); lpp_type = LPP_CONCENTRATION ; break ;
}
if ( lpp_type ) {
LPPReader r ( sensors_lpp . getBuffer (), sensors_lpp . getSize ());
uint8_t ch , type ;
while ( r . readHeader ( ch , type )) {
if ( type == lpp_type ) {
float v ;
switch ( lpp_type ) {
case LPP_TEMPERATURE : r . readTemperature ( v ); snprintf ( val , sizeof ( val ), "%.1f \xf8 ""C" , v ); break ;
case LPP_RELATIVE_HUMIDITY : r . readRelativeHumidity ( v ); snprintf ( val , sizeof ( val ), "%.0f%%" , v ); break ;
case LPP_BAROMETRIC_PRESSURE : r . readPressure ( v ); snprintf ( val , sizeof ( val ), "%.0fhPa" , v ); break ;
2026-09-10 20:12:29 +02:00
case LPP_ALTITUDE : r . readAltitude ( v ); fmtAlt ( val , sizeof ( val ), v , _node_prefs && _node_prefs -> units_imperial ); break ;
2026-05-14 11:28:36 +02:00
case LPP_LUMINOSITY : r . readLuminosity ( v ); snprintf ( val , sizeof ( val ), "%.0flux" , v ); break ;
case LPP_CONCENTRATION : r . readConcentration ( v ); snprintf ( val , sizeof ( val ), "%.0fppm" , v ); break ;
}
break ;
}
r . skipData ( type );
}
}
if ( ! val [ 0 ]) strcpy ( val , "--" );
}
if ( val [ 0 ] && label [ 0 ]) {
display . setColor ( DisplayDriver :: LIGHT );
display . setCursor ( 0 , FIELD_Y [ fi ]);
display . print ( label );
int vw = display . getTextWidth ( val );
display . setCursor ( display . width () - vw - 1 , FIELD_Y [ fi ]);
display . print ( val );
}
}
}
2026-05-10 15:18:35 +02:00
}
2026-09-05 20:40:13 +02:00
} else if ( _page == HomePage :: LOCK ) {
// Lock screen: clock + two dashboard spots + unlock-hint popup
uint32_t unix_ts = _rtc -> getCurrentTime ();
display . setColor ( DisplayDriver :: LIGHT );
display . setTextSize ( 1 );
if ( unix_ts < 1000000000UL ) {
display . drawTextCentered ( display . width () / 2 , display . height () / 2 - step , "No time sync" );
} else {
int8_t tz = _node_prefs ? _node_prefs -> tz_offset_hours : 0 ;
unix_ts += ( int32_t ) tz * 3600 ;
time_t t = ( time_t ) unix_ts ;
struct tm * ti = gmtime ( & t );
char buf [ 12 ];
bool h12 = _node_prefs && _node_prefs -> clock_12h ;
2026-09-08 11:04:14 +02:00
int date_y = drawClockTime ( display , 0 , ti , h12 , /*show_sec*/ false );
2026-09-05 20:40:13 +02:00
display . setTextSize ( 1 );
static const char * wd [] = { "Sun" , "Mon" , "Tue" , "Wed" , "Thu" , "Fri" , "Sat" };
static const char * mo [] = { "Jan" , "Feb" , "Mar" , "Apr" , "May" , "Jun" , "Jul" , "Aug" , "Sep" , "Oct" , "Nov" , "Dec" };
snprintf ( buf , sizeof ( buf ), "%s %d %s" , wd [ ti -> tm_wday ], ti -> tm_mday , mo [ ti -> tm_mon ]);
2026-09-05 22:05:15 +02:00
display . setCursor ( 0 , date_y );
display . print ( buf );
2026-09-05 20:40:13 +02:00
// Two sensor values side by side (dashboard_fields[0] and [1])
if ( _node_prefs ) {
char v0 [ 20 ] = "" , v1 [ 20 ] = "" ;
CayenneLPP * lpp_ptr = nullptr ;
uint8_t f0 = _node_prefs -> dashboard_fields [ 0 ], f1 = _node_prefs -> dashboard_fields [ 1 ];
auto isLPP = []( uint8_t f ) {
return f == DASH_TEMP || f == DASH_HUM || f == DASH_PRES || f == DASH_ALT || f == DASH_LUX || f == DASH_CO2 ;
};
if ( isLPP ( f0 ) || isLPP ( f1 )) {
sensors_lpp . reset (); sensors . querySensors ( 0xFF , sensors_lpp ); lpp_ptr = & sensors_lpp ;
}
2026-09-23 13:45:07 +02:00
bool show_msgs = f0 == DASH_MSGS || f1 == DASH_MSGS ;
int unread = show_msgs
2026-09-05 20:40:13 +02:00
? _task -> getDMUnreadTotal () + _task -> getChannelUnreadCount () + _task -> getRoomUnreadCount () : 0 ;
2026-09-23 13:45:07 +02:00
bool unread_overflow = show_msgs && _task -> getAnyUnreadOverflow ();
2026-09-05 20:40:13 +02:00
uint16_t batt_mv = _task -> getBattMilliVolts ();
2026-09-23 13:45:07 +02:00
formatDashVal ( f0 , v0 , sizeof ( v0 ), batt_mv , _node_prefs -> low_batt_mv , unread , unread_overflow , _node_prefs -> units_imperial , lpp_ptr );
formatDashVal ( f1 , v1 , sizeof ( v1 ), batt_mv , _node_prefs -> low_batt_mv , unread , unread_overflow , _node_prefs -> units_imperial , lpp_ptr );
2026-09-05 20:40:13 +02:00
if ( v0 [ 0 ] || v1 [ 0 ]) {
int sv_y = date_y + step ;
display . setColor ( DisplayDriver :: LIGHT );
if ( v0 [ 0 ] && v1 [ 0 ]) {
display . setCursor ( 0 , sv_y );
display . print ( v0 );
int vw = display . getTextWidth ( v1 );
display . setCursor ( display . width () - vw , sv_y );
display . print ( v1 );
} else {
const char * sv = v0 [ 0 ] ? v0 : v1 ;
display . drawTextCentered ( display . width () / 2 , sv_y , sv );
}
}
}
}
// Unlock-hint popup at the bottom (like alert style)
display . setTextSize ( 1 );
const int lk_lh = display . getLineHeight ();
#if defined(CARDKB_I2C)
const char * hint = _task -> lockSeqCount () == 0 ? ( _task -> hasCardKB () ? "Back+3xEnter/Fn+Esc" : "Hold Back + 3xEnter" ) :
_task -> lockSeqCount () == 1 ? "Enter x2 more..." : "Enter x1 more..." ;
#else
const char * hint = _task -> lockSeqCount () == 0 ? "Hold Back + 3xEnter" :
_task -> lockSeqCount () == 1 ? "Enter x2 more..." : "Enter x1 more..." ;
#endif
const int p = 3 ;
const int hy = display . height () - lk_lh - p * 2 ;
const int hw = display . getTextWidth ( hint );
const int hx = ( display . width () - hw ) / 2 ;
display . setColor ( DisplayDriver :: LIGHT );
display . fillRect ( hx - p , hy - p , hw + p * 2 , lk_lh + p * 2 );
display . setColor ( DisplayDriver :: DARK );
display . setCursor ( hx , hy );
display . print ( hint );
2025-08-08 20:01:31 +10:00
} else if ( _page == HomePage :: RADIO ) {
2026-05-11 15:56:30 +02:00
display . setColor ( DisplayDriver :: LIGHT );
2025-08-08 20:01:31 +10:00
// freq / sf
2026-05-22 18:05:09 +02:00
display . setCursor ( 0 , content_y );
2026-05-24 20:35:49 +02:00
snprintf ( tmp , sizeof ( tmp ), "FQ: %06.3f SF: %d" , _node_prefs -> freq , _node_prefs -> sf );
2025-08-08 20:01:31 +10:00
display . print ( tmp );
2026-05-22 18:05:09 +02:00
display . setCursor ( 0 , content_y + step );
2026-05-24 20:35:49 +02:00
snprintf ( tmp , sizeof ( tmp ), "BW: %03.2f CR: %d" , _node_prefs -> bw , _node_prefs -> cr );
2025-08-08 20:01:31 +10:00
display . print ( tmp );
2026-05-22 18:05:09 +02:00
// tx power, noise floor
display . setCursor ( 0 , content_y + step * 2 );
2026-06-10 23:30:43 +02:00
snprintf ( tmp , sizeof ( tmp ), "TX: %ddBm" , radio_driver . getTxPower ()); // live value (reflects APC)
2025-08-08 20:01:31 +10:00
display . print ( tmp );
2026-05-22 18:05:09 +02:00
display . setCursor ( 0 , content_y + step * 3 );
2026-09-10 20:12:29 +02:00
// Was gated to "n/a" while duty-cycle RX (Pwr save) was active, on the
// assumption that the floor only gets sampled during continuous RX --
// stale since RadioLibWrapper's periodic recalibration (noiseFloorCalibCheck(),
// NF_CALIB_INTERVAL_MS) started keeping it fresh even under duty-cycle,
// same live value Diagnostics already showed unconditionally.
snprintf ( tmp , sizeof ( tmp ), "Noise floor: %d" , radio_driver . getNoiseFloor ());
2025-08-08 20:01:31 +10:00
display . print ( tmp );
} else if ( _page == HomePage :: BLUETOOTH ) {
2026-05-11 15:56:30 +02:00
display . setColor ( DisplayDriver :: LIGHT );
2025-08-08 20:01:31 +10:00
display . setTextSize ( 1 );
2026-05-23 14:45:24 +02:00
display . drawXbm (( display . width () - 32 ) / 2 , content_y ,
_task -> isSerialEnabled () ? bluetooth_on : bluetooth_off , 32 , 32 );
const int text_y = content_y + 32 + 3 ;
2026-06-05 12:42:14 +02:00
// The pairing PIN is BLE-specific: show it while BLE is on but not yet
// bonded. (Gating on a plain isConnected() broke this on dual builds,
// where it's hardcoded true.)
2026-06-05 12:03:38 +02:00
const bool waiting_for_pair = _task -> isSerialEnabled () && ! _task -> isBLEConnected () && the_mesh . getBLEPin () != 0 ;
2026-05-23 14:45:24 +02:00
if ( waiting_for_pair && ! display . isLandscape ()) {
2026-05-11 16:52:24 +02:00
char pin_buf [ 16 ];
snprintf ( pin_buf , sizeof ( pin_buf ), "PIN: %d" , the_mesh . getBLEPin ());
2026-05-23 14:45:24 +02:00
display . drawTextCentered ( display . width () / 2 , text_y , pin_buf );
} else if ( waiting_for_pair ) {
char pin_buf [ 16 ];
snprintf ( pin_buf , sizeof ( pin_buf ), "PIN: %d" , the_mesh . getBLEPin ());
display . drawTextCentered ( display . width () / 2 , text_y , pin_buf );
display . drawTextCentered ( display . width () / 2 , text_y + step , "toggle: " PRESS_LABEL );
} else {
display . drawTextCentered ( display . width () / 2 , text_y , "toggle: " PRESS_LABEL );
2026-05-11 16:52:24 +02:00
}
2025-08-08 20:01:31 +10:00
} else if ( _page == HomePage :: ADVERT ) {
2026-05-11 15:56:30 +02:00
display . setColor ( DisplayDriver :: LIGHT );
2026-05-22 18:05:09 +02:00
display . drawXbm (( display . width () - 32 ) / 2 , content_y , advert_icon , 32 , 32 );
display . drawTextCentered ( display . width () / 2 , content_y + 32 + 3 , "advert: " PRESS_LABEL );
2025-09-28 09:43:28 +02:00
#if ENV_INCLUDE_GPS == 1
2025-09-23 10:39:43 +02:00
} else if ( _page == HomePage :: GPS ) {
LocationProvider * nmea = sensors . getLocationProvider ();
2025-11-28 11:11:13 +01:00
char buf [ 50 ];
2026-05-22 18:05:09 +02:00
int y = content_y ;
2025-11-28 11:11:13 +01:00
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 . drawTextLeftAlign ( 0 , y , buf );
2025-09-23 10:39:43 +02:00
if ( nmea == NULL ) {
2026-05-22 18:05:09 +02:00
y += step ;
2025-09-30 09:21:12 +02:00
display . drawTextLeftAlign ( 0 , y , "Can't access GPS" );
2025-09-23 10:39:43 +02:00
} else {
strcpy ( buf , nmea -> isValid () ? "fix" : "no fix" );
2025-09-30 09:21:12 +02:00
display . drawTextRightAlign ( display . width () - 1 , y , buf );
2026-05-22 18:05:09 +02:00
y += step ;
2025-09-30 09:21:12 +02:00
display . drawTextLeftAlign ( 0 , y , "sat" );
2026-05-24 20:35:49 +02:00
snprintf ( buf , sizeof ( buf ), "%d" , nmea -> satellitesCount ());
2025-09-30 09:21:12 +02:00
display . drawTextRightAlign ( display . width () - 1 , y , buf );
2026-05-22 18:05:09 +02:00
y += step ;
2025-09-30 09:21:12 +02:00
display . drawTextLeftAlign ( 0 , y , "pos" );
2026-05-24 20:35:49 +02:00
snprintf ( buf , sizeof ( buf ), "%.4f %.4f" ,
2025-09-23 10:39:43 +02:00
nmea -> getLatitude () / 1000000. , nmea -> getLongitude () / 1000000. );
2025-09-30 09:21:12 +02:00
display . drawTextRightAlign ( display . width () - 1 , y , buf );
2026-05-22 18:05:09 +02:00
y += step ;
2025-09-30 09:21:12 +02:00
display . drawTextLeftAlign ( 0 , y , "alt" );
2026-09-10 20:12:29 +02:00
fmtAlt ( buf , sizeof ( buf ), nmea -> getAltitude () / 1000.0f , _node_prefs && _node_prefs -> units_imperial );
2025-09-30 09:21:12 +02:00
display . drawTextRightAlign ( display . width () - 1 , y , buf );
2026-05-22 18:05:09 +02:00
y += step ;
2025-09-23 10:39:43 +02:00
}
#endif
2025-09-09 16:32:41 +02:00
#if UI_SENSORS_PAGE == 1
2025-09-05 15:20:52 +02:00
} else if ( _page == HomePage :: SENSORS ) {
2026-05-22 18:05:09 +02:00
int y = content_y ;
2025-09-05 15:20:52 +02:00
refresh_sensors ();
2025-09-09 16:32:41 +02:00
2026-06-06 17:21:41 +02:00
// Enumerate the distinct telemetry types directly from the freshly
// populated buffer. (Upstream replaced the per-sensor *_initialized flags
// with a generic registration model, so we derive availability from what
// querySensors() actually produced instead of asking the manager.)
2026-05-11 21:33:49 +02:00
uint8_t avail_types [ 16 ];
2026-06-06 17:21:41 +02:00
int avail_count = 0 ;
{
LPPReader er ( sensors_lpp . getBuffer (), sensors_lpp . getSize ());
uint8_t ech , etype ;
while ( er . readHeader ( ech , etype ) && avail_count < 16 ) {
er . skipData ( etype );
bool dup = false ;
for ( int k = 0 ; k < avail_count ; k ++ ) if ( avail_types [ k ] == etype ) { dup = true ; break ; }
if ( ! dup ) avail_types [ avail_count ++ ] = etype ;
}
2025-09-09 16:32:41 +02:00
}
2026-05-11 21:33:49 +02:00
bool need_scroll = avail_count > UI_RECENT_LIST_SIZE ;
int offset = need_scroll ? ( sensors_scroll_offset % avail_count ) : 0 ;
int show_n = need_scroll ? UI_RECENT_LIST_SIZE : avail_count ;
2025-09-09 16:32:41 +02:00
2026-05-11 21:33:49 +02:00
for ( int i = 0 ; i < show_n ; i ++ ) {
uint8_t target = avail_types [( offset + i ) % avail_count ];
// scan LPP buffer for this type
LPPReader r ( sensors_lpp . getBuffer (), sensors_lpp . getSize ());
uint8_t ch , type ;
char buf [ 22 ] = "--" ;
while ( r . readHeader ( ch , type )) {
if ( type == target ) {
float v , v2 , v3 ;
switch ( type ) {
case LPP_GPS :
r . readGPS ( v , v2 , v3 );
if ( v != 0 || v2 != 0 ) snprintf ( buf , sizeof ( buf ), "%.4f %.4f" , v , v2 );
break ;
case LPP_VOLTAGE : r . readVoltage ( v ); snprintf ( buf , sizeof ( buf ), "%.2fV" , v ); break ;
case LPP_CURRENT : r . readCurrent ( v ); snprintf ( buf , sizeof ( buf ), "%.3fA" , v ); break ;
case LPP_POWER : r . readPower ( v ); snprintf ( buf , sizeof ( buf ), "%.1fW" , v ); break ;
case LPP_TEMPERATURE : r . readTemperature ( v ); snprintf ( buf , sizeof ( buf ), "%.1f \xf8 ""C" , v ); break ;
case LPP_RELATIVE_HUMIDITY : r . readRelativeHumidity ( v ); snprintf ( buf , sizeof ( buf ), "%.0f%%" , v ); break ;
case LPP_BAROMETRIC_PRESSURE : r . readPressure ( v ); snprintf ( buf , sizeof ( buf ), "%.1fhPa" , v ); break ;
case LPP_ALTITUDE : r . readAltitude ( v ); snprintf ( buf , sizeof ( buf ), "%.0fm" , v ); break ;
case LPP_LUMINOSITY : r . readLuminosity ( v ); snprintf ( buf , sizeof ( buf ), "%.0flux" , v ); break ;
case LPP_PERCENTAGE : r . readPercentage ( v ); snprintf ( buf , sizeof ( buf ), "%.0f%%" , v ); break ;
case LPP_DISTANCE : r . readDistance ( v ); snprintf ( buf , sizeof ( buf ), "%.2fm" , v ); break ;
case LPP_CONCENTRATION : r . readConcentration ( v ); snprintf ( buf , sizeof ( buf ), "%.0fppm" , v ); break ;
default : r . skipData ( type ); continue ;
}
break ;
}
r . skipData ( type );
2025-09-09 16:32:41 +02:00
}
2026-05-11 21:33:49 +02:00
static const struct { uint8_t type ; const char * name ; } TYPE_NAMES [] = {
{ LPP_VOLTAGE , "voltage" },
{ LPP_GPS , "gps" },
{ LPP_TEMPERATURE , "temp" },
{ LPP_RELATIVE_HUMIDITY , "humidity" },
{ LPP_BAROMETRIC_PRESSURE , "pressure" },
{ LPP_ALTITUDE , "altitude" },
{ LPP_CURRENT , "current" },
{ LPP_POWER , "power" },
{ LPP_LUMINOSITY , "light" },
{ LPP_PERCENTAGE , "moisture" },
{ LPP_DISTANCE , "distance" },
{ LPP_CONCENTRATION , "CO2" },
};
const char * name = "sensor" ;
for ( auto & tn : TYPE_NAMES ) { if ( tn . type == target ) { name = tn . name ; break ; } }
2025-09-09 16:32:41 +02:00
2025-09-05 15:20:52 +02:00
display . setCursor ( 0 , y );
2025-09-09 16:32:41 +02:00
display . print ( name );
2026-05-11 21:33:49 +02:00
display . setCursor ( display . width () - display . getTextWidth ( buf ) - 1 , y );
2025-09-05 15:20:52 +02:00
display . print ( buf );
2026-05-22 18:05:09 +02:00
y += step ;
2025-09-05 15:20:52 +02:00
}
2026-05-11 21:33:49 +02:00
if ( need_scroll ) sensors_scroll_offset = ( sensors_scroll_offset + 1 ) % avail_count ;
2025-09-09 16:32:41 +02:00
else sensors_scroll_offset = 0 ;
#endif
2026-05-10 15:18:35 +02:00
} else if ( _page == HomePage :: SETTINGS ) {
2026-05-11 15:56:30 +02:00
display . setColor ( DisplayDriver :: LIGHT );
2026-05-10 15:18:35 +02:00
display . setTextSize ( 1 );
2026-05-22 18:05:09 +02:00
display . drawTextCentered ( display . width () / 2 , content_y , "Settings" );
display . drawTextCentered ( display . width () / 2 , content_y + step * 2 , PRESS_LABEL " to open" );
2026-06-25 19:04:21 +02:00
} else if ( _page == HomePage :: MAP ) {
display . setColor ( DisplayDriver :: LIGHT );
display . setTextSize ( 1 );
// Mini-map preview filling the page, with one status line at the bottom.
int info_y = display . height () - step ;
int area_h = info_y - content_y - 2 ;
bool drew = drawMapPreview ( display , 2 , content_y , display . width () - 4 , area_h );
char left [ 20 ], right [ 16 ] = { 0 };
uint32_t now_m = rtc_clock . getCurrentTime ();
LiveTrackStore & lt = _task -> liveTrack ();
int trk = lt . active ( now_m );
// Fix state lives in the top-bar GPS icon. Track count plus an arrow +
// distance (to the active target, else the nearest live-tracked
// contact) share this one status line.
snprintf ( left , sizeof ( left ), "Track:%d" , trk );
float nearest_km = statusDistanceKm ();
if ( nearest_km >= 0.0f ) geo :: fmtDist ( right , sizeof ( right ), nearest_km , _task -> useImperial ());
display . setColor ( DisplayDriver :: LIGHT );
if ( ! drew )
display . drawTextCentered ( display . width () / 2 , content_y + area_h / 2 , "No GPS / no trail" );
if ( right [ 0 ]) {
// Manual layout (not drawTextCentered) so the arrow mini-icon sits
// inline between the two text runs.
const int s = miniIconScale ( display );
const int gap = 3 ;
int lw = display . getTextWidth ( left );
int iw = ICON_MAP_ARROW . w * s ;
int rw = display . getTextWidth ( right );
int x = display . width () / 2 - ( lw + gap + iw + gap + rw ) / 2 ;
display . setCursor ( x , info_y );
display . print ( left );
miniIconDrawTop ( display , x + lw + gap , info_y + ( lh - ICON_MAP_ARROW . h * s ) / 2 , ICON_MAP_ARROW );
display . setCursor ( x + lw + gap + iw + gap , info_y );
display . print ( right );
} else {
display . drawTextCentered ( display . width () / 2 , info_y , left );
}
2026-05-12 09:40:37 +02:00
} else if ( _page == HomePage :: TOOLS ) {
display . setColor ( DisplayDriver :: LIGHT );
display . setTextSize ( 1 );
2026-05-22 18:05:09 +02:00
display . drawTextCentered ( display . width () / 2 , content_y , "Tools" );
display . drawTextCentered ( display . width () / 2 , content_y + step * 2 , PRESS_LABEL " to open" );
2026-05-10 18:45:35 +02:00
} else if ( _page == HomePage :: QUICK_MSG ) {
2026-05-11 15:56:30 +02:00
display . setColor ( DisplayDriver :: LIGHT );
2026-05-10 18:45:35 +02:00
display . setTextSize ( 1 );
2026-05-22 18:05:09 +02:00
display . drawTextCentered ( display . width () / 2 , content_y , "Messages" );
2026-05-13 10:25:55 +02:00
int total_unread = _task -> getDMUnreadTotal () + _task -> getChannelUnreadCount () + _task -> getRoomUnreadCount ();
2026-05-11 19:11:19 +02:00
if ( total_unread > 0 ) {
char badge [ 20 ];
2026-09-23 13:45:07 +02:00
snprintf ( badge , sizeof ( badge ), _task -> getAnyUnreadOverflow () ? "%d+ unread" : "%d unread" , total_unread );
2026-05-22 18:05:09 +02:00
display . drawTextCentered ( display . width () / 2 , content_y + step , badge );
2026-05-11 17:19:03 +02:00
}
2026-05-22 18:05:09 +02:00
display . drawTextCentered ( display . width () / 2 , content_y + step * 2 , PRESS_LABEL " to open" );
2026-05-24 23:00:27 +02:00
} else if ( _page == HomePage :: FAVOURITES ) {
// Grid of pinned contacts. Layout transposes to current orientation:
// landscape → 3× 2, portrait → 2× 3. Selected tile inverts via drawSelectionRow.
2026-05-24 23:17:48 +02:00
// No title — node name + battery (top bar) and the page-dots indicator above
// serve as the page identity.
2026-05-24 23:00:27 +02:00
display . setColor ( DisplayDriver :: LIGHT );
display . setTextSize ( 1 );
const int cols = display . isLandscape () ? 3 : 2 ;
const int rows = NodePrefs :: FAVOURITES_COUNT / cols ;
2026-05-24 23:17:48 +02:00
const int margin = 2 ;
const int grid_y = content_y + margin ;
const int grid_h = display . height () - grid_y - margin ;
2026-05-24 23:00:27 +02:00
const int cell_w = display . width () / cols ;
const int cell_h = grid_h / rows ;
2026-05-24 23:17:48 +02:00
const int line_h = display . getLineHeight ();
2026-05-24 23:00:27 +02:00
if ( _fav_sel >= NodePrefs :: FAVOURITES_COUNT ) _fav_sel = 0 ;
2026-07-05 22:40:14 +02:00
bool fav_changed = false ; // a stale (gone) slot was pruned this pass → persist once after the loop
2026-05-24 23:00:27 +02:00
for ( uint8_t i = 0 ; i < NodePrefs :: FAVOURITES_COUNT ; i ++ ) {
int row = i / cols ;
int col = i % cols ;
int cx = col * cell_w ;
int cy = grid_y + row * cell_h ;
bool sel = ( i == _fav_sel );
display . drawSelectionRow ( cx , cy , cell_w - 1 , cell_h - 1 , sel );
2026-08-31 15:02:01 +02:00
const uint8_t * prefix = favSlotPrefix ( i );
char name [ 26 ];
uint8_t unread = 0 ;
2026-09-23 13:45:07 +02:00
bool overflow = false ;
2026-08-31 15:02:01 +02:00
bool resolved = false ;
2026-05-24 23:00:27 +02:00
2026-08-31 15:02:01 +02:00
if ( prefix && _task -> favouriteSlotKind ( i ) == NodePrefs :: FAV_KIND_CHANNEL ) {
uint8_t ch_idx = prefix [ 0 ];
ChannelDetails ch ;
if ( the_mesh . getChannel ( ch_idx , ch ) && ch . name [ 0 ]) {
// '#' marks a channel apart from a contact tile — the two share the
// grid and Enter does something different on each.
name [ 0 ] = '#' ;
display . translateUTF8ToBlocks ( name + 1 , ch . name , sizeof ( name ) - 1 );
unread = _task -> getChannelUnread ( ch_idx );
2026-09-23 13:45:07 +02:00
overflow = unread > 0 && _task -> getChannelUnreadOverflow ( ch_idx );
2026-08-31 15:02:01 +02:00
resolved = true ;
}
} else if ( prefix ) {
2026-05-24 23:00:27 +02:00
for ( int idx = 0 ; ; idx ++ ) {
ContactInfo c ;
if ( ! the_mesh . getContactByIdx ( idx , c )) break ;
if ( memcmp ( c . id . pub_key , prefix , NodePrefs :: FAVOURITE_PREFIX_LEN ) == 0 ) {
2026-08-31 15:02:01 +02:00
display . translateUTF8ToBlocks ( name , c . name , sizeof ( name ));
unread = _task -> getDMUnread ( c . id . pub_key );
2026-09-23 13:45:07 +02:00
overflow = unread > 0 && _task -> getDMUnreadOverflow ( c . id . pub_key );
2026-08-31 15:02:01 +02:00
resolved = true ;
break ;
2026-05-24 23:00:27 +02:00
}
}
2026-07-06 20:18:36 +02:00
}
2026-08-31 15:02:01 +02:00
if ( prefix && ! resolved ) {
// The pinned target is gone — prefs outlived the contact/channel list
// (e.g. a wiped /contacts3; onContactRemoved/onChannelRemoved only
// catch a live delete). Clear the stale slot so it renders as an empty
// "+" tile instead of a blank one. Persisted once after the loop.
_task -> clearFavouriteSlot ( i );
fav_changed = true ;
}
2026-05-24 23:00:27 +02:00
2026-08-31 15:02:01 +02:00
if ( resolved ) {
2026-05-25 13:30:12 +02:00
// Reserve space for the unread badge so the name's ellipsis lands
// before it instead of underneath. Badge and name share one baseline.
2026-09-23 13:45:07 +02:00
int bw = unread > 0 ? display . unreadBadgeWidth ( unread , overflow ) + 3 : 0 ; // badge + 3 px gap
2026-05-25 13:30:12 +02:00
int name_y = cy + ( cell_h - line_h ) / 2 ;
int name_max_w = cell_w - 4 - bw ;
if ( name_max_w < 6 ) name_max_w = 6 ;
2026-08-30 17:17:55 +02:00
int r = display . drawTextEllipsized ( cx + 2 , name_y , name_max_w , name , sel );
if ( sel && r > 0 ) mq_delay = r ;
2026-07-07 01:12:51 +02:00
if ( unread > 0 )
2026-09-23 13:45:07 +02:00
display . drawUnreadBadge ( cx + cell_w - 2 , name_y , unread , sel , overflow );
2026-05-24 23:00:27 +02:00
} else {
2026-05-24 23:17:48 +02:00
int plus_y = cy + ( cell_h - line_h ) / 2 ;
2026-05-24 23:00:27 +02:00
display . drawTextCentered ( cx + cell_w / 2 , plus_y , "+" );
}
if ( sel ) display . setColor ( DisplayDriver :: LIGHT );
}
2026-07-05 22:40:14 +02:00
// Persist any pruned slots once, outside the loop — a render pass can clear
// several gone tiles but only one flash write is needed. Self-healing: once
// cleared, the slot is empty next frame so this can't re-fire per frame.
if ( fav_changed ) the_mesh . savePrefs ();
2026-08-31 15:02:01 +02:00
if ( _tile_menu . active ) _tile_menu . render ( display );
2025-08-08 20:01:31 +10:00
} else if ( _page == HomePage :: SHUTDOWN ) {
2026-05-11 15:56:30 +02:00
display . setColor ( DisplayDriver :: LIGHT );
2025-08-08 20:01:31 +10:00
display . setTextSize ( 1 );
if ( _shutdown_init ) {
2026-05-22 18:05:09 +02:00
display . drawTextCentered ( display . width () / 2 , content_y + step , "hibernating..." );
2025-08-08 20:01:31 +10:00
} else {
2026-05-22 18:05:09 +02:00
display . drawXbm (( display . width () - 32 ) / 2 , content_y , power_icon , 32 , 32 );
2026-05-23 14:45:24 +02:00
const int text_y = content_y + 32 + 3 ;
const int lh1 = display . getLineHeight ();
if ( text_y + lh1 <= display . height ()) {
char hib_hint [ 32 ];
2026-05-24 00:56:43 +02:00
snprintf ( hib_hint , sizeof ( hib_hint ), "hibernate:%s" , PRESS_LABEL );
2026-05-23 14:45:24 +02:00
if ( display . getTextWidth ( hib_hint ) < display . width ()) {
display . drawTextCentered ( display . width () / 2 , text_y , hib_hint );
} else {
display . drawTextCentered ( display . width () / 2 , text_y , "hibernate:" );
if ( text_y + step + lh1 <= display . height ())
display . drawTextCentered ( display . width () / 2 , text_y + step , PRESS_LABEL );
}
}
2025-08-08 20:01:31 +10:00
}
}
2026-05-14 17:25:27 +02:00
bool auto_adv = _node_prefs && _node_prefs -> advert_auto_interval_sec > 0 ;
2026-06-16 08:54:11 +02:00
// Any blinking status-bar indicator needs a 1 s refresh to animate evenly —
// but the status bar (and its icons) is hidden on the CLOCK page, so don't
// pay the 1 s cadence there for icons that aren't drawn.
2026-06-25 19:04:21 +02:00
bool repeating = _node_prefs && _node_prefs -> client_repeat ;
bool loc_sharing = _node_prefs && _node_prefs -> loc_share_enabled ;
bool need_blink = ( _page != HomePage :: CLOCK ) &&
( auto_adv || _task -> trail (). isActive () || repeating || loc_sharing );
2026-05-24 20:41:25 +02:00
if ( Features :: IS_EINK ) {
// slow display: poll every 30 s; inbound msgs force immediate refresh via notify()
2026-08-30 17:17:55 +02:00
return ( mq_delay > 0 && mq_delay < Features :: HOME_REFRESH_MS ) ? mq_delay : Features :: HOME_REFRESH_MS ;
2026-05-24 20:41:25 +02:00
}
2026-05-12 20:12:54 +02:00
if ( _page == HomePage :: CLOCK ) {
bool show_sec = ! _node_prefs || ! _node_prefs -> clock_hide_seconds ;
2026-08-30 17:17:55 +02:00
int ret = need_blink ? 1000 : ( show_sec ? 1000 : 60000 );
return ( mq_delay > 0 && mq_delay < ret ) ? mq_delay : ret ;
2026-05-12 20:12:54 +02:00
}
2026-08-30 17:17:55 +02:00
int ret = need_blink ? 1000 : 5000 ;
return ( mq_delay > 0 && mq_delay < ret ) ? mq_delay : ret ;
2025-08-08 20:01:31 +10:00
}
bool handleInput ( char c ) override {
2026-05-24 23:00:27 +02:00
// Favourites grid claims joystick UP/DOWN and inner LEFT/RIGHT; LEFT at the
// left column and RIGHT at the right column fall through to page nav so the
// user can still leave the page sideways.
if ( _page == HomePage :: FAVOURITES ) {
2026-08-31 15:02:01 +02:00
// The tile menu consumes all input while open.
if ( _tile_menu . active ) {
auto res = _tile_menu . handleInput ( c );
2026-05-25 08:08:12 +02:00
if ( res == PopupMenu :: SELECTED && _pin_target_slot >= 0 ) {
2026-08-31 15:02:01 +02:00
if ( _tile_menu . selectedIndex () == 1 ) { // Replace
_task -> pickFavouriteTarget ( _pin_target_slot );
_pin_target_slot = - 1 ;
return true ;
2026-05-25 08:08:12 +02:00
}
2026-08-31 15:02:01 +02:00
_task -> clearFavouriteSlot ( _pin_target_slot );
the_mesh . savePrefs ();
2026-08-31 19:52:43 +02:00
char alert [ 24 ];
snprintf ( alert , sizeof ( alert ), "Unpinned (slot %d)" , _pin_target_slot + 1 );
_task -> showAlert ( alert , 800 );
2026-05-25 08:08:12 +02:00
}
if ( res != PopupMenu :: NONE ) _pin_target_slot = - 1 ;
return true ;
}
2026-05-24 23:00:27 +02:00
DisplayDriver * d = _task -> getDisplay ();
const int cols = ( d && d -> isLandscape ()) ? 3 : 2 ;
const int rows = NodePrefs :: FAVOURITES_COUNT / cols ;
int col = _fav_sel % cols ;
int row = _fav_sel / cols ;
if (( c == KEY_LEFT || c == KEY_PREV ) && col > 0 ) { _fav_sel -- ; return true ; }
if (( c == KEY_RIGHT || c == KEY_NEXT ) && col < cols - 1 ) { _fav_sel ++ ; return true ; }
if ( c == KEY_UP && row > 0 ) { _fav_sel -= cols ; return true ; }
if ( c == KEY_DOWN && row < rows - 1 ) { _fav_sel += cols ; return true ; }
2026-08-31 15:02:01 +02:00
if ( c == KEY_CONTEXT_MENU ) {
// Filled tile → Unpin / Replace. An empty tile has nothing to offer:
// its Enter already opens the picker.
if ( favSlotPrefix ( _fav_sel )) {
_pin_target_slot = _fav_sel ;
_tile_menu . begin ( "Slot options" , 2 );
_tile_menu . addItem ( "Unpin" );
_tile_menu . addItem ( "Replace" );
2026-05-25 07:52:56 +02:00
}
2026-05-24 23:00:27 +02:00
return true ;
}
2026-08-31 15:02:01 +02:00
if ( c == KEY_ENTER ) {
// Filled slot → open its conversation, empty slot → in-place pin picker.
const uint8_t * pfx = favSlotPrefix ( _fav_sel );
if ( ! pfx ) { _task -> pickFavouriteTarget ( _fav_sel ); return true ; }
if ( _task -> favouriteSlotKind ( _fav_sel ) == NodePrefs :: FAV_KIND_CHANNEL ) {
_task -> openChannelHistory ( pfx [ 0 ]);
return true ;
}
for ( int idx = 0 ; ; idx ++ ) {
ContactInfo c2 ;
if ( ! the_mesh . getContactByIdx ( idx , c2 )) break ;
if ( memcmp ( c2 . id . pub_key , pfx , NodePrefs :: FAVOURITE_PREFIX_LEN ) == 0 ) {
// A room opens through its own entry point: posting to one needs a
// login handshake that a plain DM view would skip.
if ( c2 . type == ADV_TYPE_ROOM ) _task -> openRoomServer ( c2 );
else _task -> openContactDM ( c2 );
return true ;
}
}
_task -> showAlert ( "Contact not found" , 800 );
return true ;
}
2026-05-24 23:00:27 +02:00
// Edge LEFT/RIGHT and unhandled keys fall through to page nav below.
}
2025-09-03 16:28:58 +10:00
if ( c == KEY_LEFT || c == KEY_PREV ) {
2026-05-12 09:51:30 +02:00
_page = navPage ( _page , - 1 );
2025-08-08 20:01:31 +10:00
return true ;
}
2025-09-03 16:28:58 +10:00
if ( c == KEY_NEXT || c == KEY_RIGHT ) {
2026-05-12 09:51:30 +02:00
_page = navPage ( _page , + 1 );
2025-08-08 20:01:31 +10:00
return true ;
}
if ( c == KEY_ENTER && _page == HomePage :: BLUETOOTH ) {
if ( _task -> isSerialEnabled ()) { // toggle Bluetooth on/off
_task -> disableSerial ();
} else {
_task -> enableSerial ();
}
return true ;
}
if ( c == KEY_ENTER && _page == HomePage :: ADVERT ) {
2025-09-17 08:53:50 +08:00
_task -> notify ( UIEventType :: ack );
2025-08-08 20:01:31 +10:00
if ( the_mesh . advert ()) {
2026-08-31 19:52:43 +02:00
_task -> showAlert ( "Advert sent" , 1000 );
2025-08-08 20:01:31 +10:00
} else {
2026-08-31 19:52:43 +02:00
_task -> showAlert ( "Advert failed" , 1000 );
2025-08-08 20:01:31 +10:00
}
return true ;
}
2025-09-28 09:43:28 +02:00
#if ENV_INCLUDE_GPS == 1
2025-09-23 10:39:43 +02:00
if ( c == KEY_ENTER && _page == HomePage :: GPS ) {
_task -> toggleGPS ();
return true ;
}
#endif
2025-09-09 16:32:41 +02:00
#if UI_SENSORS_PAGE == 1
2025-09-05 15:32:02 +02:00
if ( c == KEY_ENTER && _page == HomePage :: SENSORS ) {
2026-05-25 19:26:02 +02:00
// _task->toggleGPS();
2025-09-05 15:35:04 +02:00
next_sensors_refresh = 0 ;
2025-09-05 15:32:02 +02:00
return true ;
}
2025-09-09 16:32:41 +02:00
#endif
2026-05-10 15:18:35 +02:00
if ( c == KEY_ENTER && _page == HomePage :: SETTINGS ) {
_task -> gotoSettingsScreen ();
return true ;
}
2026-06-25 19:04:21 +02:00
if ( c == KEY_ENTER && _page == HomePage :: MAP ) {
_task -> gotoMapScreen ();
return true ;
}
2026-05-12 09:40:37 +02:00
if ( c == KEY_ENTER && _page == HomePage :: TOOLS ) {
_task -> gotoToolsScreen ();
return true ;
}
2026-05-10 18:45:35 +02:00
if ( c == KEY_ENTER && _page == HomePage :: QUICK_MSG ) {
2026-07-12 19:40:53 +02:00
_task -> gotoMessagesScreen ();
2026-05-10 18:45:35 +02:00
return true ;
}
2025-08-08 20:01:31 +10:00
if ( c == KEY_ENTER && _page == HomePage :: SHUTDOWN ) {
_shutdown_init = true ; // need to wait for button to be released
return true ;
}
2026-06-30 01:54:01 +02:00
if ( c == KEY_ENTER && _page == HomePage :: CLOCK ) {
_task -> gotoClockTools (); // Alarm / Timer / Stopwatch
return true ;
}
2026-05-14 11:28:36 +02:00
if ( c == KEY_CONTEXT_MENU && _page == HomePage :: CLOCK ) {
_task -> gotoDashboardConfig ();
return true ;
}
2026-06-25 19:04:21 +02:00
if ( c == KEY_CONTEXT_MENU && _page == HomePage :: MAP ) {
_task -> quickShareMyLocation ();
return true ;
}
2025-08-08 20:01:31 +10:00
return false ;
}
};
2025-03-04 23:09:43 +11:00
2025-06-19 17:26:58 +02:00
void UITask :: begin ( DisplayDriver * display , SensorManager * sensors , NodePrefs * node_prefs ) {
2025-03-16 13:42:36 +11:00
_display = display ;
2025-06-19 17:26:58 +02:00
_sensors = sensors ;
2026-05-10 15:18:35 +02:00
_node_prefs = node_prefs ;
2026-07-02 12:21:52 +02:00
_kb . prefs = node_prefs ;
2026-05-10 15:18:35 +02:00
uint32_t aoff = autoOffMillis ();
_auto_off = millis () + ( aoff > 0 ? aoff : AUTO_OFF_MILLIS );
2025-08-08 20:01:31 +10:00
2026-09-03 00:46:47 +02:00
#if defined(SIM_PLATFORM) && defined(__EMSCRIPTEN__)
g_sim_ui_task_for_js = this ; // see sim_enqueue_key() below
#endif
2026-08-23 21:44:17 -03:00
#if defined(CARDKB_I2C)
2026-08-28 10:55:52 -03:00
// On the ENV_PIN_SDA/SCL path, CARDKB_I2C is Wire1, already brought up by
2026-08-23 21:44:17 -03:00
// sensors.begin() (EnvironmentSensorManager), which runs before this. On
2026-08-28 10:55:52 -03:00
// boards that set CARDKB_I2C=Wire directly in platformio.ini, that bus is
// brought up by the board's own begin() instead -- also before this.
// Either way, just probe for a CardKB sitting on it.
2026-08-23 21:44:17 -03:00
CARDKB_I2C . beginTransmission ( 0x5F );
_has_cardkb = ( CARDKB_I2C . endTransmission () == 0 );
2026-07-21 23:43:33 +02:00
#endif
2026-08-26 13:48:33 +02:00
#if defined(PIN_HALL_SENSOR)
// Internal pull matches the default polarity: pulled up so an active-low
// sensor reads HIGH at rest, pulled down so an active-high one reads LOW at
// rest. Most reed/Hall breakouts are open-drain, active-low -- HALL_ACTIVE_HIGH
// is only for modules wired the other way.
pinMode ( PIN_HALL_SENSOR , HALL_ACTIVE_HIGH ? INPUT_PULLDOWN : INPUT_PULLUP );
_hall_magnet_present = HALL_ACTIVE_HIGH ? ( digitalRead ( PIN_HALL_SENSOR ) == HIGH )
: ( digitalRead ( PIN_HALL_SENSOR ) == LOW );
2026-09-05 20:40:13 +02:00
// Handle booting with the cover already closed
if ( _hall_magnet_present ) {
_locked = true ;
syncLockToHome ();
}
2026-08-26 13:48:33 +02:00
#endif
2025-08-08 20:01:31 +10:00
#if defined(PIN_USER_BTN)
user_btn . begin ();
#endif
2026-06-30 22:10:18 +02:00
#if UI_HAS_JOYSTICK
// The directional joystick + Back share the same MomentaryButton machinery as
// user_btn but were never begin()'d — they only worked because the pins
// default to INPUT and the board has external pulls. That left them on the
// polling path: with BUTTON_USE_INTERRUPTS (e-ink) they'd silently never
// attach a GPIOTE channel, so edges landing during a blocking panel refresh
// were lost. begin() sets pinMode and claims an IRQ slot for each.
joystick_left . begin ();
joystick_right . begin ();
back_btn . begin ();
#if UI_HAS_JOYSTICK_UPDOWN
joystick_up . begin ();
joystick_down . begin ();
#endif
#endif
2025-09-01 17:11:55 +10:00
#if defined(PIN_USER_BTN_ANA)
analog_btn . begin ();
#endif
2025-08-08 20:01:31 +10:00
2025-03-10 17:11:55 +01:00
if ( _display != NULL ) {
_display -> turnOn ();
}
2025-04-08 22:58:17 +12:00
2025-05-20 11:52:55 +10:00
#ifdef PIN_BUZZER
2026-09-26 01:00:31 +02:00
soundctl :: applyMode ( _node_prefs , buzzer , false );
2026-05-12 09:17:37 +02:00
buzzer . setVolume ( _node_prefs -> buzzer_volume );
2026-05-10 15:18:35 +02:00
buzzer . begin ();
2025-05-20 11:52:55 +10:00
#endif
2025-05-27 19:10:56 -07:00
2025-09-07 15:16:15 +08:00
#ifdef PIN_VIBRATION
vibration . begin ();
#endif
2026-09-26 00:46:34 +02:00
msgtext :: seedQuick ( _node_prefs ); // "OK" in quick message 1 on first boot
2026-05-11 16:52:24 +02:00
2025-06-04 21:33:48 +10:00
ui_started_at = millis ();
2025-08-08 20:01:31 +10:00
_alert_expiry = 0 ;
2026-09-24 23:28:58 +02:00
_batt_mv = UITaskBase :: getBattMilliVolts (); // seed EMA with first reading
2026-06-03 08:23:11 +02:00
2026-09-25 19:20:42 +02:00
_core = new UiCore (); // also loads the persisted waypoints // before any screen -- MessagesScreen binds to its history
2026-09-24 23:28:58 +02:00
_core -> begin ( node_prefs , sensors , this );
2025-08-08 20:01:31 +10:00
splash = new SplashScreen ( this );
home = new HomeScreen ( this , & rtc_clock , sensors , node_prefs );
2026-09-05 20:40:13 +02:00
syncLockToHome (); // booted locked (e.g. cover closed) → home starts on the LOCK page
2026-06-08 20:42:18 +02:00
settings = new SettingsScreen ( this , & _kb );
2026-07-12 19:40:53 +02:00
messages_screen = new MessagesScreen ( this , & _kb );
2026-05-12 09:40:37 +02:00
tools_screen = new ToolsScreen ( this );
ringtone_edit = new RingtoneEditorScreen ( this , node_prefs );
2026-06-08 20:42:18 +02:00
bot_screen = new BotScreen ( this , node_prefs , & _kb );
2026-07-12 19:40:53 +02:00
admin_screen = new AdminScreen ( this );
2026-05-14 11:28:36 +02:00
nearby_screen = new NearbyScreen ( this );
dashboard_config = new DashboardConfigScreen ( this , node_prefs );
2026-05-14 16:08:33 +02:00
auto_advert_screen = new AutoAdvertScreen ( this , node_prefs );
2026-06-25 19:04:21 +02:00
live_share_screen = new LiveShareScreen ( this , node_prefs );
locator_screen = new LocatorScreen ( this , node_prefs );
2026-09-24 23:17:03 +02:00
trail_screen = new TrailScreen ( this , & _core -> trail . store ());
2026-06-03 14:07:53 +02:00
compass_screen = new CompassScreen ( this );
2026-06-18 22:34:52 +02:00
diag_screen = new DiagnosticsScreen ( this );
2026-06-20 09:07:00 +02:00
repeater_screen = new RepeaterScreen ( this );
2026-06-30 01:54:01 +02:00
clock_tools = new ClockToolsScreen ( this , node_prefs );
2026-07-21 20:30:03 +02:00
#if defined(PIN_GPIO1)
gpio_screen = new GpioScreen ( this , node_prefs );
#endif
2026-05-10 15:18:35 +02:00
applyBrightness ();
2026-05-20 21:53:40 +02:00
applyRotation ();
2026-05-24 10:22:03 +02:00
applyFullRefreshInterval ();
2026-07-21 20:30:03 +02:00
applyAllGpioModes (); // restore persisted pin modes to hardware before any UI/bot use
2026-05-22 23:50:21 +02:00
setCurrScreen ( splash );
2025-08-08 20:01:31 +10:00
}
2026-06-29 18:37:04 +02:00
// onShow() is invoked by setCurrScreen(), so most navigators are just that.
void UITask :: gotoSettingsScreen () { setCurrScreen ( settings ); }
void UITask :: gotoToolsScreen () { setCurrScreen ( tools_screen ); }
void UITask :: gotoBotScreen () { setCurrScreen ( bot_screen ); }
void UITask :: gotoNearbyScreen () { setCurrScreen ( nearby_screen ); }
2026-07-12 20:32:04 +02:00
void UITask :: pickAdminTarget () {
setCurrScreen ( nearby_screen ); // runs NearbyScreen::onShow()'s reset first
(( NearbyScreen * ) nearby_screen ) -> startPickAdminTarget ();
}
2026-07-17 19:38:51 +02:00
void UITask :: openAdminFor ( const ContactInfo & ci , bool from_picker ) {
2026-07-12 20:32:04 +02:00
setCurrScreen ( admin_screen ); // runs AdminScreen::onShow()'s reset first
2026-07-17 19:38:51 +02:00
(( AdminScreen * ) admin_screen ) -> startFor ( ci , from_picker );
2026-07-12 20:32:04 +02:00
}
2026-06-29 18:37:04 +02:00
void UITask :: gotoDashboardConfig () { setCurrScreen ( dashboard_config ); }
void UITask :: gotoTrailScreen () { setCurrScreen ( trail_screen ); }
void UITask :: gotoCompassScreen () { setCurrScreen ( compass_screen ); }
void UITask :: gotoDiagnosticsScreen () { setCurrScreen ( diag_screen ); }
void UITask :: gotoRepeaterScreen () { setCurrScreen ( repeater_screen ); }
2026-06-30 01:54:01 +02:00
void UITask :: gotoClockTools () { setCurrScreen ( clock_tools ); }
2026-07-21 20:30:03 +02:00
void UITask :: gotoGpioScreen () {
#if defined(PIN_GPIO1)
setCurrScreen ( gpio_screen );
#endif
}
2026-06-29 18:37:04 +02:00
void UITask :: gotoLiveShareScreen () { setCurrScreen ( live_share_screen ); }
2026-05-12 09:40:37 +02:00
2026-09-24 23:03:57 +02:00
// ── Clock tools (engine in ui-core/ClockEngine.h) ───────────────────────────
2026-09-26 00:27:52 +02:00
// The melody (soundctl::MEL_ALARM) overrides mute (playMelody →
// buzzer.playForced); the ring auto-stops after ClockEngine::RING_MS if no key
// dismisses it (see loop()).
2026-06-30 01:54:01 +02:00
2026-07-05 16:08:44 +02:00
void UITask :: wakeForAlarm () {
if ( _display != NULL ) _display -> turnOn ();
// Locked: the lock-screen blanking check (loop()) turns the display straight
// back off once _lock_wake_until is in the past — which it always is by the
// time an alarm fires. Hold the wake window open for the whole ring so the
// lock screen (and its alert overlay) stays visible while ringing.
2026-09-24 23:03:57 +02:00
if ( _locked ) _lock_wake_until = millis () + ClockEngine :: RING_MS ;
2026-07-05 16:08:44 +02:00
_next_refresh = 0 ; // draw the alert overlay immediately
}
2026-09-24 23:03:57 +02:00
void UITask :: onAlarmChanged () { _core -> clock . onAlarmChanged (); }
void UITask :: startTimer ( uint32_t duration_ms ) { _core -> clock . startTimer ( duration_ms ); }
void UITask :: stopTimer () { _core -> clock . stopTimer (); }
bool UITask :: isTimerRunning () const { return _core -> clock . isTimerRunning (); }
uint32_t UITask :: timerRemainingMs () const { return _core -> clock . timerRemainingMs (); }
bool UITask :: isRinging () const { return _core -> clock . isRinging (); }
void UITask :: dismissRing () { stopMelody (); _core -> clock . dismissRing (); clearAlert (); }
void UITask :: tickCore () {
_core -> loop ();
2026-09-24 23:28:58 +02:00
drainCoreEvents ();
// Repeat the ring melody until dismissed or the ring window elapses.
2026-09-26 00:27:52 +02:00
if ( _core -> clock . isRinging () && ! isMelodyPlaying ()) playMelody ( soundctl :: MEL_ALARM );
2026-09-24 23:28:58 +02:00
}
void UITask :: drainCoreEvents () {
2026-09-24 23:03:57 +02:00
UiEvent ev ;
while ( _core -> events . pop ( ev )) {
switch ( ev . type ) {
case UiEventType :: ClockAlert :
wakeForAlarm ();
showAlert ( ev . text , ClockEngine :: RING_MS );
2026-09-26 00:27:52 +02:00
playMelody ( soundctl :: MEL_ALARM );
2026-09-24 23:03:57 +02:00
break ;
case UiEventType :: ClockRingEnded :
stopMelody ();
clearAlert ();
break ;
2026-09-24 23:09:26 +02:00
case UiEventType :: LiveShareEnded :
showAlert ( "Live share ended" , 2500 );
break ;
2026-09-24 23:15:18 +02:00
case UiEventType :: LocatorCrossed :
showAlert ( ev . text , 3000 );
if ( ! isBuzzerQuiet ())
2026-09-26 00:27:52 +02:00
playMelody ( ev . flag ? soundctl :: MEL_ARRIVE : soundctl :: MEL_LEAVE );
2026-09-24 23:15:18 +02:00
break ;
case UiEventType :: LocatorBeep :
2026-09-26 00:27:52 +02:00
playMelody ( soundctl :: MEL_TICK );
2026-09-24 23:15:18 +02:00
break ;
2026-09-24 23:28:58 +02:00
case UiEventType :: MessageArrived :
onMessageArrived ( ev );
break ;
case UiEventType :: AdvertHeard :
notify ( ev . flag ? UIEventType :: advertReceivedFlood : UIEventType :: advertReceivedZeroHop );
break ;
2026-09-24 23:03:57 +02:00
default :
break ;
2026-07-10 16:01:42 +02:00
}
}
2026-06-30 01:54:01 +02:00
}
2026-06-29 18:37:04 +02:00
// Ringtone takes a slot argument that onShow() can't carry — pass it after the
// reset (setCurrScreen's onShow runs first, then this layers the slot on top).
2026-05-15 11:43:38 +02:00
void UITask :: gotoRingtoneEditor ( int slot ) {
2026-05-12 09:40:37 +02:00
setCurrScreen ( ringtone_edit );
2026-06-29 18:37:04 +02:00
(( RingtoneEditorScreen * ) ringtone_edit ) -> selectSlot ( slot );
2026-05-12 09:40:37 +02:00
}
2026-06-29 18:37:04 +02:00
// Map is a sub-view variant of the Trail screen: reset via onShow(), then
// switch into the map view.
2026-06-25 19:04:21 +02:00
void UITask :: gotoMapScreen () {
setCurrScreen ( trail_screen );
2026-06-29 18:37:04 +02:00
(( TrailScreen * ) trail_screen ) -> showMapView ();
2026-06-25 19:04:21 +02:00
}
2026-06-29 18:37:04 +02:00
void UITask :: gotoLocatorScreen () { setCurrScreen ( locator_screen ); }
void UITask :: gotoAutoAdvertScreen () { setCurrScreen ( auto_advert_screen ); }
2026-05-14 16:08:33 +02:00
2026-05-29 21:49:48 +02:00
bool UITask :: startPing ( const uint8_t * pub_key ) {
2026-09-24 23:05:39 +02:00
PingEngine :: StartResult r = _core -> ping . start ( pub_key );
if ( r == PingEngine :: UNSUPPORTED ) showAlert ( "Ping not supported with 3-byte path hashes" , 3000 );
return r == PingEngine :: STARTED ;
2026-05-29 21:49:48 +02:00
}
2026-09-24 23:05:39 +02:00
bool UITask :: isPingActive () const { return _core -> ping . isActive (); }
void UITask :: getPingResult ( int16_t & snr_out_x4 , int16_t & snr_back_x4 , uint32_t & rtt_ms ) const {
_core -> ping . getResult ( snr_out_x4 , snr_back_x4 , rtt_ms );
}
void UITask :: clearPing () { _core -> ping . clear (); }
2026-05-29 21:49:48 +02:00
2026-05-12 09:40:37 +02:00
void UITask :: playMelody ( const char * melody ) {
#ifdef PIN_BUZZER
buzzer . playForced ( melody );
#endif
}
void UITask :: stopMelody () {
#ifdef PIN_BUZZER
buzzer . stop ();
#endif
}
bool UITask :: isMelodyPlaying () {
#ifdef PIN_BUZZER
return buzzer . isPlaying ();
#else
return false ;
#endif
}
2026-07-12 19:40:53 +02:00
void UITask :: gotoMessagesScreen () {
(( MessagesScreen * ) messages_screen ) -> reset ();
setCurrScreen ( messages_screen );
2026-05-10 18:45:35 +02:00
}
2026-05-25 07:52:56 +02:00
void UITask :: openContactDM ( const ContactInfo & ci ) {
2026-07-12 19:40:53 +02:00
(( MessagesScreen * ) messages_screen ) -> reset ();
(( MessagesScreen * ) messages_screen ) -> enterDM ( ci );
setCurrScreen ( messages_screen );
2026-05-25 07:52:56 +02:00
}
2026-08-31 15:02:01 +02:00
void UITask :: openChannelHistory ( uint8_t channel_idx ) {
(( MessagesScreen * ) messages_screen ) -> reset ();
(( MessagesScreen * ) messages_screen ) -> enterChannel ( channel_idx );
setCurrScreen ( messages_screen );
}
void UITask :: openRoomServer ( const ContactInfo & ci ) {
(( MessagesScreen * ) messages_screen ) -> reset ();
(( MessagesScreen * ) messages_screen ) -> enterRoom ( ci );
setCurrScreen ( messages_screen );
}
2026-06-04 11:38:12 +02:00
void UITask :: shareToMessage ( const char * text ) {
2026-07-12 19:40:53 +02:00
(( MessagesScreen * ) messages_screen ) -> startShare ( text );
setCurrScreen ( messages_screen );
2026-06-04 11:38:12 +02:00
}
2026-06-25 19:04:21 +02:00
void UITask :: pickLocShareTarget () {
2026-07-12 19:40:53 +02:00
(( MessagesScreen * ) messages_screen ) -> startPickTarget ();
setCurrScreen ( messages_screen );
2026-06-25 19:04:21 +02:00
}
2026-08-31 15:02:01 +02:00
void UITask :: pickFavouriteTarget ( int slot ) {
(( MessagesScreen * ) messages_screen ) -> startPickFavourite ( slot );
setCurrScreen ( messages_screen );
}
2026-07-10 20:16:59 +02:00
void UITask :: pickBotChannelTarget () {
2026-07-12 19:40:53 +02:00
(( MessagesScreen * ) messages_screen ) -> startPickBotChannel ();
setCurrScreen ( messages_screen );
2026-07-10 20:16:59 +02:00
}
void UITask :: pickBotRoomTarget () {
2026-07-12 19:40:53 +02:00
(( MessagesScreen * ) messages_screen ) -> startPickBotRoom ();
setCurrScreen ( messages_screen );
2026-07-10 20:16:59 +02:00
}
2026-09-24 23:28:58 +02:00
// ── UI Core wiring ──────────────────────────────────────────────────────────
MyMesh :: Listener * UITask :: meshListener () { return _core ; }
bool UITask :: isViewingChannel ( uint8_t channel_idx ) {
return (( MessagesScreen * ) messages_screen ) -> isViewingChannel ( channel_idx );
}
bool UITask :: isViewingDM ( const uint8_t * pub_key ) {
return (( MessagesScreen * ) messages_screen ) -> isViewingDM ( pub_key );
}
void UITask :: onViewedHistoryGrew ( bool channel ) {
(( MessagesScreen * ) messages_screen ) -> onViewedHistoryGrew ( channel );
2026-08-30 17:35:13 +02:00
}
2026-09-24 23:28:58 +02:00
int UITask :: getMsgCount () const { return _core -> msgCount (); }
int UITask :: getRoomUnreadCount () const { return _core -> roomUnread (); }
void UITask :: clearRoomUnread () { _core -> clearRoomUnread (); }
2026-05-11 15:56:30 +02:00
2026-05-11 19:11:19 +02:00
int UITask :: getChannelUnreadCount () const {
2026-07-12 19:40:53 +02:00
return (( MessagesScreen * ) messages_screen ) -> getTotalChannelUnread ();
2026-05-11 19:11:19 +02:00
}
2026-08-31 15:02:01 +02:00
uint8_t UITask :: getChannelUnread ( uint8_t channel_idx ) const {
return (( MessagesScreen * ) messages_screen ) -> chUnread ( channel_idx );
}
2026-09-23 13:45:07 +02:00
bool UITask :: getChannelUnreadOverflow ( uint8_t channel_idx ) const {
return (( MessagesScreen * ) messages_screen ) -> chUnreadOverflow ( channel_idx );
}
bool UITask :: getAnyChannelUnreadOverflow () const {
return (( MessagesScreen * ) messages_screen ) -> anyChannelUnreadOverflow ();
}
bool UITask :: getAnyUnreadOverflow () const {
return getAnyChannelUnreadOverflow () || getAnyDMUnreadOverflow ();
}
2026-06-26 10:55:24 +02:00
void UITask :: onRoomLoginResult ( const uint8_t * pub_key , bool success , uint8_t permissions ) {
2026-09-25 19:59:00 +02:00
// Admin logins are answered to the Core's AdminSession; what reaches here is
// MessagesScreen's room login.
(( MessagesScreen * ) messages_screen ) -> onRoomLoginResult ( pub_key , success , permissions );
2026-06-26 10:55:24 +02:00
// Unlike the keypress-driven showAlert() calls elsewhere, this fires from a
// background mesh response with no keypress to schedule a redraw — without
// forcing one, the alert's short expiry can lapse before the next scheduled
// refresh ever draws it.
_next_refresh = 0 ;
}
2026-09-25 19:59:00 +02:00
void UITask :: onAdminStateChanged () {
2026-07-12 19:40:53 +02:00
_next_refresh = 0 ; // same reasoning as onRoomLoginResult above
}
2026-09-24 23:00:46 +02:00
int UITask :: getDMUnreadTotal () const { return _core -> dmUnreadTotal (); }
uint8_t UITask :: getDMUnread ( const uint8_t * pub_key ) const { return _core -> dmUnread ( pub_key ); }
bool UITask :: getDMUnreadOverflow ( const uint8_t * pub_key ) const { return _core -> dmUnreadOverflow ( pub_key ); }
bool UITask :: getAnyDMUnreadOverflow () const { return _core -> anyDMUnreadOverflow (); }
void UITask :: clearDMUnread ( const uint8_t * pub_key ) { _core -> clearDMUnread ( pub_key ); }
void UITask :: clearAllDMUnread () { _core -> clearAllDMUnread (); }
2026-07-27 22:05:52 +02:00
2025-08-08 20:01:31 +10:00
void UITask :: showAlert ( const char * text , int duration_millis ) {
2026-05-12 08:19:27 +02:00
snprintf ( _alert , sizeof ( _alert ), "%s" , text );
2025-08-08 20:01:31 +10:00
_alert_expiry = millis () + duration_millis ;
2025-05-20 11:52:55 +10:00
}
2025-09-17 08:53:50 +08:00
void UITask :: notify ( UIEventType t ) {
2025-05-20 11:52:55 +10:00
#if defined(PIN_BUZZER)
2026-06-08 21:51:03 +02:00
{
SoundNotifier sn ( buzzer , _node_prefs , _notif_mel_buf , sizeof ( _notif_mel_buf ));
switch ( t ){
case UIEventType :: contactMessage :
sn . playDM ( _last_notif_dm_valid , _last_notif_dm_prefix );
2026-05-13 10:48:42 +02:00
_last_notif_dm_valid = false ;
2025-05-20 19:09:49 +12:00
break ;
2026-06-08 21:51:03 +02:00
case UIEventType :: channelMessage :
sn . playCH ( _last_notif_ch_idx );
2026-05-15 11:43:38 +02:00
_last_notif_ch_idx = - 1 ;
2025-05-23 17:58:13 +12:00
break ;
2026-06-25 19:04:21 +02:00
case UIEventType :: roomMessage :
// Rooms have many authors and no per-room melody pref, so use the default DM
// notification (no per-sender melody/mute lookup — the author varies per post).
sn . playDM ( false , nullptr );
break ;
2026-06-07 10:53:17 +02:00
case UIEventType :: advertReceivedFlood :
2026-06-08 21:51:03 +02:00
case UIEventType :: advertReceivedZeroHop :
sn . playAD ( t == UIEventType :: advertReceivedFlood );
2026-06-04 08:46:15 +02:00
break ;
2025-05-30 22:55:53 -07:00
case UIEventType :: ack :
2025-05-30 22:58:30 -07:00
buzzer . play ( "ack:d=32,o=8,b=120:c" );
2025-05-30 22:55:53 -07:00
break ;
2025-05-20 19:33:21 +12:00
case UIEventType :: none :
2025-05-20 19:09:49 +12:00
default :
break ;
2026-06-08 21:51:03 +02:00
}
2025-05-20 19:09:49 +12:00
}
2025-05-20 11:52:55 +10:00
#endif
2025-03-10 17:11:55 +01:00
2025-09-07 15:16:15 +08:00
#ifdef PIN_VIBRATION
2025-09-17 08:53:50 +08:00
// Trigger vibration for all UI events except none
if ( t != UIEventType :: none ) {
vibration . trigger ();
}
2025-09-07 15:16:15 +08:00
#endif
2025-03-10 17:11:55 +01:00
}
2025-09-07 15:16:15 +08:00
2026-09-24 23:28:58 +02:00
// Incoming message (UiEventType::MessageArrived, filed by the Core already):
// alert overlay, wake the display, and the per-contact / per-channel sound.
void UITask :: onMessageArrived ( const UiEvent & ev ) {
if ( ev . kind == UIEventType :: contactMessage && ev . flag ) {
memcpy ( _last_notif_dm_prefix , ev . key , 4 );
2026-05-13 10:48:42 +02:00
_last_notif_dm_valid = true ;
2026-05-13 10:25:55 +02:00
}
2026-09-24 23:28:58 +02:00
if ( ev . kind == UIEventType :: channelMessage ) _last_notif_ch_idx = ev . idx ;
2025-03-10 17:11:55 +01:00
2026-05-11 17:19:03 +02:00
char alert_buf [ 80 ];
2026-09-24 23:28:58 +02:00
snprintf ( alert_buf , sizeof ( alert_buf ), "Msg: %.20s" , ev . text );
2026-05-11 17:19:03 +02:00
showAlert ( alert_buf , 3000 );
2025-03-04 23:09:43 +11:00
2026-05-17 09:55:51 +02:00
if ( _display != NULL && ! _locked ) {
2026-09-06 22:35:12 +02:00
bool wake_disabled = _node_prefs && _node_prefs -> msg_wake_screen_off ;
if ( ! wake_disabled && ! _display -> isOn () && ! isClientConnected ()) { // wake for the msg unless an app (BLE/USB) is already showing it, or the user disabled msg-wake
2025-11-03 21:25:31 +01:00
_display -> turnOn ();
}
if ( _display -> isOn ()) {
2026-05-11 17:19:03 +02:00
uint32_t aoff = autoOffMillis ();
if ( aoff > 0 ) _auto_off = millis () + aoff ;
_next_refresh = 100 ;
2025-11-03 21:25:31 +01:00
}
2025-03-10 17:11:55 +01:00
}
2026-09-24 23:28:58 +02:00
notify ( ev . kind );
2025-03-04 23:09:43 +11:00
}
2025-03-10 17:11:55 +01:00
void UITask :: userLedHandler () {
#ifdef PIN_STATUS_LED
2026-05-23 20:03:45 +02:00
unsigned long cur_time = millis ();
2025-09-03 17:22:11 +02:00
if ( cur_time > next_led_change ) {
if ( led_state == 0 ) {
led_state = 1 ;
2026-09-24 23:28:58 +02:00
if ( _core -> msgCount () > 0 ) {
2025-09-03 17:22:11 +02:00
last_led_increment = LED_ON_MSG_MILLIS ;
2025-03-10 17:11:55 +01:00
} else {
2025-09-03 17:22:11 +02:00
last_led_increment = LED_ON_MILLIS ;
2025-03-10 17:11:55 +01:00
}
2025-09-03 17:22:11 +02:00
next_led_change = cur_time + last_led_increment ;
2025-03-10 17:11:55 +01:00
} else {
2025-09-03 17:22:11 +02:00
led_state = 0 ;
next_led_change = cur_time + LED_CYCLE_MILLIS - last_led_increment ;
2025-03-04 23:09:43 +11:00
}
2025-11-20 10:58:14 +01:00
digitalWrite ( PIN_STATUS_LED , led_state == LED_STATE_ON );
2025-03-04 23:09:43 +11:00
}
2025-03-10 17:11:55 +01:00
#endif
}
2025-03-04 23:09:43 +11:00
2026-07-05 16:08:44 +02:00
// Centred alert box. Long text used to be drawn as one drawTextCentered line
// that overflowed the border on both sides (e.g. "GPS on, tracking started"
// is already wider than a 128 px OLED); wrap it to up to three lines inside
// the box instead. Uses the shared wrap scratch (s_wrap_*) — single-threaded
// render path, same contract as the message views.
void UITask :: renderAlertOverlay () {
_display -> setTextSize ( 1 );
const int lh = _display -> getLineHeight ();
const int pad = 3 ;
const int box_w = _display -> width () - 8 ;
const int box_x = 4 ;
_display -> translateUTF8ToBlocks ( s_wrap_trans , _alert , sizeof ( s_wrap_trans ));
int nl = FullscreenMsgView :: wrapLines ( * _display , s_wrap_trans , box_w - pad * 2 , s_wrap_lines , 3 );
if ( nl < 1 ) nl = 1 ;
int box_h = nl * lh + pad * 2 ;
int box_y = ( _display -> height () - box_h ) / 2 ;
_display -> setColor ( DisplayDriver :: DARK );
_display -> fillRect ( box_x , box_y , box_w , box_h );
_display -> setColor ( DisplayDriver :: LIGHT );
_display -> drawRect ( box_x , box_y , box_w , box_h );
for ( int i = 0 ; i < nl ; i ++ )
_display -> drawTextCentered ( _display -> width () / 2 , box_y + pad + i * lh , s_wrap_lines [ i ]);
}
2025-08-08 20:01:31 +10:00
void UITask :: setCurrScreen ( UIScreen * c ) {
2026-06-29 18:42:59 +02:00
// Fail safe on a null target: a screen pointer left uninitialised (member
// declared + navigator wired, but the `new XScreen()` line forgotten in
// begin()) stays nullptr thanks to the in-class initialisers. Bail here so
// that mistake is an inert no-op instead of a null deref in render()/poll().
if ( ! c ) return ;
2025-08-08 20:01:31 +10:00
curr = c ;
2026-06-29 18:42:59 +02:00
c -> onShow (); // central per-visit reset hook (see UIScreen::onShow)
2025-09-03 17:22:11 +02:00
_next_refresh = 100 ;
2025-08-08 20:01:31 +10:00
}
2026-09-05 20:40:13 +02:00
void UITask :: syncLockToHome () {
// Lock/unlock switches the home screen's page to LOCK (or back)
if ( home ) static_cast < HomeScreen *> ( home ) -> setLocked ( _locked );
}
2026-06-29 18:47:51 +02:00
bool UITask :: savePrefsIfDirty ( bool & dirty ) {
if ( ! dirty ) return false ;
the_mesh . savePrefs ();
dirty = false ;
return true ;
}
2025-09-04 21:45:42 +02:00
/*
hardware-agnostic pre-shutdown activity should be done here
2025-05-27 11:07:51 +12:00
*/
void UITask :: shutdown ( bool restart ){
2026-09-08 08:33:46 +02:00
// Every screen that edits NodePrefs (Settings, Bot, Trail, Locator, GPS
// sharing, etc.) only persists on its OWN "Cancel"/exit path (see each
// screen's own savePrefsIfDirty(_dirty) call) -- there was previously no
// flush here at all. A user who edits a setting and then triggers a
// reboot/power-off WITHOUT first backing out of that screen (e.g. the
// display auto-offs while still inside Settings, then the device is
// later hard-reset or its battery pulled; or a low-battery auto-shutdown
// fires mid-edit) silently lost that change on the next boot -- this was
// the actual mechanism behind reports of "settings don't survive a
// reboot." Unconditional and cheap: an unchanged NodePrefs still writes
// identical bytes, same as this codebase's many other direct
// the_mesh.savePrefs() call sites already do without a dirty check.
the_mesh . savePrefs ();
2026-05-10 18:23:25 +02:00
the_mesh . saveRTCTime ();
2026-09-08 13:27:45 +02:00
the_mesh . flushDirtyContacts ();
2025-05-27 11:07:51 +12:00
2026-09-24 23:17:03 +02:00
// Auto-save the live GPS trail if enabled (covers low-battery auto-shutdown).
_core -> trail . onShutdown ();
2026-07-05 22:21:42 +02:00
2025-05-27 11:07:51 +12:00
#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 ();
2026-09-03 21:50:45 +02:00
#ifdef SIM_PLATFORM
// The sim runs single-threaded on the browser's main JS thread (no real
// hardware to actually shut down) -- up to 2.5s of a real, synchronous
// busy-wait here blocks that thread and freezes the whole page for the
// duration, same class of issue as the low-battery pre-shutdown pause
// skipped below. Skip the wait entirely in the sim.
#else
2025-05-27 11:07:51 +12:00
uint32_t buzzer_timer = millis (); // fail-safe shutdown
2026-05-15 15:34:10 +02:00
while ( buzzer . isPlaying () && ( millis () - buzzer_timer ) < 2500 )
2025-05-27 11:07:51 +12:00
buzzer . loop ();
2026-09-03 21:50:45 +02:00
#endif
2025-05-27 11:07:51 +12:00
#endif // PIN_BUZZER
2025-08-08 20:01:31 +10:00
if ( restart ) {
2025-05-27 11:07:51 +12:00
_board -> reboot ();
2025-08-08 20:01:31 +10:00
} else {
_display -> turnOff ();
2025-11-22 02:06:44 +01:00
radio_driver . powerOff ();
2026-06-14 13:21:59 +02:00
// Power GPS down through its provider before SYSTEMOFF — GPIO pins retain
// state in NRF52 SYSTEMOFF, so otherwise the module keeps draining the
// battery. The provider handles the enable + reset pins and the correct
// active level. gps_enabled is persisted; applyGpsPrefs() restores it on
// the next boot.
if ( _sensors ) {
LocationProvider * loc = _sensors -> getLocationProvider ();
if ( loc ) loc -> stop ();
}
2025-05-27 11:07:51 +12:00
_board -> powerOff ();
2025-08-08 20:01:31 +10:00
}
}
bool UITask :: isButtonPressed () const {
#ifdef PIN_USER_BTN
return user_btn . isPressed ();
#else
return false ;
#endif
2025-05-27 11:07:51 +12:00
}
2026-05-17 18:32:49 +02:00
static void formatDashVal ( uint8_t field , char * val , int val_len , uint16_t batt_mv ,
2026-09-23 13:45:07 +02:00
uint16_t low_batt_mv , int unread , bool unread_overflow , bool imperial , CayenneLPP * lpp ) {
2026-05-17 10:04:28 +02:00
val [ 0 ] = '\0' ;
switch ( field ) {
case DASH_NONE : return ;
2026-05-27 22:55:50 +02:00
case DASH_BATT_V :
2026-05-17 10:04:28 +02:00
if ( batt_mv > 0 ) snprintf ( val , val_len , "%u.%02uV" , batt_mv / 1000 , ( batt_mv % 1000 ) / 10 );
else strcpy ( val , "--" );
return ;
2026-05-27 22:55:50 +02:00
case DASH_BATT_PCT :
2026-05-28 22:57:50 +02:00
if ( batt_mv > 0 ) snprintf ( val , val_len , "%d%%" , battMvToPercent ( batt_mv , low_batt_mv ));
else strcpy ( val , "--" );
2026-05-27 22:55:50 +02:00
return ;
2026-05-17 10:04:28 +02:00
case DASH_NODES :
snprintf ( val , val_len , "%d nodes" , the_mesh . getNumContacts ());
return ;
2026-08-12 22:08:03 +02:00
case DASH_MSGS :
2026-09-23 13:45:07 +02:00
snprintf ( val , val_len , unread_overflow ? "%d+ msgs" : "%d msgs" , unread );
2026-08-12 22:08:03 +02:00
return ;
2026-05-17 10:04:28 +02:00
#if ENV_INCLUDE_GPS == 1
case DASH_GPS : {
LocationProvider * loc = sensors . getLocationProvider ();
if ( loc && loc -> isValid ())
snprintf ( val , val_len , "%.2f %.2f" ,
loc -> getLatitude () / 1000000.0f , loc -> getLongitude () / 1000000.0f );
else strcpy ( val , "no fix" );
return ;
}
2026-09-08 11:04:14 +02:00
case DASH_SATS : {
LocationProvider * loc = sensors . getLocationProvider ();
if ( loc ) snprintf ( val , val_len , "%ld sats" , loc -> satellitesCount ());
else strcpy ( val , "--" );
return ;
}
2026-09-10 20:12:29 +02:00
case DASH_ALT_GPS : {
LocationProvider * loc = sensors . getLocationProvider ();
if ( loc && loc -> isValid ())
fmtAlt ( val , val_len , loc -> getAltitude () / 1000.0f , imperial );
else strcpy ( val , "no fix" );
return ;
}
2026-09-08 11:04:14 +02:00
#else
case DASH_SATS :
2026-09-10 20:12:29 +02:00
case DASH_ALT_GPS :
2026-09-08 11:04:14 +02:00
strcpy ( val , "--" );
return ;
2026-05-17 10:04:28 +02:00
#endif
default : break ;
}
2026-05-17 18:32:49 +02:00
// LPP sensor fields
2026-05-17 10:04:28 +02:00
uint8_t lpp_type = 0 ;
switch ( field ) {
case DASH_TEMP : lpp_type = LPP_TEMPERATURE ; break ;
case DASH_HUM : lpp_type = LPP_RELATIVE_HUMIDITY ; break ;
case DASH_PRES : lpp_type = LPP_BAROMETRIC_PRESSURE ; break ;
case DASH_ALT : lpp_type = LPP_ALTITUDE ; break ;
case DASH_LUX : lpp_type = LPP_LUMINOSITY ; break ;
case DASH_CO2 : lpp_type = LPP_CONCENTRATION ; break ;
}
if ( lpp_type ) {
2026-05-23 20:03:45 +02:00
if ( ! lpp ) { static CayenneLPP s_lpp ( 200 ); s_lpp . reset (); sensors . querySensors ( 0xFF , s_lpp ); lpp = & s_lpp ; }
2026-05-17 18:32:49 +02:00
LPPReader r ( lpp -> getBuffer (), lpp -> getSize ());
2026-05-17 10:04:28 +02:00
uint8_t ch , type ;
while ( r . readHeader ( ch , type )) {
if ( type == lpp_type ) {
float v ;
switch ( lpp_type ) {
case LPP_TEMPERATURE : r . readTemperature ( v ); snprintf ( val , val_len , "%.1f \xf8 ""C" , v ); return ;
case LPP_RELATIVE_HUMIDITY : r . readRelativeHumidity ( v ); snprintf ( val , val_len , "%.0f%%" , v ); return ;
case LPP_BAROMETRIC_PRESSURE : r . readPressure ( v ); snprintf ( val , val_len , "%.0fhPa" , v ); return ;
2026-09-10 20:12:29 +02:00
case LPP_ALTITUDE : r . readAltitude ( v ); fmtAlt ( val , val_len , v , imperial ); return ;
2026-05-17 10:04:28 +02:00
case LPP_LUMINOSITY : r . readLuminosity ( v ); snprintf ( val , val_len , "%.0flux" , v ); return ;
case LPP_CONCENTRATION : r . readConcentration ( v ); snprintf ( val , val_len , "%.0fppm" , v ); return ;
}
}
r . skipData ( type );
}
strcpy ( val , "--" );
}
}
2026-06-30 22:10:18 +02:00
void UITask :: enqueueKey ( char c ) {
if ( c == 0 ) return ;
uint8_t next = ( _kq_head + 1 ) % KEY_QUEUE_SIZE ;
if ( next == _kq_tail ) return ; // full: drop newest rather than clobber unprocessed keys
_key_queue [ _kq_head ] = c ;
_kq_head = next ;
}
2026-09-03 00:46:47 +02:00
#if defined(SIM_PLATFORM) && defined(__EMSCRIPTEN__)
void UITask :: injectSimKey ( char c ) {
enqueueKey ( checkDisplayOn ( c ));
}
2026-09-03 10:06:44 +02:00
void UITask :: injectSimKeyLongPress ( char c ) {
enqueueKey ( handleLongPress ( c ));
}
2026-09-03 00:46:47 +02:00
// Called directly from a host HTML page's JS (button onclick / keydown
// listener) -- e.g. `Module._sim_enqueue_key(keyCode)` -- to drive the real
// on-device menu. `c` is one of the KEY_* codes in src/helpers/ui/
// UIScreen.h (KEY_UP/DOWN/LEFT/RIGHT/ENTER/CANCEL/NEXT/PREV/SELECT), the
// exact same values the native build's stdin-poll branch above already
// enqueues -- so the host page owns key-mapping (arrow keys, on-screen
// D-pad buttons, whatever), not this function.
extern "C" EMSCRIPTEN_KEEPALIVE void sim_enqueue_key ( char c ) {
if ( g_sim_ui_task_for_js ) g_sim_ui_task_for_js -> injectSimKey ( c );
}
2026-09-03 10:06:44 +02:00
// Long-press counterpart -- the host page's own press-and-hold timer (see
// web/index.html/mesh.html) calls this instead of sim_enqueue_key() once a
// button/key has been held past the same ~1000ms threshold every real
// board's MomentaryButton uses. Real hardware never gets both a short-press
// AND a long-press event for the same physical press (MomentaryButton fires
// one or the other), so the host page's timer must do the same: fire this
// on hold-past-threshold and suppress the plain click that would otherwise
// follow on release.
extern "C" EMSCRIPTEN_KEEPALIVE void sim_enqueue_key_longpress ( char c ) {
if ( g_sim_ui_task_for_js ) g_sim_ui_task_for_js -> injectSimKeyLongPress ( c );
}
2026-09-03 13:59:26 +02:00
#ifdef PIN_BUZZER
// Polled by the host page every ~20ms (see web/index.html/mesh.html) to
// drive a Web Audio oscillator standing in for the real piezo buzzer --
// genericBuzzer's own #ifdef SIM_PLATFORM branch (src/helpers/ui/buzzer.cpp)
// tracks (is a note sounding, at what frequency) instead of touching real
// PWM/timer hardware; these two exports are just the read side of that.
extern "C" EMSCRIPTEN_KEEPALIVE int sim_buzzer_is_playing () {
return ( g_sim_ui_task_for_js && g_sim_ui_task_for_js -> isBuzzerPlaying ()) ? 1 : 0 ;
}
extern "C" EMSCRIPTEN_KEEPALIVE int sim_buzzer_freq_hz () {
return g_sim_ui_task_for_js ? ( int ) g_sim_ui_task_for_js -> buzzerFreqHz () : 0 ;
}
extern "C" EMSCRIPTEN_KEEPALIVE int sim_buzzer_get_volume () {
return g_sim_ui_task_for_js ? ( int ) g_sim_ui_task_for_js -> buzzerVolume () : 0 ;
}
2026-09-05 08:36:02 +02:00
// Write side: a host page's own mute control (a button next to the d-pad,
// say) calling this fires UITask::toggleBuzzer() -- the literal function
// the real on-device Settings > Buzzer mute toggle calls, not a
// re-implementation of it. That single call already does everything the
// real toggle does: buzzer.quiet(), writes NodePrefs.buzzer_quiet, clears
// buzzer_auto (manual mute always wins over auto-mute-on-BT-connect,
// same as pressing it on the device would), the_mesh.savePrefs(), and
// the real on-screen "Buzzer: ON/OFF" alert -- so muting from the host
// page's button is visibly the same event as muting from the keypad.
extern "C" EMSCRIPTEN_KEEPALIVE void sim_buzzer_toggle_quiet () {
if ( g_sim_ui_task_for_js ) g_sim_ui_task_for_js -> toggleBuzzer ();
}
extern "C" EMSCRIPTEN_KEEPALIVE int sim_buzzer_get_quiet () {
return ( g_sim_ui_task_for_js && g_sim_ui_task_for_js -> isBuzzerQuiet ()) ? 1 : 0 ;
}
2026-09-03 13:59:26 +02:00
#endif
2026-09-03 00:46:47 +02:00
#endif
2026-06-30 22:10:18 +02:00
bool UITask :: dequeueKey ( char & c ) {
if ( _kq_tail == _kq_head ) return false ;
c = _key_queue [ _kq_tail ];
_kq_tail = ( _kq_tail + 1 ) % KEY_QUEUE_SIZE ;
return true ;
}
2026-08-23 21:44:17 -03:00
#if defined(CARDKB_I2C)
2026-07-22 17:49:04 +02:00
// CardKB's "fn" column (key_map in M5Stack's unit_CardKB.cpp): Fn+<physical
// key> sends 0x80 + that key's row index, entirely disjoint from every other
// code this UI recognises. Indexed by (raw - 0x80); non-letter slots (digits,
// arrows, enter, tab, bs, space -- handled separately or unused) are 0.
static const char CARDKB_FN_BASE [ 48 ] = {
0 , 0 , 0 , 0 , 0 , 0 , 0 , 0 , 0 , 0 , 0 , 0 , 0 , // esc,1-0,bs,tab
'q' , 'w' , 'e' , 'r' , 't' , 'y' , 'u' , 'i' , 'o' , 'p' , 0 , 0 , 0 , // q-p, (unused), LEFT,UP
'a' , 's' , 'd' , 'f' , 'g' , 'h' , 'j' , 'k' , 'l' , 0 , 0 , 0 , // a-l, enter, DOWN,RIGHT
'z' , 'x' , 'c' , 'v' , 'b' , 'n' , 'm' , 0 , 0 , 0 , // z-m, comma,period,space
};
#endif
2026-08-28 10:55:52 -03:00
// Poll an optional CardKB (I2C keyboard, addr 0x5F) on CARDKB_I2C, feeding
2026-07-21 23:43:33 +02:00
// the same key queue as every physical button. Most of its output needs no
// translation at all: CardKB's own arrow/Enter/Esc byte codes are already
// identical to this UI's KEY_LEFT/UP/DOWN/RIGHT/ENTER/CANCEL (0xB4-0xB7, 13,
// 27), and Backspace (0x08) / printable ASCII (0x20-0x7E) collide with
2026-07-27 23:14:57 +02:00
// nothing that existed before. Plain Enter/arrows act like the physical
// centre button/joystick (grid commit/navigate) -- except in Compact mode's
// plain grid state (see below), which is designed to need no joystick at all.
// Tab (0x09, otherwise unused) is the Hold-Enter equivalent everywhere,
// including the ~30 non-keyboard Hold-Enter menus (message reply/navigate,
// Bot/Admin/Repeater, ...) and inside the on-screen keyboard itself (shift-
// lock, clear-all, accent popup on whatever cell is selected) -- it used to
// need a separate Fn+Tab for the latter, but that was pure redundancy: plain
// Tab already covered every case Fn+Tab did, just not while the keyboard was
// showing, so the carve-out was dropped instead of the shortcut. In Compact
// mode's plain grid state Tab means something more useful instead (opens the
// placeholder picker directly -- see below). Fn still gives two other clean,
// stateless modifiers:
// - Fn+Enter (0xA3) submits the field (KEY_KB_ENTER) without needing to
// navigate to the special row's DONE cell. The placeholder/accent popups
// are modal and consume it first (dismiss them with Enter/Esc), same as
// they consume every other key.
2026-07-22 17:49:04 +02:00
// - Fn+<letter> opens the accent popup for that base letter directly
// (KeyboardWidget::openAccentFor()) -- no arrow-hunting across the grid.
// CardKB is level-triggered (it keeps returning the held key's byte, not just
// once), so _cardkb_last_raw debounces it into one press per physical
// keypress, same as a MomentaryButton's CLICK event.
2026-07-21 23:43:33 +02:00
void UITask :: pollCardKB () {
2026-08-23 21:44:17 -03:00
#if defined(CARDKB_I2C)
2026-07-21 23:43:33 +02:00
if ( ! _has_cardkb ) return ;
2026-07-22 17:49:04 +02:00
// No artificial throttle: unlike a MomentaryButton (BUTTON_USE_INTERRUPTS
// latches every edge in an ISR ring buffer, so it survives a blocking e-ink
// refresh untouched), CardKB is plain I2C polling with no interrupt line on
// the Grove cable and no onboard queue -- it only ever reports "what's held
// right now". A press that starts and fully releases while curr->render()
// is blocked is physically unobservable, no software fix can recover it.
// Polling every loop() iteration (same as a digital button's check(), which
// has no throttle either) just shrinks that miss window down to exactly the
// render() duration instead of render()+30ms.
2026-08-23 21:44:17 -03:00
CARDKB_I2C . requestFrom ( 0x5F , 1 );
if ( ! CARDKB_I2C . available ()) return ;
uint8_t raw = CARDKB_I2C . read ();
2026-07-22 17:49:04 +02:00
if ( raw == _cardkb_last_raw ) return ; // still held (or still released) -- no new edge
_cardkb_last_raw = raw ;
if ( raw == 0 ) return ; // key just released, nothing to enqueue
2026-07-21 23:43:33 +02:00
2026-07-27 23:14:57 +02:00
// Compact mode (Settings > Keyboard's "Ext. KB" row) hides the letter grid
// entirely, and is meant to guarantee joystick-free operation: while it's
// the active surface (KeyboardWidget::inPlainGridState() -- showing, no
// popup open, not already mid cursor-move) arrows drive the text cursor
// directly instead of a grid selection nobody could see anyway, and plain
// Tab opens the placeholder picker directly instead of the row/col-dependent
// Hold-Enter dispatch (which would be meaningless here -- row/col are never
// deliberately navigated to in this mode). Cursor mode / the accent /
// placeholder popups all render their own visible feedback regardless of
// Compact, so none of this applies once inPlainGridState() is false --
// arrows/Tab fall through to their normal meaning there (e.g. arrows drive
// the placeholder/accent popup's own selection).
bool compact_grid = _node_prefs && _node_prefs -> keyboard_cardkb_compact && _kb . inPlainGridState ();
2026-07-21 23:43:33 +02:00
char key ;
2026-07-22 17:49:04 +02:00
if ( raw == 0xA3 ) { // Fn+Enter -- submit the field
2026-07-21 23:43:33 +02:00
key = KEY_KB_ENTER ;
2026-07-27 23:14:57 +02:00
} else if ( compact_grid && ( raw == ( uint8_t ) KEY_LEFT || raw == ( uint8_t ) KEY_UP ||
raw == ( uint8_t ) KEY_DOWN || raw == ( uint8_t ) KEY_RIGHT )) {
char woke = checkDisplayOn (( char ) raw ); // already sets _next_refresh=0 when the display was on
if ( woke && ! _locked ) _kb . moveCursorDirect (( char ) raw );
return ;
} else if ( raw == 0x09 ) { // Tab -- Hold-Enter equivalent, always (single shortcut: there used to
if ( compact_grid ) { // also be a separate Fn+Tab for this, but plain Tab already covers every
char woke = checkDisplayOn (( char ) raw ); // case Fn+Tab did -- outside the keyboard, and now inside it
if ( woke && ! _locked ) _kb . openPlaceholders (); // too -- so the modifier was pure redundancy)
return ;
}
2026-07-22 17:49:04 +02:00
key = KEY_CONTEXT_MENU ;
2026-07-24 23:51:31 +02:00
} else if ( raw == 0x80 ) {
// Fn+Esc -- CardKB's lock/unlock gesture: a single press toggles _locked
// directly (unlike the physical Hold-Back+3xEnter combo's 3-press
// sequence), so it works to unlock a locked device too, where every
// other CardKB key is correctly discarded (see the Fn+<letter> branch
// below). Esc, not the adjacent Fn+Backspace, on purpose: Fn and
// Backspace sit right next to each other on CardKB's layout, making that
// combo too easy to hit by accident; Esc is on the opposite side of the
// keyboard. One press is enough -- Fn+Esc is already a deliberate
// two-key combo, so it doesn't need the physical combo's extra 3x
// repetition to guard against accidental triggering.
if ( _display && ! _display -> isOn ()) _display -> turnOn ();
_locked = ! _locked ;
if ( _locked ) {
_lock_wake_until = millis () + 2000 ;
} else {
uint32_t aoff = autoOffMillis ();
if ( aoff > 0 ) _auto_off = millis () + aoff ;
}
2026-09-05 20:40:13 +02:00
syncLockToHome ();
2026-07-24 23:51:31 +02:00
_next_refresh = 0 ;
return ;
2026-07-22 17:49:04 +02:00
} else if ( raw >= 0x80 && raw <= 0xAF ) { // Fn+<letter> -- open its accent popup
char base = CARDKB_FN_BASE [ raw - 0x80 ];
if ( base == 0 ) return ; // Fn+digit/symbol/arrow -- not used by this UI
char woke = checkDisplayOn ( base );
2026-07-24 23:51:31 +02:00
// Every other key here goes through enqueueKey(), so it's naturally eaten
// while locked (see the dequeue-time "if (!_locked && curr)" gate in
// loop()). This path calls into the keyboard widget directly instead, so
// it needs its own _locked check -- otherwise a stray Fn+letter (e.g. the
// keyboard was left open before the device locked, or brushed against in
// a pocket) could pop the accent popup while the screen is supposed to
// ignore all input.
if ( woke && ! _locked ) _kb . openAccentFor ( base );
2026-07-22 17:49:04 +02:00
return ;
2026-07-21 23:43:33 +02:00
} else {
2026-07-27 23:14:57 +02:00
// Plain Enter would otherwise commit whatever grid cell row/col happen to
// be frozen at (there's no grid navigation to have deliberately landed on
// one in Compact) -- submit instead, same as Fn+Enter. Backspace/ASCII
// passthrough is unaffected by Compact either way.
key = ( compact_grid && raw == ( uint8_t ) KEY_ENTER ) ? KEY_KB_ENTER : ( char ) raw ;
2026-07-21 23:43:33 +02:00
}
enqueueKey ( checkDisplayOn ( key ));
#endif
}
2026-08-26 13:48:33 +02:00
// Level-triggered, like pollCardKB() -- a magnet held near the sensor reads the
// same way every tick, so this only acts on the two edges (closed/opened), not
// on every poll. Fully autonomous: closing locks and blanks the display
// immediately (no wake grace -- the cover is physically over the screen, so
// there's nothing to show), opening unlocks and wakes it, with no combo or
// keypress either way. Independent of Auto-lock (Settings > Display), which is
// a timeout-driven setting -- this is a direct physical event.
2026-08-27 10:22:50 +02:00
//
// Debounced against a mechanical reed switch chattering for a few ms as the
// magnet crosses the trigger distance -- a raw flip only becomes the new
// _hall_magnet_present once it's been steady for HALL_DEBOUNCE_MS, so a bounce
// can't fire the lock/unlock actions (each including a full display
// off/on -- slow and disruptive on e-ink) more than once per real transition.
2026-08-26 13:48:33 +02:00
void UITask :: pollHallSensor () {
#if defined(PIN_HALL_SENSOR)
2026-08-27 10:22:50 +02:00
bool raw = HALL_ACTIVE_HIGH ? ( digitalRead ( PIN_HALL_SENSOR ) == HIGH )
: ( digitalRead ( PIN_HALL_SENSOR ) == LOW );
if ( raw != _hall_candidate ) {
_hall_candidate = raw ;
_hall_candidate_since = millis ();
}
if ( _hall_candidate == _hall_magnet_present ) return ; // no debounced change yet
if ( millis () - _hall_candidate_since < HALL_DEBOUNCE_MS ) return ; // not steady long enough
bool present = _hall_candidate ;
2026-08-26 13:48:33 +02:00
_hall_magnet_present = present ;
if ( present ) { // cover closed
_locked = true ;
2026-09-05 20:40:13 +02:00
syncLockToHome ();
2026-08-26 13:48:33 +02:00
_lock_wake_until = 0 ;
if ( _display ) _display -> turnOff ();
#ifdef PIN_LED
digitalWrite ( PIN_LED , LOW ); // same as the auto-off path -- one less thing lit under a closed cover
#endif
} else { // cover opened
_locked = false ;
2026-09-05 20:40:13 +02:00
syncLockToHome ();
2026-08-26 13:48:33 +02:00
if ( _display && ! _display -> isOn ()) _display -> turnOn ();
uint32_t aoff = autoOffMillis ();
if ( aoff > 0 ) _auto_off = millis () + aoff ;
}
_next_refresh = 0 ;
#endif
}
2025-03-10 17:11:55 +01:00
void UITask :: loop () {
2026-09-24 23:28:58 +02:00
pollConnection (); // BLE link state -> hasConnection() (see UITaskBase)
drainCoreEvents (); // react to what the Core filed during mesh processing (alerts, wake, sounds)
2025-10-16 17:33:22 +11:00
#if UI_HAS_JOYSTICK
2026-05-23 15:20:40 +02:00
uint8_t joy_rot = _node_prefs ? _node_prefs -> joystick_rotation : JOYSTICK_ROTATION ;
2025-08-08 20:01:31 +10:00
int ev = user_btn . check ();
if ( ev == BUTTON_EVENT_CLICK ) {
2026-05-17 10:09:54 +02:00
if ( back_btn . isPressed ()) {
// Enter clicked while Back is held — lock/unlock sequence
2026-05-17 11:17:59 +02:00
if ( _display && ! _display -> isOn ()) {
_display -> turnOn (); // turn on display so hints are visible
}
_lock_wake_until = millis () + 5000 ; // keep display on during sequence
2026-05-17 10:09:54 +02:00
if ( millis () - _lock_seq_ms > 3000 ) _lock_seq_count = 0 ; // timeout reset
_lock_seq_count ++ ;
_lock_seq_ms = millis ();
2026-05-17 11:17:59 +02:00
_next_refresh = 0 ; // update hint immediately on each press
2026-05-17 10:09:54 +02:00
if ( _lock_seq_count >= 3 ) {
_lock_seq_count = 0 ;
2026-05-17 11:17:59 +02:00
_lock_seq_used = true ; // suppress Back release click
2026-05-17 10:09:54 +02:00
_locked = ! _locked ;
if ( _locked ) {
_lock_wake_until = millis () + 2000 ;
} else {
2026-05-17 11:17:59 +02:00
if ( _display && ! _display -> isOn ()) _display -> turnOn ();
2026-05-17 10:09:54 +02:00
uint32_t aoff = autoOffMillis ();
if ( aoff > 0 ) _auto_off = millis () + aoff ;
}
2026-09-05 20:40:13 +02:00
syncLockToHome ();
2026-05-17 10:09:54 +02:00
}
// eat the Enter — don't pass to curr
} else {
2026-06-30 22:10:18 +02:00
enqueueKey ( checkDisplayOn ( KEY_ENTER ));
2026-05-17 10:09:54 +02:00
}
2025-08-08 20:01:31 +10:00
} else if ( ev == BUTTON_EVENT_LONG_PRESS ) {
2026-06-30 22:10:18 +02:00
enqueueKey ( handleLongPress ( KEY_ENTER )); // REVISIT: could be mapped to different key code
2025-08-08 20:01:31 +10:00
}
2026-06-30 22:10:18 +02:00
// Drain each direction fully: a burst of taps captured during a blocking
// refresh replays as several CLICKs, queued here and applied before one
// redraw (see enqueueKey / the dispatch at the end of loop()).
2026-05-10 15:18:35 +02:00
#if UI_HAS_JOYSTICK_UPDOWN
2026-06-30 22:10:18 +02:00
while ( joystick_up . check () == BUTTON_EVENT_CLICK )
enqueueKey ( checkDisplayOn ( rotateJoystickKey ( KEY_UP , joy_rot )));
while ( joystick_down . check () == BUTTON_EVENT_CLICK )
enqueueKey ( checkDisplayOn ( rotateJoystickKey ( KEY_DOWN , joy_rot )));
2026-05-10 15:18:35 +02:00
#endif
2026-06-30 22:10:18 +02:00
while (( ev = joystick_left . check ()) != BUTTON_EVENT_NONE ) {
if ( ev == BUTTON_EVENT_CLICK ) enqueueKey ( checkDisplayOn ( rotateJoystickKey ( KEY_LEFT , joy_rot )));
else { if ( ev == BUTTON_EVENT_LONG_PRESS ) enqueueKey ( handleLongPress ( rotateJoystickKey ( KEY_LEFT , joy_rot ))); break ; }
2025-08-17 19:01:28 +10:00
}
2026-06-30 22:10:18 +02:00
while (( ev = joystick_right . check ()) != BUTTON_EVENT_NONE ) {
if ( ev == BUTTON_EVENT_CLICK ) enqueueKey ( checkDisplayOn ( rotateJoystickKey ( KEY_RIGHT , joy_rot )));
else { if ( ev == BUTTON_EVENT_LONG_PRESS ) enqueueKey ( handleLongPress ( rotateJoystickKey ( KEY_RIGHT , joy_rot ))); break ; }
2025-08-17 19:01:28 +10:00
}
2026-05-17 12:17:02 +02:00
if ( _lock_seq_used && millis () - _lock_seq_ms > 5000 ) {
_lock_seq_used = false ; // safety reset if Back release event was missed
}
2025-10-18 13:37:18 +02:00
ev = back_btn . check ();
2026-05-10 15:18:35 +02:00
if ( ev == BUTTON_EVENT_CLICK ) {
2026-05-17 11:17:59 +02:00
if ( _lock_seq_count > 0 || _lock_seq_used ) {
// Back released mid-sequence or after completing it — cancel/suppress
2026-05-17 09:55:51 +02:00
_lock_seq_count = 0 ;
2026-05-17 11:17:59 +02:00
_lock_seq_used = false ;
2026-05-17 10:09:54 +02:00
} else {
2026-06-30 22:10:18 +02:00
enqueueKey ( checkDisplayOn ( KEY_CANCEL ));
2026-05-17 09:55:51 +02:00
}
2026-05-10 15:18:35 +02:00
} else if ( ev == BUTTON_EVENT_TRIPLE_CLICK ) {
2026-06-30 22:10:18 +02:00
if ( ! _locked ) enqueueKey ( handleTripleClick ( KEY_SELECT ));
2025-10-18 13:37:18 +02:00
}
2025-10-16 17:33:22 +11:00
#elif defined(PIN_USER_BTN)
int ev = user_btn . check ();
if ( ev == BUTTON_EVENT_CLICK ) {
2026-06-30 22:10:18 +02:00
enqueueKey ( checkDisplayOn ( KEY_NEXT ));
2025-10-16 17:33:22 +11:00
} else if ( ev == BUTTON_EVENT_LONG_PRESS ) {
2026-06-30 22:10:18 +02:00
enqueueKey ( handleLongPress ( KEY_ENTER ));
2025-10-16 17:33:22 +11:00
} else if ( ev == BUTTON_EVENT_DOUBLE_CLICK ) {
2026-06-30 22:10:18 +02:00
enqueueKey ( handleDoubleClick ( KEY_PREV ));
2025-10-16 17:33:22 +11:00
} else if ( ev == BUTTON_EVENT_TRIPLE_CLICK ) {
2026-07-27 22:05:52 +02:00
if ( ! _locked ) enqueueKey ( handleTripleClick ( KEY_SELECT ));
2025-10-16 17:33:22 +11:00
}
2026-09-03 11:31:42 +02:00
#elif defined(SIM_PLATFORM) && !defined(__EMSCRIPTEN__)
// Native terminal input ONLY -- this branch previously had no
// __EMSCRIPTEN__ exclusion, so it also compiled into the wasm build
// (SIM_PLATFORM is defined there too, and UI_HAS_JOYSTICK/PIN_USER_BTN
// are both unset for variants/sim). Every tick it called real select()/
// read() on fd 0; under Emscripten, with no stdin ever wired up, that
// hits the runtime's default TTY device, which falls back to a real,
// blocking window.prompt("Input: ") -- so every single browser tab
// running the wasm build was popping a native dialog on nearly every
// frame, discovered by seeing Playwright's page 'dialog' event fire
// continuously from the moment the module boots. The wasm build's own
// input already comes through sim_enqueue_key()/injectSimKey() (see
// above, in the #if defined(SIM_PLATFORM) && defined(__EMSCRIPTEN__)
// block) -- this stdin-poll branch was only ever meant for Phase 1's
// native terminal target.
2026-09-03 19:32:23 +02:00
//
// stdin is put into raw/non-canonical mode by
2026-09-03 00:46:47 +02:00
// variants/sim/sim_main.cpp's main(), so keys arrive here one at a time
// with no Enter-to-submit line buffering. Non-blocking select() on fd 0
// (VMIN=0/VTIME=0 on the fd itself would also work, but select() keeps
// the intent -- "is there a key waiting?" -- explicit) takes the place of
// every concrete MomentaryButton/GPIO poll above. Every real board maps
// its own physical buttons down to the same enqueueKey() choke point;
// this is the sim's one input source instead.
// Arrow keys -> KEY_UP/DOWN/LEFT/RIGHT
// Enter/Space -> KEY_ENTER
// Esc/Backspace-> KEY_CANCEL
// w/a/s/d -> up/left/down/right (arrow keys need a real terminal;
// WASD works even through a dumb pipe/redirected stdin)
// n / p -> KEY_NEXT / KEY_PREV
{
fd_set fds ;
FD_ZERO ( & fds );
FD_SET ( 0 , & fds );
struct timeval tv = { 0 , 0 };
if ( select ( 1 , & fds , NULL , NULL , & tv ) > 0 ) {
uint8_t buf [ 16 ];
int n = ( int ) read ( 0 , buf , sizeof ( buf ));
int i = 0 ;
while ( i < n ) {
uint8_t c = buf [ i ++ ];
char key = 0 ;
if ( c == 0x1b && i + 1 < n && buf [ i ] == '[' ) {
uint8_t code = buf [ i + 1 ];
i += 2 ;
switch ( code ) {
case 'A' : key = KEY_UP ; break ;
case 'B' : key = KEY_DOWN ; break ;
case 'C' : key = KEY_RIGHT ; break ;
case 'D' : key = KEY_LEFT ; break ;
default : key = 0 ; break ;
}
} else if ( c == 0x1b ) {
key = KEY_CANCEL ;
} else if ( c == '\r' || c == '\n' || c == ' ' ) {
key = KEY_ENTER ;
} else if ( c == 127 || c == 8 ) {
key = KEY_CANCEL ;
} else if ( c == 'w' || c == 'W' ) {
key = KEY_UP ;
} else if ( c == 's' || c == 'S' ) {
key = KEY_DOWN ;
} else if ( c == 'a' || c == 'A' ) {
key = KEY_LEFT ;
} else if ( c == 'd' || c == 'D' ) {
key = KEY_RIGHT ;
} else if ( c == 'n' ) {
key = KEY_NEXT ;
} else if ( c == 'p' ) {
key = KEY_PREV ;
}
if ( key ) enqueueKey ( checkDisplayOn ( key ));
}
}
}
2025-08-17 19:01:28 +10:00
#endif
2025-09-01 17:11:55 +10:00
#if defined(PIN_USER_BTN_ANA)
2026-05-23 20:03:45 +02:00
if ( millis () - _analogue_pin_read_millis > 10 ) {
2026-04-28 21:26:56 -07:00
int ev = analog_btn . check ();
2025-10-31 13:04:59 +00:00
if ( ev == BUTTON_EVENT_CLICK ) {
2026-06-30 22:10:18 +02:00
enqueueKey ( checkDisplayOn ( KEY_NEXT ));
2025-10-31 13:04:59 +00:00
} else if ( ev == BUTTON_EVENT_LONG_PRESS ) {
2026-06-30 22:10:18 +02:00
enqueueKey ( handleLongPress ( KEY_ENTER ));
2025-10-31 13:04:59 +00:00
} else if ( ev == BUTTON_EVENT_DOUBLE_CLICK ) {
2026-06-30 22:10:18 +02:00
enqueueKey ( handleDoubleClick ( KEY_PREV ));
2025-10-31 13:04:59 +00:00
} else if ( ev == BUTTON_EVENT_TRIPLE_CLICK ) {
2026-07-27 22:05:52 +02:00
if ( ! _locked ) enqueueKey ( handleTripleClick ( KEY_SELECT ));
2025-10-31 13:04:59 +00:00
}
_analogue_pin_read_millis = millis ();
2025-09-01 17:11:55 +10:00
}
#endif
2026-07-21 23:43:33 +02:00
pollCardKB ();
2026-08-26 13:48:33 +02:00
pollHallSensor ();
2026-08-08 01:06:24 +02:00
#ifdef ENV_USE_TCA8418
{
extern char tca8418_keypad_read (); // provided by the active variant
char k = tca8418_keypad_read ();
if ( k ) {
switch ( k ) {
case KEY_UP : enqueueKey ( checkDisplayOn ( KEY_UP )); break ;
case KEY_ENTER : enqueueKey ( checkDisplayOn ( KEY_ENTER )); break ;
case KEY_DOWN : enqueueKey ( checkDisplayOn ( KEY_DOWN )); break ;
case KEY_CANCEL : enqueueKey ( checkDisplayOn ( KEY_CANCEL )); break ;
case KEY_HOME :
2026-08-14 01:07:06 +02:00
checkDisplayOn ( k ); // wake/extend same as every other key here, even though Home has no nav action
2026-08-08 01:06:24 +02:00
#ifdef LILYGO_TECHO_LITE_KEYSHIELD
extern void techo_keyshield_backlight_toggle ();
techo_keyshield_backlight_toggle ();
#endif
break ;
default :
enqueueKey ( checkDisplayOn ( k ));
break ;
}
}
}
#endif
2025-11-28 10:33:19 +01:00
#if defined(BACKLIGHT_BTN)
2026-07-27 22:05:52 +02:00
if (( int32_t )( millis () - next_backlight_btn_check ) >= 0 ) {
2025-09-02 11:43:48 +02:00
bool touch_state = digitalRead ( PIN_BUTTON2 );
2025-11-28 10:33:19 +01:00
#if defined(DISP_BACKLIGHT)
2025-09-02 11:43:48 +02:00
digitalWrite ( DISP_BACKLIGHT , ! touch_state );
2025-11-28 10:33:19 +01:00
#elif defined(EXP_PIN_BACKLIGHT)
expander . digitalWrite ( EXP_PIN_BACKLIGHT , ! touch_state );
#endif
2025-09-03 17:22:11 +02:00
next_backlight_btn_check = millis () + 300 ;
2025-09-02 11:43:48 +02:00
}
#endif
2025-08-08 20:01:31 +10:00
2026-06-30 01:54:01 +02:00
// A ringing alarm/timer is dismissed by ANY key, even when locked or on another
2026-06-30 22:13:18 +02:00
// screen — and the queued keys are swallowed so they don't also act on the view.
if ( _kq_head != _kq_tail && isRinging ()) {
2026-06-30 01:54:01 +02:00
dismissRing ();
2026-06-30 22:13:18 +02:00
_kq_head = _kq_tail = 0 ;
2026-06-30 01:54:01 +02:00
_next_refresh = 0 ;
2026-07-05 16:08:44 +02:00
// Locked: wakeForAlarm() held the wake window open for the whole ring;
// once dismissed, fall back to the usual brief lock-screen glance.
if ( _locked ) _lock_wake_until = millis () + 5000 ;
2026-06-30 01:54:01 +02:00
}
2026-06-30 22:10:18 +02:00
if ( _kq_head != _kq_tail ) {
2026-05-17 10:09:54 +02:00
if ( ! _locked && curr ) {
2026-06-30 22:10:18 +02:00
// Apply the whole queued burst, then redraw once — N taps captured during
// a blocking refresh become N navigation steps at the cost of one refresh.
char k ;
while ( dequeueKey ( k )) curr -> handleInput ( k );
2026-05-17 09:55:51 +02:00
{ uint32_t aoff = autoOffMillis (); if ( aoff > 0 ) _auto_off = millis () + aoff ; } // extend auto-off timer
2026-06-28 11:37:48 +02:00
// Note timing no longer depends on render cadence (TIMER1 IRQ advances
// notes directly — see buzzer.cpp), so a redraw right after a keypress
// can't clip a note; no need to hold it back while buzzer.isPlaying().
_next_refresh = 100 ; // trigger refresh immediately
2026-06-30 22:10:18 +02:00
} else {
_kq_head = _kq_tail = 0 ; // locked or no screen: eat all queued keys
// Locked: wake window is set only when display first turns on
if ( _locked ) _next_refresh = 0 ;
2026-05-17 09:55:51 +02:00
}
2025-08-08 20:01:31 +10:00
}
2025-03-10 17:11:55 +01:00
userLedHandler ();
2025-05-20 11:52:55 +10:00
#ifdef PIN_BUZZER
2026-09-26 00:27:52 +02:00
if ( soundctl :: autoTick ( _node_prefs , buzzer , isClientConnected ())) // BLE bonded or an open USB port
_next_refresh = 0 ;
2025-05-20 11:52:55 +10:00
if ( buzzer . isPlaying ()) buzzer . loop ();
#endif
2025-08-08 20:01:31 +10:00
if ( curr ) curr -> poll ();
2025-03-04 23:09:43 +11:00
2026-06-30 01:54:01 +02:00
2025-08-08 20:01:31 +10:00
if ( _display != NULL && _display -> isOn ()) {
2026-07-27 22:05:52 +02:00
if ( _locked && ( int32_t )( millis () - _lock_wake_until ) >= 0 ) {
2026-05-17 09:55:51 +02:00
_display -> turnOff ();
2026-09-05 20:40:13 +02:00
} else if ( _locked && millis () >= _next_refresh && home ) {
2026-05-17 09:55:51 +02:00
_display -> startFrame ();
2026-09-05 20:40:13 +02:00
home -> render ( * _display );
2026-07-05 16:08:44 +02:00
// Alert overlay on top — without this a ringing alarm on a locked device
// played its melody against a screen that never said what was ringing.
if ( millis () < _alert_expiry ) renderAlertOverlay ();
2026-05-17 09:55:51 +02:00
_display -> endFrame ();
2026-05-24 20:41:25 +02:00
_next_refresh = millis () + Features :: LOCKSCREEN_REFRESH_MS ;
2026-05-17 09:55:51 +02:00
} else if ( ! _locked && millis () >= _next_refresh && curr ) {
2025-08-08 20:01:31 +10:00
_display -> startFrame ();
2026-07-17 22:04:29 +02:00
_kb . beginFrame ();
2025-08-08 20:01:31 +10:00
int delay_millis = curr -> render ( * _display );
2026-07-17 22:04:29 +02:00
// Skip the alert overlay (new-message toast) while the keyboard is the
// thing actually on screen this frame -- it's shared across Messages/
// Bot/Settings/Admin/etc., so this covers every screen that uses it for
// full-screen text entry, not just message compose. Otherwise a message
// arriving mid-typing blanks out the letter grid for 3s with no way to
// see what's being typed.
if ( millis () < _alert_expiry && ! _kb . isVisible ()) { // alert overlay on top of any (non-keyboard) screen
2026-07-05 16:08:44 +02:00
renderAlertOverlay ();
2026-06-15 21:23:00 +02:00
// Keep refreshing the underlying screen at its own cadence (capped at the
// alert's expiry) so layouts that settle over a frame — e.g. the message-
// history scrollbar reserve — don't stay stuck behind the alert. Unchanged
// frames are skipped by the display CRC, so e-ink isn't thrashed.
_next_refresh = millis () + delay_millis ;
if ( _next_refresh > _alert_expiry ) _next_refresh = _alert_expiry ;
2025-08-08 20:01:31 +10:00
} else {
_next_refresh = millis () + delay_millis ;
}
_display -> endFrame ();
2025-03-04 23:09:43 +11:00
}
2025-09-03 18:17:37 +02:00
#if AUTO_OFF_MILLIS > 0
2026-05-28 20:02:50 +01:00
#ifdef KEEP_DISPLAY_ON_USB
// Opt-in: refresh the auto-off deadline while externally powered, so the
// timer counts from the moment external power is removed. Off by default
// because OLED panels burn in quickly; only enable for LCD targets or
// where the display is replaceable.
if ( board . isExternalPowered ()) {
_auto_off = millis () + AUTO_OFF_MILLIS ;
}
#endif
2026-07-27 22:05:52 +02:00
if ( ! _locked && autoOffMillis () > 0 && ( int32_t )( millis () - _auto_off ) >= 0 && ! isRinging ()) {
2025-03-04 23:09:43 +11:00
_display -> turnOff ();
2026-05-10 18:01:59 +02:00
#ifdef PIN_LED
digitalWrite ( PIN_LED , LOW ); // turn off status LED with display to save power
#endif
2026-05-17 09:55:51 +02:00
if ( _node_prefs && _node_prefs -> auto_lock ) {
_locked = true ;
_lock_wake_until = 0 ;
2026-09-05 20:40:13 +02:00
syncLockToHome ();
2026-05-17 09:55:51 +02:00
}
2025-03-04 23:09:43 +11:00
}
2025-09-03 18:17:37 +02:00
#endif
2025-03-04 23:09:43 +11:00
}
2025-08-08 20:01:31 +10:00
2025-09-07 15:16:15 +08:00
#ifdef PIN_VIBRATION
vibration . loop ();
#endif
2026-07-27 22:05:52 +02:00
if (( int32_t )( millis () - next_batt_chck ) >= 0 ) {
2026-09-24 23:28:58 +02:00
uint16_t raw = UITaskBase :: getBattMilliVolts ();
2026-05-10 15:18:35 +02:00
if ( raw > 0 ) {
2026-09-03 19:32:23 +02:00
#ifdef SIM_PLATFORM
// SimMainBoard::getBattMilliVolts() returns exactly whatever value the
// host page's JS last set (see sim_battery_set_mv() in
// variants/sim/SimMainBoard.h) -- a clean, instantaneous number, not a
// noisy ADC reading. Real hardware needs the EMA below to smooth a
// voltage divider's jitter under load; applying that same filter here
// just makes a value typed into the demo UI visibly crawl toward its
// target over several 8s samples, which reads as the whole sim being
// laggy for no benefit the sim actually needs.
_batt_mv = raw ;
#else
2026-05-10 15:18:35 +02:00
// EMA filter: alpha=0.2 (80% old, 20% new) — smooths ADC noise from uneven load
_batt_mv = ( _batt_mv == 0 ) ? raw : ( uint16_t )(( _batt_mv * 4u + raw ) / 5u );
2026-09-03 19:32:23 +02:00
#endif
2026-05-10 15:18:35 +02:00
}
uint16_t low_mv = _node_prefs ? _node_prefs -> low_batt_mv : 0 ;
2026-06-06 17:21:41 +02:00
// Don't shut down while on external power (charging) — avoids a shutdown loop.
if ( low_mv > 0 && _batt_mv > 0 && _batt_mv < low_mv && ! board . isExternalPowered ()) {
2025-08-30 23:09:01 +12:00
if ( _display != NULL ) {
_display -> startFrame ();
2026-05-16 17:47:22 +02:00
_display -> setTextSize ( 1 );
2026-05-10 15:18:35 +02:00
_display -> setColor ( DisplayDriver :: LIGHT );
2026-05-22 18:05:09 +02:00
int mid = _display -> height () / 2 ;
int step = _display -> lineStep ();
_display -> drawTextCentered ( _display -> width () / 2 , mid - step , "Low Battery" );
_display -> drawTextCentered ( _display -> width () / 2 , mid , "Shutting down" );
2025-08-30 23:09:01 +12:00
_display -> endFrame ();
2026-09-03 00:46:47 +02:00
#ifdef SIM_PLATFORM
// Skip the pre-shutdown UX pause in the sim.
#else
2026-06-06 17:21:41 +02:00
if ( _display -> isEink () == false ) { delay ( 2000 ); }
2026-09-03 00:46:47 +02:00
#endif
2025-08-30 23:09:01 +12:00
}
2025-08-08 20:01:31 +10:00
shutdown ();
}
2026-09-03 19:32:23 +02:00
#ifdef SIM_PLATFORM
// A real ADC read has a real cost, worth spacing out 8s apart; the sim's
// "read" is just returning a JS-set integer, so there's no reason to sit
// on a stale value for up to 8s after Set was clicked. 250ms keeps this
// a poll (not a push wired to the input's own event, which would need
// its own plumbing) while feeling immediate.
next_batt_chck = millis () + 250 ;
#else
2025-08-08 20:01:31 +10:00
next_batt_chck = millis () + 8000 ;
2026-09-03 19:32:23 +02:00
#endif
2025-08-08 20:01:31 +10:00
}
2026-05-25 08:22:12 +02:00
2026-08-07 22:14:19 +02:00
// GPS duty-cycle hold — tells the sensor manager whether *anything* needs
// an unbroken stream of fixes right now, so it knows it's safe to let GPS
// nap between reads (EnvironmentSensorManager::gpsDutyCycleLoop()).
// Deliberately excludes the COG sampler just below: that one runs
// unconditionally every ~1s specifically so a heading is ready whenever a
// screen opens, and including it here would keep GPS permanently awake and
// defeat duty-cycling entirely — it just goes stale during a sleep window
// and catches up whenever GPS is awake for any other reason.
if ( _sensors ) {
bool gps_needed_live =
2026-09-24 23:17:03 +02:00
_core -> trail . isRecording ()
2026-08-07 22:14:19 +02:00
|| ( _node_prefs && _node_prefs -> loc_share_enabled )
|| ( _node_prefs && _node_prefs -> locator_enabled && _node_prefs -> locator_has_target )
|| curr == compass_screen
2026-08-12 22:08:13 +02:00
|| ( curr == nearby_screen && (( NearbyScreen * ) nearby_screen ) -> isNavigating ())
2026-08-14 00:40:50 +02:00
|| ( curr == trail_screen && (( TrailScreen * ) trail_screen ) -> wpNeedsLiveGps ())
|| ( curr == messages_screen && (( MessagesScreen * ) messages_screen ) -> navActive ())
2026-08-12 22:08:13 +02:00
|| the_mesh . isGpsFixPending ();
2026-08-07 22:14:19 +02:00
_sensors -> setGpsKeepAwake ( gps_needed_live );
// A fresh wake (either a duty-cycle wake, or GPS forced continuously back
// on) may deliver a still-settling first fix — re-seed the locator's
// crossing state so that doesn't read as a spurious geofence crossing.
if ( _sensors -> consumeGpsWakeEvent ()) resetLocator ();
}
2026-09-24 23:17:03 +02:00
// UI Core engines (alarm + countdown, COG, live share, locator, trail, …) run
2026-09-24 23:15:18 +02:00
// regardless of the current screen / display state. Last in the loop, after
// the GPS keep-awake check above (a fresh GPS wake re-seeds the locator first).
tickCore ();
2026-06-25 19:04:21 +02:00
}
2026-09-24 23:15:18 +02:00
// Locator (engine in ui-core/LocatorEngine.h).
void UITask :: resetLocator () { _core -> locator . reset (); }
2026-06-25 19:04:21 +02:00
bool UITask :: resolvePersonPos ( const uint8_t * key , int32_t & lat , int32_t & lon ,
bool * live , uint32_t * ts ) const {
2026-09-24 23:15:18 +02:00
return _core -> locator . resolvePersonPos ( key , lat , lon , live , ts );
2026-06-25 19:04:21 +02:00
}
2026-09-24 23:15:18 +02:00
bool UITask :: activeTargetPos ( int32_t & lat , int32_t & lon ) const { return _core -> locator . activeTargetPos ( lat , lon ); }
2026-06-25 19:04:21 +02:00
void UITask :: setTarget ( uint8_t kind , const uint8_t * key , int32_t lat , int32_t lon , const char * name ) {
2026-09-24 23:15:18 +02:00
_core -> locator . setTarget ( kind , key , lat , lon , name );
2026-06-25 19:04:21 +02:00
}
void UITask :: setTargetNow ( uint8_t kind , const uint8_t * key , int32_t lat , int32_t lon , const char * name ) {
if ( ! _node_prefs ) return ;
setTarget ( kind , key , lat , lon , name );
the_mesh . savePrefs ();
showAlert ( "Target set" , 1200 );
}
2026-09-24 23:15:18 +02:00
void UITask :: clearTarget () { _core -> locator . clearTarget (); }
void UITask :: clearTargetIfWaypoint ( int32_t lat_1e6 , int32_t lon_1e6 ) { _core -> locator . clearTargetIfWaypoint ( lat_1e6 , lon_1e6 ); }
2026-06-26 17:48:05 +02:00
2026-06-25 19:04:21 +02:00
// Homing beeper: while armed with a target and inside the radius, emit a short
// tick whose interval shrinks linearly with distance — slow at the edge, rapid
// near the centre. Polls distance a few times a second; silent outside the
// radius. The beeper has its own toggle (locator_beeper), so turning it on is
// an explicit "I want to hear this" — it deliberately overrides the global
// buzzer mute (playMelody → buzzer.playForced ignores the quiet flag).
2026-09-24 23:17:03 +02:00
TrailStore & UITask :: trail () { return _core -> trail . store (); }
2026-09-24 23:07:32 +02:00
bool UITask :: currentCourse ( int & deg_out ) const { return _core -> course . currentCourse ( deg_out ); }
bool UITask :: currentLocation ( int32_t & lat , int32_t & lon ) const { return _core -> course . currentLocation ( lat , lon ); }
2026-06-04 00:12:15 +02:00
2026-09-24 23:09:26 +02:00
bool UITask :: sendLocationShare ( int32_t lat , int32_t lon ) { return _core -> live_share . send ( lat , lon ); }
void UITask :: restartLocShareSession () { _core -> live_share . restartSession (); }
void UITask :: restartLocShareClock () { _core -> live_share . restartClock (); }
LiveTrackStore & UITask :: liveTrack () { return _core -> live_share . track (); }
2026-06-25 19:04:21 +02:00
// One-shot "share my position" from the home Map page (Hold Enter). When live
// sharing is already on, push an immediate [LOC] to the same target; otherwise
// hand a [LOC] message to the recipient picker so the user chooses where it
// goes (no accidental broadcast to a default channel).
void UITask :: quickShareMyLocation () {
int32_t lat , lon ;
if ( ! currentLocation ( lat , lon )) { showAlert ( "No GPS fix" , 1000 ); return ; }
if ( _node_prefs && _node_prefs -> loc_share_enabled && sendLocationShare ( lat , lon )) {
showAlert ( "Position shared" , 900 );
return ;
}
char text [ 40 ];
snprintf ( text , sizeof ( text ), LOCATION_MSG_TAG "%.5f,%.5f" , lat / 1e6 , lon / 1e6 );
shareToMessage ( text );
}
2026-09-25 19:20:42 +02:00
WaypointStore & UITask :: waypoints () { return _core -> waypoints . store (); }
void UITask :: saveWaypoints () { _core -> waypoints . save (); }
2026-06-03 08:23:11 +02:00
2026-06-09 15:09:57 +02:00
bool UITask :: addWaypoint ( int32_t lat , int32_t lon , uint32_t ts , const char * label ) {
2026-09-25 19:20:42 +02:00
if ( _core -> waypoints . full ()) { showAlert ( "Waypoints full" , 1000 ); return false ; }
if ( _core -> waypoints . add ( lat , lon , ts , label )) {
2026-06-09 15:09:57 +02:00
showAlert ( "Waypoint saved" , 800 );
return true ;
}
showAlert ( "Waypoints full" , 1000 );
return false ;
}
bool UITask :: addWaypoint ( int32_t lat , int32_t lon , const char * label ) {
return addWaypoint ( lat , lon , ( uint32_t ) rtc_clock . getCurrentTime (), label );
}
2025-08-08 20:01:31 +10:00
char UITask :: checkDisplayOn ( char c ) {
2025-05-27 19:10:56 -07:00
if ( _display != NULL ) {
2025-08-08 20:01:31 +10:00
if ( ! _display -> isOn ()) {
2026-05-10 18:01:59 +02:00
_display -> turnOn ();
#ifdef PIN_LED
digitalWrite ( PIN_LED , LOW ); // ensure LED is off when waking display (userLedHandler takes over)
#endif
2026-05-17 09:55:51 +02:00
if ( _locked ) {
_lock_wake_until = millis () + 5000 ;
_next_refresh = 0 ;
return 0 ; // eat the waking key press
}
2026-05-23 20:03:45 +02:00
_lock_seq_count = 0 ;
_lock_seq_used = false ;
2025-08-08 20:01:31 +10:00
c = 0 ;
2025-05-27 19:10:56 -07:00
}
2026-05-17 09:55:51 +02:00
if ( ! _locked ) {
uint32_t aoff = autoOffMillis ();
if ( aoff > 0 ) _auto_off = millis () + aoff ; // extend auto-off timer
}
2025-08-08 20:01:31 +10:00
_next_refresh = 0 ; // trigger refresh
2025-05-27 19:10:56 -07:00
}
2025-08-08 20:01:31 +10:00
return c ;
2025-05-27 19:10:56 -07:00
}
2025-08-08 20:01:31 +10:00
char UITask :: handleLongPress ( char c ) {
2026-07-27 22:05:52 +02:00
// Same checkDisplayOn() gate every other input path goes through (see
// pollCardKB()'s Fn+letter handling for the same shape) -- without it, a long
// press while the display is off neither wakes it nor extends auto-off, and
// while unlocked it delivers KEY_CONTEXT_MENU to the invisible screen (found
// already open at the next wake instead of the press being consumed as a wake).
c = checkDisplayOn ( c );
if ( c == 0 ) return 0 ;
2025-08-08 20:01:31 +10:00
if ( millis () - ui_started_at < 8000 ) { // long press in first 8 seconds since startup -> CLI/rescue
the_mesh . enterCLIRescue ();
2026-05-11 18:56:26 +02:00
return 0 ;
2025-05-27 19:10:56 -07:00
}
2026-05-11 18:56:26 +02:00
if ( c == KEY_ENTER ) return KEY_CONTEXT_MENU ;
2025-08-08 20:01:31 +10:00
return c ;
2025-05-27 19:10:56 -07:00
}
2025-09-03 08:25:59 +10:00
char UITask :: handleDoubleClick ( char c ) {
2026-06-02 22:12:18 +02:00
MESH_DEBUG_PRINTLN ( "UITask: double-click triggered" );
2025-09-03 08:31:38 +10:00
checkDisplayOn ( c );
2025-09-03 08:25:59 +10:00
return c ;
}
char UITask :: handleTripleClick ( char c ) {
2025-09-03 08:31:38 +10:00
checkDisplayOn ( c );
2025-09-03 08:25:59 +10:00
toggleBuzzer ();
2026-05-23 20:03:45 +02:00
return 0 ;
2025-09-03 08:25:59 +10:00
}
2025-09-23 10:39:43 +02:00
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" );
}
}
2026-04-28 21:26:56 -07:00
}
2025-09-23 10:39:43 +02:00
return false ;
}
2026-06-26 14:11:48 +02:00
bool UITask :: hasGPS () {
if ( _sensors != NULL ) {
int num = _sensors -> getNumSettings ();
for ( int i = 0 ; i < num ; i ++ ) {
if ( strcmp ( _sensors -> getSettingName ( i ), "gps" ) == 0 ) return true ;
}
}
return false ;
}
2025-09-03 08:25:59 +10:00
void UITask :: toggleGPS () {
2026-07-21 20:30:03 +02:00
if ( _node_prefs ) applyGpsState ( _node_prefs -> gps_enabled == 0 );
}
// Sets GPS to an absolute state (vs. toggleGPS()'s flip) -- shared by the
// Home-page manual toggle and the bot's !gps on/off command, which needs to
// set a specific state rather than flip whatever it currently is.
void UITask :: applyGpsState ( bool on ) {
2026-09-25 19:20:42 +02:00
if ( ! _core -> setGpsEnabled ( on )) return ; // no GPS on this board
notify ( UIEventType :: ack );
showAlert ( on ? "GPS: Enabled" : "GPS: Disabled" , 800 );
_next_refresh = 0 ;
2025-09-03 08:25:59 +10:00
}
2026-07-21 20:30:03 +02:00
void UITask :: botSetGPS ( bool on ) {
applyGpsState ( on );
}
// Bot !buzz [seconds] -- a find-me signal, so it deliberately uses
// playForced() (bypasses the buzzer_quiet mute) rather than play(): a
// find-me beep that respects mute defeats its own purpose. Builds a simple
// repeating beep/rest RTTTL string sized to the requested duration into the
// persistent _bot_buzz_buf -- the nRF52 RTTTL player keeps a raw pointer into
// whatever buffer it's given and reads from it across loop() calls for the
// whole playback (same constraint as _notif_mel_buf), so this can't be a
// local/stack buffer.
void UITask :: botBuzz ( int seconds ) {
#if defined(PIN_BUZZER)
if ( seconds < 1 ) seconds = 5 ;
if ( seconds > 30 ) seconds = 30 ;
int pairs = seconds * 2 ; // b=120: an "8c,8p," pair is 250+250 = 500ms
int n = snprintf ( _bot_buzz_buf , sizeof ( _bot_buzz_buf ), "Buzz:b=120:" );
for ( int i = 0 ; i < pairs && n < ( int ) sizeof ( _bot_buzz_buf ) - 7 ; i ++ )
n += snprintf ( _bot_buzz_buf + n , sizeof ( _bot_buzz_buf ) - n , "8c,8p," );
buzzer . playForced ( _bot_buzz_buf );
#endif
}
#if defined(PIN_GPIO1)
static uint32_t gpioPin ( int idx ) { // idx 1..4
static const uint32_t pins [ 4 ] = { PIN_GPIO1 , PIN_GPIO2 , PIN_GPIO3 , PIN_GPIO4 };
return ( idx >= 1 && idx <= 4 ) ? pins [ idx - 1 ] : 0xFFFFFFFF ;
}
static uint8_t * gpioModeField ( NodePrefs * p , int idx ) { // idx 1..4
switch ( idx ) {
case 1 : return & p -> gpio1_mode ;
case 2 : return & p -> gpio2_mode ;
case 3 : return & p -> gpio3_mode ;
case 4 : return & p -> gpio4_mode ;
default : return NULL ;
}
}
// Push a saved mode value to the actual pin hardware -- shared by
// setGpioMode() (live edits from the UI) and applyAllGpioModes() (boot
// restore), which differ only in whether the mode gets persisted. Mode 4
// (Analog) uses the same "leave it alone" config as Off: the SAADC reads the
// pin directly regardless of the GPIO block's state, and cfg_default (no
// pull, disconnected buffer) is exactly what Nordic recommends for an ADC
// input to avoid extra leakage current -- there's nothing separate to set up
// here, unlike Input/Output.
static void applyGpioModeToPin ( uint32_t pin , uint8_t mode ) {
switch ( mode ) {
case 1 : nrf_gpio_cfg_input ( pin , NRF_GPIO_PIN_PULLUP ); break ; // Input
case 2 : nrf_gpio_cfg_output ( pin ); nrf_gpio_pin_clear ( pin ); break ; // Output, off
case 3 : nrf_gpio_cfg_output ( pin ); nrf_gpio_pin_set ( pin ); break ; // Output, on
default : nrf_gpio_cfg_default ( pin ); break ; // Off / Analog
}
}
// GPIO1 (P0.02) = AIN0, GPIO2 (P0.29) = AIN5 -- the only two user pins wired
// to the nRF52840's SAADC (confirmed against wiring_analog_nRF52.c's own
// pin->channel switch). GPIO3/GPIO4 (P0.09/P0.10) have no ADC channel.
static uint32_t gpioAnalogPsel ( int idx ) { // idx 1..4; 0 (NC) if unsupported
if ( idx == 1 ) return SAADC_CH_PSELP_PSELP_AnalogInput0 ;
if ( idx == 2 ) return SAADC_CH_PSELP_PSELP_AnalogInput5 ;
return SAADC_CH_PSELP_PSELP_NC ;
}
// One-shot SAADC read, bypassing Arduino's analogRead() -- that function
// treats its argument as an ARDUINO PIN INDEX (looked up through
// g_ADigitalPinMap[]), not a raw channel, and no Arduino index maps to our
// raw GPIO1/GPIO2 pins (same reason digitalWrite()/pinMode() can't be used
// for these pins either -- see the file-level notes on PIN_GPIO1..4).
// Mirrors wiring_analog_nRF52.c's analogRead_internal() exactly (10-bit,
// 0.6V internal reference, 1/6 gain -> 0-3.6V range) so the numbers read the
// same as a normal analogRead() would, just addressing the SAADC channel
// directly instead of going through the pin-index dispatch.
static uint16_t readAnalogMv ( uint32_t psel ) {
NRF_SAADC -> RESOLUTION = SAADC_RESOLUTION_VAL_10bit ;
NRF_SAADC -> ENABLE = ( SAADC_ENABLE_ENABLE_Enabled << SAADC_ENABLE_ENABLE_Pos );
for ( int i = 0 ; i < 8 ; i ++ ) {
NRF_SAADC -> CH [ i ]. PSELN = SAADC_CH_PSELP_PSELP_NC ;
NRF_SAADC -> CH [ i ]. PSELP = SAADC_CH_PSELP_PSELP_NC ;
}
NRF_SAADC -> CH [ 0 ]. CONFIG =
(( SAADC_CH_CONFIG_RESP_Bypass << SAADC_CH_CONFIG_RESP_Pos ) & SAADC_CH_CONFIG_RESP_Msk )
| (( SAADC_CH_CONFIG_RESP_Bypass << SAADC_CH_CONFIG_RESN_Pos ) & SAADC_CH_CONFIG_RESN_Msk )
| (( SAADC_CH_CONFIG_GAIN_Gain1_6 << SAADC_CH_CONFIG_GAIN_Pos ) & SAADC_CH_CONFIG_GAIN_Msk )
| (( SAADC_CH_CONFIG_REFSEL_Internal << SAADC_CH_CONFIG_REFSEL_Pos ) & SAADC_CH_CONFIG_REFSEL_Msk )
| (( SAADC_CH_CONFIG_TACQ_3us << SAADC_CH_CONFIG_TACQ_Pos ) & SAADC_CH_CONFIG_TACQ_Msk )
| (( SAADC_CH_CONFIG_MODE_SE << SAADC_CH_CONFIG_MODE_Pos ) & SAADC_CH_CONFIG_MODE_Msk );
NRF_SAADC -> CH [ 0 ]. PSELN = psel ;
NRF_SAADC -> CH [ 0 ]. PSELP = psel ;
volatile int16_t value = 0 ;
NRF_SAADC -> RESULT . PTR = ( uint32_t ) & value ;
NRF_SAADC -> RESULT . MAXCNT = 1 ;
NRF_SAADC -> TASKS_START = 1 ;
while ( ! NRF_SAADC -> EVENTS_STARTED );
NRF_SAADC -> EVENTS_STARTED = 0 ;
NRF_SAADC -> TASKS_SAMPLE = 1 ;
while ( ! NRF_SAADC -> EVENTS_END );
NRF_SAADC -> EVENTS_END = 0 ;
NRF_SAADC -> TASKS_STOP = 1 ;
while ( ! NRF_SAADC -> EVENTS_STOPPED );
NRF_SAADC -> EVENTS_STOPPED = 0 ;
NRF_SAADC -> ENABLE = ( SAADC_ENABLE_ENABLE_Disabled << SAADC_ENABLE_ENABLE_Pos );
if ( value < 0 ) value = 0 ;
// 10-bit, 1/6 gain, 0.6V internal ref -> full-scale = 0.6V / (1/6) = 3.6V
return ( uint16_t )((( uint32_t ) value * 3600 ) / 1024 );
}
#endif
2026-07-25 00:00:07 +02:00
// Set a user GPIO pin to a specific mode (0=Off 1=In 2=Out-low 3=Out-high
// 4=Analog), apply it to the actual pin, and persist. The Off->In->Out->...
// cycling itself lives in GpioScreen; the bot's !gpioN on/off and boot
// restore also route through here.
2026-07-21 20:30:03 +02:00
void UITask :: setGpioMode ( int idx , uint8_t mode ) {
#if defined(PIN_GPIO1)
if ( ! _node_prefs ) return ;
uint8_t * f = gpioModeField ( _node_prefs , idx );
uint32_t pin = gpioPin ( idx );
if ( ! f || pin == 0xFFFFFFFF ) return ;
if ( mode == 4 && ! gpioSupportsAnalog ( idx )) mode = 0 ; // no ADC channel on this pin -- fall back to Off
* f = mode ;
applyGpioModeToPin ( pin , mode );
the_mesh . savePrefs ();
#else
( void ) idx ; ( void ) mode ;
#endif
}
// Boot-time restore: push each pin's saved mode to hardware before any UI/bot
// interaction (mirrors MyMesh::applyGpsPrefs()'s role for the GPS toggle --
// there's no generic "restore all settings" hook in this codebase, each
// persisted hardware toggle gets its own bespoke boot call). Deliberately
// doesn't call savePrefs() -- nothing changed, just re-applying what's
// already on disk.
void UITask :: applyAllGpioModes () {
#if defined(PIN_GPIO1)
if ( ! _node_prefs ) return ;
for ( int i = 1 ; i <= 4 ; i ++ ) {
uint8_t * f = gpioModeField ( _node_prefs , i );
if ( f ) applyGpioModeToPin ( gpioPin ( i ), * f );
}
#endif
}
bool UITask :: botSetGPIO ( int idx , bool on ) {
#if defined(PIN_GPIO1)
if ( ! _node_prefs ) return false ;
uint8_t * f = gpioModeField ( _node_prefs , idx );
if ( ! f || ( * f != 2 && * f != 3 )) return false ; // not configured as Output
setGpioMode ( idx , on ? 3 : 2 );
return true ;
#else
( void ) idx ; ( void ) on ;
return false ;
#endif
}
bool UITask :: botGetGPIO ( int idx , bool & is_output , bool & value ) {
#if defined(PIN_GPIO1)
if ( ! _node_prefs ) return false ;
uint8_t * f = gpioModeField ( _node_prefs , idx );
uint32_t pin = gpioPin ( idx );
if ( ! f || * f == 0 || * f == 4 || pin == 0xFFFFFFFF ) return false ; // Off / Analog / unsupported
is_output = ( * f == 2 || * f == 3 );
value = is_output ? ( nrf_gpio_pin_out_read ( pin ) != 0 ) : ( nrf_gpio_pin_read ( pin ) != 0 );
return true ;
#else
( void ) idx ; ( void ) is_output ; ( void ) value ;
return false ;
#endif
}
bool UITask :: gpioSupportsAnalog ( int idx ) const {
#if defined(PIN_GPIO1)
return idx == 1 || idx == 2 ;
#else
( void ) idx ;
return false ;
#endif
}
bool UITask :: botGetGPIOAnalog ( int idx , int & millivolts ) {
#if defined(PIN_GPIO1)
if ( ! _node_prefs || ! gpioSupportsAnalog ( idx )) return false ;
uint8_t * f = gpioModeField ( _node_prefs , idx );
if ( ! f || * f != 4 ) return false ; // not in Analog mode
millivolts = readAnalogMv ( gpioAnalogPsel ( idx ));
return true ;
#else
( void ) idx ; ( void ) millivolts ;
return false ;
#endif
}
2026-05-10 15:18:35 +02:00
void UITask :: applyTxPower () {
2026-09-25 19:20:42 +02:00
radioctl :: applyTxPower ( _node_prefs );
2026-05-10 15:18:35 +02:00
}
2026-06-10 23:30:43 +02:00
void UITask :: applyPowerSave () {
2026-09-25 23:33:11 +02:00
rptctl :: applyPowerSave ( _node_prefs ); // forced off while repeating (ui-core/RepeaterControl.h)
2026-06-10 23:30:43 +02:00
}
void UITask :: applyApc () {
2026-09-25 19:20:42 +02:00
radioctl :: applyApc ();
2026-06-10 23:30:43 +02:00
}
2026-08-07 22:14:19 +02:00
#if ENV_INCLUDE_GPS == 1
void UITask :: applyGpsInterval () {
if ( _node_prefs == NULL || _sensors == NULL ) return ;
char buf [ 12 ];
sprintf ( buf , "%u" , _node_prefs -> gps_interval );
_sensors -> setSettingValue ( "gps_interval" , buf );
}
#endif
2026-06-20 09:07:00 +02:00
void UITask :: applyRadioParams () {
if ( _node_prefs == NULL ) return ;
2026-09-25 19:20:42 +02:00
radioctl :: applyParams (); // companion params, or the repeater profile if relaying with one set
2026-06-20 09:07:00 +02:00
}
2026-05-10 15:18:35 +02:00
void UITask :: applyBrightness () {
if ( _display != NULL && _node_prefs != NULL ) {
_display -> setBrightness ( _node_prefs -> display_brightness );
}
}
2026-05-20 21:53:40 +02:00
void UITask :: applyRotation () {
if ( _display != NULL && _node_prefs != NULL ) {
_display -> setDisplayRotation ( _node_prefs -> display_rotation );
_next_refresh = 0 ;
}
}
2026-05-24 10:22:03 +02:00
void UITask :: applyFullRefreshInterval () {
if ( _display != NULL && _node_prefs != NULL ) {
static const uint8_t OPTS [] = { 0 , 5 , 10 , 20 , 30 };
static const int OPTS_COUNT = 5 ;
uint8_t idx = _node_prefs -> eink_full_refresh_every ;
if ( idx >= OPTS_COUNT ) idx = 0 ;
_display -> setFullRefreshInterval ( OPTS [ idx ]);
}
}
2026-05-10 15:18:35 +02:00
void UITask :: setBrightnessLevel ( uint8_t level ) {
if ( _node_prefs == NULL ) return ;
if ( level > 4 ) level = 4 ;
_node_prefs -> display_brightness = level ;
applyBrightness ();
_next_refresh = 0 ;
}
2026-05-12 09:17:37 +02:00
void UITask :: setBuzzerVolumeLevel ( uint8_t level ) {
#ifdef PIN_BUZZER
if ( _node_prefs == NULL ) return ;
2026-09-26 00:27:52 +02:00
soundctl :: setVolume ( _node_prefs , buzzer , level );
2026-05-12 09:17:37 +02:00
_next_refresh = 0 ;
#endif
}
2025-09-03 08:25:59 +10:00
void UITask :: toggleBuzzer () {
2025-05-27 19:10:56 -07:00
#ifdef PIN_BUZZER
2026-09-26 01:00:31 +02:00
if ( ! _node_prefs ) return ;
bool on = buzzer . isQuiet (); // leaves Auto too
soundctl :: setMode ( _node_prefs , buzzer , on ? soundctl :: MODE_ON : soundctl :: MODE_OFF , isClientConnected ());
if ( on ) notify ( UIEventType :: ack );
2025-11-20 18:55:39 -08:00
the_mesh . savePrefs ();
2025-12-11 09:26:09 +01:00
showAlert ( buzzer . isQuiet () ? "Buzzer: OFF" : "Buzzer: ON" , 800 );
2026-05-12 20:12:54 +02:00
_next_refresh = 0 ;
2025-05-27 19:10:56 -07:00
#endif
}
2026-05-12 20:12:54 +02:00
int UITask :: getBuzzerMode () {
#ifdef PIN_BUZZER
2026-09-26 01:00:31 +02:00
return soundctl :: mode ( _node_prefs );
2026-05-12 20:12:54 +02:00
#else
return 1 ;
#endif
}
void UITask :: cycleBuzzerMode () {
#ifdef PIN_BUZZER
if ( ! _node_prefs ) return ;
int mode = getBuzzerMode ();
2026-09-26 00:27:52 +02:00
mode = ( mode + 1 ) % soundctl :: MODE_COUNT ; // ON → OFF → Auto → ON
soundctl :: setMode ( _node_prefs , buzzer , ( uint8_t ) mode , isClientConnected ());
if ( mode == soundctl :: MODE_ON ) notify ( UIEventType :: ack );
2026-05-12 20:12:54 +02:00
static const char * labels [] = { "Buzzer: ON" , "Buzzer: OFF" , "Buzzer: Auto" };
showAlert ( labels [ mode ], 800 );
_next_refresh = 0 ;
#endif
}