Files
trail-mate/src/main.cpp
T
vicliuandGitHub e429db9797 Feature ble (#10)
* Implement TDeck touch-friendly home grid and power management updates

* Add initial M5Stack Tab5 ESP-IDF environment and board skeleton

* BLE/Meshtastic: apply config at runtime and fix Admin/Telemetry for app compatibility

Meshtastic Admin set_* immediate responses:
- After set_channel: respond with get_channel_response (echo applied channel).
- After set_config: respond with get_config_response for the config type that was set.
- After set_module_config: respond with get_module_config_response after saveModuleConfig().
- After store_ui_config: respond with get_ui_config_response.
This prevents the Meshtastic app from marking these operations as failed when the device applied config but did not send a reply.

DeviceMetrics and Telemetry:
- Add buildDeviceMetrics() in meshtastic_ble (battery from board, uptime_seconds from millis).
- Set has_device_metrics and device_metrics in buildSelfNodeInfo() so the app receives battery and uptime for the local node.
- Give Telemetry module config explicit defaults in loadModuleConfig() (device_update_interval, device_telemetry_enabled, etc.) so get_module_config returns a valid structure.

Apply config at runtime (so BLE-written config takes effect):
- Add AppContext::applyPositionConfig() to push gps_interval_ms and gps_mode to GpsService (setCollectionInterval, setGnssConfig). Call it from BLE set_config when position config is written.
- Add screen_sleep.h with getScreenSleepTimeout() / setScreenSleepTimeout() declarations (implemented in main.cpp). BLE display, device_ui, and store_ui_config now call setScreenSleepTimeout() instead of writing Preferences only, so the in-memory screen timeout updates immediately without reboot.
- Settings UI includes screen_sleep.h and uses the same API.

Other:
- BLE topbar asset and icon when BLE is enabled (ble_topbar.png/c, ui_status, LV_Helper).
- Node store: PersistedNodeEntry and INodeStore gain hw_model; mt_adapter and contact_service pass/store it for Meshtastic app compatibility.
- Variant env tweaks (tlora_pager, tdeck). Event bus and minor cleanups.

* feat: align Meshtastic BLE/iOS flow and add T-Deck keyboard support

- add BLE pairing popup and align more Meshtastic admin/status behavior with iOS expectations
- complete BLE MQTT client proxy bridge and persist OK-to-MQTT / ignore-MQTT channel settings
- add native T-Deck I2C keyboard input, backlight control, and SPACE-to-menu behavior
- fix chat bubble wrapping, contact removal sync, and pager wake/menu regressions

* fix: restore lilygo_twatch_s3 build

- remove duplicate M5Tab5Board isGPSReady declaration
- exclude M5Tab5Board.cpp from T-Watch S3 source filters to avoid duplicate board singletons
- verified with pio run -e lilygo_twatch_s3
2026-03-06 21:01:35 +08:00

1762 lines
59 KiB
C++
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#if defined(ARDUINO_T_DECK)
#include "board/TDeckBoard.h"
#elif defined(ARDUINO_T_WATCH_S3)
#include "board/TWatchS3Board.h"
#else
#include "board/TLoRaPagerBoard.h"
#endif
#include "freertos/FreeRTOS.h"
#include "freertos/semphr.h"
#include "freertos/task.h"
#include "ui/LV_Helper.h"
#include <Preferences.h>
#include <cmath>
#include <cstring>
#include <ctime>
#include <esp_sleep.h>
#include "app/app_context.h"
#include "display/DisplayConfig.h"
#include "ui/app_screen.h"
#include "ui/assets/images.h"
#include "ui/ui_boot.h"
#include "ui/ui_common.h"
#include "ui/ui_status.h"
#include "ui/ui_theme.h"
#include "ui/watch_face.h"
#include "ui/widgets/system_notification.h"
// Custom app icons generated as C images (RGB565A8)
extern "C"
{
extern const lv_image_dsc_t gps_icon;
extern const lv_image_dsc_t Satellite;
extern const lv_image_dsc_t Chat;
extern const lv_image_dsc_t Setting;
extern const lv_image_dsc_t contact;
extern const lv_image_dsc_t team_icon;
extern const lv_image_dsc_t tracker_icon;
extern const lv_image_dsc_t rf;
extern const lv_image_dsc_t sstv;
extern const lv_image_dsc_t Spectrum;
#if defined(ARDUINO_LILYGO_LORA_SX1262) && defined(USING_AUDIO_CODEC)
extern const lv_image_dsc_t walkie_talkie;
#endif
// Note: img_usb is already declared in images.h (C++ linkage)
}
// Debug switch for performance timing logs
// Set to 1 to enable [MAIN] timing logs, 0 to disable
#define MAIN_TIMING_DEBUG 0
#ifndef HAS_GPS
#define HAS_GPS 1
#endif
#ifndef HAS_SD
#define HAS_SD 1
#endif
#ifndef HAS_PSRAM
#define HAS_PSRAM 1
#endif
// Forward declarations for app entry functions (implemented in ui_*.cpp)
void ui_gps_enter(lv_obj_t* parent);
void ui_gps_exit(lv_obj_t* parent);
void ui_gnss_skyplot_enter(lv_obj_t* parent);
void ui_gnss_skyplot_exit(lv_obj_t* parent);
void ui_chat_enter(lv_obj_t* parent);
void ui_chat_exit(lv_obj_t* parent);
void ui_contacts_enter(lv_obj_t* parent);
void ui_contacts_exit(lv_obj_t* parent);
void ui_team_enter(lv_obj_t* parent);
void ui_team_exit(lv_obj_t* parent);
void ui_tracker_enter(lv_obj_t* parent);
void ui_tracker_exit(lv_obj_t* parent);
void ui_setting_enter(lv_obj_t* parent);
void ui_setting_exit(lv_obj_t* parent);
void ui_pc_link_enter(lv_obj_t* parent);
void ui_pc_link_exit(lv_obj_t* parent);
void ui_sstv_enter(lv_obj_t* parent);
void ui_sstv_exit(lv_obj_t* parent);
void ui_energy_sweep_enter(lv_obj_t* parent);
void ui_energy_sweep_exit(lv_obj_t* parent);
#if defined(ARDUINO_LILYGO_LORA_SX1262) && defined(USING_AUDIO_CODEC)
void ui_walkie_talkie_enter(lv_obj_t* parent);
void ui_walkie_talkie_exit(lv_obj_t* parent);
#endif
#ifdef ARDUINO_USB_MODE
void ui_usb_enter(lv_obj_t* parent);
void ui_usb_exit(lv_obj_t* parent);
#endif
// GPS data access - now provided by GpsService
// Use gps::GpsState and gps::GpsService::getInstance().getData()
// Forward declaration for user activity update (for screen sleep management)
void updateUserActivity();
// Forward declaration to check if screen is sleeping
bool isScreenSleeping();
#include "screen_sleep.h"
// Forward declaration to disable/enable screen sleep (for USB mode, etc.)
void disableScreenSleep();
void enableScreenSleep();
bool isScreenSleepDisabled();
bool isScreenSaverActive();
void wakeScreenSaver();
void enterFromScreenSaver();
// GPS data access - now provided by GpsService
// Use gps::GpsService::getInstance().getData()
// Use gps::GpsService::getInstance().getCollectionInterval()/setCollectionInterval()
// Factory-style menu structure (global for ui_*.cpp access)
lv_obj_t* main_screen = nullptr;
lv_obj_t* menu_panel = nullptr;
lv_obj_t* app_panel = nullptr;
lv_group_t* menu_g = nullptr;
lv_group_t* app_g = nullptr;
lv_obj_t* desc_label = nullptr;
lv_obj_t* time_label = nullptr; // Time display label at top left of menu
lv_obj_t* battery_label = nullptr; // Battery display label at top right of menu
lv_obj_t* node_id_label = nullptr; // Node ID label at bottom left of menu
namespace
{
bool format_menu_time(char* out, size_t out_len)
{
if (!out || out_len < 6)
{
return false;
}
time_t now = time(nullptr);
if (now <= 0)
{
return false;
}
time_t local = ui_apply_timezone_offset(now);
struct tm* info = gmtime(&local);
if (!info)
{
return false;
}
strftime(out, out_len, "%H:%M", info);
return true;
}
#if defined(ARDUINO_LILYGO_LORA_SX1262)
constexpr int kLowBatteryWarnPercent = 15;
constexpr int kCriticalBatteryPercent = 5;
constexpr uint8_t kCriticalStreakToShutdown = 2;
constexpr uint32_t kLowBatteryWarnCooldownMs = 15 * 60 * 1000;
constexpr uint32_t kCriticalBatteryWarnCooldownMs = 5 * 60 * 1000;
constexpr uint32_t kCriticalShutdownDelayMs = 3000;
static uint32_t s_last_low_battery_warn_ms = 0;
static uint32_t s_last_critical_battery_warn_ms = 0;
static uint8_t s_critical_battery_streak = 0;
static lv_timer_t* s_low_battery_shutdown_timer = nullptr;
static void cancel_low_battery_shutdown()
{
if (s_low_battery_shutdown_timer)
{
lv_timer_del(s_low_battery_shutdown_timer);
s_low_battery_shutdown_timer = nullptr;
}
}
static void low_battery_shutdown_cb(lv_timer_t* timer)
{
(void)timer;
s_low_battery_shutdown_timer = nullptr;
board.softwareShutdown();
}
static void handle_low_battery(int level, bool charging)
{
if (level < 0)
{
return;
}
if (charging)
{
board.setPowerTier(0);
s_critical_battery_streak = 0;
cancel_low_battery_shutdown();
return;
}
// Low-battery tier: 0=Normal, 1=Low(<=20%), 2=Critical(<=10%)
if (level <= 10)
{
board.setPowerTier(2);
}
else if (level <= 20)
{
board.setPowerTier(1);
}
else
{
board.setPowerTier(0);
}
const uint32_t now_ms = millis();
if (level <= kCriticalBatteryPercent)
{
s_critical_battery_streak++;
if (now_ms - s_last_critical_battery_warn_ms >= kCriticalBatteryWarnCooldownMs)
{
ui::SystemNotification::show("Battery critical", 2500);
s_last_critical_battery_warn_ms = now_ms;
}
if (s_critical_battery_streak >= kCriticalStreakToShutdown && s_low_battery_shutdown_timer == nullptr)
{
ui::SystemNotification::show("Shutting down to protect battery", 3000);
s_low_battery_shutdown_timer = lv_timer_create(low_battery_shutdown_cb, kCriticalShutdownDelayMs, nullptr);
if (s_low_battery_shutdown_timer)
{
lv_timer_set_repeat_count(s_low_battery_shutdown_timer, 1);
}
}
return;
}
s_critical_battery_streak = 0;
cancel_low_battery_shutdown();
if (level <= kLowBatteryWarnPercent)
{
if (now_ms - s_last_low_battery_warn_ms >= kLowBatteryWarnCooldownMs)
{
char msg[48];
snprintf(msg, sizeof(msg), "Low battery: %d%%", level);
ui::SystemNotification::show(msg, 2500);
s_last_low_battery_warn_ms = now_ms;
}
}
}
#endif
#if defined(ARDUINO_T_WATCH_S3)
int s_watch_face_battery = -1;
void update_watch_face_time()
{
if (!watch_face_is_ready())
{
return;
}
uint32_t self_id = app::AppContext::getInstance().getSelfNodeId();
watch_face_set_node_id(self_id);
int battery = s_watch_face_battery >= 0 ? s_watch_face_battery : -1;
if (!board.isRTCReady())
{
watch_face_set_time(-1, -1, -1, -1, nullptr, battery);
return;
}
time_t now = time(nullptr);
if (now <= 0)
{
watch_face_set_time(-1, -1, -1, -1, nullptr, battery);
return;
}
time_t local = ui_apply_timezone_offset(now);
struct tm* info = gmtime(&local);
if (!info)
{
watch_face_set_time(-1, -1, -1, -1, nullptr, battery);
return;
}
char weekday[8] = "---";
strftime(weekday, sizeof(weekday), "%a", info);
watch_face_set_time(info->tm_hour, info->tm_min, info->tm_mon + 1, info->tm_mday, weekday, battery);
}
void show_watch_face()
{
if (!watch_face_is_ready() || main_screen == nullptr)
{
return;
}
menu_show();
lv_obj_clear_flag(main_screen, LV_OBJ_FLAG_HIDDEN);
watch_face_show(true);
update_watch_face_time();
}
void hide_watch_face()
{
if (!watch_face_is_ready() || main_screen == nullptr)
{
return;
}
watch_face_show(false);
lv_obj_clear_flag(main_screen, LV_OBJ_FLAG_HIDDEN);
}
void watch_face_unlock()
{
hide_watch_face();
menu_show();
}
#endif
// App function types (like factory example)
class FunctionAppScreen : public AppScreen
{
public:
FunctionAppScreen(const char* name,
const lv_image_dsc_t* icon,
void (*enter)(lv_obj_t*),
void (*exit)(lv_obj_t*))
: name_(name), icon_(icon), enter_(enter), exit_(exit) {}
const char* name() const override { return name_; }
const lv_image_dsc_t* icon() const override { return icon_; }
void enter(lv_obj_t* parent) override
{
if (enter_)
{
enter_(parent);
}
}
void exit(lv_obj_t* parent) override
{
if (exit_)
{
exit_(parent);
}
}
private:
const char* name_;
const lv_image_dsc_t* icon_;
void (*enter_)(lv_obj_t*);
void (*exit_)(lv_obj_t*);
};
static FunctionAppScreen s_gps_app("Map", &gps_icon, ui_gps_enter, ui_gps_exit);
static FunctionAppScreen s_skyplot_app("Sky Plot", &Satellite, ui_gnss_skyplot_enter, ui_gnss_skyplot_exit);
static FunctionAppScreen s_tracker_app("Tracker", &tracker_icon, ui_tracker_enter, ui_tracker_exit);
static FunctionAppScreen s_chat_app("Chat", &Chat, ui_chat_enter, ui_chat_exit);
static FunctionAppScreen s_contacts_app("Contacts", &contact, ui_contacts_enter, ui_contacts_exit);
static FunctionAppScreen s_team_app("Team", &team_icon, ui_team_enter, ui_team_exit);
static FunctionAppScreen s_pc_link_app("Data Exchange", &rf, ui_pc_link_enter, ui_pc_link_exit);
static FunctionAppScreen s_sstv_app("SSTV", &sstv, ui_sstv_enter, ui_sstv_exit);
static FunctionAppScreen s_energy_sweep_app("Energy Sweep", &Spectrum, ui_energy_sweep_enter, ui_energy_sweep_exit);
#if defined(ARDUINO_LILYGO_LORA_SX1262) && defined(USING_AUDIO_CODEC)
static FunctionAppScreen s_walkie_app("Walkie Talkie", &walkie_talkie, ui_walkie_talkie_enter, ui_walkie_talkie_exit);
#endif
static FunctionAppScreen s_setting_app("Setting", &Setting, ui_setting_enter, ui_setting_exit);
#ifdef ARDUINO_USB_MODE
static FunctionAppScreen s_usb_app("USB Mass Storage", &img_usb, ui_usb_enter, ui_usb_exit);
#if HAS_GPS
static AppScreen* kAppScreens[] = {&s_gps_app, &s_skyplot_app, &s_tracker_app, &s_chat_app, &s_contacts_app,
&s_team_app, &s_pc_link_app,
#if !defined(ARDUINO_T_DECK)
&s_sstv_app,
#endif
&s_energy_sweep_app,
#if defined(ARDUINO_LILYGO_LORA_SX1262) && defined(USING_AUDIO_CODEC)
&s_walkie_app,
#endif
#if HAS_SD
&s_usb_app,
#endif
&s_setting_app};
#if defined(ARDUINO_LILYGO_LORA_SX1262) && defined(USING_AUDIO_CODEC)
#if HAS_SD
#else
#endif
#else
#if HAS_SD
#else
#endif
#endif
#else
static AppScreen* kAppScreens[] = {&s_chat_app, &s_contacts_app, &s_team_app, &s_pc_link_app,
#if !defined(ARDUINO_T_DECK)
&s_sstv_app,
#endif
&s_energy_sweep_app,
#if defined(ARDUINO_LILYGO_LORA_SX1262) && defined(USING_AUDIO_CODEC)
&s_walkie_app,
#endif
#if HAS_SD
&s_usb_app,
#endif
&s_setting_app};
#if defined(ARDUINO_LILYGO_LORA_SX1262) && defined(USING_AUDIO_CODEC)
#if HAS_SD
#else
#endif
#else
#if HAS_SD
#else
#endif
#endif
#endif
#else
#if HAS_GPS
static AppScreen* kAppScreens[] = {&s_gps_app, &s_skyplot_app, &s_tracker_app, &s_chat_app, &s_contacts_app,
&s_team_app, &s_pc_link_app,
#if !defined(ARDUINO_T_DECK)
&s_sstv_app,
#endif
&s_energy_sweep_app,
#if defined(ARDUINO_LILYGO_LORA_SX1262) && defined(USING_AUDIO_CODEC)
&s_walkie_app,
#endif
&s_setting_app};
#if defined(ARDUINO_LILYGO_LORA_SX1262) && defined(USING_AUDIO_CODEC)
#else
#endif
#else
static AppScreen* kAppScreens[] = {&s_chat_app, &s_contacts_app, &s_team_app, &s_pc_link_app,
#if !defined(ARDUINO_T_DECK)
&s_sstv_app,
#endif
&s_energy_sweep_app,
#if defined(ARDUINO_LILYGO_LORA_SX1262) && defined(USING_AUDIO_CODEC)
&s_walkie_app,
#endif
&s_setting_app};
#if defined(ARDUINO_LILYGO_LORA_SX1262) && defined(USING_AUDIO_CODEC)
#else
#endif
#endif
#endif
static constexpr size_t NUM_APPS = sizeof(kAppScreens) / sizeof(kAppScreens[0]);
#if LVGL_VERSION_MAJOR == 9
static uint32_t name_change_id;
#endif
struct MenuAppUi
{
const char* name = nullptr;
lv_obj_t* icon = nullptr;
};
static MenuAppUi s_menu_apps[NUM_APPS];
// set_default_group and menu_show are implemented in ui_common.cpp
void menu_hidden()
{
lv_tileview_set_tile_by_index(main_screen, 0, 1, LV_ANIM_ON);
}
static void btn_event_cb(lv_event_t* e)
{
lv_event_code_t c = lv_event_get_code(e);
auto* data = static_cast<MenuAppUi*>(lv_event_get_user_data(e));
const char* text = data ? data->name : nullptr;
if (c == LV_EVENT_FOCUSED)
{
#if LVGL_VERSION_MAJOR == 9
lv_obj_send_event(desc_label, (lv_event_code_t)name_change_id, (void*)text);
#else
// For LVGL v8, would use lv_msg_send
#endif
}
}
static void create_app(lv_obj_t* parent, AppScreen* app, size_t idx)
{
const char* name = app ? app->name() : "";
const lv_image_dsc_t* img = app ? app->icon() : nullptr;
lv_obj_t* btn = lv_btn_create(parent);
#if defined(ARDUINO_T_DECK)
// T-Deck: 320x240. Target 4 columns:
// 4 * 72px cells + 3 * 6px gaps = 324px
// 配合 panel 的实际可用宽度,使左右留白更均衡。
lv_coord_t w = 72;
lv_coord_t h = 83; // 稍微再高一点,避免内容太挤
#else
// Pager: keep existing wide horizontal carousel layout.
uint32_t phy_hor_res = lv_display_get_physical_horizontal_resolution(NULL);
lv_coord_t w = 150;
lv_coord_t h = LV_PCT(100);
#endif
lv_obj_set_size(btn, w, h);
lv_obj_set_style_bg_opa(btn, LV_OPA_COVER, 0);
lv_obj_set_style_bg_color(btn, ui::theme::surface(), 0);
lv_obj_set_style_border_width(btn, 2, 0);
lv_obj_set_style_border_color(btn, ui::theme::border(), 0);
lv_obj_set_style_radius(btn, 10, 0);
lv_obj_set_style_shadow_width(btn, 0, 0);
lv_obj_set_style_outline_width(btn, 0, 0);
lv_obj_set_style_bg_color(btn, ui::theme::accent(), LV_STATE_FOCUSED);
lv_obj_set_style_border_color(btn, ui::theme::border(), LV_STATE_FOCUSED);
lv_obj_set_style_bg_color(btn, ui::theme::accent(), LV_STATE_FOCUS_KEY);
lv_obj_set_style_border_color(btn, ui::theme::border(), LV_STATE_FOCUS_KEY);
#if defined(ARDUINO_T_DECK)
// T-Deck grid cells: remove heavy frames, keep a clean, flat look.
lv_obj_set_style_bg_opa(btn, LV_OPA_TRANSP, 0);
lv_obj_set_style_border_width(btn, 0, 0);
lv_obj_set_style_radius(btn, 0, 0);
// Use flex column so icon和文字共用一条垂直中线。
lv_obj_set_flex_flow(btn, LV_FLEX_FLOW_COLUMN);
lv_obj_set_flex_align(btn,
LV_FLEX_ALIGN_CENTER,
LV_FLEX_ALIGN_CENTER,
LV_FLEX_ALIGN_CENTER);
// 去掉左右 padding,避免额外占宽;只保留少量上下和行间距。
lv_obj_set_style_pad_left(btn, 0, 0);
lv_obj_set_style_pad_right(btn, 0, 0);
lv_obj_set_style_pad_top(btn, 4, 0);
lv_obj_set_style_pad_bottom(btn, 4, 0);
lv_obj_set_style_pad_row(btn, 2, 0);
#else
if (phy_hor_res < 320)
{
lv_obj_set_style_radius(btn, 10, 0);
}
#endif
lv_obj_t* icon = nullptr;
lv_obj_t* label = nullptr;
if (img != NULL)
{
icon = lv_image_create(btn);
lv_image_set_src(icon, img);
#if defined(ARDUINO_T_DECK)
// Scale down icons on T-Deck to fit 4-column grid gracefully.
// Use the image center as the transform pivot so scaling does not shift it left/up.
lv_coord_t iw = lv_image_get_src_width(icon);
lv_coord_t ih = lv_image_get_src_height(icon);
lv_obj_set_style_transform_pivot_x(icon, iw / 2, 0);
lv_obj_set_style_transform_pivot_y(icon, ih / 2, 0);
lv_obj_set_style_transform_scale(icon, 176, 0); // ~69%
// 图标在 flex 列中设置 grow,让它占据更多垂直空间,整体偏上。
lv_obj_set_flex_grow(icon, 1);
#else
lv_obj_center(icon);
#endif
}
#if defined(ARDUINO_T_DECK)
// Add a short label under the icon for touch-first UX.
if (name && name[0] != '\0')
{
label = lv_label_create(btn);
lv_label_set_text(label, name);
// Fix label width so long text can trigger scrolling inside the cell.
lv_obj_set_width(label, w - 4);
lv_obj_set_style_text_align(label, LV_TEXT_ALIGN_CENTER, 0);
// 文字由 flex 自动排在图标下方,靠近 cell 底部;使用更小字号以留出空间。
lv_obj_set_style_text_font(label, &lv_font_montserrat_12, 0);
// Use warm text color for T-Deck app names.
lv_obj_set_style_text_color(label, lv_color_hex(0x6B4A1E), 0);
lv_obj_set_style_text_color(label, lv_color_hex(0x6B4A1E), LV_STATE_FOCUSED);
// Long labels scroll from right to left within the cell.
lv_label_set_long_mode(label, LV_LABEL_LONG_SCROLL_CIRCULAR);
}
#endif
if (idx < NUM_APPS)
{
s_menu_apps[idx].name = name;
s_menu_apps[idx].icon = icon;
lv_obj_set_user_data(btn, &s_menu_apps[idx]);
}
if (icon && name && strcmp(name, "Chat") == 0)
{
lv_obj_t* badge = lv_obj_create(btn);
lv_obj_set_size(badge, LV_SIZE_CONTENT, LV_SIZE_CONTENT);
lv_obj_set_style_bg_color(badge, lv_color_hex(0xE53935), 0);
lv_obj_set_style_bg_opa(badge, LV_OPA_COVER, 0);
lv_obj_set_style_border_width(badge, 0, 0);
lv_obj_set_style_radius(badge, LV_RADIUS_CIRCLE, 0);
lv_obj_set_style_pad_left(badge, 6, 0);
lv_obj_set_style_pad_right(badge, 6, 0);
lv_obj_set_style_pad_top(badge, 2, 0);
lv_obj_set_style_pad_bottom(badge, 2, 0);
lv_obj_set_style_min_width(badge, 20, 0);
lv_obj_set_style_min_height(badge, 20, 0);
lv_obj_clear_flag(badge, LV_OBJ_FLAG_SCROLLABLE);
lv_obj_add_flag(badge, LV_OBJ_FLAG_HIDDEN);
lv_obj_align_to(badge, icon, LV_ALIGN_TOP_LEFT, -4, -4);
lv_obj_t* badge_label = lv_label_create(badge);
lv_label_set_text(badge_label, "");
lv_obj_set_style_text_color(badge_label, lv_color_white(), 0);
lv_obj_set_style_text_font(badge_label, &lv_font_montserrat_14, 0);
lv_obj_center(badge_label);
::ui::status::register_chat_badge(badge, badge_label);
}
/* Text change event callback */
if (idx < NUM_APPS)
{
lv_obj_add_event_cb(btn, btn_event_cb, LV_EVENT_FOCUSED, &s_menu_apps[idx]);
lv_obj_add_event_cb(btn, btn_event_cb, LV_EVENT_FOCUSED, &s_menu_apps[idx]);
}
/* Click to select event callback */
lv_obj_add_event_cb(
btn, [](lv_event_t* e)
{
lv_event_code_t c = lv_event_get_code(e);
auto* target_app = static_cast<AppScreen*>(lv_event_get_user_data(e));
lv_obj_t *parent = lv_obj_get_child(main_screen, 1);
if (lv_obj_has_flag(main_screen, LV_OBJ_FLAG_HIDDEN)) {
return;
}
if (c == LV_EVENT_CLICKED) {
set_default_group(nullptr);
ui_switch_to_app(target_app, parent);
menu_hidden();
} },
LV_EVENT_CLICKED, app);
}
void menu_name_label_event_cb(lv_event_t* e)
{
#if LVGL_VERSION_MAJOR == 9
const char* v = (const char*)lv_event_get_param(e);
if (v)
{
lv_label_set_text(lv_event_get_target_obj(e), v);
}
#else
// For LVGL v8
#endif
}
// App entry functions are implemented in ui_gps.cpp, ui_chat.cpp, ui_setting.cpp
} // namespace
// GPS data access - now provided by TLoRaPagerBoard
// GPS data collection task is now in TLoRaPagerBoard class
// Screen sleep management
static uint32_t last_user_activity_time = 0; // Timestamp of last user activity
static SemaphoreHandle_t activity_mutex = NULL;
static TaskHandle_t screen_sleep_task_handle = NULL;
static bool screen_sleeping = false;
static bool screen_sleep_disabled = false; // Flag to disable screen sleep (e.g., during USB mode)
static uint8_t saved_keyboard_brightness = 127; // Save keyboard brightness before sleep (default 127)
static uint32_t screen_sleep_timeout_ms = 60000; // Default 60 seconds, can be configured
static bool screen_sleep_timeout_loaded = false;
static Preferences preferences; // For saving/loading settings
static bool screen_saver_active = false;
static lv_obj_t* screen_saver_layer = nullptr;
static lv_obj_t* screen_saver_time_label = nullptr;
static lv_obj_t* screen_saver_unread_label = nullptr;
static lv_obj_t* screen_saver_hint_label = nullptr;
static lv_timer_t* screen_saver_timer = nullptr;
static constexpr uint32_t kScreenSaverDurationMs = 3000;
namespace
{
constexpr const char* kSettingsNs = "settings";
constexpr const char* kScreenTimeoutKey = "screen_timeout";
constexpr uint32_t kScreenTimeoutMinMs = 10000;
constexpr uint32_t kScreenTimeoutMaxMs = 300000;
constexpr uint32_t kScreenTimeoutDefaultMs = 60000;
static uint32_t clamp_screen_timeout(uint32_t timeout_ms)
{
if (timeout_ms < kScreenTimeoutMinMs)
{
return kScreenTimeoutDefaultMs;
}
if (timeout_ms > kScreenTimeoutMaxMs)
{
return kScreenTimeoutMaxMs;
}
return timeout_ms;
}
static uint32_t read_screen_timeout_ms()
{
Preferences prefs;
prefs.begin(kSettingsNs, true);
uint32_t value = prefs.getUInt(kScreenTimeoutKey, 0);
prefs.end();
return clamp_screen_timeout(value);
}
static void write_screen_timeout_ms(uint32_t timeout_ms)
{
Preferences prefs;
prefs.begin(kSettingsNs, false);
prefs.putUInt(kScreenTimeoutKey, timeout_ms);
prefs.end();
}
} // namespace
// Power management wrapper function for accessing preferences
Preferences& getPreferencesInstance() { return preferences; }
// Access function for screen sleep task handle (used by power management)
TaskHandle_t getScreenSleepTaskHandle() { return screen_sleep_task_handle; }
// GPS data collection task is now in GpsService
// Use gps::GpsService::getInstance().getData() to access GPS data
/**
* Check if screen is currently sleeping
* @return true if screen is sleeping, false otherwise
*/
bool isScreenSleeping()
{
bool sleeping = false;
if (activity_mutex != NULL)
{
if (xSemaphoreTake(activity_mutex, pdMS_TO_TICKS(10)) == pdTRUE)
{
sleeping = screen_sleeping;
xSemaphoreGive(activity_mutex);
}
}
return sleeping;
}
static void init_screen_saver()
{
if (screen_saver_layer)
{
return;
}
screen_saver_layer = lv_obj_create(lv_screen_active());
lv_obj_set_size(screen_saver_layer, LV_PCT(100), LV_PCT(100));
lv_obj_align(screen_saver_layer, LV_ALIGN_CENTER, 0, 0);
lv_obj_set_style_bg_color(screen_saver_layer, lv_color_hex(0xF6E6C6), 0); // WarmBG
lv_obj_set_style_bg_opa(screen_saver_layer, LV_OPA_COVER, 0);
lv_obj_set_style_border_width(screen_saver_layer, 0, 0);
lv_obj_clear_flag(screen_saver_layer, LV_OBJ_FLAG_SCROLLABLE);
lv_obj_clear_flag(screen_saver_layer, LV_OBJ_FLAG_CLICKABLE);
screen_saver_time_label = lv_label_create(screen_saver_layer);
lv_obj_set_style_text_color(screen_saver_time_label, lv_color_hex(0x6B4A1E), 0); // Text
lv_obj_set_style_text_font(screen_saver_time_label, &lv_font_montserrat_36, 0);
lv_label_set_text(screen_saver_time_label, "--:--");
lv_obj_align(screen_saver_time_label, LV_ALIGN_CENTER, 0, -26);
screen_saver_unread_label = lv_label_create(screen_saver_layer);
lv_obj_set_style_text_color(screen_saver_unread_label, lv_color_hex(0x6B4A1E), 0); // Text
lv_obj_set_style_text_font(screen_saver_unread_label, &lv_font_montserrat_20, 0);
lv_label_set_text(screen_saver_unread_label, "Unread: 0");
lv_obj_align(screen_saver_unread_label, LV_ALIGN_CENTER, 0, 10);
screen_saver_hint_label = lv_label_create(screen_saver_layer);
lv_obj_set_style_text_color(screen_saver_hint_label, lv_color_hex(0x8A6A3A), 0); // TextDim
lv_obj_set_style_text_font(screen_saver_hint_label, &lv_font_montserrat_14, 0);
// Hint depends on board type: keyboard vs. touch.
#if defined(ARDUINO_T_DECK)
lv_label_set_text(screen_saver_hint_label, "Press SPACE to enter main menu");
#else
lv_label_set_text(screen_saver_hint_label, "Press SPACE to enter");
#endif
lv_obj_align(screen_saver_hint_label, LV_ALIGN_CENTER, 0, 40);
lv_obj_add_flag(screen_saver_layer, LV_OBJ_FLAG_HIDDEN);
}
static void hide_screen_saver_layer()
{
if (!screen_saver_layer)
{
return;
}
lv_obj_add_flag(screen_saver_layer, LV_OBJ_FLAG_HIDDEN);
if (screen_saver_timer)
{
lv_timer_pause(screen_saver_timer);
}
}
static void screen_saver_refresh()
{
if (!screen_saver_layer || !screen_saver_time_label || !screen_saver_unread_label)
{
return;
}
char time_buf[16] = "--:--";
if (!format_menu_time(time_buf, sizeof(time_buf)))
{
snprintf(time_buf, sizeof(time_buf), "--:--");
}
lv_label_set_text(screen_saver_time_label, time_buf);
int unread = ui::status::get_total_unread();
char unread_buf[32];
if (unread > 0)
{
snprintf(unread_buf, sizeof(unread_buf), "Unread: %d", unread);
}
else
{
snprintf(unread_buf, sizeof(unread_buf), "Unread: 0");
}
lv_label_set_text(screen_saver_unread_label, unread_buf);
}
static void screen_saver_timer_cb(lv_timer_t* /*timer*/)
{
if (activity_mutex != NULL)
{
if (xSemaphoreTake(activity_mutex, pdMS_TO_TICKS(10)) == pdTRUE)
{
screen_saver_active = false;
screen_sleeping = true;
xSemaphoreGive(activity_mutex);
}
}
hide_screen_saver_layer();
if (board.hasKeyboard())
{
board.keyboardSetBrightness(0);
}
board.setBrightness(0);
board.enterScreenSleep();
}
bool isScreenSaverActive()
{
bool active = false;
if (activity_mutex != NULL)
{
if (xSemaphoreTake(activity_mutex, pdMS_TO_TICKS(10)) == pdTRUE)
{
active = screen_saver_active;
xSemaphoreGive(activity_mutex);
}
}
return active;
}
void wakeScreenSaver()
{
if (screen_sleep_disabled)
{
updateUserActivity();
return;
}
if (!screen_saver_layer)
{
return;
}
if (activity_mutex != NULL)
{
if (xSemaphoreTake(activity_mutex, pdMS_TO_TICKS(10)) == pdTRUE)
{
screen_saver_active = true;
screen_sleeping = true;
xSemaphoreGive(activity_mutex);
}
}
screen_saver_refresh();
lv_obj_clear_flag(screen_saver_layer, LV_OBJ_FLAG_HIDDEN);
lv_obj_move_foreground(screen_saver_layer);
// Render the screen saver before turning the backlight on to avoid a flash of the main UI.
lv_refr_now(NULL);
board.setBrightness(DEVICE_MAX_BRIGHTNESS_LEVEL);
if (screen_saver_timer == nullptr)
{
screen_saver_timer = lv_timer_create(screen_saver_timer_cb, kScreenSaverDurationMs, nullptr);
}
else
{
lv_timer_set_period(screen_saver_timer, kScreenSaverDurationMs);
lv_timer_reset(screen_saver_timer);
lv_timer_resume(screen_saver_timer);
}
}
void enterFromScreenSaver()
{
if (!isScreenSaverActive())
{
return;
}
if (activity_mutex != NULL)
{
if (xSemaphoreTake(activity_mutex, pdMS_TO_TICKS(10)) == pdTRUE)
{
// Leaving screen saver: mark both saver and sleep state as inactive.
screen_saver_active = false;
screen_sleeping = false;
last_user_activity_time = millis();
xSemaphoreGive(activity_mutex);
}
}
hide_screen_saver_layer();
updateUserActivity();
// After leaving the screen saver, always return to the main menu.
menu_show();
}
/**
* Get current screen sleep timeout
* Cached in memory; settings updates call setScreenSleepTimeout().
* @return Screen sleep timeout in milliseconds
*/
uint32_t getScreenSleepTimeout()
{
if (!screen_sleep_timeout_loaded)
{
uint32_t timeout = read_screen_timeout_ms();
if (activity_mutex != NULL)
{
if (xSemaphoreTake(activity_mutex, pdMS_TO_TICKS(10)) == pdTRUE)
{
screen_sleep_timeout_ms = timeout;
screen_sleep_timeout_loaded = true;
xSemaphoreGive(activity_mutex);
}
}
}
uint32_t timeout = screen_sleep_timeout_ms;
if (activity_mutex != NULL)
{
if (xSemaphoreTake(activity_mutex, pdMS_TO_TICKS(10)) == pdTRUE)
{
timeout = screen_sleep_timeout_ms;
xSemaphoreGive(activity_mutex);
}
}
return timeout;
}
/**
* Set screen sleep timeout
* @param timeout_ms Screen sleep timeout in milliseconds (minimum 10 seconds)
*/
void setScreenSleepTimeout(uint32_t timeout_ms)
{
timeout_ms = clamp_screen_timeout(timeout_ms);
// Save to preferences
write_screen_timeout_ms(timeout_ms);
if (activity_mutex != NULL)
{
if (xSemaphoreTake(activity_mutex, portMAX_DELAY) == pdTRUE)
{
screen_sleep_timeout_ms = timeout_ms;
screen_sleep_timeout_loaded = true;
xSemaphoreGive(activity_mutex);
}
}
}
// GPS collection interval functions are now in GpsService
// Use gps::GpsService::getInstance().getCollectionInterval()/setCollectionInterval()
/**
* Disable screen sleep (e.g., during USB Mass Storage mode)
* This prevents the screen from sleeping and keeps USB functionality active
*/
void disableScreenSleep()
{
bool hide_saver = false;
if (activity_mutex != NULL)
{
if (xSemaphoreTake(activity_mutex, portMAX_DELAY) == pdTRUE)
{
screen_sleep_disabled = true;
if (screen_saver_active)
{
screen_saver_active = false;
hide_saver = true;
}
// Wake up screen if it's sleeping
if (screen_sleeping)
{
screen_sleeping = false;
board.setBrightness(DEVICE_MAX_BRIGHTNESS_LEVEL);
// Restore keyboard brightness
if (board.hasKeyboard())
{
board.keyboardSetBrightness(saved_keyboard_brightness);
}
}
xSemaphoreGive(activity_mutex);
}
}
if (hide_saver)
{
hide_screen_saver_layer();
}
}
/**
* Enable screen sleep (restore normal behavior)
*/
void enableScreenSleep()
{
if (activity_mutex != NULL)
{
if (xSemaphoreTake(activity_mutex, portMAX_DELAY) == pdTRUE)
{
screen_sleep_disabled = false;
// Reset activity time to current time to prevent immediate sleep
last_user_activity_time = millis();
xSemaphoreGive(activity_mutex);
}
}
}
/**
* Check if screen sleep is currently disabled
* @return true if screen sleep is disabled, false otherwise
*/
bool isScreenSleepDisabled()
{
bool disabled = false;
if (activity_mutex != NULL)
{
if (xSemaphoreTake(activity_mutex, pdMS_TO_TICKS(10)) == pdTRUE)
{
disabled = screen_sleep_disabled;
xSemaphoreGive(activity_mutex);
}
}
return disabled;
}
/**
* Update user activity timestamp (call this when user interacts with device)
* This function is thread-safe and can be called from any context
*/
void updateUserActivity()
{
bool woke_from_sleep = false;
bool hide_saver = false;
if (activity_mutex != NULL)
{
if (xSemaphoreTake(activity_mutex, pdMS_TO_TICKS(10)) == pdTRUE)
{
last_user_activity_time = millis();
if (screen_saver_active)
{
screen_saver_active = false;
hide_saver = true;
}
// If screen is sleeping, wake it up
if (screen_sleeping)
{
screen_sleeping = false;
board.setBrightness(DEVICE_MAX_BRIGHTNESS_LEVEL);
// Restore keyboard brightness
if (board.hasKeyboard())
{
board.keyboardSetBrightness(saved_keyboard_brightness);
}
woke_from_sleep = true;
}
xSemaphoreGive(activity_mutex);
}
}
if (hide_saver)
{
hide_screen_saver_layer();
}
#if defined(ARDUINO_T_WATCH_S3)
if (woke_from_sleep)
{
show_watch_face();
}
#endif
}
/**
* Screen sleep management task
* Monitors user activity and puts screen to sleep after 60 seconds of inactivity
* Wakes up screen when user input is detected
*/
static void screenSleepTask(void* pvParameters)
{
(void)pvParameters;
TickType_t last_wake_time = xTaskGetTickCount();
const TickType_t check_interval = pdMS_TO_TICKS(1000); // Check every 1 second
while (true)
{
// User activity detection is now handled by LVGL input device callbacks:
// - lv_encoder_read() calls updateUserActivity() for rotary encoder
// - keypad_read() calls updateUserActivity() for keyboard
// No need to poll here, as it would consume input events before LVGL can process them
// Check if screen should sleep or wake
if (activity_mutex != NULL)
{
if (xSemaphoreTake(activity_mutex, pdMS_TO_TICKS(10)) == pdTRUE)
{
uint32_t current_time = millis();
uint32_t time_since_activity = current_time - last_user_activity_time;
// Use cached timeout; settings updates call setScreenSleepTimeout().
uint32_t current_timeout = screen_sleep_timeout_ms;
// Skip sleep if screen sleep is disabled (e.g., during USB mode)
if (screen_sleep_disabled)
{
if (screen_sleeping)
{
screen_sleeping = false;
board.exitScreenSleep();
board.setBrightness(DEVICE_MAX_BRIGHTNESS_LEVEL);
if (board.hasKeyboard())
{
board.keyboardSetBrightness(saved_keyboard_brightness);
}
}
xSemaphoreGive(activity_mutex);
}
else
{
// Normal sleep logic
if (!screen_sleeping && time_since_activity >= current_timeout)
{
// Put screen to sleep: backlight off then peripheral power off
screen_sleeping = true;
if (board.hasKeyboard())
{
saved_keyboard_brightness = board.keyboardGetBrightness();
board.keyboardSetBrightness(0);
}
board.setBrightness(0);
board.enterScreenSleep();
}
else if (screen_sleeping && time_since_activity < current_timeout)
{
// Wake up: restore peripheral power then backlight
screen_sleeping = false;
board.exitScreenSleep();
board.setBrightness(DEVICE_MAX_BRIGHTNESS_LEVEL);
if (board.hasKeyboard())
{
board.keyboardSetBrightness(saved_keyboard_brightness);
}
}
xSemaphoreGive(activity_mutex);
}
}
}
// Wait for next check
vTaskDelayUntil(&last_wake_time, check_interval);
}
}
void setup()
{
Serial.begin(115200);
delay(100); // Give Serial time to stabilize before printing logs
Serial.printf("\n\n[Setup] ===== SYSTEM STARTUP =====\n");
Serial.printf("[Setup] Serial initialized at 115200 baud\n");
// Check if waking up from deep sleep
esp_sleep_wakeup_cause_t wakeup_reason = esp_sleep_get_wakeup_cause();
bool waking_from_sleep = (wakeup_reason != ESP_SLEEP_WAKEUP_UNDEFINED);
if (waking_from_sleep)
{
Serial.printf("[Setup] Wakeup cause: %d\n", wakeup_reason);
}
#if defined(ARDUINO_T_DECK)
#if HAS_GPS
board.begin();
#else
board.begin(NO_HW_GPS);
#endif
#elif defined(ARDUINO_T_WATCH_S3)
#if HAS_GPS
board.begin(NO_HW_SD | NO_HW_NFC);
#else
board.begin(NO_HW_GPS | NO_HW_SD | NO_HW_NFC);
#endif
#else
#if HAS_GPS
board.begin(NO_HW_NFC);
#else
board.begin(NO_HW_GPS | NO_HW_NFC);
#endif
#endif
// If waking from deep sleep, perform wake up initialization
if (waking_from_sleep)
{
board.wakeUp();
}
Serial.printf("[Setup] heap=%u psram=%u\n", ESP.getFreeHeap(), ESP.getFreePsram());
Serial.println("[Setup] LVGL init begin");
beginLvglHelper(static_cast<LilyGo_Display&>(instance));
Serial.println("[Setup] LVGL init done");
// Initialize system notification component
ui::SystemNotification::init();
if (!waking_from_sleep)
{
ui::boot::show();
}
// Initialize chat application context
app::AppContext& app_ctx = app::AppContext::getInstance();
bool use_mock = false; // Enable real LoRa adapter for logging and radio tests
#if HAS_GPS
#if defined(ARDUINO_T_WATCH_S3)
if (app_ctx.init(board, &instance, nullptr, nullptr, use_mock))
#else
if (app_ctx.init(board, &instance, &instance, &instance, use_mock))
#endif
#else
if (app_ctx.init(board, &instance, nullptr, nullptr, use_mock))
#endif
{
Serial.printf("[Setup] Chat application context initialized\n");
}
else
{
Serial.printf("[Setup] WARNING: Failed to initialize chat context\n");
}
// Set screen background (like factory example)
lv_obj_set_style_bg_color(lv_screen_active(), lv_color_black(), LV_PART_MAIN);
lv_obj_set_style_radius(lv_screen_active(), 0, 0);
// Create groups (like factory example)
menu_g = lv_group_create();
app_g = lv_group_create();
set_default_group(menu_g);
static lv_style_t style_frameless;
lv_style_init(&style_frameless);
lv_style_set_radius(&style_frameless, 0);
lv_style_set_border_width(&style_frameless, 0);
lv_style_set_bg_opa(&style_frameless, LV_OPA_TRANSP);
lv_style_set_shadow_width(&style_frameless, 0);
/* Create tileview (like factory example) */
main_screen = lv_tileview_create(lv_screen_active());
lv_obj_align(main_screen, LV_ALIGN_TOP_RIGHT, 0, 0);
lv_obj_set_size(main_screen, LV_PCT(100), LV_PCT(100));
/* Create two views for switching menus and app UI */
menu_panel = lv_tileview_add_tile(main_screen, 0, 0, LV_DIR_HOR);
lv_obj_set_style_bg_color(menu_panel, ui::theme::page_bg(), 0);
lv_obj_set_style_bg_opa(menu_panel, LV_OPA_COVER, 0);
app_panel = lv_tileview_add_tile(main_screen, 0, 1, LV_DIR_HOR);
if (app_panel)
{
lv_obj_set_style_bg_color(app_panel, lv_color_white(), 0);
lv_obj_set_style_bg_opa(app_panel, LV_OPA_COVER, 0);
}
lv_obj_set_scrollbar_mode(main_screen, LV_SCROLLBAR_MODE_OFF);
lv_obj_remove_flag(main_screen, LV_OBJ_FLAG_SCROLLABLE);
/* Create top bar background */
lv_obj_t* menu_topbar = lv_obj_create(menu_panel);
lv_obj_set_size(menu_topbar, LV_PCT(100), 30);
lv_obj_align(menu_topbar, LV_ALIGN_TOP_MID, 0, 0);
lv_obj_set_style_bg_color(menu_topbar, ui::theme::accent(), 0);
lv_obj_set_style_bg_opa(menu_topbar, LV_OPA_COVER, 0);
lv_obj_set_style_border_width(menu_topbar, 0, 0);
lv_obj_set_style_radius(menu_topbar, 0, 0);
lv_obj_clear_flag(menu_topbar, LV_OBJ_FLAG_SCROLLABLE);
lv_obj_clear_flag(menu_topbar, LV_OBJ_FLAG_CLICKABLE);
/* Create time label at top left of menu */
time_label = lv_label_create(menu_panel);
lv_obj_set_width(time_label, LV_SIZE_CONTENT);
lv_obj_align(time_label, LV_ALIGN_TOP_LEFT, 5, 0); // Top left, 5px from left edge
lv_obj_set_style_text_align(time_label, LV_TEXT_ALIGN_LEFT, 0);
lv_obj_set_style_text_color(time_label, ui::theme::text(), 0);
lv_obj_set_style_bg_opa(time_label, LV_OPA_TRANSP, 0);
lv_obj_set_style_pad_all(time_label, 4, 0);
// Make sure time label is on top
lv_obj_move_foreground(time_label);
if (lv_display_get_physical_horizontal_resolution(NULL) < 320)
{
lv_obj_set_style_text_font(time_label, &lv_font_montserrat_14, 0);
}
else
{
lv_obj_set_style_text_font(time_label, &lv_font_montserrat_18, 0);
}
lv_label_set_text(time_label, "--:--");
/* Create battery label at top right of menu */
battery_label = lv_label_create(menu_panel);
lv_obj_set_width(battery_label, LV_SIZE_CONTENT);
lv_obj_align(battery_label, LV_ALIGN_TOP_RIGHT, -5, 0); // Top right, 5px from right edge
lv_obj_set_style_text_align(battery_label, LV_TEXT_ALIGN_RIGHT, 0);
lv_obj_set_style_text_color(battery_label, ui::theme::text(), 0);
lv_obj_set_style_bg_opa(battery_label, LV_OPA_TRANSP, 0);
lv_obj_set_style_pad_all(battery_label, 4, 0);
// Make sure battery label is on top
lv_obj_move_foreground(battery_label);
if (lv_display_get_physical_horizontal_resolution(NULL) < 320)
{
lv_obj_set_style_text_font(battery_label, &lv_font_montserrat_14, 0);
}
else
{
lv_obj_set_style_text_font(battery_label, &lv_font_montserrat_18, 0);
}
lv_label_set_text(battery_label, "?%");
/* Create menu status icons row (topbar icons only on main menu) */
lv_obj_t* menu_status_row = lv_obj_create(menu_panel);
lv_obj_set_size(menu_status_row, LV_SIZE_CONTENT, LV_SIZE_CONTENT);
lv_obj_set_style_bg_opa(menu_status_row, LV_OPA_TRANSP, 0);
lv_obj_set_style_border_width(menu_status_row, 0, 0);
lv_obj_set_style_pad_all(menu_status_row, 0, 0);
lv_obj_set_style_pad_column(menu_status_row, 2, 0);
lv_obj_set_style_radius(menu_status_row, 0, 0);
lv_obj_clear_flag(menu_status_row, LV_OBJ_FLAG_SCROLLABLE);
lv_obj_set_scrollbar_mode(menu_status_row, LV_SCROLLBAR_MODE_OFF);
lv_obj_set_flex_flow(menu_status_row, LV_FLEX_FLOW_ROW);
lv_obj_set_flex_align(menu_status_row, LV_FLEX_ALIGN_START, LV_FLEX_ALIGN_CENTER, LV_FLEX_ALIGN_CENTER);
lv_obj_add_flag(menu_status_row, LV_OBJ_FLAG_HIDDEN);
lv_obj_align(menu_status_row, LV_ALIGN_TOP_MID, 0, 2);
lv_obj_move_foreground(menu_status_row);
lv_obj_t* menu_route_icon = lv_image_create(menu_status_row);
lv_obj_t* menu_tracker_icon = lv_image_create(menu_status_row);
lv_obj_t* menu_gps_icon = lv_image_create(menu_status_row);
lv_obj_t* menu_team_icon = lv_image_create(menu_status_row);
lv_obj_t* menu_msg_icon = lv_image_create(menu_status_row);
lv_obj_t* menu_ble_icon = lv_image_create(menu_status_row);
lv_obj_add_flag(menu_route_icon, LV_OBJ_FLAG_HIDDEN);
lv_obj_add_flag(menu_tracker_icon, LV_OBJ_FLAG_HIDDEN);
lv_obj_add_flag(menu_gps_icon, LV_OBJ_FLAG_HIDDEN);
lv_obj_add_flag(menu_team_icon, LV_OBJ_FLAG_HIDDEN);
lv_obj_add_flag(menu_msg_icon, LV_OBJ_FLAG_HIDDEN);
lv_obj_add_flag(menu_ble_icon, LV_OBJ_FLAG_HIDDEN);
::ui::status::register_menu_status_row(menu_status_row,
menu_route_icon,
menu_tracker_icon,
menu_gps_icon,
menu_team_icon,
menu_msg_icon,
menu_ble_icon);
/* Initialize the menu view - moved down to make room for time */
lv_obj_t* panel = lv_obj_create(menu_panel);
lv_obj_set_scrollbar_mode(panel, LV_SCROLLBAR_MODE_OFF);
#if defined(ARDUINO_T_DECK)
// T-Deck: make the grid area taller to fit 4xN layout more comfortably.
// Screen height is 240px; 80% ≈ 192px, 比之前 70% 多大约 20 像素。
lv_obj_set_size(panel, LV_PCT(100), LV_PCT(80));
#else
lv_obj_set_size(panel, LV_PCT(100), LV_PCT(70));
#endif
#if defined(ARDUINO_T_DECK)
// T-Deck: vertical scrollable grid of apps (4 columns, multiple rows).
lv_obj_set_scroll_dir(panel, LV_DIR_VER);
lv_obj_set_flex_flow(panel, LV_FLEX_FLOW_ROW_WRAP);
lv_obj_set_style_pad_row(panel, 6, 0);
// Column padding 0 so that 4 cells of 25% width always fit in one row.
lv_obj_set_style_pad_column(panel, 0, 0);
#else
// Pager and other boards: horizontal carousel row.
lv_obj_set_scroll_snap_x(panel, LV_SCROLL_SNAP_CENTER);
lv_obj_set_flex_flow(panel, LV_FLEX_FLOW_ROW);
#endif
// Move panel down to make room for time label (adjust offset based on screen size).
int panel_offset = 30; // Offset to keep time label readable
if (lv_display_get_physical_vertical_resolution(NULL) > 320)
{
panel_offset = 35; // Slightly more space on larger screens
}
lv_obj_align(panel, LV_ALIGN_TOP_MID, 0, panel_offset);
lv_obj_add_style(panel, &style_frameless, 0);
/* Add applications */
for (int i = 0; i < NUM_APPS; ++i)
{
create_app(panel, kAppScreens[i], static_cast<size_t>(i));
lv_group_add_obj(menu_g, lv_obj_get_child(panel, i));
}
int offset = -10;
if (lv_display_get_physical_vertical_resolution(NULL) > 320)
{
offset = -45;
}
/* Initialize the label */
desc_label = lv_label_create(menu_panel);
#if defined(ARDUINO_T_DECK)
// On T-Deck, app name is already shown under each icon; hide bottom description label.
lv_obj_add_flag(desc_label, LV_OBJ_FLAG_HIDDEN);
#endif
lv_obj_set_width(desc_label, LV_PCT(100));
lv_obj_align(desc_label, LV_ALIGN_BOTTOM_MID, 0, offset);
lv_obj_set_style_text_align(desc_label, LV_TEXT_ALIGN_CENTER, 0);
lv_obj_set_style_text_color(desc_label, ui::theme::text(), 0);
#if !defined(ARDUINO_T_WATCH_S3)
node_id_label = lv_label_create(menu_panel);
lv_obj_set_width(node_id_label, LV_SIZE_CONTENT);
lv_obj_set_style_text_align(node_id_label, LV_TEXT_ALIGN_LEFT, 0);
lv_obj_set_style_text_color(node_id_label, ui::theme::text_muted(), 0);
lv_obj_align(node_id_label, LV_ALIGN_BOTTOM_LEFT, 5, offset);
char node_id_buf[24];
uint32_t self_id = app_ctx.getSelfNodeId();
if (self_id != 0)
{
snprintf(node_id_buf, sizeof(node_id_buf), "ID: !%08lX",
static_cast<unsigned long>(self_id));
}
else
{
snprintf(node_id_buf, sizeof(node_id_buf), "ID: -");
}
lv_label_set_text(node_id_label, node_id_buf);
#endif
if (lv_display_get_physical_horizontal_resolution(NULL) < 320)
{
lv_obj_set_style_text_font(desc_label, &lv_font_montserrat_16, 0);
lv_obj_align(desc_label, LV_ALIGN_BOTTOM_MID, 0, -25);
#if !defined(ARDUINO_T_WATCH_S3)
lv_obj_set_style_text_font(node_id_label, &lv_font_montserrat_12, 0);
lv_obj_align(node_id_label, LV_ALIGN_BOTTOM_LEFT, 5, -25);
#endif
}
else
{
lv_obj_set_style_text_font(desc_label, &lv_font_montserrat_20, 0);
#if !defined(ARDUINO_T_WATCH_S3)
lv_obj_set_style_text_font(node_id_label, &lv_font_montserrat_14, 0);
#endif
}
#if defined(ARDUINO_T_DECK) && !defined(ARDUINO_T_WATCH_S3)
// T-Deck (320x240): keep node ID lighter and lower to reduce visual crowding.
lv_obj_set_style_text_font(node_id_label, &lv_font_montserrat_12, 0);
lv_obj_align(node_id_label, LV_ALIGN_BOTTOM_LEFT, 4, -4);
#endif
lv_label_set_long_mode(desc_label, LV_LABEL_LONG_SCROLL_CIRCULAR);
#if LVGL_VERSION_MAJOR == 9
name_change_id = lv_event_register_id();
lv_obj_add_event_cb(desc_label, menu_name_label_event_cb, (lv_event_code_t)name_change_id, NULL);
lv_obj_send_event(lv_obj_get_child(panel, 0), LV_EVENT_FOCUSED, NULL);
#else
// For LVGL v8
#endif
lv_obj_update_snap(panel, LV_ANIM_ON);
#if defined(ARDUINO_M5STACK_TAB5)
// On Tab5 (touch-only), create a bottom virtual key bar to emulate
// Back/Up/Down/OK keyboard navigation.
extern void ui_tab5_init_virtual_keys(lv_obj_t * root);
ui_tab5_init_virtual_keys(menu_panel);
#endif
::ui::status::init();
#if defined(ARDUINO_T_WATCH_S3)
watch_face_create(lv_screen_active());
watch_face_set_unlock_cb(watch_face_unlock);
watch_face_show(false);
#endif
// Create timer to update time display (minimum resource usage)
// Update every 60 seconds, display format: HH:MM (no seconds)
// This minimizes I2C communication and UI updates
const uint32_t time_update_interval_ms = 60000; // 60 seconds = minimum update frequency
lv_timer_t* time_timer = lv_timer_create([](lv_timer_t* timer)
{
if (time_label == nullptr)
{
#if defined(ARDUINO_T_WATCH_S3)
update_watch_face_time();
#endif
return;
}
// Check if RTC is ready
if (!board.isRTCReady())
{
lv_label_set_text(time_label, "--:--");
#if defined(ARDUINO_T_WATCH_S3)
update_watch_face_time();
#endif
return;
}
// Get current time with timezone offset (HH:MM format, no seconds)
char time_str[16];
if (format_menu_time(time_str, sizeof(time_str)))
{
// Only update if text actually changed (saves UI redraw)
static char last_time_str[16] = "";
if (strcmp(time_str, last_time_str) != 0)
{
lv_label_set_text(time_label, time_str);
strncpy(last_time_str, time_str, sizeof(last_time_str) - 1);
last_time_str[sizeof(last_time_str) - 1] = '\0';
}
}
else
{
// If RTC read fails, show error indicator
lv_label_set_text(time_label, "??:??");
}
#if defined(ARDUINO_T_WATCH_S3)
update_watch_face_time();
#endif
},
time_update_interval_ms, NULL);
lv_timer_set_repeat_count(time_timer, -1); // Repeat indefinitely
// Create timer to update battery display (minimum resource usage)
// Update every 60 seconds (1 minute) - battery changes slowly, minimize I2C communication
const uint32_t battery_update_interval_ms = 60000; // 60 seconds = minimum update frequency
lv_timer_t* battery_timer = lv_timer_create([](lv_timer_t* timer)
{
if (battery_label == nullptr)
{
return;
}
char battery_str[32];
bool charging = board.isCharging();
int level = board.getBatteryLevel();
if (level < 0)
{
// Battery gauge not available
lv_label_set_text(battery_label, "?%");
return;
}
#if defined(ARDUINO_LILYGO_LORA_SX1262)
handle_low_battery(level, charging);
#endif
#if defined(ARDUINO_T_WATCH_S3)
s_watch_face_battery = level;
#endif
// Format battery display with shared helper
ui_format_battery(level, charging, battery_str, sizeof(battery_str));
// Only update if text actually changed (saves UI redraw)
static char last_battery_str[32] = "";
if (strcmp(battery_str, last_battery_str) != 0)
{
lv_label_set_text(battery_label, battery_str);
strncpy(last_battery_str, battery_str, sizeof(last_battery_str) - 1);
last_battery_str[sizeof(last_battery_str) - 1] = '\0';
}
#if defined(ARDUINO_T_WATCH_S3)
update_watch_face_time();
#endif
},
battery_update_interval_ms, NULL);
lv_timer_set_repeat_count(battery_timer, -1); // Repeat indefinitely
// Update time immediately (don't wait for first timer tick)
if (board.isRTCReady())
{
char time_str[16];
if (format_menu_time(time_str, sizeof(time_str)))
{
lv_label_set_text(time_label, time_str);
}
}
// Update battery immediately (don't wait for first timer tick)
char battery_str[32];
bool charging = board.isCharging();
int level = board.getBatteryLevel();
if (level >= 0)
{
ui_format_battery(level, charging, battery_str, sizeof(battery_str));
lv_label_set_text(battery_label, battery_str);
}
#if defined(ARDUINO_LILYGO_LORA_SX1262)
handle_low_battery(level, charging);
#endif
#if defined(ARDUINO_T_WATCH_S3)
s_watch_face_battery = level;
update_watch_face_time();
#endif
board.setBrightness(DEVICE_MAX_BRIGHTNESS_LEVEL);
init_screen_saver();
#if defined(ARDUINO_T_WATCH_S3)
show_watch_face();
#endif
// GPS data collection task is now created in TLoRaPagerBoard::begin()
// Create activity mutex for screen sleep management
activity_mutex = xSemaphoreCreateMutex();
if (activity_mutex == NULL)
{
log_e("Failed to create activity mutex");
}
else
{
// Initialize activity time
last_user_activity_time = millis();
// Load screen sleep timeout from preferences
screen_sleep_timeout_ms = read_screen_timeout_ms();
screen_sleep_timeout_loaded = true;
}
// Create screen sleep management task
BaseType_t sleep_task_result = xTaskCreate(
screenSleepTask,
"screen_sleep",
2 * 1024, // Stack size
NULL,
3, // Priority (lower than GPS task)
&screen_sleep_task_handle);
if (sleep_task_result != pdPASS)
{
log_e("Failed to create screen sleep task");
}
else
{
log_d("Screen sleep management task created successfully");
}
// If waking from deep sleep, update user activity to prevent immediate screen sleep
if (waking_from_sleep)
{
updateUserActivity();
log_d("Updated user activity after waking from sleep");
}
ui::boot::mark_ready();
}
// Forward declaration to check if USB mode is active
#ifdef ARDUINO_USB_MODE
extern bool ui_usb_is_active();
#endif
void loop()
{
static uint32_t last_lvgl_ms = 0;
constexpr uint32_t kLvglIntervalMs = 20;
uint32_t now_ms = millis();
#ifdef ARDUINO_USB_MODE
// If USB mode is active, run USB loop (like Launcher's loop() function)
// This ensures USB tasks get CPU time and prevents other tasks from interfering
if (ui_usb_is_active())
{
// Process LVGL for USB mode
if (now_ms - last_lvgl_ms >= kLvglIntervalMs)
{
last_lvgl_ms = now_ms;
lv_timer_handler();
}
// Yield to allow USB and other critical tasks to run
// This is critical for USB stability (like Launcher's yield())
yield();
// Small delay to prevent CPU spinning
delay(10);
return;
}
#endif
// Normal processing when NOT in low power mode
// Handle power button events
board.handlePowerButton();
// Update chat application context
app::AppContext& app_ctx = app::AppContext::getInstance();
app_ctx.update();
#if MAIN_TIMING_DEBUG
static uint32_t last_loop_ms = 0;
static uint32_t loop_count = 0;
// Record loop() call interval
if (last_loop_ms > 0)
{
uint32_t interval = now_ms - last_loop_ms;
if (interval > 50)
{ // Only log intervals > 50ms (indicating delay)
Serial.printf("[MAIN] loop() interval: %lu ms (count=%lu)\n", interval, loop_count);
}
}
last_loop_ms = now_ms;
loop_count++;
#endif
// Normal processing only when NOT in low power mode
bool run_lvgl = (now_ms - last_lvgl_ms >= kLvglIntervalMs);
#if MAIN_TIMING_DEBUG
uint32_t t_before = 0;
#endif
if (run_lvgl)
{
last_lvgl_ms = now_ms;
#if MAIN_TIMING_DEBUG
t_before = millis();
#endif
lv_timer_handler();
}
#if MAIN_TIMING_DEBUG
if (run_lvgl)
{
uint32_t t_after = millis();
uint32_t handler_duration = t_after - t_before;
// Log lv_timer_handler() execution time
if (handler_duration > 10)
{
Serial.printf("[MAIN] lv_timer_handler() took %lu ms\n", handler_duration);
}
}
#endif
delay(2);
}