#include "UITask.h" #include "../ui-core/SoundNotifier.h" #include "../ui-core/SoundControl.h" #include "../ui-core/Telemetry.h" #include #include "../MyMesh.h" #include "../MsgExpand.h" #include "../Features.h" #include "../GeoUtils.h" #include "target.h" #include "CardKB.h" #ifdef WIFI_SSID #include #endif #ifdef SIM_PLATFORM #include #include #ifdef __EMSCRIPTEN__ #include // 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 #ifndef AUTO_OFF_MILLIS #define AUTO_OFF_MILLIS 15000 // 15 seconds #endif // 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 #define MESHCORE_VERSION "1.17.1" #endif #define BOOT_SCREEN_MILLIS 3000 // 3 seconds #ifdef PIN_STATUS_LED #define LED_ON_MILLIS 20 #define LED_ON_MSG_MILLIS 200 #define LED_CYCLE_MILLIS 4000 #endif #define LONG_PRESS_MILLIS 1200 #include "icons.h" #include "../ui-core/Lettering.h" // boot splash wordmark + lettering (shared with the L2) #include "GfxUtils.h" // gfx::drawLine — connects trail points on the Home map preview #include "SplashScreen.h" static const int QUICK_MSGS_MAX = 10; // 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 }; // ── 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). #include "FullscreenMsgView.h" #include "../ui-core/MessageText.h" #include "SensorPlaceholders.h" #include "../ui-core/UiCore.h" // shared UI Core: history rings + unread models (MessagesScreen views them) #include "../ui-core/SettingsSchema.h" // the settings both frontends share (SettingsScreen.h) #include "../ui-core/SettingsCli.h" // and their companion CLI keys #include "SettingsScreen.h" #include "MessagesScreen.h" // ── Custom screens (separate files to ease upstream merges) ─────────────────── #include "RingtoneEditorScreen.h" #include "BotScreen.h" #include "AdminScreen.h" #include "../ui-core/NearbyModel.h" #include "../ui-core/RadioControl.h" #include "NearbyScreen.h" #include "DashboardConfigScreen.h" #include "AutoAdvertScreen.h" #include "BatteryCurveScreen.h" #include "LiveShareScreen.h" #include "LocatorScreen.h" #include "TrailScreen.h" #include "CompassScreen.h" #include "StatusScreen.h" // Home › Status (was Tools › Diagnostics + Satellites) #include "RepeaterScreen.h" #if defined(PIN_GPIO1) #include "GpioScreen.h" #endif #include "ToolsScreen.h" #include "CalendarView.h" // the month under a tall clock, Clock tools › Calendar #include "ClockToolsScreen.h" // Alarm / Timer / Stopwatch / Calendar (Clock page › Enter) #include "../ui-core/Battery.h" #include "HomeScreen.h" void UITask::begin(DisplayDriver* display, SensorManager* sensors, NodePrefs* node_prefs) { _display = display; _sensors = sensors; _node_prefs = node_prefs; _kb.prefs = node_prefs; if (node_prefs) battery::useCurve(node_prefs->batt_curve_mv); // Settings > Battery curve uint32_t aoff = autoOffMillis(); _auto_off = millis() + (aoff > 0 ? aoff : AUTO_OFF_MILLIS); #if defined(SIM_PLATFORM) && defined(__EMSCRIPTEN__) g_sim_ui_task_for_js = this; // see sim_enqueue_key() below #endif #if defined(CARDKB_I2C) // On the ENV_PIN_SDA/SCL path, CARDKB_I2C is Wire1, already brought up by // sensors.begin() (EnvironmentSensorManager), which runs before this. On // 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. CARDKB_I2C.beginTransmission(0x5F); _has_cardkb = (CARDKB_I2C.endTransmission() == 0); #endif #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); // Handle booting with the cover already closed if (_hall_magnet_present) { _locked = true; syncLockToHome(); } #endif // 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; } #if defined(PIN_USER_BTN) user_btn.begin(); #endif #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 #if defined(PIN_USER_BTN_ANA) analog_btn.begin(); #endif if (_display != NULL) { _display->turnOn(); } #ifdef PIN_BUZZER soundctl::applyMode(_node_prefs, buzzer, false, rtc_clock.getCurrentTime()); buzzer.setVolume(_node_prefs->buzzer_volume); buzzer.begin(); #endif #ifdef PIN_VIBRATION vibration.begin(); #endif msgtext::seedQuick(_node_prefs); // "OK" in quick message 1 on first boot ui_started_at = millis(); _alert_expiry = 0; _batt_mv = UITaskBase::getBattMilliVolts(); // seed EMA with first reading _core = new UiCore(); // also loads the persisted waypoints // before any screen -- MessagesScreen binds to its history _core->begin(node_prefs, sensors, this); splash = new SplashScreen(this); home = new HomeScreen(this, &rtc_clock, sensors, node_prefs); syncLockToHome(); // booted locked (e.g. cover closed) → home starts on the LOCK page settings = new SettingsScreen(this, &_kb); messages_screen = new MessagesScreen(this, &_kb); tools_screen = new ToolsScreen(this); ringtone_edit = new RingtoneEditorScreen(this, node_prefs); bot_screen = new BotScreen(this, node_prefs, &_kb); admin_screen = new AdminScreen(this); nearby_screen = new NearbyScreen(this); dashboard_config = new DashboardConfigScreen(this, node_prefs); auto_advert_screen = new AutoAdvertScreen(this, node_prefs); batt_curve_screen = new BatteryCurveScreen(this, node_prefs); live_share_screen = new LiveShareScreen(this, node_prefs); locator_screen = new LocatorScreen(this, node_prefs); trail_screen = new TrailScreen(this, &_core->trail.store()); compass_screen = new CompassScreen(this); #if ENV_INCLUDE_GPS == 1 && defined(GPS_SKYVIEW) #endif status_screen = new StatusScreen(this); repeater_screen = new RepeaterScreen(this); clock_tools = new ClockToolsScreen(this, node_prefs); #if defined(PIN_GPIO1) gpio_screen = new GpioScreen(this, node_prefs); #endif startCardKBCapture(); // after every screen: an optional task must not starve one applyBrightness(); applyRotation(); applyFullRefreshInterval(); applyUiScale(); // Settings > Display > Text size / Icon size applyAllGpioModes(); // restore persisted pin modes to hardware before any UI/bot use // Locked above (a PIN, or the cover closed) before Home existed to be told. if (_locked) syncLockToHome(); setCurrScreen(splash); } // onShow() is invoked by setCurrScreen(), so most navigators are just that. void UITask::gotoSettingsScreen() { setCurrScreen(settings); } void UITask::gotoToolsScreen() { _tool_from_home = false; setCurrScreen(tools_screen); } void UITask::leaveTool() { if (_tool_from_home) gotoHomeScreen(); else gotoToolsScreen(); } void UITask::gotoBotScreen() { setCurrScreen(bot_screen); } void UITask::gotoNearbyScreen() { setCurrScreen(nearby_screen); } void UITask::pickAdminTarget() { setCurrScreen(nearby_screen); // runs NearbyScreen::onShow()'s reset first ((NearbyScreen*)nearby_screen)->startPickAdminTarget(); } void UITask::openAdminFor(const ContactInfo& ci, bool from_picker) { setCurrScreen(admin_screen); // runs AdminScreen::onShow()'s reset first ((AdminScreen*)admin_screen)->startFor(ci, from_picker); } void UITask::gotoDashboardConfig() { setCurrScreen(dashboard_config); } void UITask::gotoTrailScreen() { setCurrScreen(trail_screen); } void UITask::gotoCompassScreen() { setCurrScreen(compass_screen); } void UITask::gotoStatusScreen(uint8_t tab) { ((StatusScreen*)status_screen)->showTab(tab); setCurrScreen(status_screen); } void UITask::gotoRepeaterScreen() { setCurrScreen(repeater_screen); } void UITask::gotoClockTools() { setCurrScreen(clock_tools); } void UITask::gotoGpioScreen() { #if defined(PIN_GPIO1) setCurrScreen(gpio_screen); #endif } void UITask::gotoLiveShareScreen() { setCurrScreen(live_share_screen); } // ── Clock tools (engine in ui-core/ClockEngine.h) ─────────────────────────── // 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()). 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. if (_locked) _lock_wake_until = millis() + ClockEngine::RING_MS; _next_refresh = 0; // draw the alert overlay immediately } 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(); drainCoreEvents(); // Repeat the ring melody until dismissed or the ring window elapses. if (_core->clock.isRinging() && !isMelodyPlaying()) playMelody(soundctl::MEL_ALARM); } void UITask::drainCoreEvents() { UiEvent ev; while (_core->events.pop(ev)) { switch (ev.type) { case UiEventType::ClockAlert: wakeForAlarm(); showAlert(ev.text, ClockEngine::RING_MS); playMelody(soundctl::MEL_ALARM); break; case UiEventType::ClockRingEnded: stopMelody(); clearAlert(); break; case UiEventType::LiveShareEnded: showAlert("Live share ended", 2500); break; case UiEventType::LocatorCrossed: showAlert(ev.text, 3000); if (!isBuzzerQuiet()) playMelody(ev.flag ? soundctl::MEL_ARRIVE : soundctl::MEL_LEAVE); break; case UiEventType::LocatorBeep: playMelody(soundctl::MEL_TICK); break; case UiEventType::MessageArrived: onMessageArrived(ev); break; case UiEventType::AdvertHeard: notify(ev.flag ? UIEventType::advertReceivedFlood : UIEventType::advertReceivedZeroHop); break; default: break; } } } // 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). void UITask::gotoRingtoneEditor(int slot) { setCurrScreen(ringtone_edit); ((RingtoneEditorScreen*)ringtone_edit)->selectSlot(slot); } // Map is a sub-view variant of the Trail screen: reset via onShow(), then // switch into the map view. void UITask::gotoMapScreen() { setCurrScreen(trail_screen); ((TrailScreen*)trail_screen)->showMapView(); } void UITask::gotoLocatorScreen() { setCurrScreen(locator_screen); } void UITask::gotoAutoAdvertScreen() { setCurrScreen(auto_advert_screen); } void UITask::gotoBatteryCurve() { setCurrScreen(batt_curve_screen); } bool UITask::startPing(const uint8_t* pub_key) { 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; } 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(); } 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 } void UITask::gotoMessagesScreen() { ((MessagesScreen*)messages_screen)->reset(); setCurrScreen(messages_screen); } #ifdef SIM_PLATFORM 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); } // 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; } } } // 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) * 1e6f); c->gps_lon = (int32_t)((19.9372f + N[i].dlo) * 1e6f); c->lastmod = now - i * 600; } } setCurrScreen(nearby_screen); } void UITask::simDisplay(bool on) { if (!_display) return; if (on) _display->turnOn(); else _display->turnOff(); _next_refresh = 0; } void UITask::simCompose(const char* text) { gotoMessagesScreen(); ((MessagesScreen*)messages_screen)->simCompose(text); } #endif void UITask::openContactDM(const ContactInfo& ci) { ((MessagesScreen*)messages_screen)->reset(); ((MessagesScreen*)messages_screen)->enterDM(ci); setCurrScreen(messages_screen); } 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); } void UITask::shareToMessage(const char* text) { ((MessagesScreen*)messages_screen)->startShare(text); setCurrScreen(messages_screen); } void UITask::pickLocShareTarget() { ((MessagesScreen*)messages_screen)->startPickTarget(); setCurrScreen(messages_screen); } void UITask::pickFavouriteTarget(int slot) { ((MessagesScreen*)messages_screen)->startPickFavourite(slot); setCurrScreen(messages_screen); } void UITask::pickBotChannelTarget() { ((MessagesScreen*)messages_screen)->startPickBotChannel(); setCurrScreen(messages_screen); } void UITask::pickBotRoomTarget() { ((MessagesScreen*)messages_screen)->startPickBotRoom(); setCurrScreen(messages_screen); } // ── UI Core wiring ────────────────────────────────────────────────────────── MyMesh::Listener* UITask::meshListener() { return _core; } // On screen: a conversation left open under the lock or a dark display isn't read. bool UITask::messagesInView() const { return curr == messages_screen && _display && _display->isOn() && !_locked; } bool UITask::isViewingChannel(uint8_t channel_idx) { return messagesInView() && ((MessagesScreen*)messages_screen)->isViewingChannel(channel_idx); } bool UITask::isViewingDM(const uint8_t* pub_key) { return messagesInView() && ((MessagesScreen*)messages_screen)->isViewingDM(pub_key); } void UITask::onViewedHistoryGrew(bool channel) { ((MessagesScreen*)messages_screen)->onViewedHistoryGrew(channel); } int UITask::getMsgCount() const { return _core->msgCount(); } int UITask::getRoomUnreadCount() const { return _core->roomUnread(); } void UITask::clearRoomUnread() { _core->clearRoomUnread(); } int UITask::getChannelUnreadCount() const { return ((MessagesScreen*)messages_screen)->getTotalChannelUnread(); } uint8_t UITask::getChannelUnread(uint8_t channel_idx) const { return ((MessagesScreen*)messages_screen)->chUnread(channel_idx); } 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(); } void UITask::onRoomLoginResult(const uint8_t* pub_key, bool success, uint8_t permissions) { // 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); // 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; } void UITask::onAdminStateChanged() { _next_refresh = 0; // same reasoning as onRoomLoginResult above } 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(); } void UITask::showAlert(const char* text, int duration_millis) { snprintf(_alert, sizeof(_alert), "%s", text); if (!alertShowing() || _alert_badge) _alert_t0 = millis(); // a banner already down only changes its text _alert_badge = false; _alert_expiry = (millis() + duration_millis) | 1; // 0 means none } void UITask::notify(UIEventType t) { #if defined(PIN_BUZZER) { 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); _last_notif_dm_valid = false; break; case UIEventType::channelMessage: sn.playCH(_last_notif_ch_idx); _last_notif_ch_idx = -1; break; 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; case UIEventType::advertReceivedFlood: case UIEventType::advertReceivedZeroHop: sn.playAD(t == UIEventType::advertReceivedFlood); break; case UIEventType::ack: buzzer.play("ack:d=32,o=8,b=120:c"); break; case UIEventType::none: default: break; } } #endif #ifdef PIN_VIBRATION // Trigger vibration for all UI events except none if (t != UIEventType::none) { vibration.trigger(); } #endif } // 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); _last_notif_dm_valid = true; } if (ev.kind == UIEventType::channelMessage) _last_notif_ch_idx = ev.idx; // 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))); 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) { char alert_buf[80]; snprintf(alert_buf, sizeof(alert_buf), "Msg: %.20s", ev.text); showAlert(alert_buf, 2000); } if (_display != NULL && !_locked) { bool wake_disabled = _node_prefs && (!_node_prefs->msg_wake || inQuietHours(*_node_prefs, rtc_clock.getCurrentTime())); 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 _display->turnOn(); } if (_display->isOn()) { uint32_t aoff = autoOffMillis(); if (aoff > 0) _auto_off = millis() + aoff; _next_refresh = 0; } } notify(ev.kind); } void UITask::userLedHandler() { #ifdef PIN_STATUS_LED unsigned long cur_time = millis(); if (cur_time > next_led_change) { if (led_state == 0) { led_state = 1; if (_core->msgCount() > 0) { last_led_increment = LED_ON_MSG_MILLIS; } else { last_led_increment = LED_ON_MILLIS; } next_led_change = cur_time + last_led_increment; } else { led_state = 0; next_led_change = cur_time + LED_CYCLE_MILLIS - last_led_increment; } digitalWrite(PIN_STATUS_LED, led_state == LED_STATE_ON); } #endif } // 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 // render path, same contract as the message views. void UITask::renderAlertOverlay() { if (_alert_badge) { renderMsgBadge(); return; } _display->setTextSize(1); const int lh = _display->getLineHeight(); const int pad = 3; const int W = _display->width(); _display->translateUTF8ToBlocks(s_wrap_trans, _alert, sizeof(s_wrap_trans)); int nl = FullscreenMsgView::wrapLines(*_display, s_wrap_trans, W - pad * 2 - 2, s_wrap_lines, 3); if (nl < 1) nl = 1; 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); for (int i = 0; i < nl; i++) _display->drawTextCentered(W / 2, y + pad + i * lh, s_wrap_lines[i]); } // 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); } void UITask::setCurrScreen(UIScreen* c) { // 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; curr = c; c->onShow(); // central per-visit reset hook (see UIScreen::onShow) _next_refresh = 0; } void UITask::syncLockToHome() { // Lock/unlock switches the home screen's page to LOCK (or back) if (home) static_cast(home)->setLocked(_locked); } bool UITask::passwordLockEnabled() const { return _node_prefs && screenlock::isSet(*_node_prefs); } void UITask::setNodeLockPassword(const char* plain) { if (_node_prefs) screenlock::set(*_node_prefs, plain, the_mesh.getRNG()); } void UITask::beginUnlockPrompt() { _unlock_kb = true; // Track that keyboard is visible and is waiting for input _kb.beginPin("", KeyboardWidget::PIN_MAX_LEN, true, "PIN"); // masked _kb.clearPlaceholders(); _lock_wake_until = millis() + 5000; // keep the display on while typing _next_refresh = 0; } void UITask::cancelUnlockPrompt() { _unlock_kb = false; _kb.pin_mode = false; _kb.buf[0] = '\0'; // clear input _kb.len = 0; _kb.cursor_pos = 0; _next_refresh = 0; } void UITask::handleUnlockKey(char c) { auto res = _kb.handleInput(c); 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)) { // Match: Unlock _pin_tries.ok(); _unlock_kb = false; _kb.pin_mode = false; _locked = false; if (_display && !_display->isOn()) _display->turnOn(); uint32_t aoff = autoOffMillis(); if (aoff > 0) _auto_off = millis() + aoff; syncLockToHome(); } else { // 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); _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; } bool UITask::savePrefsIfDirty(bool& dirty) { if (!dirty) return false; the_mesh.savePrefs(); dirty = false; return true; } /* hardware-agnostic pre-shutdown activity should be done here */ void UITask::shutdown(bool restart){ // 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(); the_mesh.saveRTCTime(); the_mesh.flushDirtyContacts(); // Auto-save the live GPS trail if enabled (covers low-battery auto-shutdown). _core->trail.onShutdown(); #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(); #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 uint32_t buzzer_timer = millis(); // fail-safe shutdown while (buzzer.isPlaying() && (millis() - buzzer_timer) < 2500) buzzer.loop(); #endif #endif // PIN_BUZZER if (restart) { _board->reboot(); } else { _display->turnOff(); radio_driver.powerOff(); // 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(); } _board->powerOff(); } } bool UITask::isButtonPressed() const { #ifdef PIN_USER_BTN return user_btn.isPressed(); #else return false; #endif } 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; } #if defined(SIM_PLATFORM) && defined(__EMSCRIPTEN__) void UITask::injectSimKey(char c) { enqueueKey(checkDisplayOn(c)); } void UITask::injectSimKeyLongPress(char c) { enqueueKey(handleLongPress(c)); } // 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); } // 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); } // 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); } extern "C" EMSCRIPTEN_KEEPALIVE void sim_nodes() { if (g_sim_ui_task_for_js) g_sim_ui_task_for_js->simNodes(); } // 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); } // 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); } // 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); } #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; } // Write side: a host page's own mute control (a button next to the d-pad, // say) calling this fires UITask::toggleBuzzer() -- the literal function // the real on-device Settings > Buzzer mute toggle calls, not a // re-implementation of it. That single call already does everything the // real toggle does: buzzer.quiet(), writes NodePrefs.buzzer_quiet, clears // buzzer_auto (manual mute always wins over auto-mute-on-BT-connect, // same as pressing it on the device would), the_mesh.savePrefs(), and // the real on-screen "Buzzer: On/Off" alert -- so muting from the host // page's button is visibly the same event as muting from the keypad. extern "C" EMSCRIPTEN_KEEPALIVE void sim_buzzer_toggle_quiet() { if (g_sim_ui_task_for_js) g_sim_ui_task_for_js->toggleBuzzer(); } extern "C" EMSCRIPTEN_KEEPALIVE int sim_buzzer_get_quiet() { return (g_sim_ui_task_for_js && g_sim_ui_task_for_js->isBuzzerQuiet()) ? 1 : 0; } #endif #endif 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; } // 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. // // 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. void UITask::pollHallSensor() { #if defined(PIN_HALL_SENSOR) 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; _hall_magnet_present = present; if (present) { // cover closed cancelUnlockPrompt(); // a cover can't be over the password prompt _locked = true; syncLockToHome(); _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 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; } } _next_refresh = 0; #endif } #include "PerfL1.h" void UITask::loop() { #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; 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", 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), sizeof(StatusScreen), sizeof(RepeaterScreen), sizeof(ClockToolsScreen), sizeof(KeyboardWidget), sizeof(UITask), 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; case perfl1::STATUS: gotoStatusScreen(StatusScreen::TAB_RADIO); break; 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 pollConnection(); // BLE link state -> hasConnection() (see UITaskBase) drainCoreEvents(); // react to what the Core filed during mesh processing (alerts, wake, sounds) { // a conversation in view again (display on, unlocked): on to what came in meanwhile bool on = messagesInView(); if (on && !_display_was_on) ((MessagesScreen*)messages_screen)->onDisplayOn(); _display_was_on = on; } if (status_screen) ((StatusScreen*)status_screen)->sample(); // Status history lines, once a minute #if UI_HAS_JOYSTICK uint8_t joy_rot = _node_prefs ? _node_prefs->joystick_rotation : JOYSTICK_ROTATION; #if FEAT_DISPLAY_ROTATION_SETTING && defined(JOYSTICK_UPRIGHT_ROTATION) if (_node_prefs) joy_rot += (JOYSTICK_UPRIGHT_ROTATION - _node_prefs->display_rotation) & 3; #endif int ev = user_btn.check(); if (ev == BUTTON_EVENT_CLICK) { if (back_btn.isPressed() && !_unlock_kb) { // Enter clicked while Back is held — lock/unlock sequence if (_display && !_display->isOn()) { _display->turnOn(); // turn on display so hints are visible } _lock_wake_until = millis() + 5000; // keep display on during sequence if (millis() - _lock_seq_ms > 3000) _lock_seq_count = 0; // timeout reset _lock_seq_count++; _lock_seq_ms = millis(); _next_refresh = 0; // update hint immediately on each press if (_lock_seq_count >= 3) { _lock_seq_count = 0; _lock_seq_used = true; // suppress Back release click toggleLock(); } // eat the Enter — don't pass to curr } else { // While the password keyboard is open, Back+Enter is just typing if (_unlock_kb) { _lock_seq_count = 0; _lock_seq_ms = 0; } enqueueKey(checkDisplayOn(KEY_ENTER)); } } else if (ev == BUTTON_EVENT_LONG_PRESS) { enqueueKey(handleLongPress(KEY_ENTER)); // REVISIT: could be mapped to different key code } // 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()). #if UI_HAS_JOYSTICK_UPDOWN 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))); #endif 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; } } 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; } } if (_lock_seq_used && millis() - _lock_seq_ms > 5000) { _lock_seq_used = false; // safety reset if Back release event was missed } ev = back_btn.check(); if (ev == BUTTON_EVENT_CLICK) { if (_lock_seq_count > 0 || _lock_seq_used) { // Back released mid-sequence or after completing it — cancel/suppress _lock_seq_count = 0; _lock_seq_used = false; } else { enqueueKey(checkDisplayOn(KEY_CANCEL)); } } else if (ev == BUTTON_EVENT_TRIPLE_CLICK) { if (!_locked) enqueueKey(handleTripleClick(KEY_SELECT)); } #elif defined(PIN_USER_BTN) int ev = user_btn.check(); if (ev == BUTTON_EVENT_CLICK) { enqueueKey(checkDisplayOn(KEY_NEXT)); } else if (ev == BUTTON_EVENT_LONG_PRESS) { enqueueKey(handleLongPress(KEY_ENTER)); } else if (ev == BUTTON_EVENT_DOUBLE_CLICK) { enqueueKey(handleDoubleClick(KEY_PREV)); } else if (ev == BUTTON_EVENT_TRIPLE_CLICK) { if (!_locked) enqueueKey(handleTripleClick(KEY_SELECT)); } #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. // // stdin is put into raw/non-canonical mode by // 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)); } } } #endif #if defined(PIN_USER_BTN_ANA) if (millis() - _analogue_pin_read_millis > 10) { int ev = analog_btn.check(); if (ev == BUTTON_EVENT_CLICK) { enqueueKey(checkDisplayOn(KEY_NEXT)); } else if (ev == BUTTON_EVENT_LONG_PRESS) { enqueueKey(handleLongPress(KEY_ENTER)); } else if (ev == BUTTON_EVENT_DOUBLE_CLICK) { enqueueKey(handleDoubleClick(KEY_PREV)); } else if (ev == BUTTON_EVENT_TRIPLE_CLICK) { if (!_locked) enqueueKey(handleTripleClick(KEY_SELECT)); } _analogue_pin_read_millis = millis(); } #endif pollCardKB(); pollHallSensor(); #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: checkDisplayOn(k); // wake/extend same as every other key here, even though Home has no nav action #ifdef LILYGO_TECHO_LITE_KEYSHIELD extern void techo_keyshield_backlight_toggle(); techo_keyshield_backlight_toggle(); #endif break; default: enqueueKey(checkDisplayOn(k)); break; } } } #endif #if defined(BACKLIGHT_BTN) if ((int32_t)(millis() - next_backlight_btn_check) >= 0) { bool touch_state = digitalRead(PIN_BUTTON2); #if defined(DISP_BACKLIGHT) digitalWrite(DISP_BACKLIGHT, !touch_state); #elif defined(EXP_PIN_BACKLIGHT) expander.digitalWrite(EXP_PIN_BACKLIGHT, !touch_state); #endif next_backlight_btn_check = millis() + 300; } #endif // A ringing alarm/timer is dismissed by ANY key, even when locked or on another // screen — and the queued keys are swallowed so they don't also act on the view. if (_kq_head != _kq_tail && isRinging()) { dismissRing(); _kq_head = _kq_tail = 0; _next_refresh = 0; // 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; } if (_kq_head != _kq_tail) { if (_unlock_kb) { // Lock-screen password keyboard: Consume every key press char k; while (dequeueKey(k)) handleUnlockKey(k); _next_refresh = 0; // redraw immediately after key press } else if (!_locked && curr) { // 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); { uint32_t aoff = autoOffMillis(); if (aoff > 0) _auto_off = millis() + aoff; } // extend auto-off timer // Note timing no longer depends on render cadence (TIMER1 IRQ advances // notes directly — see buzzer.cpp), so a redraw right after a keypress // can't clip a note; no need to hold it back while buzzer.isPlaying(). _next_refresh = 0; // trigger refresh immediately } else { _kq_head = _kq_tail = 0; // locked or no screen: eat all queued keys // Locked: wake window is set only when display first turns on if (_locked) { _lock_hint_ms = millis(); _next_refresh = 0; } // the hint; not on the waking press, so a glance shows the data } } userLedHandler(); #ifdef PIN_BUZZER if (soundctl::tick(_node_prefs, buzzer, isClientConnected(), rtc_clock.getCurrentTime())) // BLE bonded or an open USB port _next_refresh = 0; if (buzzer.isPlaying()) buzzer.loop(); #endif if (curr) curr->poll(); // 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; } } if (_display != NULL && _display->isOn()) { setCardKBBackground(true); // keep sampling the keyboard while the frame blocks // 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) { _display->turnOff(); } else if (_locked && _unlock_kb && refreshDue()) { // While the prompt is up the password keyboard replaces the lockscreen view PERF_T0(); _display->startFrame(); _badge_in_bar = false; _kb.beginFrame(); int delay_millis = _kb.render(*_display); if (alertShowing()) renderAlertOverlay(); // "Wrong PIN", and a ringing alarm PERF_T1(); _display->endFrame(); PERF_T2(); _next_refresh = millis() + delay_millis; alertFrameCap(); } else if (_locked && refreshDue() && home) { PERF_T0(); _display->startFrame(); _badge_in_bar = false; if (curr && curr != home && (millis() - ui_started_at < BOOT_SCREEN_MILLIS)) { // 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; } // Alert overlay on top — without this a ringing alarm on a locked device // played its melody against a screen that never said what was ringing. if (alertShowing()) { renderAlertOverlay(); alertFrameCap(); } PERF_T1(); _display->endFrame(); PERF_T2(); } else if (!_locked && refreshDue() && curr) { PERF_T0(); _display->startFrame(); _badge_in_bar = false; _kb.beginFrame(); int delay_millis = curr->render(*_display); // Skip the alert overlay (new-message toast) while the keyboard is the // thing actually on screen this frame -- it's shared across Messages/ // Bot/Settings/Admin/etc., so this covers every screen that uses it for // full-screen text entry, not just message compose. Otherwise a message // arriving mid-typing blanks out the letter grid for 3s with no way to // see what's being typed. if (alertShowing() && !_kb.isVisible()) { // alert overlay on top of any (non-keyboard) screen renderAlertOverlay(); // 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; alertFrameCap(); // the slide or the blink, a frame at a time if ((int32_t)(_next_refresh - _alert_expiry) > 0) _next_refresh = _alert_expiry; } else { _next_refresh = millis() + delay_millis; } PERF_T1(); _display->endFrame(); PERF_T2(); } setCardKBBackground(false); #if AUTO_OFF_MILLIS > 0 #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 if (!_locked && autoOffMillis() > 0 && (int32_t)(millis() - _auto_off) >= 0 && !isRinging()) { _display->turnOff(); #ifdef PIN_LED digitalWrite(PIN_LED, LOW); // turn off status LED with display to save power #endif if (_node_prefs && _node_prefs->auto_lock) { cancelUnlockPrompt(); // idle-lock isn't a password prompt _locked = true; _lock_wake_until = 0; syncLockToHome(); } } #endif } #ifdef PIN_VIBRATION vibration.loop(); #endif if ((int32_t)(millis() - next_batt_chck) >= 0) { uint16_t raw = UITaskBase::getBattMilliVolts(); if (raw > 0) { #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 // 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); #endif } uint16_t low_mv = _node_prefs ? _node_prefs->low_batt_mv : 0; // 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()) { if (_display != NULL) { _display->startFrame(); _display->setTextSize(1); _display->setColor(DisplayDriver::LIGHT); 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"); _display->endFrame(); #ifdef SIM_PLATFORM // Skip the pre-shutdown UX pause in the sim. #else if (_display->isEink() == false) { delay(2000); } #endif } shutdown(); } #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 next_batt_chck = millis() + 8000; #endif } // 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 = _core->trail.isRecording() || (_node_prefs && _node_prefs->loc_share_enabled) || (_node_prefs && _node_prefs->locator_enabled && _node_prefs->locator_has_target) || curr == compass_screen || (curr == status_screen && ((StatusScreen*)status_screen)->wantsLiveGps()) || (curr == nearby_screen && ((NearbyScreen*)nearby_screen)->isNavigating()) || (curr == trail_screen && ((TrailScreen*)trail_screen)->wpNeedsLiveGps()) || (curr == messages_screen && ((MessagesScreen*)messages_screen)->navActive()) || the_mesh.isGpsFixPending(); _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(); } // UI Core engines (alarm + countdown, COG, live share, locator, trail, …) run // 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(); } // Locator (engine in ui-core/LocatorEngine.h). void UITask::resetLocator() { _core->locator.reset(); } bool UITask::resolvePersonPos(const uint8_t* key, int32_t& lat, int32_t& lon, bool* live, uint32_t* ts) const { return _core->locator.resolvePersonPos(key, lat, lon, live, ts); } bool UITask::activeTargetPos(int32_t& lat, int32_t& lon) const { return _core->locator.activeTargetPos(lat, lon); } void UITask::setTarget(uint8_t kind, const uint8_t* key, int32_t lat, int32_t lon, const char* name) { _core->locator.setTarget(kind, key, lat, lon, name); } 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); } void UITask::clearTarget() { _core->locator.clearTarget(); } void UITask::clearTargetIfWaypoint(int32_t lat_1e6, int32_t lon_1e6) { _core->locator.clearTargetIfWaypoint(lat_1e6, lon_1e6); } // 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). TrailStore& UITask::trail() { return _core->trail.store(); } 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); } 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(); } // 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); } WaypointStore& UITask::waypoints() { return _core->waypoints.store(); } void UITask::saveWaypoints() { _core->waypoints.save(); } bool UITask::addWaypoint(int32_t lat, int32_t lon, uint32_t ts, const char* label) { if (_core->waypoints.full()) { showAlert("Waypoints full", 1000); return false; } if (_core->waypoints.add(lat, lon, ts, label)) { 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); } char UITask::checkDisplayOn(char c) { the_mesh.noteUserInput(); if (_display != NULL) { if (!_display->isOn()) { _display->turnOn(); #ifdef PIN_LED digitalWrite(PIN_LED, LOW); // ensure LED is off when waking display (userLedHandler takes over) #endif if (_locked) { _lock_wake_until = millis() + 5000; _next_refresh = 0; return 0; // eat the waking key press } _lock_seq_count = 0; _lock_seq_used = false; c = 0; } if (!_locked) { uint32_t aoff = autoOffMillis(); if (aoff > 0) _auto_off = millis() + aoff; // extend auto-off timer } _next_refresh = 0; // trigger refresh } return c; } char UITask::handleLongPress(char c) { // 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; if (millis() - ui_started_at < 8000) { // long press in first 8 seconds since startup -> CLI/rescue the_mesh.enterCLIRescue(); return 0; } if (c == KEY_ENTER) return KEY_CONTEXT_MENU; return c; } char UITask::handleDoubleClick(char c) { MESH_DEBUG_PRINTLN("UITask: double-click triggered"); checkDisplayOn(c); return c; } char UITask::handleTripleClick(char c) { checkDisplayOn(c); toggleBuzzer(); return 0; } bool UITask::getGPSState() { if (_sensors != NULL) { int num = _sensors->getNumSettings(); for (int i = 0; i < num; i++) { if (strcmp(_sensors->getSettingName(i), "gps") == 0) { return !strcmp(_sensors->getSettingValue(i), "1"); } } } return false; } 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; } void UITask::toggleGPS(bool announce) { if (_node_prefs) applyGpsState(_node_prefs->gps_enabled == 0, announce); } // Sets GPS to an absolute state (vs. toggleGPS()'s flip) -- shared by the // Home-page manual toggle and the bot's !gps on/off command, which needs to // set a specific state rather than flip whatever it currently is. void UITask::applyGpsState(bool on, bool announce) { if (!_core->setGpsEnabled(on)) return; // no GPS on this board notify(UIEventType::ack); if (announce) showAlert(on ? "GPS: Enabled" : "GPS: Disabled", 800); _next_refresh = 0; } 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 // 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. 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 } void UITask::applyTxPower() { radioctl::applyTxPower(_node_prefs); } void UITask::applyPowerSave() { rptctl::applyPowerSave(_node_prefs); // forced off while repeating (ui-core/RepeaterControl.h) } void UITask::applyApc() { radioctl::applyApc(); } void UITask::applyRadioParams() { if (_node_prefs == NULL) return; radioctl::applyParams(); // companion params, or the repeater profile if relaying with one set } void UITask::applyBrightness() { if (_display != NULL && _node_prefs != NULL) { _display->setBrightness(_node_prefs->display_brightness); } } void UITask::applyRotation() { if (_display != NULL && _node_prefs != NULL) { #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 _display->setDisplayRotation(_node_prefs->display_rotation); _next_refresh = 0; } } 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]); } } // 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 } void UITask::applySoundPrefs() { #ifdef PIN_BUZZER if (_node_prefs) setBuzzerVolumeLevel(_node_prefs->buzzer_volume); // with a sample at the new level #endif } // 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; } void UITask::setBuzzerVolumeLevel(uint8_t level) { #ifdef PIN_BUZZER if (_node_prefs == NULL) return; soundctl::setVolume(_node_prefs, buzzer, level); _next_refresh = 0; #endif } void UITask::toggleBuzzer() { #ifdef PIN_BUZZER 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); the_mesh.savePrefs(); showAlert(buzzer.isQuiet() ? "Buzzer: Off" : "Buzzer: On", 800); _next_refresh = 0; #endif } int UITask::getBuzzerMode() { #ifdef PIN_BUZZER return soundctl::mode(_node_prefs); #else return 1; #endif } void UITask::cycleBuzzerMode() { #ifdef PIN_BUZZER if (!_node_prefs) return; int mode = getBuzzerMode(); 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); static const char* labels[] = { "Buzzer: On", "Buzzer: Off", "Buzzer: Auto" }; showAlert(labels[mode], 800); _next_refresh = 0; #endif }