From bf91e7530d35e859fae46eef02de45a5b5a7a45a Mon Sep 17 00:00:00 2001 From: Ken Van Hoeylandt Date: Fri, 10 Jan 2025 23:44:32 +0100 Subject: [PATCH] Time & date, system events and much more (#152) ## Time & Date - Added time to statusbar widget - Added Time & Date Settings app - Added TimeZone app for selecting TimeZone - Added `tt::time` namespace with timezone code ## Other changes - Added `SystemEvent` to publish/subscribe to system wide (e.g. for init code, but also for time settings changes) - Changed the way the statusbar widget works: now there's only 1 that gets shown/hidden, instead of 1 instance per app instance. - Moved `lowercase()` function to new namespace: `tt::string` - Increased T-Deck flash & PSRAM SPI frequencies to 120 MHz (from 80 MHz) - Temporary work-around (+ TODO item) for LVGL stack size (issue with WiFi app) - Suppress T-Deck keystroke debugging to debug level (privacy issue) - Improved SDL dependency wiring in various `CMakeLists.txt` - `Loader` service had some variables renamed to the newer C++ style (from previous C style) --- App/CMakeLists.txt | 9 +- Boards/LilygoTdeck/Source/Lvgl.cpp | 4 +- .../Source/hal/TdeckDisplayConstants.h | 2 +- .../LilygoTdeck/Source/hal/TdeckKeyboard.cpp | 4 +- Boards/M5stackCore2/Source/InitLvgl.cpp | 2 +- Boards/M5stackCoreS3/Source/InitLvgl.cpp | 2 +- Boards/Simulator/CMakeLists.txt | 7 + Boards/Simulator/Source/LvglTask.cpp | 1 - CMakeLists.txt | 62 +-- COPYRIGHT.md | 6 + Data/screenshot-AppList.png | Bin 0 -> 4036 bytes Data/screenshot-Launcher.png | Bin 0 -> 4095 bytes Data/system/app/TimeZone/search.png | Bin 0 -> 753 bytes Data/system/app_icon_time_date_settings.png | Bin 0 -> 392 bytes Data/system/timezones.csv | 461 ++++++++++++++++++ Data/system_sources/app/TimeZone/search.svg | 42 ++ .../app_icon_time_date_settings.svg | 42 ++ Documentation/ideas.md | 8 + Tactility/Private/service/gui/Gui_i.h | 7 +- Tactility/Private/service/loader/Loader_i.h | 9 +- Tactility/Source/Tactility.cpp | 4 + Tactility/Source/app/boot/Boot.cpp | 3 + Tactility/Source/app/files/FileUtils.cpp | 17 +- .../app/timedatesettings/TimeDateSettings.cpp | 136 ++++++ .../app/timedatesettings/TimeDateSettings.h | 7 + Tactility/Source/app/timezone/TimeZone.cpp | 243 +++++++++ Tactility/Source/app/timezone/TimeZone.h | 12 + Tactility/Source/lvgl/Init.cpp | 5 + Tactility/Source/lvgl/Statusbar.cpp | 85 +++- Tactility/Source/service/gui/Gui.cpp | 50 +- Tactility/Source/service/gui/GuiDraw.cpp | 35 +- Tactility/Source/service/gui/Keyboard.cpp | 4 +- Tactility/Source/service/loader/Loader.cpp | 33 +- Tactility/Source/service/loader/Loader.h | 2 +- TactilityCore/Source/CoreExtraDefines.h | 2 + TactilityCore/Source/StringUtils.h | 19 + TactilityCore/Source/Timer.cpp | 1 - TactilityHeadless/CMakeLists.txt | 2 +- .../Private/network/NtpPrivate.h | 7 + TactilityHeadless/Private/time/TimePrivate.h | 7 + TactilityHeadless/Source/Assets.h | 1 + .../Source/TactilityHeadless.cpp | 5 + TactilityHeadless/Source/hal/Configuration.h | 4 +- TactilityHeadless/Source/hal/Hal.cpp | 7 + .../Source/kernel/SystemEvents.cpp | 87 ++++ .../Source/kernel/SystemEvents.h | 31 ++ TactilityHeadless/Source/network/Ntp.cpp | 34 ++ TactilityHeadless/Source/time/Time.cpp | 101 ++++ TactilityHeadless/Source/time/Time.h | 32 ++ sdkconfig.board.lilygo-tdeck | 7 +- 50 files changed, 1498 insertions(+), 153 deletions(-) create mode 100644 Data/screenshot-AppList.png create mode 100644 Data/screenshot-Launcher.png create mode 100644 Data/system/app/TimeZone/search.png create mode 100644 Data/system/app_icon_time_date_settings.png create mode 100644 Data/system/timezones.csv create mode 100644 Data/system_sources/app/TimeZone/search.svg create mode 100644 Data/system_sources/app_icon_time_date_settings.svg create mode 100644 Tactility/Source/app/timedatesettings/TimeDateSettings.cpp create mode 100644 Tactility/Source/app/timedatesettings/TimeDateSettings.h create mode 100644 Tactility/Source/app/timezone/TimeZone.cpp create mode 100644 Tactility/Source/app/timezone/TimeZone.h create mode 100644 TactilityHeadless/Private/network/NtpPrivate.h create mode 100644 TactilityHeadless/Private/time/TimePrivate.h create mode 100644 TactilityHeadless/Source/kernel/SystemEvents.cpp create mode 100644 TactilityHeadless/Source/kernel/SystemEvents.h create mode 100644 TactilityHeadless/Source/network/Ntp.cpp create mode 100644 TactilityHeadless/Source/time/Time.cpp create mode 100644 TactilityHeadless/Source/time/Time.h diff --git a/App/CMakeLists.txt b/App/CMakeLists.txt index 97296e93..12e71367 100644 --- a/App/CMakeLists.txt +++ b/App/CMakeLists.txt @@ -24,6 +24,7 @@ if (DEFINED ENV{ESP_IDF_VERSION}) ) else() + file(GLOB_RECURSE SOURCES "Source/*.c*") add_executable(AppSim ${SOURCES}) target_link_libraries(AppSim @@ -33,9 +34,11 @@ else() PRIVATE Simulator ) - find_package(SDL2 REQUIRED CONFIG) - target_link_libraries(AppSim PRIVATE ${SDL2_LIBRARIES}) - include_directories(${SDL2_INCLUDE_DIRS}) + if (NOT DEFINED ENV{SKIP_SDL}) + find_package(SDL2 REQUIRED CONFIG) + include_directories(${SDL2_INCLUDE_DIRS}) + target_link_libraries(AppSim PRIVATE ${SDL2_LIBRARIES}) + endif() add_definitions(-D_Nullable=) add_definitions(-D_Nonnull=) diff --git a/Boards/LilygoTdeck/Source/Lvgl.cpp b/Boards/LilygoTdeck/Source/Lvgl.cpp index cef35f25..3f012424 100644 --- a/Boards/LilygoTdeck/Source/Lvgl.cpp +++ b/Boards/LilygoTdeck/Source/Lvgl.cpp @@ -8,8 +8,8 @@ // LVGL // The minimum task stack seems to be about 3500, but that crashes the wifi app in some scenarios -// At 4000, it crashes when the fps renderer is available -#define TDECK_LVGL_TASK_STACK_DEPTH 8192 +// At 8192, it sometimes crashes when wifi-auto enables and is busy connecting and then you open WifiManage +#define TDECK_LVGL_TASK_STACK_DEPTH 9216 bool tdeck_init_lvgl() { static lv_disp_t* display = nullptr; diff --git a/Boards/LilygoTdeck/Source/hal/TdeckDisplayConstants.h b/Boards/LilygoTdeck/Source/hal/TdeckDisplayConstants.h index 80644dff..b209ac06 100644 --- a/Boards/LilygoTdeck/Source/hal/TdeckDisplayConstants.h +++ b/Boards/LilygoTdeck/Source/hal/TdeckDisplayConstants.h @@ -4,7 +4,7 @@ #define TDECK_LCD_PIN_CS GPIO_NUM_12 #define TDECK_LCD_PIN_DC GPIO_NUM_11 // RS #define TDECK_LCD_PIN_BACKLIGHT GPIO_NUM_42 -#define TDECK_LCD_SPI_FREQUENCY 40000000 +#define TDECK_LCD_SPI_FREQUENCY 80000000 #define TDECK_LCD_HORIZONTAL_RESOLUTION 320 #define TDECK_LCD_VERTICAL_RESOLUTION 240 #define TDECK_LCD_BITS_PER_PIXEL 16 diff --git a/Boards/LilygoTdeck/Source/hal/TdeckKeyboard.cpp b/Boards/LilygoTdeck/Source/hal/TdeckKeyboard.cpp index 427d56ae..d3220a99 100644 --- a/Boards/LilygoTdeck/Source/hal/TdeckKeyboard.cpp +++ b/Boards/LilygoTdeck/Source/hal/TdeckKeyboard.cpp @@ -30,11 +30,11 @@ static void keyboard_read_callback(TT_UNUSED lv_indev_t* indev, lv_indev_data_t* if (keyboard_i2c_read(&read_buffer)) { if (read_buffer == 0 && read_buffer != last_buffer) { - TT_LOG_I(TAG, "Released %d", last_buffer); + TT_LOG_D(TAG, "Released %d", last_buffer); data->key = last_buffer; data->state = LV_INDEV_STATE_RELEASED; } else if (read_buffer != 0) { - TT_LOG_I(TAG, "Pressed %d", read_buffer); + TT_LOG_D(TAG, "Pressed %d", read_buffer); data->key = read_buffer; data->state = LV_INDEV_STATE_PRESSED; } diff --git a/Boards/M5stackCore2/Source/InitLvgl.cpp b/Boards/M5stackCore2/Source/InitLvgl.cpp index faa92dfe..dd362b62 100644 --- a/Boards/M5stackCore2/Source/InitLvgl.cpp +++ b/Boards/M5stackCore2/Source/InitLvgl.cpp @@ -8,7 +8,7 @@ // LVGL // The minimum task stack seems to be about 3500, but that crashes the wifi app in some scenarios // At 4000, it crashes when the fps renderer is available -#define CORE2_LVGL_TASK_STACK_DEPTH 8192 +#define CORE2_LVGL_TASK_STACK_DEPTH 9216 bool initLvgl() { const lvgl_port_cfg_t lvgl_cfg = { diff --git a/Boards/M5stackCoreS3/Source/InitLvgl.cpp b/Boards/M5stackCoreS3/Source/InitLvgl.cpp index faa92dfe..dd362b62 100644 --- a/Boards/M5stackCoreS3/Source/InitLvgl.cpp +++ b/Boards/M5stackCoreS3/Source/InitLvgl.cpp @@ -8,7 +8,7 @@ // LVGL // The minimum task stack seems to be about 3500, but that crashes the wifi app in some scenarios // At 4000, it crashes when the fps renderer is available -#define CORE2_LVGL_TASK_STACK_DEPTH 8192 +#define CORE2_LVGL_TASK_STACK_DEPTH 9216 bool initLvgl() { const lvgl_port_cfg_t lvgl_cfg = { diff --git a/Boards/Simulator/CMakeLists.txt b/Boards/Simulator/CMakeLists.txt index 4793d4e8..113af056 100644 --- a/Boards/Simulator/CMakeLists.txt +++ b/Boards/Simulator/CMakeLists.txt @@ -11,13 +11,20 @@ if (NOT DEFINED ENV{ESP_IDF_VERSION}) PRIVATE ${SOURCES} PUBLIC ${HEADERS} ) + target_link_libraries(Simulator PRIVATE Tactility PRIVATE TactilityCore PRIVATE TactilityHeadless PRIVATE lvgl + PRIVATE ${SDL2_LIBRARIES} ) + if (NOT DEFINED ENV{SKIP_SDL}) + find_package(SDL2 REQUIRED CONFIG) + target_link_libraries(Simulator PRIVATE ${SDL2_LIBRARIES}) + endif() + add_definitions(-D_Nullable=) add_definitions(-D_Nonnull=) diff --git a/Boards/Simulator/Source/LvglTask.cpp b/Boards/Simulator/Source/LvglTask.cpp index e137b75b..d023bbcf 100644 --- a/Boards/Simulator/Source/LvglTask.cpp +++ b/Boards/Simulator/Source/LvglTask.cpp @@ -2,7 +2,6 @@ #include "lvgl.h" #include "Log.h" -#include "TactilityCore.h" #include "Thread.h" #include "lvgl/LvglSync.h" diff --git a/CMakeLists.txt b/CMakeLists.txt index d26df98c..e4bce471 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -64,7 +64,6 @@ project(Tactility) # Defined as regular project for PC and component for ESP if (NOT DEFINED ENV{ESP_IDF_VERSION}) add_subdirectory(Tactility) - add_subdirectory(TactilityC) add_subdirectory(TactilityCore) add_subdirectory(TactilityHeadless) add_subdirectory(Boards/Simulator) @@ -96,65 +95,14 @@ if (NOT DEFINED ENV{ESP_IDF_VERSION}) add_subdirectory(Tests) # LVGL - add_subdirectory(Libraries/lvgl) # Added as idf component for ESP and as library for other targets include_directories(lvgl PUBLIC ${PROJECT_SOURCE_DIR}/Libraries/lvgl_conf) if (NOT DEFINED ENV{SKIP_SDL}) - target_include_directories(lvgl PUBLIC ${SDL2_IMAGE_INCLUDE_DIRS}) + find_package(SDL2 REQUIRED CONFIG) + target_include_directories(lvgl PUBLIC ${SDL2_INCLUDE_DIRS}) + target_link_libraries(lvgl + PRIVATE ${SDL2_LIBRARIES} + ) endif() target_compile_definitions(lvgl PUBLIC "-DLV_CONF_PATH=\"${PROJECT_SOURCE_DIR}/Libraries/lvgl_conf/lv_conf_simulator.h\"") - - # SDL - # TODO: This is a temporary skipping option for running unit tests - # TODO: Remove when github action for SDL is working again - if (NOT DEFINED ENV{SKIP_SDL}) - find_package(SDL2 REQUIRED CONFIG) - - option(LV_USE_DRAW_SDL "Use SDL draw unit" OFF) - option(LV_USE_LIBPNG "Use libpng to decode PNG" OFF) - option(LV_USE_LIBJPEG_TURBO "Use libjpeg turbo to decode JPEG" OFF) - option(LV_USE_FFMPEG "Use libffmpeg to display video using lv_ffmpeg" OFF) - option(LV_USE_FREETYPE "Use freetype lib" OFF) - - add_compile_definitions($<$:LV_USE_DRAW_SDL=1>) - add_compile_definitions($<$:LV_USE_LIBPNG=1>) - add_compile_definitions($<$:LV_USE_LIBJPEG_TURBO=1>) - add_compile_definitions($<$:LV_USE_FFMPEG=1>) - - if (LV_USE_DRAW_SDL) - set(CMAKE_MODULE_PATH ${CMAKE_MODULE_PATH} "${CMAKE_SOURCE_DIR}/cmake") - # Need to install libsdl2-image-dev - # `sudo apt install libsdl2-image-dev` - # `brew install sdl2_image` - find_package(SDL2_image REQUIRED) - include_directories(lvgl PUBLIC ${PROJECT_SOURCE_DIR}/Libraries/lvgl_conf) - target_link_libraries(AppSim ${SDL2_IMAGE_LIBRARIES}) - target_link_libraries(Simulator ${SDL2_IMAGE_LIBRARIES}) - endif(LV_USE_DRAW_SDL) - - if (LV_USE_LIBPNG) - find_package(PNG REQUIRED) - target_include_directories(lvgl PUBLIC ${PNG_INCLUDE_DIR}) - target_link_libraries(AppSim ${PNG_LIBRARY}) - endif(LV_USE_LIBPNG) - - if (LV_USE_LIBJPEG_TURBO) - # Need to install libjpeg-turbo8-dev - # `sudo apt install libjpeg-turbo8-dev` - # `brew install libjpeg-turbo` - find_package(JPEG REQUIRED) - target_include_directories(lvgl PUBLIC ${JPEG_INCLUDE_DIRS}) - target_link_libraries(AppSim ${JPEG_LIBRARIES}) - endif(LV_USE_LIBJPEG_TURBO) - - if (LV_USE_FFMPEG) - target_link_libraries(main avformat avcodec avutil swscale) - endif(LV_USE_FFMPEG) - - if (LV_USE_FREETYPE) - find_package(Freetype REQUIRED) - target_link_libraries(AppSim ${FREETYPE_LIBRARIES}) - target_include_directories(lvgl PUBLIC ${FREETYPE_INCLUDE_DIRS}) - endif(LV_USE_FREETYPE) - endif() endif() diff --git a/COPYRIGHT.md b/COPYRIGHT.md index 2415cb53..f06c55b9 100644 --- a/COPYRIGHT.md +++ b/COPYRIGHT.md @@ -33,6 +33,12 @@ Website: https://fonts.google.com/icons License: Multiple (SIL Open Font License, Apache License, Ubuntu Font License): https://developers.google.com/fonts/faq +### Timezones CSV + +Website: https://github.com/nayarsystems/posix_tz_db + +License: [MIT](https://github.com/nayarsystems/posix_tz_db/blob/master/LICENSE) + ### Other Components See `/components` for the respective projects and their licenses. diff --git a/Data/screenshot-AppList.png b/Data/screenshot-AppList.png new file mode 100644 index 0000000000000000000000000000000000000000..a8ba8fd4d42cb96567fdc7b187d686cf9ba6ab79 GIT binary patch literal 4036 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 SiKFj03*7LgL8Trt=Klb!^}tC0 literal 0 HcmV?d00001 diff --git a/Data/screenshot-Launcher.png b/Data/screenshot-Launcher.png new file mode 100644 index 0000000000000000000000000000000000000000..ef16d21da693adb672df49c92f691e30469a7d21 GIT binary patch literal 4095 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 literal 0 HcmV?d00001 diff --git a/Data/system/app/TimeZone/search.png b/Data/system/app/TimeZone/search.png new file mode 100644 index 0000000000000000000000000000000000000000..b71e195050aa2312c407b1e5e03141892b960e06 GIT binary patch literal 753 zcmVpF8FWQhbW?9;ba!ELWdL_~cP?peYja~^aAhuUa%Y?FJQ@H10)9zE zK~zYIt&~4Z6LA#BKkr8yNg64=_O2zaHPOU4FsKW0W5mHl6NxowjElRjj3mlJh>1ju z4oq$)hy%ghKVi}c0|d0kwZ%#t3iO^s@5qI4M~!@^_sj3S_j&K-{eGgVMCH0}Mop?B z$Ov!&uz}x#tSSD2XzID+WLN1@5(T(!{TfE5fq{gWJrO3Ig7p~K?;7BG^@ku&0TbM{ zQ9fei2TB%n5XeSi7j~M~NV!bwSU^zun;^M@OyTd27mLNMh;Q2U8lxx^2o`|AOUJe< ziGXUgn!%*M(;15@jFoKbMO=XAd1arT4}m|T3_FhXKF;5$NmV$QxlAP`2e55>MaA3< z&nogL$=?Lq0?@|a9Zzx&RVwr^fh-1yq4j1n7Ym@XiU6Mu%{)=6%m-$wx79uu4^Rr7 z2>IDe8Vf;t9)bl3TXE1|ugmT$2Dg?E9S zb-l)DH_2+r~G%&bYRApWa8|u@Ka5)%DCKB%}I##mt zv&R8gS>aS)w((5ic2Y%wz5o~LyrV=&_;XS&({gO9BFb>^N{{-AmleHJw5>r!#{i$s zCeBp7dL^-g!&+}PbG@zhc~D1{om7gi`Fwse>QnXV6+vc_I*A2zE4Q|N1J&0+51=CQ zz$xUOh4&^tv^y0Gxj95`0c|=3PjA(>@1GdpILLpGD5|5zbs`;vDi(5cir%HOU81U& jPPBti!FCN5ZR_4&G=3Bru+9oF00000NkvXXu0mjfiBnN9 literal 0 HcmV?d00001 diff --git a/Data/system/app_icon_time_date_settings.png b/Data/system/app_icon_time_date_settings.png new file mode 100644 index 0000000000000000000000000000000000000000..ea0be2846f07ff6c8d34b988334516f2313e5f4a GIT binary patch literal 392 zcmV;30eAk1P)KclYm8DuyG86) zNGj3F-aby1N6znSbA$Z_XK+DKnvmUhOv(Z6w;wcoh*#*uWx0#J;Dp zT+Z!?XyGtR*VEE<65Yy772e}p(Q)LmU{ocrg3U3(5VbKW<;2&Twh3up)J6>+Waiaa zd?V(u&;*YHe84(dIL7xncR#~33$le<{KZYFKf=t5V7$Y1sX51vhezv7jbOZn*=KU= zqrh_w`z_9JQv5d+{$V*HRv!U>q2KUlaezH^>)a2+7ok(;?heTf@G-1" +"Africa/Ceuta","CET-1CEST,M3.5.0,M10.5.0/3" +"Africa/Conakry","GMT0" +"Africa/Dakar","GMT0" +"Africa/Dar_es_Salaam","EAT-3" +"Africa/Djibouti","EAT-3" +"Africa/Douala","WAT-1" +"Africa/El_Aaiun","<+01>-1" +"Africa/Freetown","GMT0" +"Africa/Gaborone","CAT-2" +"Africa/Harare","CAT-2" +"Africa/Johannesburg","SAST-2" +"Africa/Juba","CAT-2" +"Africa/Kampala","EAT-3" +"Africa/Khartoum","CAT-2" +"Africa/Kigali","CAT-2" +"Africa/Kinshasa","WAT-1" +"Africa/Lagos","WAT-1" +"Africa/Libreville","WAT-1" +"Africa/Lome","GMT0" +"Africa/Luanda","WAT-1" +"Africa/Lubumbashi","CAT-2" +"Africa/Lusaka","CAT-2" +"Africa/Malabo","WAT-1" +"Africa/Maputo","CAT-2" +"Africa/Maseru","SAST-2" +"Africa/Mbabane","SAST-2" +"Africa/Mogadishu","EAT-3" +"Africa/Monrovia","GMT0" +"Africa/Nairobi","EAT-3" +"Africa/Ndjamena","WAT-1" +"Africa/Niamey","WAT-1" +"Africa/Nouakchott","GMT0" +"Africa/Ouagadougou","GMT0" +"Africa/Porto-Novo","WAT-1" +"Africa/Sao_Tome","GMT0" +"Africa/Tripoli","EET-2" +"Africa/Tunis","CET-1" +"Africa/Windhoek","CAT-2" +"America/Adak","HST10HDT,M3.2.0,M11.1.0" +"America/Anchorage","AKST9AKDT,M3.2.0,M11.1.0" +"America/Anguilla","AST4" +"America/Antigua","AST4" +"America/Araguaina","<-03>3" +"America/Argentina/Buenos_Aires","<-03>3" +"America/Argentina/Catamarca","<-03>3" +"America/Argentina/Cordoba","<-03>3" +"America/Argentina/Jujuy","<-03>3" +"America/Argentina/La_Rioja","<-03>3" +"America/Argentina/Mendoza","<-03>3" +"America/Argentina/Rio_Gallegos","<-03>3" +"America/Argentina/Salta","<-03>3" +"America/Argentina/San_Juan","<-03>3" +"America/Argentina/San_Luis","<-03>3" +"America/Argentina/Tucuman","<-03>3" +"America/Argentina/Ushuaia","<-03>3" +"America/Aruba","AST4" +"America/Asuncion","<-04>4<-03>,M10.1.0/0,M3.4.0/0" +"America/Atikokan","EST5" +"America/Bahia","<-03>3" +"America/Bahia_Banderas","CST6" +"America/Barbados","AST4" +"America/Belem","<-03>3" +"America/Belize","CST6" +"America/Blanc-Sablon","AST4" +"America/Boa_Vista","<-04>4" +"America/Bogota","<-05>5" +"America/Boise","MST7MDT,M3.2.0,M11.1.0" +"America/Cambridge_Bay","MST7MDT,M3.2.0,M11.1.0" +"America/Campo_Grande","<-04>4" +"America/Cancun","EST5" +"America/Caracas","<-04>4" +"America/Cayenne","<-03>3" +"America/Cayman","EST5" +"America/Chicago","CST6CDT,M3.2.0,M11.1.0" +"America/Chihuahua","CST6" +"America/Costa_Rica","CST6" +"America/Creston","MST7" +"America/Cuiaba","<-04>4" +"America/Curacao","AST4" +"America/Danmarkshavn","GMT0" +"America/Dawson","MST7" +"America/Dawson_Creek","MST7" +"America/Denver","MST7MDT,M3.2.0,M11.1.0" +"America/Detroit","EST5EDT,M3.2.0,M11.1.0" +"America/Dominica","AST4" +"America/Edmonton","MST7MDT,M3.2.0,M11.1.0" +"America/Eirunepe","<-05>5" +"America/El_Salvador","CST6" +"America/Fortaleza","<-03>3" +"America/Fort_Nelson","MST7" +"America/Glace_Bay","AST4ADT,M3.2.0,M11.1.0" +"America/Godthab","<-02>2<-01>,M3.5.0/-1,M10.5.0/0" +"America/Goose_Bay","AST4ADT,M3.2.0,M11.1.0" +"America/Grand_Turk","EST5EDT,M3.2.0,M11.1.0" +"America/Grenada","AST4" +"America/Guadeloupe","AST4" +"America/Guatemala","CST6" +"America/Guayaquil","<-05>5" +"America/Guyana","<-04>4" +"America/Halifax","AST4ADT,M3.2.0,M11.1.0" +"America/Havana","CST5CDT,M3.2.0/0,M11.1.0/1" +"America/Hermosillo","MST7" +"America/Indiana/Indianapolis","EST5EDT,M3.2.0,M11.1.0" +"America/Indiana/Knox","CST6CDT,M3.2.0,M11.1.0" +"America/Indiana/Marengo","EST5EDT,M3.2.0,M11.1.0" +"America/Indiana/Petersburg","EST5EDT,M3.2.0,M11.1.0" +"America/Indiana/Tell_City","CST6CDT,M3.2.0,M11.1.0" +"America/Indiana/Vevay","EST5EDT,M3.2.0,M11.1.0" +"America/Indiana/Vincennes","EST5EDT,M3.2.0,M11.1.0" +"America/Indiana/Winamac","EST5EDT,M3.2.0,M11.1.0" +"America/Inuvik","MST7MDT,M3.2.0,M11.1.0" +"America/Iqaluit","EST5EDT,M3.2.0,M11.1.0" +"America/Jamaica","EST5" +"America/Juneau","AKST9AKDT,M3.2.0,M11.1.0" +"America/Kentucky/Louisville","EST5EDT,M3.2.0,M11.1.0" +"America/Kentucky/Monticello","EST5EDT,M3.2.0,M11.1.0" +"America/Kralendijk","AST4" +"America/La_Paz","<-04>4" +"America/Lima","<-05>5" +"America/Los_Angeles","PST8PDT,M3.2.0,M11.1.0" +"America/Lower_Princes","AST4" +"America/Maceio","<-03>3" +"America/Managua","CST6" +"America/Manaus","<-04>4" +"America/Marigot","AST4" +"America/Martinique","AST4" +"America/Matamoros","CST6CDT,M3.2.0,M11.1.0" +"America/Mazatlan","MST7" +"America/Menominee","CST6CDT,M3.2.0,M11.1.0" +"America/Merida","CST6" +"America/Metlakatla","AKST9AKDT,M3.2.0,M11.1.0" +"America/Mexico_City","CST6" +"America/Miquelon","<-03>3<-02>,M3.2.0,M11.1.0" +"America/Moncton","AST4ADT,M3.2.0,M11.1.0" +"America/Monterrey","CST6" +"America/Montevideo","<-03>3" +"America/Montreal","EST5EDT,M3.2.0,M11.1.0" +"America/Montserrat","AST4" +"America/Nassau","EST5EDT,M3.2.0,M11.1.0" +"America/New_York","EST5EDT,M3.2.0,M11.1.0" +"America/Nipigon","EST5EDT,M3.2.0,M11.1.0" +"America/Nome","AKST9AKDT,M3.2.0,M11.1.0" +"America/Noronha","<-02>2" +"America/North_Dakota/Beulah","CST6CDT,M3.2.0,M11.1.0" +"America/North_Dakota/Center","CST6CDT,M3.2.0,M11.1.0" +"America/North_Dakota/New_Salem","CST6CDT,M3.2.0,M11.1.0" +"America/Nuuk","<-02>2<-01>,M3.5.0/-1,M10.5.0/0" +"America/Ojinaga","CST6CDT,M3.2.0,M11.1.0" +"America/Panama","EST5" +"America/Pangnirtung","EST5EDT,M3.2.0,M11.1.0" +"America/Paramaribo","<-03>3" +"America/Phoenix","MST7" +"America/Port-au-Prince","EST5EDT,M3.2.0,M11.1.0" +"America/Port_of_Spain","AST4" +"America/Porto_Velho","<-04>4" +"America/Puerto_Rico","AST4" +"America/Punta_Arenas","<-03>3" +"America/Rainy_River","CST6CDT,M3.2.0,M11.1.0" +"America/Rankin_Inlet","CST6CDT,M3.2.0,M11.1.0" +"America/Recife","<-03>3" +"America/Regina","CST6" +"America/Resolute","CST6CDT,M3.2.0,M11.1.0" +"America/Rio_Branco","<-05>5" +"America/Santarem","<-03>3" +"America/Santiago","<-04>4<-03>,M9.1.6/24,M4.1.6/24" +"America/Santo_Domingo","AST4" +"America/Sao_Paulo","<-03>3" +"America/Scoresbysund","<-02>2<-01>,M3.5.0/-1,M10.5.0/0" +"America/Sitka","AKST9AKDT,M3.2.0,M11.1.0" +"America/St_Barthelemy","AST4" +"America/St_Johns","NST3:30NDT,M3.2.0,M11.1.0" +"America/St_Kitts","AST4" +"America/St_Lucia","AST4" +"America/St_Thomas","AST4" +"America/St_Vincent","AST4" +"America/Swift_Current","CST6" +"America/Tegucigalpa","CST6" +"America/Thule","AST4ADT,M3.2.0,M11.1.0" +"America/Thunder_Bay","EST5EDT,M3.2.0,M11.1.0" +"America/Tijuana","PST8PDT,M3.2.0,M11.1.0" +"America/Toronto","EST5EDT,M3.2.0,M11.1.0" +"America/Tortola","AST4" +"America/Vancouver","PST8PDT,M3.2.0,M11.1.0" +"America/Whitehorse","MST7" +"America/Winnipeg","CST6CDT,M3.2.0,M11.1.0" +"America/Yakutat","AKST9AKDT,M3.2.0,M11.1.0" +"America/Yellowknife","MST7MDT,M3.2.0,M11.1.0" +"Antarctica/Casey","<+08>-8" +"Antarctica/Davis","<+07>-7" +"Antarctica/DumontDUrville","<+10>-10" +"Antarctica/Macquarie","AEST-10AEDT,M10.1.0,M4.1.0/3" +"Antarctica/Mawson","<+05>-5" +"Antarctica/McMurdo","NZST-12NZDT,M9.5.0,M4.1.0/3" +"Antarctica/Palmer","<-03>3" +"Antarctica/Rothera","<-03>3" +"Antarctica/Syowa","<+03>-3" +"Antarctica/Troll","<+00>0<+02>-2,M3.5.0/1,M10.5.0/3" +"Antarctica/Vostok","<+05>-5" +"Arctic/Longyearbyen","CET-1CEST,M3.5.0,M10.5.0/3" +"Asia/Aden","<+03>-3" +"Asia/Almaty","<+05>-5" +"Asia/Amman","<+03>-3" +"Asia/Anadyr","<+12>-12" +"Asia/Aqtau","<+05>-5" +"Asia/Aqtobe","<+05>-5" +"Asia/Ashgabat","<+05>-5" +"Asia/Atyrau","<+05>-5" +"Asia/Baghdad","<+03>-3" +"Asia/Bahrain","<+03>-3" +"Asia/Baku","<+04>-4" +"Asia/Bangkok","<+07>-7" +"Asia/Barnaul","<+07>-7" +"Asia/Beirut","EET-2EEST,M3.5.0/0,M10.5.0/0" +"Asia/Bishkek","<+06>-6" +"Asia/Brunei","<+08>-8" +"Asia/Chita","<+09>-9" +"Asia/Choibalsan","<+08>-8" +"Asia/Colombo","<+0530>-5:30" +"Asia/Damascus","<+03>-3" +"Asia/Dhaka","<+06>-6" +"Asia/Dili","<+09>-9" +"Asia/Dubai","<+04>-4" +"Asia/Dushanbe","<+05>-5" +"Asia/Famagusta","EET-2EEST,M3.5.0/3,M10.5.0/4" +"Asia/Gaza","EET-2EEST,M3.4.4/50,M10.4.4/50" +"Asia/Hebron","EET-2EEST,M3.4.4/50,M10.4.4/50" +"Asia/Ho_Chi_Minh","<+07>-7" +"Asia/Hong_Kong","HKT-8" +"Asia/Hovd","<+07>-7" +"Asia/Irkutsk","<+08>-8" +"Asia/Jakarta","WIB-7" +"Asia/Jayapura","WIT-9" +"Asia/Jerusalem","IST-2IDT,M3.4.4/26,M10.5.0" +"Asia/Kabul","<+0430>-4:30" +"Asia/Kamchatka","<+12>-12" +"Asia/Karachi","PKT-5" +"Asia/Kathmandu","<+0545>-5:45" +"Asia/Khandyga","<+09>-9" +"Asia/Kolkata","IST-5:30" +"Asia/Krasnoyarsk","<+07>-7" +"Asia/Kuala_Lumpur","<+08>-8" +"Asia/Kuching","<+08>-8" +"Asia/Kuwait","<+03>-3" +"Asia/Macau","CST-8" +"Asia/Magadan","<+11>-11" +"Asia/Makassar","WITA-8" +"Asia/Manila","PST-8" +"Asia/Muscat","<+04>-4" +"Asia/Nicosia","EET-2EEST,M3.5.0/3,M10.5.0/4" +"Asia/Novokuznetsk","<+07>-7" +"Asia/Novosibirsk","<+07>-7" +"Asia/Omsk","<+06>-6" +"Asia/Oral","<+05>-5" +"Asia/Phnom_Penh","<+07>-7" +"Asia/Pontianak","WIB-7" +"Asia/Pyongyang","KST-9" +"Asia/Qatar","<+03>-3" +"Asia/Qyzylorda","<+05>-5" +"Asia/Riyadh","<+03>-3" +"Asia/Sakhalin","<+11>-11" +"Asia/Samarkand","<+05>-5" +"Asia/Seoul","KST-9" +"Asia/Shanghai","CST-8" +"Asia/Singapore","<+08>-8" +"Asia/Srednekolymsk","<+11>-11" +"Asia/Taipei","CST-8" +"Asia/Tashkent","<+05>-5" +"Asia/Tbilisi","<+04>-4" +"Asia/Tehran","<+0330>-3:30" +"Asia/Thimphu","<+06>-6" +"Asia/Tokyo","JST-9" +"Asia/Tomsk","<+07>-7" +"Asia/Ulaanbaatar","<+08>-8" +"Asia/Urumqi","<+06>-6" +"Asia/Ust-Nera","<+10>-10" +"Asia/Vientiane","<+07>-7" +"Asia/Vladivostok","<+10>-10" +"Asia/Yakutsk","<+09>-9" +"Asia/Yangon","<+0630>-6:30" +"Asia/Yekaterinburg","<+05>-5" +"Asia/Yerevan","<+04>-4" +"Atlantic/Azores","<-01>1<+00>,M3.5.0/0,M10.5.0/1" +"Atlantic/Bermuda","AST4ADT,M3.2.0,M11.1.0" +"Atlantic/Canary","WET0WEST,M3.5.0/1,M10.5.0" +"Atlantic/Cape_Verde","<-01>1" +"Atlantic/Faroe","WET0WEST,M3.5.0/1,M10.5.0" +"Atlantic/Madeira","WET0WEST,M3.5.0/1,M10.5.0" +"Atlantic/Reykjavik","GMT0" +"Atlantic/South_Georgia","<-02>2" +"Atlantic/Stanley","<-03>3" +"Atlantic/St_Helena","GMT0" +"Australia/Adelaide","ACST-9:30ACDT,M10.1.0,M4.1.0/3" +"Australia/Brisbane","AEST-10" +"Australia/Broken_Hill","ACST-9:30ACDT,M10.1.0,M4.1.0/3" +"Australia/Currie","AEST-10AEDT,M10.1.0,M4.1.0/3" +"Australia/Darwin","ACST-9:30" +"Australia/Eucla","<+0845>-8:45" +"Australia/Hobart","AEST-10AEDT,M10.1.0,M4.1.0/3" +"Australia/Lindeman","AEST-10" +"Australia/Lord_Howe","<+1030>-10:30<+11>-11,M10.1.0,M4.1.0" +"Australia/Melbourne","AEST-10AEDT,M10.1.0,M4.1.0/3" +"Australia/Perth","AWST-8" +"Australia/Sydney","AEST-10AEDT,M10.1.0,M4.1.0/3" +"Europe/Amsterdam","CET-1CEST,M3.5.0,M10.5.0/3" +"Europe/Andorra","CET-1CEST,M3.5.0,M10.5.0/3" +"Europe/Astrakhan","<+04>-4" +"Europe/Athens","EET-2EEST,M3.5.0/3,M10.5.0/4" +"Europe/Belgrade","CET-1CEST,M3.5.0,M10.5.0/3" +"Europe/Berlin","CET-1CEST,M3.5.0,M10.5.0/3" +"Europe/Bratislava","CET-1CEST,M3.5.0,M10.5.0/3" +"Europe/Brussels","CET-1CEST,M3.5.0,M10.5.0/3" +"Europe/Bucharest","EET-2EEST,M3.5.0/3,M10.5.0/4" +"Europe/Budapest","CET-1CEST,M3.5.0,M10.5.0/3" +"Europe/Busingen","CET-1CEST,M3.5.0,M10.5.0/3" +"Europe/Chisinau","EET-2EEST,M3.5.0,M10.5.0/3" +"Europe/Copenhagen","CET-1CEST,M3.5.0,M10.5.0/3" +"Europe/Dublin","IST-1GMT0,M10.5.0,M3.5.0/1" +"Europe/Gibraltar","CET-1CEST,M3.5.0,M10.5.0/3" +"Europe/Guernsey","GMT0BST,M3.5.0/1,M10.5.0" +"Europe/Helsinki","EET-2EEST,M3.5.0/3,M10.5.0/4" +"Europe/Isle_of_Man","GMT0BST,M3.5.0/1,M10.5.0" +"Europe/Istanbul","<+03>-3" +"Europe/Jersey","GMT0BST,M3.5.0/1,M10.5.0" +"Europe/Kaliningrad","EET-2" +"Europe/Kiev","EET-2EEST,M3.5.0/3,M10.5.0/4" +"Europe/Kirov","MSK-3" +"Europe/Lisbon","WET0WEST,M3.5.0/1,M10.5.0" +"Europe/Ljubljana","CET-1CEST,M3.5.0,M10.5.0/3" +"Europe/London","GMT0BST,M3.5.0/1,M10.5.0" +"Europe/Luxembourg","CET-1CEST,M3.5.0,M10.5.0/3" +"Europe/Madrid","CET-1CEST,M3.5.0,M10.5.0/3" +"Europe/Malta","CET-1CEST,M3.5.0,M10.5.0/3" +"Europe/Mariehamn","EET-2EEST,M3.5.0/3,M10.5.0/4" +"Europe/Minsk","<+03>-3" +"Europe/Monaco","CET-1CEST,M3.5.0,M10.5.0/3" +"Europe/Moscow","MSK-3" +"Europe/Oslo","CET-1CEST,M3.5.0,M10.5.0/3" +"Europe/Paris","CET-1CEST,M3.5.0,M10.5.0/3" +"Europe/Podgorica","CET-1CEST,M3.5.0,M10.5.0/3" +"Europe/Prague","CET-1CEST,M3.5.0,M10.5.0/3" +"Europe/Riga","EET-2EEST,M3.5.0/3,M10.5.0/4" +"Europe/Rome","CET-1CEST,M3.5.0,M10.5.0/3" +"Europe/Samara","<+04>-4" +"Europe/San_Marino","CET-1CEST,M3.5.0,M10.5.0/3" +"Europe/Sarajevo","CET-1CEST,M3.5.0,M10.5.0/3" +"Europe/Saratov","<+04>-4" +"Europe/Simferopol","MSK-3" +"Europe/Skopje","CET-1CEST,M3.5.0,M10.5.0/3" +"Europe/Sofia","EET-2EEST,M3.5.0/3,M10.5.0/4" +"Europe/Stockholm","CET-1CEST,M3.5.0,M10.5.0/3" +"Europe/Tallinn","EET-2EEST,M3.5.0/3,M10.5.0/4" +"Europe/Tirane","CET-1CEST,M3.5.0,M10.5.0/3" +"Europe/Ulyanovsk","<+04>-4" +"Europe/Uzhgorod","EET-2EEST,M3.5.0/3,M10.5.0/4" +"Europe/Vaduz","CET-1CEST,M3.5.0,M10.5.0/3" +"Europe/Vatican","CET-1CEST,M3.5.0,M10.5.0/3" +"Europe/Vienna","CET-1CEST,M3.5.0,M10.5.0/3" +"Europe/Vilnius","EET-2EEST,M3.5.0/3,M10.5.0/4" +"Europe/Volgograd","MSK-3" +"Europe/Warsaw","CET-1CEST,M3.5.0,M10.5.0/3" +"Europe/Zagreb","CET-1CEST,M3.5.0,M10.5.0/3" +"Europe/Zaporozhye","EET-2EEST,M3.5.0/3,M10.5.0/4" +"Europe/Zurich","CET-1CEST,M3.5.0,M10.5.0/3" +"Indian/Antananarivo","EAT-3" +"Indian/Chagos","<+06>-6" +"Indian/Christmas","<+07>-7" +"Indian/Cocos","<+0630>-6:30" +"Indian/Comoro","EAT-3" +"Indian/Kerguelen","<+05>-5" +"Indian/Mahe","<+04>-4" +"Indian/Maldives","<+05>-5" +"Indian/Mauritius","<+04>-4" +"Indian/Mayotte","EAT-3" +"Indian/Reunion","<+04>-4" +"Pacific/Apia","<+13>-13" +"Pacific/Auckland","NZST-12NZDT,M9.5.0,M4.1.0/3" +"Pacific/Bougainville","<+11>-11" +"Pacific/Chatham","<+1245>-12:45<+1345>,M9.5.0/2:45,M4.1.0/3:45" +"Pacific/Chuuk","<+10>-10" +"Pacific/Easter","<-06>6<-05>,M9.1.6/22,M4.1.6/22" +"Pacific/Efate","<+11>-11" +"Pacific/Enderbury","<+13>-13" +"Pacific/Fakaofo","<+13>-13" +"Pacific/Fiji","<+12>-12" +"Pacific/Funafuti","<+12>-12" +"Pacific/Galapagos","<-06>6" +"Pacific/Gambier","<-09>9" +"Pacific/Guadalcanal","<+11>-11" +"Pacific/Guam","ChST-10" +"Pacific/Honolulu","HST10" +"Pacific/Kiritimati","<+14>-14" +"Pacific/Kosrae","<+11>-11" +"Pacific/Kwajalein","<+12>-12" +"Pacific/Majuro","<+12>-12" +"Pacific/Marquesas","<-0930>9:30" +"Pacific/Midway","SST11" +"Pacific/Nauru","<+12>-12" +"Pacific/Niue","<-11>11" +"Pacific/Norfolk","<+11>-11<+12>,M10.1.0,M4.1.0/3" +"Pacific/Noumea","<+11>-11" +"Pacific/Pago_Pago","SST11" +"Pacific/Palau","<+09>-9" +"Pacific/Pitcairn","<-08>8" +"Pacific/Pohnpei","<+11>-11" +"Pacific/Port_Moresby","<+10>-10" +"Pacific/Rarotonga","<-10>10" +"Pacific/Saipan","ChST-10" +"Pacific/Tahiti","<-10>10" +"Pacific/Tarawa","<+12>-12" +"Pacific/Tongatapu","<+13>-13" +"Pacific/Wake","<+12>-12" +"Pacific/Wallis","<+12>-12" +"Etc/GMT","GMT0" +"Etc/GMT-0","GMT0" +"Etc/GMT-1","<+01>-1" +"Etc/GMT-2","<+02>-2" +"Etc/GMT-3","<+03>-3" +"Etc/GMT-4","<+04>-4" +"Etc/GMT-5","<+05>-5" +"Etc/GMT-6","<+06>-6" +"Etc/GMT-7","<+07>-7" +"Etc/GMT-8","<+08>-8" +"Etc/GMT-9","<+09>-9" +"Etc/GMT-10","<+10>-10" +"Etc/GMT-11","<+11>-11" +"Etc/GMT-12","<+12>-12" +"Etc/GMT-13","<+13>-13" +"Etc/GMT-14","<+14>-14" +"Etc/GMT0","GMT0" +"Etc/GMT+0","GMT0" +"Etc/GMT+1","<-01>1" +"Etc/GMT+2","<-02>2" +"Etc/GMT+3","<-03>3" +"Etc/GMT+4","<-04>4" +"Etc/GMT+5","<-05>5" +"Etc/GMT+6","<-06>6" +"Etc/GMT+7","<-07>7" +"Etc/GMT+8","<-08>8" +"Etc/GMT+9","<-09>9" +"Etc/GMT+10","<-10>10" +"Etc/GMT+11","<-11>11" +"Etc/GMT+12","<-12>12" +"Etc/UCT","UTC0" +"Etc/UTC","UTC0" +"Etc/Greenwich","GMT0" +"Etc/Universal","UTC0" +"Etc/Zulu","UTC0" diff --git a/Data/system_sources/app/TimeZone/search.svg b/Data/system_sources/app/TimeZone/search.svg new file mode 100644 index 00000000..11c909dd --- /dev/null +++ b/Data/system_sources/app/TimeZone/search.svg @@ -0,0 +1,42 @@ + + + + + + diff --git a/Data/system_sources/app_icon_time_date_settings.svg b/Data/system_sources/app_icon_time_date_settings.svg new file mode 100644 index 00000000..200affaa --- /dev/null +++ b/Data/system_sources/app_icon_time_date_settings.svg @@ -0,0 +1,42 @@ + + + + + + diff --git a/Documentation/ideas.md b/Documentation/ideas.md index 6e44d387..1511a279 100644 --- a/Documentation/ideas.md +++ b/Documentation/ideas.md @@ -12,8 +12,16 @@ - M5Stack CoreS3 SD card mounts, but cannot be read. There is currently a notice about it [here](https://github.com/espressif/esp-bsp/blob/master/bsp/m5stack_core_s3/README.md). - EventFlag: Fix return value of set/get/wait (the errors are weirdly mixed in) - Consistently use either ESP_TARGET or ESP_PLATFORM +- tt_check() failure during app argument bundle nullptr check seems to trigger SIGSEGV +- Fix bug in T-Deck/etc: esp_lvgl_port settings has a large stack depth (~9kB) to fix an issue where the T-Deck would get a stackoverflow. This sometimes happens when WiFi is auto-enabled and you open the app while it is still connecting. # TODOs +- Try to fix SDL pipeline issue with apt-get: + ```yaml + - name: Install SDL + run: sudo apt-get install -y libsdl2-dev cmake + ``` +- Make "blocking" argument the last one, and put it default to false (or remove it entirely?): void startApp(const std::string& id, bool blocking, std::shared_ptr parameters) { - Boot hooks instead of a single boot method in config. Define different boot phases/levels in enum. - Add toggle to Display app for sysmon overlay: https://docs.lvgl.io/master/API/others/sysmon/index.html - CrashHandler: use "corrupted" flag diff --git a/Tactility/Private/service/gui/Gui_i.h b/Tactility/Private/service/gui/Gui_i.h index 5d53f17c..7386d643 100644 --- a/Tactility/Private/service/gui/Gui_i.h +++ b/Tactility/Private/service/gui/Gui_i.h @@ -23,13 +23,14 @@ struct Gui { PubSubSubscription* loader_pubsub_subscription = nullptr; // Layers and Canvas - lv_obj_t* lvgl_parent = nullptr; + lv_obj_t* appRootWidget = nullptr; + lv_obj_t* statusbarWidget = nullptr; // App-specific - ViewPort* app_view_port = nullptr; + ViewPort* appViewPort = nullptr; lv_obj_t* _Nullable keyboard = nullptr; - lv_group_t* keyboard_group = nullptr; + lv_group_t* keyboardGroup = nullptr; }; /** Update GUI, request redraw */ diff --git a/Tactility/Private/service/loader/Loader_i.h b/Tactility/Private/service/loader/Loader_i.h index f0af6ea3..caab3a32 100644 --- a/Tactility/Private/service/loader/Loader_i.h +++ b/Tactility/Private/service/loader/Loader_i.h @@ -90,10 +90,13 @@ public: // endregion LoaderMessage struct Loader { - std::shared_ptr pubsub_internal = std::make_shared(); - std::shared_ptr pubsub_external = std::make_shared(); + std::shared_ptr pubsubInternal = std::make_shared(); + std::shared_ptr pubsubExternal = std::make_shared(); Mutex mutex = Mutex(Mutex::TypeRecursive); - std::stack app_stack; + std::stack appStack; + /** The dispatcher thread needs a callstack large enough to accommodate all the dispatched methods. + * This includes full LVGL redraw via Gui::redraw() + */ std::unique_ptr dispatcherThread = std::make_unique("loader_dispatcher", 6144); // Files app requires ~5k }; diff --git a/Tactility/Source/Tactility.cpp b/Tactility/Source/Tactility.cpp index 966f6767..8d648910 100644 --- a/Tactility/Source/Tactility.cpp +++ b/Tactility/Source/Tactility.cpp @@ -54,6 +54,8 @@ namespace app { namespace settings { extern const AppManifest manifest; } namespace systeminfo { extern const AppManifest manifest; } namespace textviewer { extern const AppManifest manifest; } + namespace timedatesettings { extern const AppManifest manifest; } + namespace timezone { extern const AppManifest manifest; } namespace usbsettings { extern const AppManifest manifest; } namespace wifiapsettings { extern const AppManifest manifest; } namespace wificonnect { extern const AppManifest manifest; } @@ -87,6 +89,8 @@ static const std::vector system_apps = { &app::selectiondialog::manifest, &app::systeminfo::manifest, &app::textviewer::manifest, + &app::timedatesettings::manifest, + &app::timezone::manifest, &app::usbsettings::manifest, &app::wifiapsettings::manifest, &app::wificonnect::manifest, diff --git a/Tactility/Source/app/boot/Boot.cpp b/Tactility/Source/app/boot/Boot.cpp index 36645524..df4e16eb 100644 --- a/Tactility/Source/app/boot/Boot.cpp +++ b/Tactility/Source/app/boot/Boot.cpp @@ -10,6 +10,7 @@ #include "lvgl.h" #include "Tactility.h" #include "hal/usb/Usb.h" +#include "kernel/SystemEvents.h" #ifdef ESP_PLATFORM #include "kernel/PanicHandler.h" @@ -35,6 +36,8 @@ struct Data { static int32_t bootThreadCallback(TT_UNUSED void* context) { TickType_t start_time = kernel::getTicks(); + kernel::systemEventPublish(kernel::SystemEvent::BootSplash); + auto* lvgl_display = lv_display_get_default(); tt_assert(lvgl_display != nullptr); auto* hal_display = (hal::Display*)lv_display_get_user_data(lvgl_display); diff --git a/Tactility/Source/app/files/FileUtils.cpp b/Tactility/Source/app/files/FileUtils.cpp index 648323c0..c68f1c5e 100644 --- a/Tactility/Source/app/files/FileUtils.cpp +++ b/Tactility/Source/app/files/FileUtils.cpp @@ -2,6 +2,7 @@ #include "TactilityCore.h" #include #include +#include namespace tt::app::files { @@ -69,25 +70,13 @@ bool isSupportedExecutableFile(const std::string& filename) { #endif } -template -std::basic_string lowercase(const std::basic_string& input) { - std::basic_string output = input; - std::transform( - output.begin(), - output.end(), - output.begin(), - [](const T character) { return static_cast(std::tolower(character)); } - ); - return std::move(output); -} - bool isSupportedImageFile(const std::string& filename) { // Currently only the PNG library is built into Tactility - return lowercase(filename).ends_with(".png"); + return string::lowercase(filename).ends_with(".png"); } bool isSupportedTextFile(const std::string& filename) { - std::string filename_lower = lowercase(filename); + std::string filename_lower = string::lowercase(filename); return filename_lower.ends_with(".txt") || filename_lower.ends_with(".ini") || filename_lower.ends_with(".json") || diff --git a/Tactility/Source/app/timedatesettings/TimeDateSettings.cpp b/Tactility/Source/app/timedatesettings/TimeDateSettings.cpp new file mode 100644 index 00000000..7f29ae11 --- /dev/null +++ b/Tactility/Source/app/timedatesettings/TimeDateSettings.cpp @@ -0,0 +1,136 @@ +#include +#include "lvgl.h" +#include "lvgl/Toolbar.h" +#include "service/loader/Loader.h" +#include "app/timezone/TimeZone.h" +#include "Assets.h" +#include "Tactility.h" +#include "time/Time.h" +#include "lvgl/LvglSync.h" + +#define TAG "text_viewer" + +namespace tt::app::timedatesettings { + +extern const AppManifest manifest; + +struct Data { + Mutex mutex = Mutex(Mutex::TypeRecursive); + lv_obj_t* regionLabelWidget = nullptr; +}; + +/** Returns the app data if the app is active. Note that this could clash if the same app is started twice and a background thread is slow. */ +std::shared_ptr _Nullable optData() { + app::AppContext* app = service::loader::getCurrentApp(); + if (app->getManifest().id == manifest.id) { + return std::static_pointer_cast(app->getData()); + } else { + return nullptr; + } +} + +static void onConfigureTimeZonePressed(TT_UNUSED lv_event_t* event) { + timezone::start(); +} + +static void onTimeFormatChanged(lv_event_t* event) { + auto* widget = lv_event_get_target_obj(event); + bool show_24 = lv_obj_has_state(widget, LV_STATE_CHECKED); + time::setTimeFormat24Hour(show_24); +} + +static void onShow(AppContext& app, lv_obj_t* parent) { + auto data = std::static_pointer_cast(app.getData()); + + lv_obj_set_flex_flow(parent, LV_FLEX_FLOW_COLUMN); + + lvgl::toolbar_create(parent, app); + + auto* main_wrapper = lv_obj_create(parent); + lv_obj_set_flex_flow(main_wrapper, LV_FLEX_FLOW_COLUMN); + lv_obj_set_width(main_wrapper, LV_PCT(100)); + lv_obj_set_flex_grow(main_wrapper, 1); + + auto* region_wrapper = lv_obj_create(main_wrapper); + lv_obj_set_width(region_wrapper, LV_PCT(100)); + lv_obj_set_height(region_wrapper, LV_SIZE_CONTENT); + lv_obj_set_style_pad_all(region_wrapper, 0, 0); + lv_obj_set_style_border_width(region_wrapper, 0, 0); + + auto* region_prefix_label = lv_label_create(region_wrapper); + lv_label_set_text(region_prefix_label, "Region: "); + lv_obj_align(region_prefix_label, LV_ALIGN_LEFT_MID, 0, 0); + + auto* region_label = lv_label_create(region_wrapper); + std::string timeZoneName = time::getTimeZoneName(); + if (timeZoneName.empty()) { + timeZoneName = "not set"; + } + data->regionLabelWidget = region_label; + lv_label_set_text(region_label, timeZoneName.c_str()); + // TODO: Find out why Y offset is needed + lv_obj_align_to(region_label, region_prefix_label, LV_ALIGN_OUT_RIGHT_MID, 0, 8); + + auto* region_button = lv_button_create(region_wrapper); + lv_obj_align(region_button, LV_ALIGN_TOP_RIGHT, 0, 0); + auto* region_button_image = lv_image_create(region_button); + lv_obj_add_event_cb(region_button, onConfigureTimeZonePressed, LV_EVENT_SHORT_CLICKED, nullptr); + lv_image_set_src(region_button_image, LV_SYMBOL_SETTINGS); + + auto* time_format_wrapper= lv_obj_create(main_wrapper); + lv_obj_set_width(time_format_wrapper, LV_PCT(100)); + lv_obj_set_height(time_format_wrapper, LV_SIZE_CONTENT); + lv_obj_set_style_pad_all(time_format_wrapper, 0, 0); + lv_obj_set_style_border_width(time_format_wrapper, 0, 0); + + auto* time_24h_label = lv_label_create(time_format_wrapper); + lv_label_set_text(time_24h_label, "24-hour clock"); + lv_obj_align(time_24h_label, LV_ALIGN_LEFT_MID, 0, 0); + + auto* time_24h_switch = lv_switch_create(time_format_wrapper); + lv_obj_align(time_24h_switch, LV_ALIGN_RIGHT_MID, 0, 0); + lv_obj_add_event_cb(time_24h_switch, onTimeFormatChanged, LV_EVENT_VALUE_CHANGED, nullptr); + if (time::isTimeFormat24Hour()) { + lv_obj_add_state(time_24h_switch, LV_STATE_CHECKED); + } else { + lv_obj_remove_state(time_24h_switch, LV_STATE_CHECKED); + } +} + +static void onStart(AppContext& app) { + auto data = std::make_shared(); + app.setData(data); +} + +static void onResult(AppContext& app, Result result, const Bundle& bundle) { + if (result == ResultOk) { + auto data = std::static_pointer_cast(app.getData()); + auto name = timezone::getResultName(bundle); + auto code = timezone::getResultCode(bundle); + TT_LOG_I(TAG, "Result name=%s code=%s", name.c_str(), code.c_str()); + time::setTimeZone(name, code); + + if (!name.empty()) { + if (lvgl::lock(100 / portTICK_PERIOD_MS)) { + lv_label_set_text(data->regionLabelWidget, name.c_str()); + lvgl::unlock(); + } + } + } +} + +extern const AppManifest manifest = { + .id = "TimeDateSettings", + .name = "Time & Date", + .icon = TT_ASSETS_APP_ICON_TIME_DATE_SETTINGS, + .type = TypeSettings, + .onStart = onStart, + .onShow = onShow, + .onResult = onResult +}; + +void start() { + service::loader::startApp(manifest.id); +} + +} // namespace diff --git a/Tactility/Source/app/timedatesettings/TimeDateSettings.h b/Tactility/Source/app/timedatesettings/TimeDateSettings.h new file mode 100644 index 00000000..e1e6e93f --- /dev/null +++ b/Tactility/Source/app/timedatesettings/TimeDateSettings.h @@ -0,0 +1,7 @@ +#pragma once + +namespace tt::app::timedatesettings { + +void start(); + +} \ No newline at end of file diff --git a/Tactility/Source/app/timezone/TimeZone.cpp b/Tactility/Source/app/timezone/TimeZone.cpp new file mode 100644 index 00000000..31abd562 --- /dev/null +++ b/Tactility/Source/app/timezone/TimeZone.cpp @@ -0,0 +1,243 @@ +#include "TimeZone.h" +#include "app/AppManifest.h" +#include "app/AppContext.h" +#include "service/loader/Loader.h" +#include "lvgl.h" +#include "lvgl/Toolbar.h" +#include "Partitions.h" +#include "TactilityHeadless.h" +#include "lvgl/LvglSync.h" +#include "service/gui/Gui.h" +#include +#include +#include + +namespace tt::app::timezone { + +#define TAG "timezone_select" + +#define RESULT_BUNDLE_CODE_INDEX "code" +#define RESULT_BUNDLE_NAME_INDEX "name" + +extern const AppManifest manifest; + +struct TimeZoneEntry { + std::string name; + std::string code; +}; + +struct Data { + Mutex mutex; + std::vector entries; + std::unique_ptr updateTimer; + lv_obj_t* listWidget = nullptr; + lv_obj_t* filterTextareaWidget = nullptr; +}; + +static void updateList(std::shared_ptr& data); + +static bool parseEntry(const std::string& input, std::string& outName, std::string& outCode) { + std::string partial_strip = input.substr(1, input.size() - 3); + auto first_end_quote = partial_strip.find('"'); + if (first_end_quote == std::string::npos) { + return false; + } else { + outName = partial_strip.substr(0, first_end_quote); + outCode = partial_strip.substr(first_end_quote + 3); + return true; + } +} + +// region Result + +std::string getResultName(const Bundle& bundle) { + std::string result; + bundle.optString(RESULT_BUNDLE_NAME_INDEX, result); + return result; +} + +std::string getResultCode(const Bundle& bundle) { + std::string result; + bundle.optString(RESULT_BUNDLE_CODE_INDEX, result); + return result; +} + +void setResultName(std::shared_ptr& bundle, const std::string& name) { + bundle->putString(RESULT_BUNDLE_NAME_INDEX, name); +} + +void setResultCode(std::shared_ptr& bundle, const std::string& code) { + bundle->putString(RESULT_BUNDLE_CODE_INDEX, code); +} + +// endregion + +static void onUpdateTimer(std::shared_ptr context) { + auto data = std::static_pointer_cast(context); + updateList(data); +} + +static void onTextareaValueChanged(TT_UNUSED lv_event_t* e) { + auto* app = service::loader::getCurrentApp(); + auto app_data = app->getData(); + auto data = std::static_pointer_cast(app_data); + + if (data->mutex.lock(100 / portTICK_PERIOD_MS)) { + if (data->updateTimer->isRunning()) { + data->updateTimer->stop(); + } + + data->updateTimer->start(500 / portTICK_PERIOD_MS); + + data->mutex.unlock(); + } +} + +static void onListItemSelected(lv_event_t* e) { + auto index = reinterpret_cast(lv_event_get_user_data(e)); + TT_LOG_I(TAG, "Selected item at index %zu", index); + auto* app = service::loader::getCurrentApp(); + auto data = std::static_pointer_cast(app->getData()); + + auto& entry = data->entries[index]; + + auto bundle = std::make_shared(); + setResultName(bundle, entry.name); + setResultCode(bundle, entry.code); + app->setResult(app::ResultOk, bundle); + + service::loader::stopApp(); +} + +static void createListItem(lv_obj_t* list, const std::string& title, size_t index) { + lv_obj_t* btn = lv_list_add_button(list, nullptr, title.c_str()); + lv_obj_add_event_cb(btn, &onListItemSelected, LV_EVENT_SHORT_CLICKED, (void*)index); +} + +static void readTimeZones(const std::shared_ptr& data, std::string filter) { + auto path = std::string(MOUNT_POINT_SYSTEM) + "/timezones.csv"; + auto* file = fopen(path.c_str(), "rb"); + if (file == nullptr) { + TT_LOG_E(TAG, "Failed to open %s", path.c_str()); + return; + } + char line[96]; + std::string name; + std::string code; + uint32_t count = 0; + std::vector entries; + while (fgets(line, 96, file)) { + if (parseEntry(line, name, code)) { + if (tt::string::lowercase(name).find(filter) != std::string::npos) { + count++; + entries.push_back({ + .name = name, + .code = code + }); + + // Safety guard + if (count > 50) { + // TODO: Show warning that we're not displaying a complete list + break; + } + } + } else { + TT_LOG_E(TAG, "Parse error at line %lu", count); + } + } + + fclose(file); + + if (data->mutex.lock(100 / portTICK_PERIOD_MS)) { + data->entries = std::move(entries); + data->mutex.unlock(); + } else { + TT_LOG_E(TAG, LOG_MESSAGE_MUTEX_LOCK_FAILED); + } + + TT_LOG_I(TAG, "Processed %lu entries", count); +} + +static void updateList(std::shared_ptr& data) { + if (lvgl::lock(100 / portTICK_PERIOD_MS)) { + std::string filter = tt::string::lowercase(std::string(lv_textarea_get_text(data->filterTextareaWidget))); + readTimeZones(data, filter); + lvgl::unlock(); + } else { + TT_LOG_E(TAG, LOG_MESSAGE_MUTEX_LOCK_FAILED_FMT, "LVGL"); + return; + } + + if (lvgl::lock(100 / portTICK_PERIOD_MS)) { + if (data->mutex.lock(100 / portTICK_PERIOD_MS)) { + lv_obj_clean(data->listWidget); + + uint32_t index = 0; + for (auto& entry : data->entries) { + createListItem(data->listWidget, entry.name, index); + index++; + } + + data->mutex.unlock(); + } + + lvgl::unlock(); + } +} + +static void onShow(AppContext& app, lv_obj_t* parent) { + auto data = std::static_pointer_cast(app.getData()); + + lv_obj_set_flex_flow(parent, LV_FLEX_FLOW_COLUMN); + lvgl::toolbar_create(parent, app); + + auto* search_wrapper = lv_obj_create(parent); + lv_obj_set_size(search_wrapper, LV_PCT(100), LV_SIZE_CONTENT); + lv_obj_set_flex_flow(search_wrapper, LV_FLEX_FLOW_ROW); + lv_obj_set_flex_align(search_wrapper, LV_FLEX_ALIGN_START, LV_FLEX_ALIGN_CENTER, LV_FLEX_ALIGN_START); + lv_obj_set_style_pad_all(search_wrapper, 0, 0); + lv_obj_set_style_border_width(search_wrapper, 0, 0); + + auto* icon = lv_image_create(search_wrapper); + lv_obj_set_style_margin_left(icon, 8, 0); + lv_obj_set_style_image_recolor_opa(icon, 255, 0); + lv_obj_set_style_image_recolor(icon, lv_theme_get_color_primary(parent), 0); + + std::string icon_path = app.getPaths()->getSystemPathLvgl("search.png"); + lv_image_set_src(icon, icon_path.c_str()); + lv_obj_set_style_image_recolor(icon, lv_theme_get_color_primary(parent), 0); + + auto* textarea = lv_textarea_create(search_wrapper); + lv_textarea_set_placeholder_text(textarea, "e.g. Europe/Amsterdam"); + lv_textarea_set_one_line(textarea, true); + lv_obj_add_event_cb(textarea, onTextareaValueChanged, LV_EVENT_VALUE_CHANGED, nullptr); + data->filterTextareaWidget = textarea; + lv_obj_set_flex_grow(textarea, 1); + service::gui::keyboardAddTextArea(textarea); + + auto* list = lv_list_create(parent); + lv_obj_set_width(list, LV_PCT(100)); + lv_obj_set_flex_grow(list, 1); + lv_obj_set_style_border_width(list, 0, 0); + data->listWidget = list; +} + +static void onStart(AppContext& app) { + auto data = std::make_shared(); + data->updateTimer = std::make_unique(Timer::TypeOnce, onUpdateTimer, data); + app.setData(data); +} + +extern const AppManifest manifest = { + .id = "TimeZone", + .name = "Select timezone", + .type = TypeHidden, + .onStart = onStart, + .onShow = onShow, +}; + +void start() { + service::loader::startApp(manifest.id); +} + +} diff --git a/Tactility/Source/app/timezone/TimeZone.h b/Tactility/Source/app/timezone/TimeZone.h new file mode 100644 index 00000000..725723e8 --- /dev/null +++ b/Tactility/Source/app/timezone/TimeZone.h @@ -0,0 +1,12 @@ +#pragma once + +#include "Bundle.h" + +namespace tt::app::timezone { + +void start(); + +std::string getResultName(const Bundle& bundle); +std::string getResultCode(const Bundle& bundle); + +} diff --git a/Tactility/Source/lvgl/Init.cpp b/Tactility/Source/lvgl/Init.cpp index b153f8ea..a52bc167 100644 --- a/Tactility/Source/lvgl/Init.cpp +++ b/Tactility/Source/lvgl/Init.cpp @@ -6,6 +6,7 @@ #include "hal/Keyboard.h" #include "lvgl/LvglKeypad.h" #include "lvgl/Lvgl.h" +#include "kernel/SystemEvents.h" namespace tt::lvgl { @@ -76,6 +77,8 @@ bool initKeyboard(hal::Display* display, hal::Keyboard* keyboard) { void init(const hal::Configuration& config) { TT_LOG_I(TAG, "Starting"); + kernel::systemEventPublish(kernel::SystemEvent::BootInitLvglBegin); + if (config.initLvgl != nullptr && !config.initLvgl()) { TT_LOG_E(TAG, "LVGL init failed"); return; @@ -98,6 +101,8 @@ void init(const hal::Configuration& config) { } TT_LOG_I(TAG, "Finished"); + + kernel::systemEventPublish(kernel::SystemEvent::BootInitLvglEnd); } } // namespace diff --git a/Tactility/Source/lvgl/Statusbar.cpp b/Tactility/Source/lvgl/Statusbar.cpp index b9f0bd96..82328b28 100644 --- a/Tactility/Source/lvgl/Statusbar.cpp +++ b/Tactility/Source/lvgl/Statusbar.cpp @@ -1,4 +1,6 @@ #define LV_USE_PRIVATE_API 1 // For actual lv_obj_t declaration + +#include #include "Statusbar.h" #include "Mutex.h" @@ -8,11 +10,15 @@ #include "LvglSync.h" #include "lvgl.h" +#include "kernel/SystemEvents.h" +#include "time/Time.h" namespace tt::lvgl { #define TAG "statusbar" +static void onUpdateTime(TT_UNUSED std::shared_ptr context); + struct StatusbarIcon { std::string image; bool visible = false; @@ -23,12 +29,18 @@ struct StatusbarData { Mutex mutex = Mutex(Mutex::TypeRecursive); std::shared_ptr pubsub = std::make_shared(); StatusbarIcon icons[STATUSBAR_ICON_LIMIT] = {}; + Timer* time_update_timer = new Timer(Timer::TypeOnce, onUpdateTime, nullptr); + uint8_t time_hours = 0; + uint8_t time_minutes = 0; + bool time_set = false; + kernel::SystemEventSubscription systemEventSubscription = 0; }; static StatusbarData statusbar_data; typedef struct { lv_obj_t obj; + lv_obj_t* time; lv_obj_t* icons[STATUSBAR_ICON_LIMIT]; lv_obj_t* battery_icon; PubSubSubscription* pubsub_subscription; @@ -46,8 +58,40 @@ static void statusbar_constructor(const lv_obj_class_t* class_p, lv_obj_t* obj); static void statusbar_destructor(const lv_obj_class_t* class_p, lv_obj_t* obj); static void statusbar_event(const lv_obj_class_t* class_p, lv_event_t* event); +static void update_time(Statusbar* statusbar); static void update_main(Statusbar* statusbar); +static TickType_t getNextUpdateTime() { + time_t now = ::time(nullptr); + struct tm* tm_struct = localtime(&now); + uint32_t seconds_to_wait = 60U - tm_struct->tm_sec; + TT_LOG_I(TAG, "Update in %lu s", seconds_to_wait); + return pdMS_TO_TICKS(seconds_to_wait * 1000U); +} + +static void onUpdateTime(TT_UNUSED std::shared_ptr context) { + time_t now = ::time(nullptr); + struct tm* tm_struct = localtime(&now); + + if (statusbar_data.mutex.lock(100 / portTICK_PERIOD_MS)) { + if (tm_struct->tm_year >= (2025 - 1900)) { + statusbar_data.time_hours = tm_struct->tm_hour; + statusbar_data.time_minutes = tm_struct->tm_min; + statusbar_data.time_set = true; + + // Reschedule + statusbar_data.time_update_timer->start(getNextUpdateTime()); + + // Notify widget + tt_pubsub_publish(statusbar_data.pubsub, nullptr); + } else { + statusbar_data.time_update_timer->start(pdMS_TO_TICKS(60000U)); + } + + statusbar_data.mutex.unlock(); + } +} + static const lv_obj_class_t statusbar_class = { .base_class = &lv_obj_class, .constructor_cb = &statusbar_constructor, @@ -73,6 +117,15 @@ static void statusbar_pubsub_event(TT_UNUSED const void* message, void* obj) { } } +static void onNetworkConnected(TT_UNUSED kernel::SystemEvent event) { + if (statusbar_data.mutex.lock(100 / portTICK_PERIOD_MS)) { + statusbar_data.time_update_timer->stop(); + statusbar_data.time_update_timer->start(5); + + statusbar_data.mutex.unlock(); + } +} + static void statusbar_constructor(const lv_obj_class_t* class_p, lv_obj_t* obj) { LV_UNUSED(class_p); LV_TRACE_OBJ_CREATE("begin"); @@ -80,6 +133,14 @@ static void statusbar_constructor(const lv_obj_class_t* class_p, lv_obj_t* obj) LV_TRACE_OBJ_CREATE("finished"); auto* statusbar = (Statusbar*)obj; statusbar->pubsub_subscription = tt_pubsub_subscribe(statusbar_data.pubsub, &statusbar_pubsub_event, statusbar); + + if (!statusbar_data.time_update_timer->isRunning()) { + statusbar_data.time_update_timer->start(50 / portTICK_PERIOD_MS); + statusbar_data.systemEventSubscription = kernel::systemEventAddListener( + kernel::SystemEvent::Time, + onNetworkConnected + ); + } } static void statusbar_destructor(TT_UNUSED const lv_obj_class_t* class_p, lv_obj_t* obj) { @@ -108,15 +169,21 @@ lv_obj_t* statusbar_create(lv_obj_t* parent) { obj_set_style_no_padding(obj); lv_obj_center(obj); lv_obj_set_flex_flow(obj, LV_FLEX_FLOW_ROW); + lv_obj_set_flex_align(obj, LV_FLEX_ALIGN_CENTER, LV_FLEX_ALIGN_CENTER, LV_FLEX_ALIGN_CENTER); - lv_obj_t* left_spacer = lv_obj_create(obj); + statusbar->time = lv_label_create(obj); + lv_obj_set_style_text_color(statusbar->time, lv_color_white(), 0); + lv_obj_set_style_margin_left(statusbar->time, 4, 0); + update_time(statusbar); + + auto* left_spacer = lv_obj_create(obj); lv_obj_set_size(left_spacer, 1, 1); obj_set_style_bg_invisible(left_spacer); lv_obj_set_flex_grow(left_spacer, 1); statusbar_lock(TtWaitForever); for (int i = 0; i < STATUSBAR_ICON_LIMIT; ++i) { - lv_obj_t* image = lv_image_create(obj); + auto* image = lv_image_create(obj); lv_obj_set_size(image, STATUSBAR_ICON_SIZE, STATUSBAR_ICON_SIZE); obj_set_style_no_padding(image); obj_set_style_bg_blacken(image); @@ -129,7 +196,19 @@ lv_obj_t* statusbar_create(lv_obj_t* parent) { return obj; } +static void update_time(Statusbar* statusbar) { + if (statusbar_data.time_set) { + bool format24 = time::isTimeFormat24Hour(); + int hours = format24 ? statusbar_data.time_hours : statusbar_data.time_hours % 12; + lv_label_set_text_fmt(statusbar->time, "%d:%02d", hours, statusbar_data.time_minutes); + } else { + lv_label_set_text(statusbar->time, ""); + } +} + static void update_main(Statusbar* statusbar) { + update_time(statusbar); + if (statusbar_lock(50 / portTICK_PERIOD_MS)) { for (int i = 0; i < STATUSBAR_ICON_LIMIT; ++i) { update_icon(statusbar->icons[i], &(statusbar_data.icons[i])); @@ -150,8 +229,6 @@ static void statusbar_event(TT_UNUSED const lv_obj_class_t* class_p, lv_event_t* if (code == LV_EVENT_VALUE_CHANGED) { lv_obj_invalidate(obj); - } else if (code == LV_EVENT_DRAW_MAIN) { - // NO-OP } } diff --git a/Tactility/Source/service/gui/Gui.cpp b/Tactility/Source/service/gui/Gui.cpp index a38a70e3..4e4c2905 100644 --- a/Tactility/Source/service/gui/Gui.cpp +++ b/Tactility/Source/service/gui/Gui.cpp @@ -1,9 +1,10 @@ #include "Tactility.h" #include "service/gui/Gui_i.h" #include "service/loader/Loader_i.h" -#include "lvgl/LvglKeypad.h" #include "lvgl/LvglSync.h" #include "RtosCompat.h" +#include "lvgl/Style.h" +#include "lvgl/Statusbar.h" namespace tt::service::gui { @@ -11,11 +12,11 @@ namespace tt::service::gui { // Forward declarations void redraw(Gui*); -static int32_t gui_main(void*); +static int32_t guiMain(TT_UNUSED void* p); Gui* gui = nullptr; -void loader_callback(const void* message, TT_UNUSED void* context) { +void onLoaderMessage(const void* message, TT_UNUSED void* context) { auto* event = static_cast(message); if (event->type == loader::LoaderEventTypeApplicationShowing) { app::AppContext& app = event->app_showing.app; @@ -32,13 +33,34 @@ Gui* gui_alloc() { instance->thread = new Thread( "gui", 4096, // Last known minimum was 2800 for launching desktop - &gui_main, + &guiMain, nullptr ); - instance->loader_pubsub_subscription = tt_pubsub_subscribe(loader::getPubsub(), &loader_callback, instance); + instance->loader_pubsub_subscription = tt_pubsub_subscribe(loader::getPubsub(), &onLoaderMessage, instance); tt_check(lvgl::lock(1000 / portTICK_PERIOD_MS)); - instance->keyboard_group = lv_group_create(); - instance->lvgl_parent = lv_scr_act(); + instance->keyboardGroup = lv_group_create(); + auto* screen_root = lv_scr_act(); + + lvgl::obj_set_style_bg_blacken(screen_root); + + lv_obj_t* vertical_container = lv_obj_create(screen_root); + lv_obj_set_size(vertical_container, LV_PCT(100), LV_PCT(100)); + lv_obj_set_flex_flow(vertical_container, LV_FLEX_FLOW_COLUMN); + lvgl::obj_set_style_no_padding(vertical_container); + lvgl::obj_set_style_bg_blacken(vertical_container); + + instance->statusbarWidget = lvgl::statusbar_create(vertical_container); + + auto* app_container = lv_obj_create(vertical_container); + lvgl::obj_set_style_no_padding(app_container); + lv_obj_set_style_border_width(app_container, 0, 0); + lvgl::obj_set_style_bg_blacken(app_container); + lv_obj_set_width(app_container, LV_PCT(100)); + lv_obj_set_flex_grow(app_container, 1); + lv_obj_set_flex_flow(app_container, LV_FLEX_FLOW_COLUMN); + + instance->appRootWidget = app_container; + lvgl::unlock(); return instance; @@ -48,9 +70,9 @@ void gui_free(Gui* instance) { tt_assert(instance != nullptr); delete instance->thread; - lv_group_delete(instance->keyboard_group); + lv_group_delete(instance->keyboardGroup); tt_check(lvgl::lock(1000 / portTICK_PERIOD_MS)); - lv_group_del(instance->keyboard_group); + lv_group_del(instance->keyboardGroup); lvgl::unlock(); delete instance; @@ -74,15 +96,15 @@ void requestDraw() { void showApp(app::AppContext& app, ViewPortShowCallback on_show, ViewPortHideCallback on_hide) { lock(); - tt_check(gui->app_view_port == nullptr); - gui->app_view_port = view_port_alloc(app, on_show, on_hide); + tt_check(gui->appViewPort == nullptr); + gui->appViewPort = view_port_alloc(app, on_show, on_hide); unlock(); requestDraw(); } void hideApp() { lock(); - ViewPort* view_port = gui->app_view_port; + ViewPort* view_port = gui->appViewPort; tt_check(view_port != nullptr); // We must lock the LVGL port, because the viewport hide callbacks @@ -92,11 +114,11 @@ void hideApp() { lvgl::unlock(); view_port_free(view_port); - gui->app_view_port = nullptr; + gui->appViewPort = nullptr; unlock(); } -static int32_t gui_main(TT_UNUSED void* p) { +static int32_t guiMain(TT_UNUSED void* p) { tt_check(gui); Gui* local_gui = gui; diff --git a/Tactility/Source/service/gui/GuiDraw.cpp b/Tactility/Source/service/gui/GuiDraw.cpp index 026abdec..d448d747 100644 --- a/Tactility/Source/service/gui/GuiDraw.cpp +++ b/Tactility/Source/service/gui/GuiDraw.cpp @@ -9,27 +9,14 @@ namespace tt::service::gui { #define TAG "gui" -static lv_obj_t* create_app_views(Gui* gui, lv_obj_t* parent, app::AppContext& app) { - lvgl::obj_set_style_bg_blacken(parent); - - lv_obj_t* vertical_container = lv_obj_create(parent); - lv_obj_set_size(vertical_container, LV_PCT(100), LV_PCT(100)); - lv_obj_set_flex_flow(vertical_container, LV_FLEX_FLOW_COLUMN); - lvgl::obj_set_style_no_padding(vertical_container); - lvgl::obj_set_style_bg_blacken(vertical_container); - - // TODO: Move statusbar into separate ViewPort - app::Flags flags = app.getFlags(); - if (flags.showStatusbar) { - lvgl::statusbar_create(vertical_container); - } - - lv_obj_t* child_container = lv_obj_create(vertical_container); +static lv_obj_t* createAppViews(Gui* gui, lv_obj_t* parent, app::AppContext& app) { + lv_obj_send_event(gui->statusbarWidget, LV_EVENT_DRAW_MAIN, nullptr); + lv_obj_t* child_container = lv_obj_create(parent); lv_obj_set_width(child_container, LV_PCT(100)); lv_obj_set_flex_grow(child_container, 1); if (keyboardIsEnabled()) { - gui->keyboard = lv_keyboard_create(vertical_container); + gui->keyboard = lv_keyboard_create(parent); lv_obj_add_flag(gui->keyboard, LV_OBJ_FLAG_HIDDEN); } else { gui->keyboard = nullptr; @@ -45,12 +32,20 @@ void redraw(Gui* gui) { lock(); if (lvgl::lock(1000)) { - lv_obj_clean(gui->lvgl_parent); + lv_obj_clean(gui->appRootWidget); - ViewPort* view_port = gui->app_view_port; + ViewPort* view_port = gui->appViewPort; if (view_port != nullptr) { app::AppContext& app = view_port->app; - lv_obj_t* container = create_app_views(gui, gui->lvgl_parent, app); + + app::Flags flags = app.getFlags(); + if (flags.showStatusbar) { + lv_obj_remove_flag(gui->statusbarWidget, LV_OBJ_FLAG_HIDDEN); + } else { + lv_obj_add_flag(gui->statusbarWidget, LV_OBJ_FLAG_HIDDEN); + } + + lv_obj_t* container = createAppViews(gui, gui->appRootWidget, app); view_port_show(view_port, container); } else { TT_LOG_W(TAG, "nothing to draw"); diff --git a/Tactility/Source/service/gui/Keyboard.cpp b/Tactility/Source/service/gui/Keyboard.cpp index 1945ffb8..214d5bc7 100644 --- a/Tactility/Source/service/gui/Keyboard.cpp +++ b/Tactility/Source/service/gui/Keyboard.cpp @@ -54,9 +54,9 @@ void keyboardAddTextArea(lv_obj_t* textarea) { } // lv_obj_t auto-remove themselves from the group when they are destroyed (last checked in LVGL 8.3) - lv_group_add_obj(gui->keyboard_group, textarea); + lv_group_add_obj(gui->keyboardGroup, textarea); - lvgl::keypad_activate(gui->keyboard_group); + lvgl::keypad_activate(gui->keyboardGroup); lvgl::unlock(); unlock(); diff --git a/Tactility/Source/service/loader/Loader.cpp b/Tactility/Source/service/loader/Loader.cpp index 56eaf272..8c9202f4 100644 --- a/Tactility/Source/service/loader/Loader.cpp +++ b/Tactility/Source/service/loader/Loader.cpp @@ -11,7 +11,6 @@ #else #include "lvgl/LvglSync.h" -#include "TactilityHeadless.h" #endif namespace tt::service::loader { @@ -42,7 +41,7 @@ static void loader_free() { loader_singleton = nullptr; } -void startApp(const std::string& id, bool blocking, std::shared_ptr parameters) { +void startApp(const std::string& id, bool blocking, const std::shared_ptr& parameters) { TT_LOG_I(TAG, "Start app %s", id.c_str()); tt_assert(loader_singleton); @@ -67,7 +66,7 @@ void stopApp() { app::AppContext* _Nullable getCurrentApp() { tt_assert(loader_singleton); if (loader_singleton->mutex.lock(10 / portTICK_PERIOD_MS)) { - app::AppInstance* app = loader_singleton->app_stack.top(); + app::AppInstance* app = loader_singleton->appStack.top(); loader_singleton->mutex.unlock(); return dynamic_cast(app); } else { @@ -80,7 +79,7 @@ std::shared_ptr getPubsub() { // it's safe to return pubsub without locking // because it's never freed and loader is never exited // also the loader instance cannot be obtained until the pubsub is created - return loader_singleton->pubsub_external; + return loader_singleton->pubsubExternal; } static const char* appStateToString(app::State state) { @@ -129,7 +128,7 @@ static void transitionAppToState(app::AppInstance& app, app::State state) { .app = app } }; - tt_pubsub_publish(loader_singleton->pubsub_external, &event_showing); + tt_pubsub_publish(loader_singleton->pubsubExternal, &event_showing); app.setState(app::StateShowing); break; } @@ -140,7 +139,7 @@ static void transitionAppToState(app::AppInstance& app, app::State state) { .app = app } }; - tt_pubsub_publish(loader_singleton->pubsub_external, &event_hiding); + tt_pubsub_publish(loader_singleton->pubsubExternal, &event_hiding); app.setState(app::StateHiding); break; } @@ -167,11 +166,11 @@ static LoaderStatus startAppWithManifestInternal( return LoaderStatusErrorInternal; } - auto previous_app = !loader_singleton->app_stack.empty() ? loader_singleton->app_stack.top() : nullptr; + auto previous_app = !loader_singleton->appStack.empty() ? loader_singleton->appStack.top() : nullptr; auto new_app = new app::AppInstance(*manifest, parameters); new_app->mutableFlags().showStatusbar = (manifest->type != app::TypeBoot); - loader_singleton->app_stack.push(new_app); + loader_singleton->appStack.push(new_app); transitionAppToState(*new_app, app::StateInitial); transitionAppToState(*new_app, app::StateStarted); @@ -183,7 +182,7 @@ static LoaderStatus startAppWithManifestInternal( transitionAppToState(*new_app, app::StateShowing); LoaderEventInternal event_internal = {.type = LoaderEventTypeApplicationStarted}; - tt_pubsub_publish(loader_singleton->pubsub_internal, &event_internal); + tt_pubsub_publish(loader_singleton->pubsubInternal, &event_internal); LoaderEvent event_external = { .type = LoaderEventTypeApplicationStarted, @@ -191,7 +190,7 @@ static LoaderStatus startAppWithManifestInternal( .app = *new_app } }; - tt_pubsub_publish(loader_singleton->pubsub_external, &event_external); + tt_pubsub_publish(loader_singleton->pubsubExternal, &event_external); return LoaderStatusOk; } @@ -228,7 +227,7 @@ static void stopAppInternal() { return; } - size_t original_stack_size = loader_singleton->app_stack.size(); + size_t original_stack_size = loader_singleton->appStack.size(); if (original_stack_size == 0) { TT_LOG_E(TAG, "Stop app: no app running"); @@ -236,7 +235,7 @@ static void stopAppInternal() { } // Stop current app - app::AppInstance* app_to_stop = loader_singleton->app_stack.top(); + app::AppInstance* app_to_stop = loader_singleton->appStack.top(); if (original_stack_size == 1 && app_to_stop->getManifest().type != app::TypeBoot) { TT_LOG_E(TAG, "Stop app: can't stop root app"); @@ -249,7 +248,7 @@ static void stopAppInternal() { transitionAppToState(*app_to_stop, app::StateHiding); transitionAppToState(*app_to_stop, app::StateStopped); - loader_singleton->app_stack.pop(); + loader_singleton->appStack.pop(); delete app_to_stop; #ifdef ESP_PLATFORM @@ -259,8 +258,8 @@ static void stopAppInternal() { app::AppOnResult on_result = nullptr; app::AppInstance* app_to_resume = nullptr; // If there's a previous app, resume it - if (!loader_singleton->app_stack.empty()) { - app_to_resume = loader_singleton->app_stack.top(); + if (!loader_singleton->appStack.empty()) { + app_to_resume = loader_singleton->appStack.top(); tt_assert(app_to_resume); transitionAppToState(*app_to_resume, app::StateShowing); @@ -272,7 +271,7 @@ static void stopAppInternal() { // WARNING: After this point we cannot change the app states from this method directly anymore as we don't have a lock! LoaderEventInternal event_internal = {.type = LoaderEventTypeApplicationStopped}; - tt_pubsub_publish(loader_singleton->pubsub_internal, &event_internal); + tt_pubsub_publish(loader_singleton->pubsubInternal, &event_internal); LoaderEvent event_external = { .type = LoaderEventTypeApplicationStopped, @@ -280,7 +279,7 @@ static void stopAppInternal() { .manifest = manifest } }; - tt_pubsub_publish(loader_singleton->pubsub_external, &event_external); + tt_pubsub_publish(loader_singleton->pubsubExternal, &event_external); if (on_result != nullptr && app_to_resume != nullptr) { if (result_holder != nullptr) { diff --git a/Tactility/Source/service/loader/Loader.h b/Tactility/Source/service/loader/Loader.h index 02866449..bd206964 100644 --- a/Tactility/Source/service/loader/Loader.h +++ b/Tactility/Source/service/loader/Loader.h @@ -24,7 +24,7 @@ typedef enum { * @param[in] blocking whether this call is blocking or not. You cannot call this from an LVGL thread. * @param[in] parameters optional parameters to pass onto the application */ -void startApp(const std::string& id, bool blocking = false, std::shared_ptr _Nullable parameters = nullptr); +void startApp(const std::string& id, bool blocking = false, const std::shared_ptr& _Nullable parameters = nullptr); /** @brief Stop the currently showing app. Show the previous app if any app was still running. */ void stopApp(); diff --git a/TactilityCore/Source/CoreExtraDefines.h b/TactilityCore/Source/CoreExtraDefines.h index eb0759ca..75167c24 100644 --- a/TactilityCore/Source/CoreExtraDefines.h +++ b/TactilityCore/Source/CoreExtraDefines.h @@ -33,3 +33,5 @@ * @param[in] lower lower bounds for x */ #define TT_CLAMP(x, upper, lower) (TT_MIN(upper, TT_MAX(x, lower))) + +#define TT_STRINGIFY(x) #x diff --git a/TactilityCore/Source/StringUtils.h b/TactilityCore/Source/StringUtils.h index 694ff1fb..df5b43d7 100644 --- a/TactilityCore/Source/StringUtils.h +++ b/TactilityCore/Source/StringUtils.h @@ -1,5 +1,6 @@ #pragma once +#include #include #include #include @@ -48,4 +49,22 @@ std::vector split(const std::string& input, const std::string& deli */ std::string join(const std::vector& input, const std::string& delimiter); +/** + * Returns the lowercase value of a string. + * @param[in] the string with lower and/or uppercase characters + * @return a string with only lowercase characters + */ +template +std::basic_string lowercase(const std::basic_string& input) { + std::basic_string output = input; + std::transform( + output.begin(), + output.end(), + output.begin(), + [](const T character) { return static_cast(std::tolower(character)); } + ); + return std::move(output); +} + + } // namespace diff --git a/TactilityCore/Source/Timer.cpp b/TactilityCore/Source/Timer.cpp index a9a7f66a..8daea062 100644 --- a/TactilityCore/Source/Timer.cpp +++ b/TactilityCore/Source/Timer.cpp @@ -2,7 +2,6 @@ #include #include "Check.h" -#include "kernel/Kernel.h" #include "RtosCompat.h" namespace tt { diff --git a/TactilityHeadless/CMakeLists.txt b/TactilityHeadless/CMakeLists.txt index 545ec491..c949298a 100644 --- a/TactilityHeadless/CMakeLists.txt +++ b/TactilityHeadless/CMakeLists.txt @@ -6,7 +6,7 @@ set(CMAKE_CXX_STANDARD_REQUIRED ON) if (DEFINED ENV{ESP_IDF_VERSION}) file(GLOB_RECURSE SOURCE_FILES Source/*.c*) - list(APPEND REQUIRES_LIST TactilityCore esp_wifi nvs_flash driver spiffs vfs fatfs) + list(APPEND REQUIRES_LIST TactilityCore esp_wifi nvs_flash driver spiffs vfs fatfs lwip) if("${IDF_TARGET}" STREQUAL "esp32s3") list(APPEND REQUIRES_LIST esp_tinyusb) endif() diff --git a/TactilityHeadless/Private/network/NtpPrivate.h b/TactilityHeadless/Private/network/NtpPrivate.h new file mode 100644 index 00000000..3de319e2 --- /dev/null +++ b/TactilityHeadless/Private/network/NtpPrivate.h @@ -0,0 +1,7 @@ +#pragma once + +namespace tt::network::ntp { + +void init(); + +} diff --git a/TactilityHeadless/Private/time/TimePrivate.h b/TactilityHeadless/Private/time/TimePrivate.h new file mode 100644 index 00000000..92b13222 --- /dev/null +++ b/TactilityHeadless/Private/time/TimePrivate.h @@ -0,0 +1,7 @@ +#pragma once + +namespace tt::time { + +void init(); + +} diff --git a/TactilityHeadless/Source/Assets.h b/TactilityHeadless/Source/Assets.h index 9536d1d5..40217c6b 100644 --- a/TactilityHeadless/Source/Assets.h +++ b/TactilityHeadless/Source/Assets.h @@ -14,3 +14,4 @@ #define TT_ASSETS_APP_ICON_I2C_SETTINGS TT_ASSET("app_icon_i2c.png") #define TT_ASSETS_APP_ICON_SETTINGS TT_ASSET("app_icon_settings.png") #define TT_ASSETS_APP_ICON_SYSTEM_INFO TT_ASSET("app_icon_system_info.png") +#define TT_ASSETS_APP_ICON_TIME_DATE_SETTINGS TT_ASSET("app_icon_time_date_settings.png") diff --git a/TactilityHeadless/Source/TactilityHeadless.cpp b/TactilityHeadless/Source/TactilityHeadless.cpp index 6926cf21..2f8afe53 100644 --- a/TactilityHeadless/Source/TactilityHeadless.cpp +++ b/TactilityHeadless/Source/TactilityHeadless.cpp @@ -4,6 +4,9 @@ #include "hal/Hal_i.h" #include "service/ServiceManifest.h" #include "service/ServiceRegistry.h" +#include "kernel/SystemEvents.h" +#include "network/NtpPrivate.h" +#include "time/TimePrivate.h" #ifdef ESP_PLATFORM #include "EspInit.h" @@ -39,7 +42,9 @@ void initHeadless(const hal::Configuration& config) { initEsp(); #endif hardwareConfig = &config; + time::init(); hal::init(config); + network::ntp::init(); register_and_start_system_services(); } diff --git a/TactilityHeadless/Source/hal/Configuration.h b/TactilityHeadless/Source/hal/Configuration.h index 7ce0df3d..0c80e080 100644 --- a/TactilityHeadless/Source/hal/Configuration.h +++ b/TactilityHeadless/Source/hal/Configuration.h @@ -10,8 +10,6 @@ typedef bool (*InitBoot)(); typedef bool (*InitHardware)(); typedef bool (*InitLvgl)(); -typedef void (*SetBacklightDuty)(uint8_t); - class Display; class Keyboard; typedef Display* (*CreateDisplay)(); @@ -57,7 +55,7 @@ struct Configuration { const CreatePower _Nullable power = nullptr; /** - * A list of i2c devices (can be empty, but preferably accurately represents the device capabilities) + * A list of i2c interfaces */ const std::vector i2c = {}; }; diff --git a/TactilityHeadless/Source/hal/Hal.cpp b/TactilityHeadless/Source/hal/Hal.cpp index 2feb470d..bbd34062 100644 --- a/TactilityHeadless/Source/hal/Hal.cpp +++ b/TactilityHeadless/Source/hal/Hal.cpp @@ -1,16 +1,21 @@ #include "hal/Hal_i.h" #include "hal/i2c/I2c.h" +#include "kernel/SystemEvents.h" #define TAG "hal" namespace tt::hal { void init(const Configuration& configuration) { + kernel::systemEventPublish(kernel::SystemEvent::BootInitHalBegin); + + kernel::systemEventPublish(kernel::SystemEvent::BootInitI2cBegin); tt_check(i2c::init(configuration.i2c), "I2C init failed"); if (configuration.initHardware != nullptr) { TT_LOG_I(TAG, "Init hardware"); tt_check(configuration.initHardware(), "Hardware init failed"); } + kernel::systemEventPublish(kernel::SystemEvent::BootInitI2cEnd); if (configuration.initBoot != nullptr) { TT_LOG_I(TAG, "Init power"); @@ -23,6 +28,8 @@ void init(const Configuration& configuration) { TT_LOG_W(TAG, "SD card mount failed (init can continue)"); } } + + kernel::systemEventPublish(kernel::SystemEvent::BootInitHalEnd); } } // namespace diff --git a/TactilityHeadless/Source/kernel/SystemEvents.cpp b/TactilityHeadless/Source/kernel/SystemEvents.cpp new file mode 100644 index 00000000..bb9b053f --- /dev/null +++ b/TactilityHeadless/Source/kernel/SystemEvents.cpp @@ -0,0 +1,87 @@ +#include "SystemEvents.h" +#include "Mutex.h" +#include "CoreExtraDefines.h" +#include + +#define TAG "system_event" + +namespace tt::kernel { + +struct SubscriptionData { + SystemEventSubscription id; + SystemEvent event; + OnSystemEvent handler; +}; + +static Mutex mutex; +static SystemEventSubscription subscriptionCounter = 0; +static std::list subscriptions; + +static const char* getEventName(SystemEvent event) { + switch (event) { + case SystemEvent::BootInitHalBegin: + return TT_STRINGIFY(SystemEvent::BootInitHalBegin); + case SystemEvent::BootInitHalEnd: + return TT_STRINGIFY(SystemEvent::BootInitHalEnd); + case SystemEvent::BootInitI2cBegin: + return TT_STRINGIFY(SystemEvent::BootInitI2cBegin); + case SystemEvent::BootInitI2cEnd: + return TT_STRINGIFY(SystemEvent::BootInitI2cEnd); + case SystemEvent::BootInitLvglBegin: + return TT_STRINGIFY(SystemEvent::BootInitLvglBegin); + case SystemEvent::BootInitLvglEnd: + return TT_STRINGIFY(SystemEvent::BootInitLvglEnd); + case SystemEvent::BootSplash: + return TT_STRINGIFY(SystemEvent::BootSplash); + case SystemEvent::NetworkConnected: + return TT_STRINGIFY(SystemEvent::NetworkConnected); + case SystemEvent::NetworkDisconnected: + return TT_STRINGIFY(SystemEvent::NetworkDisconnected); + case SystemEvent::Time: + return TT_STRINGIFY(SystemEvent::Time); + } + + tt_crash(); // Missing case above +} + +void systemEventPublish(SystemEvent event) { + TT_LOG_I(TAG, "%s", getEventName(event)); + + if (mutex.lock(portMAX_DELAY)) { + for (auto& subscription : subscriptions) { + if (subscription.event == event) { + subscription.handler(event); + } + } + + mutex.unlock(); + } +} + +SystemEventSubscription systemEventAddListener(SystemEvent event, OnSystemEvent handler) { + if (mutex.lock(portMAX_DELAY)) { + auto id = ++subscriptionCounter; + + subscriptions.push_back({ + .id = id, + .event = event, + .handler = handler + }); + + mutex.unlock(); + return id; + } else { + tt_crash(); + } +} + +void systemEventRemoveListener(SystemEventSubscription subscription) { + if (mutex.lock(portMAX_DELAY)) { + std::erase_if(subscriptions, [subscription](auto& item) { + return (item.id == subscription); + }); + mutex.unlock(); + } +} + +} diff --git a/TactilityHeadless/Source/kernel/SystemEvents.h b/TactilityHeadless/Source/kernel/SystemEvents.h new file mode 100644 index 00000000..3e49568f --- /dev/null +++ b/TactilityHeadless/Source/kernel/SystemEvents.h @@ -0,0 +1,31 @@ +#pragma once + +#include + +namespace tt::kernel { + +enum class SystemEvent { + BootInitHalBegin, + BootInitHalEnd, + BootInitI2cBegin, + BootInitI2cEnd, + BootInitLvglBegin, + BootInitLvglEnd, + BootSplash, + /** Gained IP address */ + NetworkConnected, + NetworkDisconnected, + /** An important system time-related event, such as NTP update or time-zone change */ + Time, +}; + +/** Value 0 mean "no subscription" */ +typedef uint32_t SystemEventSubscription; + +typedef void (*OnSystemEvent)(SystemEvent event); + +void systemEventPublish(SystemEvent event); +SystemEventSubscription systemEventAddListener(SystemEvent event, OnSystemEvent handler); +void systemEventRemoveListener(SystemEventSubscription subscription); + +} \ No newline at end of file diff --git a/TactilityHeadless/Source/network/Ntp.cpp b/TactilityHeadless/Source/network/Ntp.cpp new file mode 100644 index 00000000..d102f69b --- /dev/null +++ b/TactilityHeadless/Source/network/Ntp.cpp @@ -0,0 +1,34 @@ +#include "network/NtpPrivate.h" + +#ifdef ESP_PLATFORM +#include "kernel/SystemEvents.h" +#include "TactilityCore.h" +#include +#include +#endif + +#define TAG "ntp" + +namespace tt::network::ntp { + +#ifdef ESP_PLATFORM + +static void onTimeSynced(struct timeval* tv) { + TT_LOG_I(TAG, "Time synced (%llu)", tv->tv_sec); + kernel::systemEventPublish(kernel::SystemEvent::Time); +} + +void init() { + esp_sntp_config_t config = ESP_NETIF_SNTP_DEFAULT_CONFIG("pool.ntp.org"); + config.sync_cb = onTimeSynced; + esp_netif_sntp_init(&config); +} + +#else + +void init() { +} + +#endif + +} diff --git a/TactilityHeadless/Source/time/Time.cpp b/TactilityHeadless/Source/time/Time.cpp new file mode 100644 index 00000000..531249e7 --- /dev/null +++ b/TactilityHeadless/Source/time/Time.cpp @@ -0,0 +1,101 @@ +#include +#include "Time.h" +#include "Preferences.h" +#include "kernel/SystemEvents.h" + +namespace tt::time { + +#ifdef ESP_PLATFORM + +#define TIME_SETTINGS_NAMESPACE "time" + +#define TIMEZONE_PREFERENCES_KEY_NAME "tz_name" +#define TIMEZONE_PREFERENCES_KEY_CODE "tz_code" +#define TIMEZONE_PREFERENCES_KEY_TIME24 "tz_time24" + +void init() { + auto code= getTimeZoneCode(); + if (!code.empty()) { + setenv("TZ", code.c_str(), 1); + tzset(); + } +} + +void setTimeZone(const std::string& name, const std::string& code) { + Preferences preferences(TIME_SETTINGS_NAMESPACE); + preferences.putString(TIMEZONE_PREFERENCES_KEY_NAME, name); + preferences.putString(TIMEZONE_PREFERENCES_KEY_CODE, code); + + setenv("TZ", code.c_str(), 1); + tzset(); + + kernel::systemEventPublish(kernel::SystemEvent::Time); +} + +std::string getTimeZoneName() { + Preferences preferences(TIME_SETTINGS_NAMESPACE); + std::string result; + if (preferences.optString(TIMEZONE_PREFERENCES_KEY_NAME, result)) { + return result; + } else { + return {}; + } +} + +std::string getTimeZoneCode() { + Preferences preferences(TIME_SETTINGS_NAMESPACE); + std::string result; + if (preferences.optString(TIMEZONE_PREFERENCES_KEY_CODE, result)) { + return result; + } else { + return {}; + } +} + +bool isTimeFormat24Hour() { + Preferences preferences(TIME_SETTINGS_NAMESPACE); + bool show24Hour = true; + preferences.optBool(TIMEZONE_PREFERENCES_KEY_TIME24, show24Hour); + return show24Hour; +} + +void setTimeFormat24Hour(bool show24Hour) { + Preferences preferences(TIME_SETTINGS_NAMESPACE); + preferences.putBool(TIMEZONE_PREFERENCES_KEY_TIME24, show24Hour); + kernel::systemEventPublish(kernel::SystemEvent::Time); +} + +#else + +static std::string timeZoneName; +static std::string timeZoneCode; +static bool show24Hour = true; + +void init() {} + +void setTimeZone(const std::string& name, const std::string& code) { + timeZoneName = name; + timeZoneCode = code; + kernel::systemEventPublish(kernel::SystemEvent::Time); +} + +std::string getTimeZoneName() { + return timeZoneName; +} + +std::string getTimeZoneCode() { + return timeZoneCode; +} + +bool isTimeFormat24Hour() { + return show24Hour; +} + +void setTimeFormat24Hour(bool enabled) { + show24Hour = enabled; + kernel::systemEventPublish(kernel::SystemEvent::Time); +} + +#endif + +} diff --git a/TactilityHeadless/Source/time/Time.h b/TactilityHeadless/Source/time/Time.h new file mode 100644 index 00000000..88448e4f --- /dev/null +++ b/TactilityHeadless/Source/time/Time.h @@ -0,0 +1,32 @@ +#pragma once + +#include + +namespace tt::time { + +/** + * Set the timezone + * @param[in] name human-readable name + * @param[in] code the technical code (from timezones.csv) + */ +void setTimeZone(const std::string& name, const std::string& code); + +/** + * Get the name of the timezone + */ +std::string getTimeZoneName(); + +/** + * Get the code of the timezone (see timezones.csv) + */ +std::string getTimeZoneCode(); + +/** @return true when clocks should be shown as a 24 hours one instead of 12 hours */ +bool isTimeFormat24Hour(); + +/** Set whether clocks should be shown as a 24 hours instead of 12 hours + * @param[in] show24Hour + */ +void setTimeFormat24Hour(bool show24Hour); + +} diff --git a/sdkconfig.board.lilygo-tdeck b/sdkconfig.board.lilygo-tdeck index 7f991e02..b5fecce8 100644 --- a/sdkconfig.board.lilygo-tdeck +++ b/sdkconfig.board.lilygo-tdeck @@ -29,6 +29,7 @@ CONFIG_FATFS_LFN_HEAP=y CONFIG_FATFS_VOLUME_COUNT=3 # Hardware: Main +CONFIG_IDF_EXPERIMENTAL_FEATURES=y CONFIG_TT_BOARD_LILYGO_TDECK=y CONFIG_IDF_TARGET="esp32s3" CONFIG_ESP_DEFAULT_CPU_FREQ_MHZ_240=y @@ -38,13 +39,15 @@ CONFIG_FLASHMODE_QIO=y # Hardware: SPI RAM CONFIG_ESP32S3_SPIRAM_SUPPORT=y CONFIG_SPIRAM_MODE_OCT=y -CONFIG_SPIRAM_SPEED_80M=y +CONFIG_SPIRAM_SPEED_120M=y CONFIG_SPIRAM_USE_MALLOC=y CONFIG_SPIRAM_TRY_ALLOCATE_WIFI_LWIP=y +# SPI Flash (can set back to 80MHz after ESP-IDF bug is resolved) +CONFIG_ESPTOOLPY_FLASHFREQ_120M=y # LVGL CONFIG_LV_DISP_DEF_REFR_PERIOD=17 CONFIG_LV_INDEV_DEF_READ_PERIOD=17 CONFIG_LV_DPI_DEF=139 # USB CONFIG_TINYUSB_MSC_ENABLED=y -CONFIG_TINYUSB_MSC_MOUNT_PATH="/sdcard" +CONFIG_TINYUSB_MSC_MOUNT_PATH="/sdcard" \ No newline at end of file