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"
2026-09-28 23:19:47 +02:00
#include "../ui-core/Telemetry.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"
2026-10-09 08:48:03 -10:00
2026-10-11 21:26:58 +02:00
#include "CardKB.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-08 20:01:31 +10:00
#include "icons.h"
2026-10-01 22:59:44 +02:00
#include "../ui-core/Lettering.h" // boot splash wordmark + lettering (shared with the L2)
2026-06-25 19:04:21 +02:00
#include "GfxUtils.h" // gfx::drawLine — connects trail points on the Home map preview
2026-10-11 21:26:58 +02:00
#include "SplashScreen.h"
2025-08-08 20:01:31 +10:00
2026-05-11 16:52:24 +02:00
static const int QUICK_MSGS_MAX = 10 ;
2026-05-12 09:51:30 +02:00
2026-10-02 13:49:26 +02:00
// Telemetry (ui-core/Telemetry.h) in this display's font and width: tight for
// the dashboard's short fields, spaced ("49 m") on the Status screen's rows.
static const telemetry :: Style L1_TELEMETRY = { " \xc2\xb0 " , false , false , 3 };
static const telemetry :: Style L1_INFO = { " \xc2\xb0 " , true , false , 5 };
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-10-01 23:51:47 +02:00
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-09-24 23:00:46 +02:00
#include "../ui-core/UiCore.h" // shared UI Core: history rings + unread models (MessagesScreen views them)
2026-09-28 23:19:47 +02:00
#include "../ui-core/SettingsSchema.h" // the settings both frontends share (SettingsScreen.h)
2026-10-10 09:59:35 +02:00
#include "../ui-core/SettingsCli.h" // and their companion CLI keys
2026-09-28 23:19:47 +02:00
#include "SettingsScreen.h"
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-10-02 11:25:58 +02:00
#include "BatteryCurveScreen.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-10-01 23:51:47 +02:00
#include "StatusScreen.h" // Home › Status (was Tools › Diagnostics + Satellites)
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-10-09 09:38:29 +02:00
#include "CalendarView.h" // the month under a tall clock, Clock tools › Calendar
#include "ClockToolsScreen.h" // Alarm / Timer / Stopwatch / Calendar (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-10-11 21:26:58 +02:00
#include "HomeScreen.h"
2025-08-08 20:01:31 +10:00
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-10-02 11:25:58 +02:00
if ( node_prefs ) battery :: useCurve ( node_prefs -> batt_curve_mv ); // Settings > Battery curve
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
2026-09-06 17:52:41 +02:00
// Lock device on boot if password is enabled to prevent bypassing it by resetting device
if ( passwordLockEnabled ()) {
_locked = true ;
// Add BOOT_SCREEN_MILLIS to make sure splash screen still shows
_lock_wake_until = millis () + BOOT_SCREEN_MILLIS + 5000 ;
}
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-28 21:59:40 +02:00
soundctl :: applyMode ( _node_prefs , buzzer , false , rtc_clock . getCurrentTime ());
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-10-02 11:25:58 +02:00
batt_curve_screen = new BatteryCurveScreen ( 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-09-28 16:33:41 +02:00
#if ENV_INCLUDE_GPS == 1 && defined(GPS_SKYVIEW)
#endif
2026-10-01 23:51:47 +02:00
status_screen = new StatusScreen ( 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-10-09 08:48:03 -10:00
startCardKBCapture (); // after every screen: an optional task must not starve one
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-10-10 16:36:39 -10:00
applyUiScale (); // Settings > Display > Text size / Icon size
2026-07-21 20:30:03 +02:00
applyAllGpioModes (); // restore persisted pin modes to hardware before any UI/bot use
2026-09-29 00:06:01 +02:00
// Locked above (a PIN, or the cover closed) before Home existed to be told.
if ( _locked ) syncLockToHome ();
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 ); }
2026-10-06 14:09:22 +02:00
void UITask :: gotoToolsScreen () { _tool_from_home = false ; setCurrScreen ( tools_screen ); }
void UITask :: leaveTool () { if ( _tool_from_home ) gotoHomeScreen (); else gotoToolsScreen (); }
2026-06-29 18:37:04 +02:00
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 ); }
2026-10-01 23:51:47 +02:00
void UITask :: gotoStatusScreen ( uint8_t tab ) {
(( StatusScreen * ) status_screen ) -> showTab ( tab );
setCurrScreen ( status_screen );
}
2026-06-29 18:37:04 +02:00
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-10-02 11:25:58 +02:00
void UITask :: gotoBatteryCurve () { setCurrScreen ( batt_curve_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-10-07 22:58:01 +02:00
#ifdef SIM_PLATFORM
2026-10-08 14:32:54 +02:00
void UITask :: simMessage ( const char * text ) {
UiEvent ev = {};
ev . type = UiEventType :: MessageArrived ;
ev . kind = UIEventType :: channelMessage ;
ev . idx = 0 ;
snprintf ( ev . text , sizeof ( ev . text ), "%s" , text );
onMessageArrived ( ev );
}
2026-10-08 22:20:04 +02:00
// Sim tests: read and new messages (UiCore::simUnreadDemo), then channel 0
// (which 0) or the first chat contact (1) opened as from the list.
void UITask :: simUnread ( int which ) {
if ( which < 0 ) { _core -> simUnreadDemo (); return ; }
if ( which == 0 ) { openChannelHistory ( 0 ); return ; }
for ( int i = 0 ; i < the_mesh . getNumContacts (); i ++ ) {
ContactInfo c ;
if ( the_mesh . getContactByIdx ( MAX_ANON_CONTACTS + i , c ) && c . type == ADV_TYPE_CHAT ) { openContactDM ( c ); return ; }
}
}
2026-10-09 09:38:29 +02:00
// Sim tests: a GPS fix in Kraków and eight nodes around it, then Nodes.
void UITask :: simNodes () {
sim_location_provider (). set ( 50.0614f , 19.9372f );
static const struct { const char * name ; float dla , dlo ; uint8_t type ; } N [] = {
{ "Kasia-T1" , 0.012f , 0.020f , ADV_TYPE_CHAT }, { "Wawel rpt" , - 0.006f , - 0.004f , ADV_TYPE_REPEATER },
{ "Kopiec" , 0.004f , - 0.045f , ADV_TYPE_REPEATER }, { "Tomek" , 0.0005f , 0.0006f , ADV_TYPE_CHAT },
{ "Nowa Huta" , 0.020f , 0.110f , ADV_TYPE_REPEATER }, { "Room Rynek" , 0.0008f , 0.0004f , ADV_TYPE_ROOM },
{ "Ola" , - 0.030f , 0.015f , ADV_TYPE_CHAT }, { "Bielany" , 0.000f , - 0.080f , ADV_TYPE_REPEATER },
};
const uint32_t now = rtc_clock . getCurrentTime ();
for ( int i = 0 ; i < ( int )( sizeof ( N ) / sizeof ( N [ 0 ])); i ++ ) {
uint8_t key [ PUB_KEY_SIZE ];
for ( int k = 0 ; k < PUB_KEY_SIZE ; k ++ ) key [ k ] = ( uint8_t )( 0x5A ^ ( i * 37 + k * 11 ));
the_mesh . addDiscoveredContact ( key , N [ i ]. name , N [ i ]. type );
if ( ContactInfo * c = the_mesh . lookupContactByPubKey ( key , PUB_KEY_SIZE )) {
c -> gps_lat = ( int32_t )(( 50.0614f + N [ i ]. dla ) * 1e6 f );
c -> gps_lon = ( int32_t )(( 19.9372f + N [ i ]. dlo ) * 1e6 f );
c -> lastmod = now - i * 600 ;
}
}
setCurrScreen ( nearby_screen );
}
2026-10-08 23:41:40 +02:00
void UITask :: simDisplay ( bool on ) {
if ( ! _display ) return ;
if ( on ) _display -> turnOn (); else _display -> turnOff ();
_next_refresh = 0 ;
}
2026-10-07 22:58:01 +02:00
void UITask :: simCompose ( const char * text ) {
gotoMessagesScreen ();
(( MessagesScreen * ) messages_screen ) -> simCompose ( text );
}
#endif
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 ; }
2026-10-08 23:41:40 +02:00
// On screen: a conversation left open under the lock or a dark display isn't read.
2026-10-08 23:58:52 +02:00
bool UITask :: messagesInView () const {
return curr == messages_screen && _display && _display -> isOn () && ! _locked ;
}
2026-09-24 23:28:58 +02:00
bool UITask :: isViewingChannel ( uint8_t channel_idx ) {
2026-10-08 23:58:52 +02:00
return messagesInView () && (( MessagesScreen * ) messages_screen ) -> isViewingChannel ( channel_idx );
2026-09-24 23:28:58 +02:00
}
bool UITask :: isViewingDM ( const uint8_t * pub_key ) {
2026-10-08 23:58:52 +02:00
return messagesInView () && (( MessagesScreen * ) messages_screen ) -> isViewingDM ( pub_key );
2026-09-24 23:28:58 +02:00
}
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 );
2026-10-08 14:32:54 +02:00
if ( ! alertShowing () || _alert_badge ) _alert_t0 = millis (); // a banner already down only changes its text
_alert_badge = false ;
2026-10-02 13:49:16 +02:00
_alert_expiry = ( millis () + duration_millis ) | 1 ; // 0 means none
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-10-01 22:13:11 +02:00
// No toast when the message lands in the thread that is open on screen:
// it already shows up there, and the box would only cover it.
bool in_view = curr == messages_screen && _display && _display -> isOn () && ! _locked &&
(( ev . kind == UIEventType :: contactMessage && ev . flag && isViewingDM ( ev . key )) ||
( ev . kind == UIEventType :: channelMessage && ev . idx >= 0 && isViewingChannel (( uint8_t ) ev . idx )));
2026-10-08 14:32:54 +02:00
const uint8_t style = _node_prefs ? _node_prefs -> msg_alert : NodePrefs :: MSG_ALERT_NORMAL ;
if ( ! in_view && style == NodePrefs :: MSG_ALERT_COMPACT ) {
// The envelope counts the run: each message adds one and blinks it again.
_alert_count = alertShowing () && _alert_badge ? _alert_count + 1 : 1 ;
_alert_badge = true ;
_alert_t0 = millis ();
_alert_expiry = ( millis () + BADGE_MS ) | 1 ;
} else if ( ! in_view && style == NodePrefs :: MSG_ALERT_NORMAL ) {
2026-10-01 22:13:11 +02:00
char alert_buf [ 80 ];
snprintf ( alert_buf , sizeof ( alert_buf ), "Msg: %.20s" , ev . text );
2026-10-07 16:35:45 +02:00
showAlert ( alert_buf , 2000 );
2026-10-01 22:13:11 +02:00
}
2025-03-04 23:09:43 +11:00
2026-05-17 09:55:51 +02:00
if ( _display != NULL && ! _locked ) {
2026-10-03 00:12:18 +02:00
bool wake_disabled = _node_prefs && ( ! _node_prefs -> msg_wake || inQuietHours ( * _node_prefs , rtc_clock . getCurrentTime ()));
2026-09-06 22:35:12 +02:00
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 ;
2026-10-02 13:49:16 +02:00
_next_refresh = 0 ;
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-10-07 16:35:45 +02:00
// The alert as a banner over the top edge: it slides down, stays, slides back
// up (e-ink just appears and goes), so what is on screen below it stays
// readable. 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); it wraps to up to three lines inside the
// banner instead. Uses the shared wrap scratch (s_wrap_*) -- single-threaded
2026-07-05 16:08:44 +02:00
// render path, same contract as the message views.
void UITask :: renderAlertOverlay () {
2026-10-08 14:32:54 +02:00
if ( _alert_badge ) { renderMsgBadge (); return ; }
2026-07-05 16:08:44 +02:00
_display -> setTextSize ( 1 );
const int lh = _display -> getLineHeight ();
const int pad = 3 ;
2026-10-07 16:35:45 +02:00
const int W = _display -> width ();
2026-07-05 16:08:44 +02:00
_display -> translateUTF8ToBlocks ( s_wrap_trans , _alert , sizeof ( s_wrap_trans ));
2026-10-07 16:35:45 +02:00
int nl = FullscreenMsgView :: wrapLines ( * _display , s_wrap_trans , W - pad * 2 - 2 , s_wrap_lines , 3 );
2026-07-05 16:08:44 +02:00
if ( nl < 1 ) nl = 1 ;
2026-10-07 16:35:45 +02:00
const int h = nl * lh + pad * 2 ;
// How far down it is, in percent: eased out over MOVE ms in and out.
const int MOVE = 150 ;
int k = 100 ;
if ( ! _display -> isEink ()) {
const int el = ( int )( millis () - _alert_t0 ), rem = ( int )( _alert_expiry - millis ());
if ( el < MOVE ) k = el * 100 / MOVE ;
if ( rem < MOVE && rem * 100 / MOVE < k ) k = rem * 100 / MOVE ;
if ( k < 0 ) k = 0 ;
k = 100 - ( 100 - k ) * ( 100 - k ) / 100 ;
}
const int y = - h + h * k / 100 ;
_display -> setColor ( DisplayDriver :: DARK );
_display -> fillRect ( 0 , y , W , h );
_display -> setColor ( DisplayDriver :: LIGHT );
_display -> drawRect ( 0 , y , W , h );
2026-07-05 16:08:44 +02:00
for ( int i = 0 ; i < nl ; i ++ )
2026-10-07 16:35:45 +02:00
_display -> drawTextCentered ( W / 2 , y + pad + i * lh , s_wrap_lines [ i ]);
2026-07-05 16:08:44 +02:00
}
2026-10-08 14:32:54 +02:00
// Settings > Message alert Compact: an envelope and how many messages came. On
// Home it sits among the status icons (HomeScreen::renderBatteryIndicator);
// it blinks for a while after each one (not on e-ink), then holds till it goes.
void UITask :: renderMsgBadge () {
if ( _badge_in_bar ) return ; // among the Home status icons already
// Elsewhere in the header's line, at its right, clear of the line under it.
DisplayDriver & d = * _display ;
const int s = miniIconScale ( d ), h = d . headerH () - d . sepH (), w = mailCountWidth ( d , _alert_count );
const int x = d . width () - w - 2 * s ;
d . setColor ( DisplayDriver :: DARK );
d . fillRect ( x - 2 * s , 0 , w + 4 * s , h );
d . setColor ( DisplayDriver :: LIGHT );
if ( msgBadgeLit ()) drawMailCount ( d , x , 0 , h , _alert_count );
}
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)
2026-10-02 13:49:16 +02:00
_next_refresh = 0 ;
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-09-06 16:25:03 +02:00
bool UITask :: passwordLockEnabled () const {
2026-09-29 00:06:01 +02:00
return _node_prefs && screenlock :: isSet ( * _node_prefs );
2026-09-06 16:25:03 +02:00
}
2026-09-06 19:49:07 +02:00
void UITask :: setNodeLockPassword ( const char * plain ) {
2026-09-29 00:06:01 +02:00
if ( _node_prefs ) screenlock :: set ( * _node_prefs , plain , the_mesh . getRNG ());
2026-09-06 19:49:07 +02:00
}
2026-09-06 16:25:03 +02:00
void UITask :: beginUnlockPrompt () {
_unlock_kb = true ; // Track that keyboard is visible and is waiting for input
2026-09-29 00:06:01 +02:00
_kb . beginPin ( "" , KeyboardWidget :: PIN_MAX_LEN , true , "PIN" ); // masked
2026-09-06 16:25:03 +02:00
_kb . clearPlaceholders ();
_lock_wake_until = millis () + 5000 ; // keep the display on while typing
_next_refresh = 0 ;
}
void UITask :: cancelUnlockPrompt () {
_unlock_kb = false ;
2026-09-29 00:06:01 +02:00
_kb . pin_mode = false ;
2026-09-08 22:25:39 +02:00
_kb . buf [ 0 ] = '\0' ; // clear input
_kb . len = 0 ;
_kb . cursor_pos = 0 ;
2026-10-02 13:49:16 +02:00
_next_refresh = 0 ;
2026-09-06 16:25:03 +02:00
}
void UITask :: handleUnlockKey ( char c ) {
auto res = _kb . handleInput ( c );
2026-09-29 00:06:01 +02:00
if ( res == KeyboardWidget :: DONE && _pin_tries . blocked ()) {
char msg [ 32 ];
snprintf ( msg , sizeof ( msg ), "Wait %lu s" , ( unsigned long ) _pin_tries . secondsLeft ());
showAlert ( msg , 1200 );
} else if ( res == KeyboardWidget :: DONE ) { // Process input on submit
if ( _node_prefs && screenlock :: check ( * _node_prefs , _kb . buf )) {
2026-09-06 16:25:03 +02:00
// Match: Unlock
2026-09-29 00:06:01 +02:00
_pin_tries . ok ();
2026-09-06 16:25:03 +02:00
_unlock_kb = false ;
2026-09-29 00:06:01 +02:00
_kb . pin_mode = false ;
2026-09-06 16:25:03 +02:00
_locked = false ;
if ( _display && ! _display -> isOn ()) _display -> turnOn ();
uint32_t aoff = autoOffMillis ();
if ( aoff > 0 ) _auto_off = millis () + aoff ;
syncLockToHome ();
} else {
2026-09-29 00:06:01 +02:00
// Invalid: Clear input and reset keyboard; TRIES misses pause the entry
char msg [ 32 ];
if ( _pin_tries . miss ()) snprintf ( msg , sizeof ( msg ), "Wait %lu s" , ( unsigned long )( screenlock :: PAUSE_MS / 1000 ));
else snprintf ( msg , sizeof ( msg ), "Wrong PIN, %u left" , ( unsigned ) _pin_tries . left ());
showAlert ( msg , 1200 );
2026-09-06 16:25:03 +02:00
_kb . len = 0 ;
_kb . buf [ 0 ] = '\0' ;
_kb . cursor_pos = 0 ;
_kb . page = _kb . row = _kb . col = 0 ;
_kb . caps = _kb . caps_lock = false ;
_next_refresh = 0 ;
}
} else if ( res == KeyboardWidget :: CANCELLED ) {
cancelUnlockPrompt ();
}
_lock_wake_until = millis () + 5000 ; // keep the display on while typing
}
void UITask :: toggleLock () {
if ( _unlock_kb ) {
cancelUnlockPrompt ();
return ;
}
if ( _locked ) {
if ( passwordLockEnabled ()) { // Prompt for password
beginUnlockPrompt ();
} else { // ...or unlock instantly
_locked = false ;
if ( _display && ! _display -> isOn ()) _display -> turnOn ();
uint32_t aoff = autoOffMillis ();
if ( aoff > 0 ) _auto_off = millis () + aoff ;
}
} else { // Device is currently unlocked -> lock it
_locked = true ;
_lock_wake_until = millis () + 2000 ; // Briefly show lockscreen before blanking
}
syncLockToHome ();
_next_refresh = 0 ;
}
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-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
2026-10-07 22:58:01 +02:00
// Sim tests: a channel post's keyboard, holding `text`.
extern "C" EMSCRIPTEN_KEEPALIVE void sim_compose ( const char * text ) {
if ( g_sim_ui_task_for_js ) g_sim_ui_task_for_js -> simCompose ( text );
}
2026-10-08 22:20:04 +02:00
// Sim tests: -1 files read and new messages, 0 opens channel 0, 1 the first DM.
extern "C" EMSCRIPTEN_KEEPALIVE void sim_unread ( int which ) {
if ( g_sim_ui_task_for_js ) g_sim_ui_task_for_js -> simUnread ( which );
}
2026-10-09 09:38:29 +02:00
extern "C" EMSCRIPTEN_KEEPALIVE void sim_nodes () {
if ( g_sim_ui_task_for_js ) g_sim_ui_task_for_js -> simNodes ();
}
2026-10-08 23:41:40 +02:00
// Sim tests: the display off (0) or on (1), as the auto-off and a key press do.
extern "C" EMSCRIPTEN_KEEPALIVE void sim_display ( int on ) {
if ( g_sim_ui_task_for_js ) g_sim_ui_task_for_js -> simDisplay ( on != 0 );
}
2026-10-08 14:32:54 +02:00
// Sim tests: a channel message arrives, alerted in Settings > Message alert's
// style (0 Normal, 1 Compact, 2 Off).
extern "C" EMSCRIPTEN_KEEPALIVE void sim_message ( const char * text , int style ) {
if ( ! g_sim_ui_task_for_js ) return ;
if ( NodePrefs * p = g_sim_ui_task_for_js -> getNodePrefs ()) p -> msg_alert = ( uint8_t ) style ;
g_sim_ui_task_for_js -> simMessage ( text );
}
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
2026-10-06 14:12:27 +02:00
// the real on-screen "Buzzer: On/Off" alert -- so muting from the host
2026-09-05 08:36:02 +02:00
// 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-07-21 23:43:33 +02:00
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
2026-09-06 16:25:03 +02:00
cancelUnlockPrompt (); // a cover can't be over the password prompt
2026-08-26 13:48:33 +02:00
_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
2026-09-06 20:25:50 +02:00
if ( passwordLockEnabled ()) { // Redirect flow to usual unlock prompt when password is enabled
_locked = true ;
if ( _display ) _display -> turnOn ();
beginUnlockPrompt ();
} else {
_locked = false ;
syncLockToHome ();
if ( _display && ! _display -> isOn ()) _display -> turnOn ();
uint32_t aoff = autoOffMillis ();
if ( aoff > 0 ) _auto_off = millis () + aoff ;
}
2026-08-26 13:48:33 +02:00
}
_next_refresh = 0 ;
#endif
}
2026-10-11 21:26:58 +02:00
#include "PerfL1.h"
2026-10-01 22:01:53 +02:00
2025-03-10 17:11:55 +01:00
void UITask :: loop () {
2026-10-01 22:01:53 +02:00
#if defined(UI_PERF_L1) && defined(NRF52_PLATFORM)
{ static uint32_t start = millis ();
perfl1 :: disp = _display ;
{ static bool sz = false ;
if ( ! sz && millis () - start > 7000 ) { sz = true ;
2026-10-01 23:51:47 +02:00
Serial . printf ( "SIZE UiCore %u Splash %u Home %u Settings %u Messages %u Tools %u Ringtone %u Bot %u Admin %u Nearby %u Dash %u AutoAdv %u LiveSh %u Locator %u Trail %u Compass %u Status %u Repeater %u ClockTools %u Kbd %u UITask %u StaticPool32 %u Packet %u \n " ,
2026-10-01 22:01:53 +02:00
sizeof ( UiCore ), sizeof ( SplashScreen ), sizeof ( HomeScreen ), sizeof ( SettingsScreen ), sizeof ( MessagesScreen ), sizeof ( ToolsScreen ),
sizeof ( RingtoneEditorScreen ), sizeof ( BotScreen ), sizeof ( AdminScreen ), sizeof ( NearbyScreen ), sizeof ( DashboardConfigScreen ),
sizeof ( AutoAdvertScreen ), sizeof ( LiveShareScreen ), sizeof ( LocatorScreen ), sizeof ( TrailScreen ), sizeof ( CompassScreen ),
2026-10-01 23:51:47 +02:00
sizeof ( StatusScreen ), sizeof ( RepeaterScreen ), sizeof ( ClockToolsScreen ), sizeof ( KeyboardWidget ), sizeof ( UITask ),
2026-10-01 22:01:53 +02:00
sizeof ( StaticPoolPacketManager ), sizeof ( mesh :: Packet ));
TaskStatus_t st [ 16 ]; uint32_t tot = 0 ;
UBaseType_t n = uxTaskGetSystemState ( st , 16 , NULL );
( void ) n ; ( void ) tot ;
} }
if ( millis () - start > 8000 ) {
char k = perfl1 :: tick ();
if ( k ) enqueueKey ( checkDisplayOn ( k ));
if ( perfl1 :: open_now != perfl1 :: NONE ) {
checkDisplayOn ( 0 );
switch ( perfl1 :: open_now ) {
case perfl1 :: HOME : gotoHomeScreen (); break ;
case perfl1 :: SETTINGS : gotoSettingsScreen (); break ;
case perfl1 :: MESSAGES : gotoMessagesScreen (); break ;
case perfl1 :: TOOLS : gotoToolsScreen (); break ;
case perfl1 :: NEARBY : gotoNearbyScreen (); break ;
case perfl1 :: TRAIL : gotoTrailScreen (); break ;
case perfl1 :: MAP : gotoMapScreen (); break ;
case perfl1 :: COMPASS : gotoCompassScreen (); break ;
2026-10-01 23:51:47 +02:00
case perfl1 :: STATUS : gotoStatusScreen ( StatusScreen :: TAB_RADIO ); break ;
2026-10-01 22:01:53 +02:00
case perfl1 :: LIVE : gotoLiveShareScreen (); break ;
case perfl1 :: LOCATOR : gotoLocatorScreen (); break ;
case perfl1 :: ADVERT : gotoAutoAdvertScreen (); break ;
case perfl1 :: REPEATER : gotoRepeaterScreen (); break ;
case perfl1 :: BOT : gotoBotScreen (); break ;
case perfl1 :: CLOCKTOOLS : gotoClockTools (); break ;
case perfl1 :: CHANNEL0 : openChannelHistory ( 0 ); break ;
default : break ;
}
perfl1 :: open_now = perfl1 :: NONE ;
_next_refresh = 0 ;
}
} }
#endif
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)
2026-10-08 23:41:40 +02:00
{ // a conversation in view again (display on, unlocked): on to what came in meanwhile
2026-10-08 23:58:52 +02:00
bool on = messagesInView ();
if ( on && ! _display_was_on ) (( MessagesScreen * ) messages_screen ) -> onDisplayOn ();
2026-10-08 23:41:40 +02:00
_display_was_on = on ;
}
2026-10-01 23:51:47 +02:00
if ( status_screen ) (( StatusScreen * ) status_screen ) -> sample (); // Status history lines, once a minute
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 ;
2026-10-02 19:24:36 +02:00
#if FEAT_DISPLAY_ROTATION_SETTING && defined(JOYSTICK_UPRIGHT_ROTATION)
if ( _node_prefs ) joy_rot += ( JOYSTICK_UPRIGHT_ROTATION - _node_prefs -> display_rotation ) & 3 ;
#endif
2025-08-08 20:01:31 +10:00
int ev = user_btn . check ();
if ( ev == BUTTON_EVENT_CLICK ) {
2026-09-06 20:25:50 +02:00
if ( back_btn . isPressed () && ! _unlock_kb ) {
2026-05-17 10:09:54 +02:00
// 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-09-06 16:25:03 +02:00
toggleLock ();
2026-05-17 10:09:54 +02:00
}
// eat the Enter — don't pass to curr
} else {
2026-09-06 20:25:50 +02:00
// While the password keyboard is open, Back+Enter is just typing
if ( _unlock_kb ) {
_lock_seq_count = 0 ;
_lock_seq_ms = 0 ;
}
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-09-06 16:25:03 +02:00
if ( _unlock_kb ) {
// Lock-screen password keyboard: Consume every key press
char k ;
while ( dequeueKey ( k )) handleUnlockKey ( k );
2026-10-02 13:49:16 +02:00
_next_refresh = 0 ; // redraw immediately after key press
2026-09-06 16:25:03 +02:00
} else 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().
2026-10-02 13:49:16 +02:00
_next_refresh = 0 ; // 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
2026-10-02 11:58:56 +02:00
if ( _locked ) { _lock_hint_ms = millis (); _next_refresh = 0 ; } // the hint; not on the waking press, so a glance shows the data
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-28 21:59:40 +02:00
if ( soundctl :: tick ( _node_prefs , buzzer , isClientConnected (), rtc_clock . getCurrentTime ())) // BLE bonded or an open USB port
2026-09-26 00:27:52 +02:00
_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-10-07 21:49:59 +02:00
// A cable plugged in or pulled out: redraw at once, so the bolt by the
// battery follows it instead of waiting for the screen's next refresh.
{ static bool ext = board . isExternalPowered ();
const bool now = board . isExternalPowered ();
if ( now != ext ) { ext = now ; _next_refresh = 0 ; } }
2026-06-30 01:54:01 +02:00
2025-08-08 20:01:31 +10:00
if ( _display != NULL && _display -> isOn ()) {
2026-10-09 08:48:03 -10:00
setCardKBBackground ( true ); // keep sampling the keyboard while the frame blocks
2026-09-06 16:25:03 +02:00
// Lock-screen password prompt
if ( _locked && _unlock_kb && ( int32_t )( millis () - _lock_wake_until ) >= 0 ) {
cancelUnlockPrompt (); // Cancel unlock attempt on idle
_next_refresh = 0 ;
}
if ( _locked && ! _unlock_kb && ( int32_t )( millis () - _lock_wake_until ) >= 0 ) {
2026-05-17 09:55:51 +02:00
_display -> turnOff ();
2026-10-02 13:49:16 +02:00
} else if ( _locked && _unlock_kb && refreshDue ()) {
2026-09-06 16:25:03 +02:00
// While the prompt is up the password keyboard replaces the lockscreen view
2026-10-01 22:01:53 +02:00
PERF_T0 ();
2026-09-06 16:25:03 +02:00
_display -> startFrame ();
2026-10-08 14:32:54 +02:00
_badge_in_bar = false ;
2026-09-06 16:25:03 +02:00
_kb . beginFrame ();
int delay_millis = _kb . render ( * _display );
2026-10-02 13:49:16 +02:00
if ( alertShowing ()) renderAlertOverlay (); // "Wrong PIN", and a ringing alarm
2026-10-01 22:01:53 +02:00
PERF_T1 ();
2026-09-06 16:25:03 +02:00
_display -> endFrame ();
2026-10-01 22:01:53 +02:00
PERF_T2 ();
2026-09-06 16:25:03 +02:00
_next_refresh = millis () + delay_millis ;
2026-10-08 14:32:54 +02:00
alertFrameCap ();
2026-10-02 13:49:16 +02:00
} else if ( _locked && refreshDue () && home ) {
2026-10-01 22:01:53 +02:00
PERF_T0 ();
2026-05-17 09:55:51 +02:00
_display -> startFrame ();
2026-10-08 14:32:54 +02:00
_badge_in_bar = false ;
2026-09-08 21:13:52 +02:00
if ( curr && curr != home && ( millis () - ui_started_at < BOOT_SCREEN_MILLIS )) {
2026-09-06 17:52:41 +02:00
// Boot splash is still up on a boot-locked device
_next_refresh = millis () + curr -> render ( * _display );
} else {
home -> render ( * _display );
_next_refresh = millis () + Features :: LOCKSCREEN_REFRESH_MS ;
}
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.
2026-10-07 16:35:45 +02:00
if ( alertShowing ()) {
renderAlertOverlay ();
2026-10-08 14:32:54 +02:00
alertFrameCap ();
2026-10-07 16:35:45 +02:00
}
2026-10-01 22:01:53 +02:00
PERF_T1 ();
2026-05-17 09:55:51 +02:00
_display -> endFrame ();
2026-10-01 22:01:53 +02:00
PERF_T2 ();
2026-10-02 13:49:16 +02:00
} else if ( ! _locked && refreshDue () && curr ) {
2026-10-01 22:01:53 +02:00
PERF_T0 ();
2025-08-08 20:01:31 +10:00
_display -> startFrame ();
2026-10-08 14:32:54 +02:00
_badge_in_bar = false ;
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.
2026-10-02 13:49:16 +02:00
if ( alertShowing () && ! _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 ;
2026-10-08 14:32:54 +02:00
alertFrameCap (); // the slide or the blink, a frame at a time
2026-10-02 13:49:16 +02:00
if (( int32_t )( _next_refresh - _alert_expiry ) > 0 ) _next_refresh = _alert_expiry ;
2025-08-08 20:01:31 +10:00
} else {
_next_refresh = millis () + delay_millis ;
}
2026-10-01 22:01:53 +02:00
PERF_T1 ();
2025-08-08 20:01:31 +10:00
_display -> endFrame ();
2026-10-01 22:01:53 +02:00
PERF_T2 ();
2025-03-04 23:09:43 +11:00
}
2026-10-09 08:48:03 -10:00
setCardKBBackground ( false );
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 ) {
2026-09-06 16:25:03 +02:00
cancelUnlockPrompt (); // idle-lock isn't a password prompt
2026-05-17 09:55:51 +02:00
_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-10-01 23:51:47 +02:00
|| ( curr == status_screen && (( StatusScreen * ) status_screen ) -> wantsLiveGps ())
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 ) {
2026-10-08 23:41:32 +02:00
the_mesh . noteUserInput ();
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 ;
}
2026-10-02 10:23:58 +02:00
void UITask :: toggleGPS ( bool announce ) {
if ( _node_prefs ) applyGpsState ( _node_prefs -> gps_enabled == 0 , announce );
2026-07-21 20:30:03 +02:00
}
// 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.
2026-10-02 10:23:58 +02:00
void UITask :: applyGpsState ( bool on , bool announce ) {
2026-09-25 19:20:42 +02:00
if ( ! _core -> setGpsEnabled ( on )) return ; // no GPS on this board
notify ( UIEventType :: ack );
2026-10-02 10:23:58 +02:00
if ( announce ) showAlert ( on ? "GPS: Enabled" : "GPS: Disabled" , 800 );
2026-09-25 19:20:42 +02:00
_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-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 ) {
2026-10-02 19:24:36 +02:00
#if FEAT_DISPLAY_ROTATION_SETTING && defined(DISPLAY_ROTATION)
// Each build is laid out for one shape and only turns it upside down: a
// rotation of the other shape (a fresh device's 0, another build's, or
// from before the split) comes back to this build's own.
uint8_t & rot = _node_prefs -> display_rotation ;
if (( rot ^ DISPLAY_ROTATION ) & 1 ) rot = DISPLAY_ROTATION & 3 ;
#endif
2026-05-20 21:53:40 +02:00
_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-10-10 16:36:39 -10:00
// Text size and Icon size (Settings > Display, NodePrefs ui_text_scale /
// ui_icon_scale), on the builds that offer them. Elsewhere the fields stay 0:
// the build's font scale and Auto icons, as stock.
void UITask :: applyUiScale () {
if ( ! _node_prefs ) return ;
#ifdef EINK_FONT_SCALE
if ( _display != NULL ) _display -> setUiScale ( _node_prefs -> ui_text_scale );
#endif
#if STATUS_ICONS_SCALED
statusIconScaleSetting () = _node_prefs -> ui_icon_scale ;
#endif
2026-10-09 08:48:02 -10:00
}
2026-09-28 23:19:47 +02:00
void UITask :: applySoundPrefs () {
#ifdef PIN_BUZZER
if ( _node_prefs ) setBuzzerVolumeLevel ( _node_prefs -> buzzer_volume ); // with a sample at the new level
#endif
2026-05-10 15:18:35 +02:00
}
2026-10-10 09:59:35 +02:00
// The companion CLI's settings keys (ui-core/SettingsCli.h).
bool UITask :: cliCommand ( const char * command , char * reply , int n ) {
if ( ! settingscli :: handle ( * _core , command , reply , n )) return false ;
_next_refresh = 0 ; // a screen showing the value draws it anew
return true ;
}
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 ();
2026-10-06 14:12:27 +02: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-10-06 14:12:27 +02:00
static const char * labels [] = { "Buzzer: On" , "Buzzer: Off" , "Buzzer: Auto" };
2026-05-12 20:12:54 +02:00
showAlert ( labels [ mode ], 800 );
_next_refresh = 0 ;
#endif
}