From 7e0716829b197add49bdc178cdbb7d1912a35c6d Mon Sep 17 00:00:00 2001 From: Benjamin Faershtein <119711889+RCGV1@users.noreply.github.com> Date: Fri, 28 Aug 2026 20:53:19 -0700 Subject: [PATCH 01/15] MUI: virtualize TFT node list --- CMakeLists.txt | 4 +- generated/ui_240x320/screens.c | 226 +-- generated/ui_240x320/screens.h | 12 - generated/ui_320x240/screens.c | 226 +-- generated/ui_320x240/screens.h | 12 - include/graphics/common/MeshtasticView.h | 7 +- .../graphics/common/NodeDiscoverySyncGate.h | 47 + .../graphics/common/NodeListRowPresentation.h | 227 +++ include/graphics/common/NodeStore.h | 135 ++ include/graphics/common/VisibleNodeIndex.h | 68 + include/graphics/view/TFT/TFTView_320x240.h | 76 +- include/graphics/view/TFT/VirtualNodeList.h | 203 ++ source/graphics/TFT/TFTView_320x240.cpp | 1798 +++++++---------- source/graphics/common/MeshtasticView.cpp | 27 +- source/graphics/common/NodeStore.cpp | 375 ++++ source/graphics/common/ViewController.cpp | 15 + source/graphics/common/VisibleNodeIndex.cpp | 242 +++ source/graphics/view/TFT/VirtualNodeList.cpp | 1355 +++++++++++++ studio/240x320/TFTView_240x320.eez-project | 580 +----- studio/320x240/TFT320x240.eez-project | 589 +----- tests/test_NodeDiscoverySyncGate.cpp | 57 + tests/test_NodeStore.cpp | 498 +++++ tests/test_VirtualNodeList.cpp | 193 ++ tests/test_VisibleNodeIndex.cpp | 394 ++++ 24 files changed, 4699 insertions(+), 2667 deletions(-) create mode 100644 include/graphics/common/NodeDiscoverySyncGate.h create mode 100644 include/graphics/common/NodeListRowPresentation.h create mode 100644 include/graphics/common/NodeStore.h create mode 100644 include/graphics/common/VisibleNodeIndex.h create mode 100644 include/graphics/view/TFT/VirtualNodeList.h create mode 100644 source/graphics/common/NodeStore.cpp create mode 100644 source/graphics/common/VisibleNodeIndex.cpp create mode 100644 source/graphics/view/TFT/VirtualNodeList.cpp create mode 100644 tests/test_NodeDiscoverySyncGate.cpp create mode 100644 tests/test_NodeStore.cpp create mode 100644 tests/test_VirtualNodeList.cpp create mode 100644 tests/test_VisibleNodeIndex.cpp diff --git a/CMakeLists.txt b/CMakeLists.txt index 74f2f3cc7..ecd2ff13c 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -68,7 +68,7 @@ target_include_directories(DeviceUI PRIVATE ${GENERATED_FILES_DIR} ${CMAKE_CURRE if(ENABLE_DOCTESTS) target_include_directories(DeviceUI PRIVATE ${doctest_SOURCE_DIR}) - target_compile_definitions(DeviceUI PRIVATE UNIT_TEST) + target_compile_definitions(DeviceUI PUBLIC UNIT_TEST) if(ENABLE_DEBUG_LOG) target_compile_definitions(DeviceUI PRIVATE DEBUG_UNIT_TEST) endif() @@ -91,4 +91,4 @@ if(ENABLE_DOCTESTS) ) set_target_properties(tests PROPERTIES RUNTIME_OUTPUT_DIRECTORY ${CMAKE_BINARY_DIR}/bin) add_test(NAME tests COMMAND tests) -endif() \ No newline at end of file +endif() diff --git a/generated/ui_240x320/screens.c b/generated/ui_240x320/screens.c index ae8d48371..b24508b7c 100644 --- a/generated/ui_240x320/screens.c +++ b/generated/ui_240x320/screens.c @@ -949,159 +949,7 @@ void create_screen_main_screen() { lv_obj_set_style_flex_flow(obj, LV_FLEX_FLOW_COLUMN, LV_PART_MAIN | LV_STATE_DEFAULT); lv_obj_set_style_flex_grow(obj, 0, LV_PART_MAIN | LV_STATE_DEFAULT); lv_obj_set_style_pad_row(obj, 5, LV_PART_MAIN | LV_STATE_DEFAULT); - { - lv_obj_t *parent_obj = obj; - { - // NodePanel - lv_obj_t *obj = lv_obj_create(parent_obj); - objects.node_panel = obj; - lv_obj_set_pos(obj, LV_PCT(0), 0); - lv_obj_set_size(obj, LV_PCT(100), 53); - lv_obj_remove_flag(obj, LV_OBJ_FLAG_SCROLLABLE); - add_style_node_panel_style(obj); - lv_obj_set_style_radius(obj, 10, LV_PART_MAIN | LV_STATE_DEFAULT); - lv_obj_set_style_pad_top(obj, 0, LV_PART_MAIN | LV_STATE_DEFAULT); - lv_obj_set_style_pad_bottom(obj, 0, LV_PART_MAIN | LV_STATE_DEFAULT); - lv_obj_set_style_bg_color(obj, lv_color_hex(0xfffff4), LV_PART_MAIN | LV_STATE_PRESSED); - { - lv_obj_t *parent_obj = obj; - { - // NodeImage - lv_obj_t *obj = lv_image_create(parent_obj); - objects.node_image = obj; - lv_obj_set_pos(obj, -5, 3); - lv_obj_set_size(obj, 32, 32); - lv_image_set_src(obj, &img_node_client_image); - lv_image_set_pivot(obj, 0, 0); - lv_obj_remove_flag(obj, LV_OBJ_FLAG_SCROLLABLE); - lv_obj_set_style_image_recolor(obj, lv_color_hex(0xffffff), LV_PART_MAIN | LV_STATE_DEFAULT); - lv_obj_set_style_align(obj, LV_ALIGN_TOP_LEFT, LV_PART_MAIN | LV_STATE_DEFAULT); - lv_obj_set_style_radius(obj, 6, LV_PART_MAIN | LV_STATE_DEFAULT); - lv_obj_set_style_bg_color(obj, lv_color_hex(0x5d9388), LV_PART_MAIN | LV_STATE_DEFAULT); - lv_obj_set_style_border_color(obj, lv_color_hex(0xff5555), LV_PART_MAIN | LV_STATE_DEFAULT); - lv_obj_set_style_image_recolor_opa(obj, 255, LV_PART_MAIN | LV_STATE_DEFAULT); - lv_obj_set_style_border_width(obj, 3, LV_PART_MAIN | LV_STATE_DEFAULT); - lv_obj_set_style_bg_opa(obj, 255, LV_PART_MAIN | LV_STATE_DEFAULT); - } - { - // NodeButton - lv_obj_t *obj = lv_button_create(parent_obj); - objects.node_button = obj; - lv_obj_set_pos(obj, 0, 0); - lv_obj_set_size(obj, LV_PCT(106), LV_PCT(100)); - add_style_node_button_style(obj); - lv_obj_set_style_shadow_width(obj, 0, LV_PART_MAIN | LV_STATE_DEFAULT); - lv_obj_set_style_align(obj, LV_ALIGN_CENTER, LV_PART_MAIN | LV_STATE_DEFAULT); - lv_obj_set_style_max_height(obj, 132, LV_PART_MAIN | LV_STATE_DEFAULT); - lv_obj_set_style_min_height(obj, 50, LV_PART_MAIN | LV_STATE_DEFAULT); - } - { - // UserNameLabel - lv_obj_t *obj = lv_label_create(parent_obj); - objects.user_name_label = obj; - lv_obj_set_pos(obj, -6, 35); - lv_obj_set_size(obj, LV_PCT(80), LV_SIZE_CONTENT); - lv_label_set_long_mode(obj, LV_LABEL_LONG_SCROLL); - lv_obj_set_style_align(obj, LV_ALIGN_TOP_LEFT, LV_PART_MAIN | LV_STATE_DEFAULT); - lv_label_set_text_static(obj, "Meshtastic"); - } - { - // UserNameShortLabel - lv_obj_t *obj = lv_label_create(parent_obj); - objects.user_name_short_label = obj; - lv_obj_set_pos(obj, 30, 10); - lv_obj_set_size(obj, LV_SIZE_CONTENT, LV_SIZE_CONTENT); - lv_obj_remove_flag(obj, LV_OBJ_FLAG_SCROLLABLE); - lv_obj_set_style_align(obj, LV_ALIGN_TOP_LEFT, LV_PART_MAIN | LV_STATE_DEFAULT); - lv_obj_set_style_text_font(obj, &ui_font_montserrat_14, LV_PART_MAIN | LV_STATE_DEFAULT); - lv_label_set_text_static(obj, "ABCD"); - } - { - // BatteryLabel - lv_obj_t *obj = lv_label_create(parent_obj); - objects.battery_label = obj; - lv_obj_set_pos(obj, 8, 17); - lv_obj_set_size(obj, LV_SIZE_CONTENT, LV_SIZE_CONTENT); - lv_label_set_long_mode(obj, LV_LABEL_LONG_CLIP); - lv_obj_remove_flag(obj, LV_OBJ_FLAG_SCROLLABLE); - lv_obj_set_style_align(obj, LV_ALIGN_TOP_RIGHT, LV_PART_MAIN | LV_STATE_DEFAULT); - lv_obj_set_style_text_align(obj, LV_TEXT_ALIGN_RIGHT, LV_PART_MAIN | LV_STATE_DEFAULT); - lv_label_set_text_static(obj, ""); - } - { - // LastHeardLabel - lv_obj_t *obj = lv_label_create(parent_obj); - objects.last_heard_label = obj; - lv_obj_set_pos(obj, 8, 33); - lv_obj_set_size(obj, LV_SIZE_CONTENT, LV_SIZE_CONTENT); - lv_label_set_long_mode(obj, LV_LABEL_LONG_CLIP); - lv_obj_remove_flag(obj, LV_OBJ_FLAG_SCROLLABLE); - lv_obj_set_style_align(obj, LV_ALIGN_TOP_RIGHT, LV_PART_MAIN | LV_STATE_DEFAULT); - lv_obj_set_style_text_align(obj, LV_TEXT_ALIGN_RIGHT, LV_PART_MAIN | LV_STATE_DEFAULT); - lv_label_set_text_static(obj, ""); - } - { - // SignalLabel - lv_obj_t *obj = lv_label_create(parent_obj); - objects.signal_label = obj; - lv_obj_set_pos(obj, 8, 1); - lv_obj_set_size(obj, LV_SIZE_CONTENT, LV_SIZE_CONTENT); - lv_label_set_long_mode(obj, LV_LABEL_LONG_CLIP); - lv_obj_remove_flag(obj, LV_OBJ_FLAG_SCROLLABLE); - lv_obj_set_style_align(obj, LV_ALIGN_TOP_RIGHT, LV_PART_MAIN | LV_STATE_DEFAULT); - lv_obj_set_style_text_align(obj, LV_TEXT_ALIGN_RIGHT, LV_PART_MAIN | LV_STATE_DEFAULT); - lv_label_set_text_static(obj, ""); - } - { - // PositionLabel - lv_obj_t *obj = lv_label_create(parent_obj); - objects.position_label = obj; - lv_obj_set_pos(obj, -5, 49); - lv_obj_set_size(obj, 120, LV_SIZE_CONTENT); - lv_label_set_long_mode(obj, LV_LABEL_LONG_CLIP); - lv_obj_remove_flag(obj, LV_OBJ_FLAG_SCROLLABLE); - lv_obj_set_style_align(obj, LV_ALIGN_TOP_LEFT, LV_PART_MAIN | LV_STATE_DEFAULT); - lv_obj_set_style_text_color(obj, lv_color_hex(0x05f6cb), LV_PART_MAIN | LV_STATE_DEFAULT); - lv_label_set_text_static(obj, ""); - } - { - // Position2Label - lv_obj_t *obj = lv_label_create(parent_obj); - objects.position2_label = obj; - lv_obj_set_pos(obj, -5, 63); - lv_obj_set_size(obj, 108, LV_SIZE_CONTENT); - lv_label_set_long_mode(obj, LV_LABEL_LONG_SCROLL); - lv_obj_remove_flag(obj, LV_OBJ_FLAG_SCROLLABLE); - lv_obj_set_style_align(obj, LV_ALIGN_TOP_LEFT, LV_PART_MAIN | LV_STATE_DEFAULT); - lv_label_set_text_static(obj, ""); - } - { - // Telemetry1Label - lv_obj_t *obj = lv_label_create(parent_obj); - objects.telemetry1_label = obj; - lv_obj_set_pos(obj, 8, 49); - lv_obj_set_size(obj, 130, LV_SIZE_CONTENT); - lv_label_set_long_mode(obj, LV_LABEL_LONG_CLIP); - lv_obj_remove_flag(obj, LV_OBJ_FLAG_SCROLLABLE); - lv_obj_set_style_align(obj, LV_ALIGN_TOP_RIGHT, LV_PART_MAIN | LV_STATE_DEFAULT); - lv_obj_set_style_text_align(obj, LV_TEXT_ALIGN_RIGHT, LV_PART_MAIN | LV_STATE_DEFAULT); - lv_label_set_text_static(obj, ""); - } - { - // Telemetry2Label - lv_obj_t *obj = lv_label_create(parent_obj); - objects.telemetry2_label = obj; - lv_obj_set_pos(obj, 8, 63); - lv_obj_set_size(obj, 130, LV_SIZE_CONTENT); - lv_label_set_long_mode(obj, LV_LABEL_LONG_CLIP); - lv_obj_remove_flag(obj, LV_OBJ_FLAG_SCROLLABLE); - lv_obj_set_style_align(obj, LV_ALIGN_TOP_RIGHT, LV_PART_MAIN | LV_STATE_DEFAULT); - lv_obj_set_style_text_align(obj, LV_TEXT_ALIGN_RIGHT, LV_PART_MAIN | LV_STATE_DEFAULT); - lv_label_set_text_static(obj, ""); - } - } - } - } + } { // GroupsPanel @@ -3431,7 +3279,7 @@ void create_screen_main_screen() { lv_obj_set_style_pad_bottom(obj, 0, LV_PART_MAIN | LV_STATE_DEFAULT); lv_obj_set_style_bg_opa(obj, 0, LV_PART_MAIN | LV_STATE_DEFAULT); lv_obj_set_style_border_width(obj, 0, LV_PART_MAIN | LV_STATE_DEFAULT); - create_user_widget_ok_cancel_widget(obj, 248); + create_user_widget_ok_cancel_widget(obj, 236); lv_obj_set_style_align(obj, LV_ALIGN_BOTTOM_MID, LV_PART_MAIN | LV_STATE_DEFAULT); } } @@ -3743,7 +3591,7 @@ void create_screen_main_screen() { lv_obj_set_style_pad_bottom(obj, 0, LV_PART_MAIN | LV_STATE_DEFAULT); lv_obj_set_style_bg_opa(obj, 0, LV_PART_MAIN | LV_STATE_DEFAULT); lv_obj_set_style_border_width(obj, 0, LV_PART_MAIN | LV_STATE_DEFAULT); - create_user_widget_ok_cancel_widget(obj, 269); + create_user_widget_ok_cancel_widget(obj, 257); lv_obj_set_style_align(obj, LV_ALIGN_BOTTOM_MID, LV_PART_MAIN | LV_STATE_DEFAULT); } } @@ -3789,7 +3637,7 @@ void create_screen_main_screen() { lv_obj_set_style_pad_bottom(obj, 0, LV_PART_MAIN | LV_STATE_DEFAULT); lv_obj_set_style_bg_opa(obj, 0, LV_PART_MAIN | LV_STATE_DEFAULT); lv_obj_set_style_border_width(obj, 0, LV_PART_MAIN | LV_STATE_DEFAULT); - create_user_widget_ok_cancel_widget(obj, 275); + create_user_widget_ok_cancel_widget(obj, 263); lv_obj_set_style_align(obj, LV_ALIGN_BOTTOM_MID, LV_PART_MAIN | LV_STATE_DEFAULT); } } @@ -3868,7 +3716,7 @@ void create_screen_main_screen() { lv_obj_set_style_pad_bottom(obj, 0, LV_PART_MAIN | LV_STATE_DEFAULT); lv_obj_set_style_bg_opa(obj, 0, LV_PART_MAIN | LV_STATE_DEFAULT); lv_obj_set_style_border_width(obj, 0, LV_PART_MAIN | LV_STATE_DEFAULT); - create_user_widget_ok_cancel_widget(obj, 283); + create_user_widget_ok_cancel_widget(obj, 271); lv_obj_set_style_align(obj, LV_ALIGN_BOTTOM_MID, LV_PART_MAIN | LV_STATE_DEFAULT); } } @@ -3915,7 +3763,7 @@ void create_screen_main_screen() { lv_obj_set_style_pad_bottom(obj, 0, LV_PART_MAIN | LV_STATE_DEFAULT); lv_obj_set_style_bg_opa(obj, 0, LV_PART_MAIN | LV_STATE_DEFAULT); lv_obj_set_style_border_width(obj, 0, LV_PART_MAIN | LV_STATE_DEFAULT); - create_user_widget_ok_cancel_widget(obj, 289); + create_user_widget_ok_cancel_widget(obj, 277); lv_obj_set_style_align(obj, LV_ALIGN_BOTTOM_MID, LV_PART_MAIN | LV_STATE_DEFAULT); } } @@ -4026,7 +3874,7 @@ void create_screen_main_screen() { lv_obj_set_style_pad_bottom(obj, 0, LV_PART_MAIN | LV_STATE_DEFAULT); lv_obj_set_style_bg_opa(obj, 0, LV_PART_MAIN | LV_STATE_DEFAULT); lv_obj_set_style_border_width(obj, 0, LV_PART_MAIN | LV_STATE_DEFAULT); - create_user_widget_ok_cancel_widget(obj, 298); + create_user_widget_ok_cancel_widget(obj, 286); lv_obj_set_style_align(obj, LV_ALIGN_BOTTOM_MID, LV_PART_MAIN | LV_STATE_DEFAULT); } } @@ -4090,7 +3938,7 @@ void create_screen_main_screen() { lv_obj_set_style_pad_bottom(obj, 0, LV_PART_MAIN | LV_STATE_DEFAULT); lv_obj_set_style_bg_opa(obj, 0, LV_PART_MAIN | LV_STATE_DEFAULT); lv_obj_set_style_border_width(obj, 0, LV_PART_MAIN | LV_STATE_DEFAULT); - create_user_widget_ok_cancel_widget(obj, 305); + create_user_widget_ok_cancel_widget(obj, 293); lv_obj_set_style_align(obj, LV_ALIGN_BOTTOM_MID, LV_PART_MAIN | LV_STATE_DEFAULT); } } @@ -4136,7 +3984,7 @@ void create_screen_main_screen() { lv_obj_set_style_pad_bottom(obj, 0, LV_PART_MAIN | LV_STATE_DEFAULT); lv_obj_set_style_bg_opa(obj, 0, LV_PART_MAIN | LV_STATE_DEFAULT); lv_obj_set_style_border_width(obj, 0, LV_PART_MAIN | LV_STATE_DEFAULT); - create_user_widget_ok_cancel_widget(obj, 311); + create_user_widget_ok_cancel_widget(obj, 299); lv_obj_set_style_align(obj, LV_ALIGN_BOTTOM_MID, LV_PART_MAIN | LV_STATE_DEFAULT); } } @@ -4201,7 +4049,7 @@ void create_screen_main_screen() { lv_obj_set_style_pad_bottom(obj, 0, LV_PART_MAIN | LV_STATE_DEFAULT); lv_obj_set_style_bg_opa(obj, 0, LV_PART_MAIN | LV_STATE_DEFAULT); lv_obj_set_style_border_width(obj, 0, LV_PART_MAIN | LV_STATE_DEFAULT); - create_user_widget_ok_cancel_widget(obj, 318); + create_user_widget_ok_cancel_widget(obj, 306); lv_obj_set_style_align(obj, LV_ALIGN_BOTTOM_MID, LV_PART_MAIN | LV_STATE_DEFAULT); } } @@ -4307,7 +4155,7 @@ void create_screen_main_screen() { lv_obj_set_style_pad_bottom(obj, 0, LV_PART_MAIN | LV_STATE_DEFAULT); lv_obj_set_style_bg_opa(obj, 0, LV_PART_MAIN | LV_STATE_DEFAULT); lv_obj_set_style_border_width(obj, 0, LV_PART_MAIN | LV_STATE_DEFAULT); - create_user_widget_ok_cancel_widget(obj, 326); + create_user_widget_ok_cancel_widget(obj, 314); lv_obj_set_style_align(obj, LV_ALIGN_BOTTOM_MID, LV_PART_MAIN | LV_STATE_DEFAULT); } { @@ -4394,7 +4242,7 @@ void create_screen_main_screen() { lv_obj_set_style_pad_bottom(obj, 0, LV_PART_MAIN | LV_STATE_DEFAULT); lv_obj_set_style_bg_opa(obj, 0, LV_PART_MAIN | LV_STATE_DEFAULT); lv_obj_set_style_border_width(obj, 0, LV_PART_MAIN | LV_STATE_DEFAULT); - create_user_widget_ok_cancel_widget(obj, 334); + create_user_widget_ok_cancel_widget(obj, 322); lv_obj_set_style_align(obj, LV_ALIGN_BOTTOM_MID, LV_PART_MAIN | LV_STATE_DEFAULT); } } @@ -4470,7 +4318,7 @@ void create_screen_main_screen() { lv_obj_set_style_pad_bottom(obj, 0, LV_PART_MAIN | LV_STATE_DEFAULT); lv_obj_set_style_bg_opa(obj, 0, LV_PART_MAIN | LV_STATE_DEFAULT); lv_obj_set_style_border_width(obj, 0, LV_PART_MAIN | LV_STATE_DEFAULT); - create_user_widget_ok_cancel_widget(obj, 341); + create_user_widget_ok_cancel_widget(obj, 329); lv_obj_set_style_align(obj, LV_ALIGN_BOTTOM_MID, LV_PART_MAIN | LV_STATE_DEFAULT); } } @@ -4516,7 +4364,7 @@ void create_screen_main_screen() { lv_obj_set_style_pad_bottom(obj, 0, LV_PART_MAIN | LV_STATE_DEFAULT); lv_obj_set_style_bg_opa(obj, 0, LV_PART_MAIN | LV_STATE_DEFAULT); lv_obj_set_style_border_width(obj, 0, LV_PART_MAIN | LV_STATE_DEFAULT); - create_user_widget_ok_cancel_widget(obj, 347); + create_user_widget_ok_cancel_widget(obj, 335); lv_obj_set_style_align(obj, LV_ALIGN_BOTTOM_MID, LV_PART_MAIN | LV_STATE_DEFAULT); } } @@ -4593,7 +4441,7 @@ void create_screen_main_screen() { lv_obj_set_style_pad_bottom(obj, 0, LV_PART_MAIN | LV_STATE_DEFAULT); lv_obj_set_style_bg_opa(obj, 0, LV_PART_MAIN | LV_STATE_DEFAULT); lv_obj_set_style_border_width(obj, 0, LV_PART_MAIN | LV_STATE_DEFAULT); - create_user_widget_ok_cancel_widget(obj, 354); + create_user_widget_ok_cancel_widget(obj, 342); lv_obj_set_style_align(obj, LV_ALIGN_BOTTOM_MID, LV_PART_MAIN | LV_STATE_DEFAULT); } } @@ -4659,7 +4507,7 @@ void create_screen_main_screen() { lv_obj_set_style_pad_bottom(obj, 0, LV_PART_MAIN | LV_STATE_DEFAULT); lv_obj_set_style_bg_opa(obj, 0, LV_PART_MAIN | LV_STATE_DEFAULT); lv_obj_set_style_border_width(obj, 0, LV_PART_MAIN | LV_STATE_DEFAULT); - create_user_widget_ok_cancel_widget(obj, 362); + create_user_widget_ok_cancel_widget(obj, 350); lv_obj_set_style_align(obj, LV_ALIGN_BOTTOM_MID, LV_PART_MAIN | LV_STATE_DEFAULT); } } @@ -4704,7 +4552,7 @@ void create_screen_main_screen() { lv_obj_set_style_pad_bottom(obj, 0, LV_PART_MAIN | LV_STATE_DEFAULT); lv_obj_set_style_bg_opa(obj, 0, LV_PART_MAIN | LV_STATE_DEFAULT); lv_obj_set_style_border_width(obj, 0, LV_PART_MAIN | LV_STATE_DEFAULT); - create_user_widget_ok_cancel_widget(obj, 368); + create_user_widget_ok_cancel_widget(obj, 356); lv_obj_set_style_align(obj, LV_ALIGN_BOTTOM_MID, LV_PART_MAIN | LV_STATE_DEFAULT); } } @@ -4849,7 +4697,7 @@ void create_screen_main_screen() { lv_obj_set_style_pad_bottom(obj, 0, LV_PART_MAIN | LV_STATE_DEFAULT); lv_obj_set_style_bg_opa(obj, 0, LV_PART_MAIN | LV_STATE_DEFAULT); lv_obj_set_style_border_width(obj, 0, LV_PART_MAIN | LV_STATE_DEFAULT); - create_user_widget_ok_cancel_widget(obj, 381); + create_user_widget_ok_cancel_widget(obj, 369); lv_obj_set_style_align(obj, LV_ALIGN_BOTTOM_MID, LV_PART_MAIN | LV_STATE_DEFAULT); } { @@ -6548,7 +6396,7 @@ void create_screen_main_screen() { lv_obj_set_style_pad_bottom(obj, 0, LV_PART_MAIN | LV_STATE_DEFAULT); lv_obj_set_style_bg_opa(obj, 0, LV_PART_MAIN | LV_STATE_DEFAULT); lv_obj_set_style_border_width(obj, 0, LV_PART_MAIN | LV_STATE_DEFAULT); - create_user_widget_ok_cancel_widget(obj, 488); + create_user_widget_ok_cancel_widget(obj, 476); lv_obj_set_style_align(obj, LV_ALIGN_BOTTOM_MID, LV_PART_MAIN | LV_STATE_DEFAULT); } } @@ -6610,24 +6458,24 @@ void create_screen_main_screen() { } void tick_screen_main_screen() { - tick_user_widget_ok_cancel_widget(248); - tick_user_widget_ok_cancel_widget(269); - tick_user_widget_ok_cancel_widget(275); - tick_user_widget_ok_cancel_widget(283); - tick_user_widget_ok_cancel_widget(289); - tick_user_widget_ok_cancel_widget(298); - tick_user_widget_ok_cancel_widget(305); - tick_user_widget_ok_cancel_widget(311); - tick_user_widget_ok_cancel_widget(318); - tick_user_widget_ok_cancel_widget(326); - tick_user_widget_ok_cancel_widget(334); - tick_user_widget_ok_cancel_widget(341); - tick_user_widget_ok_cancel_widget(347); - tick_user_widget_ok_cancel_widget(354); - tick_user_widget_ok_cancel_widget(362); - tick_user_widget_ok_cancel_widget(368); - tick_user_widget_ok_cancel_widget(381); - tick_user_widget_ok_cancel_widget(488); + tick_user_widget_ok_cancel_widget(236); + tick_user_widget_ok_cancel_widget(257); + tick_user_widget_ok_cancel_widget(263); + tick_user_widget_ok_cancel_widget(271); + tick_user_widget_ok_cancel_widget(277); + tick_user_widget_ok_cancel_widget(286); + tick_user_widget_ok_cancel_widget(293); + tick_user_widget_ok_cancel_widget(299); + tick_user_widget_ok_cancel_widget(306); + tick_user_widget_ok_cancel_widget(314); + tick_user_widget_ok_cancel_widget(322); + tick_user_widget_ok_cancel_widget(329); + tick_user_widget_ok_cancel_widget(335); + tick_user_widget_ok_cancel_widget(342); + tick_user_widget_ok_cancel_widget(350); + tick_user_widget_ok_cancel_widget(356); + tick_user_widget_ok_cancel_widget(369); + tick_user_widget_ok_cancel_widget(476); } void create_user_widget_ok_cancel_widget(lv_obj_t *parent_obj, int startWidgetIndex) { diff --git a/generated/ui_240x320/screens.h b/generated/ui_240x320/screens.h index 6032389a6..382d3c3cb 100644 --- a/generated/ui_240x320/screens.h +++ b/generated/ui_240x320/screens.h @@ -142,18 +142,6 @@ typedef struct _objects_t { lv_obj_t *home_qr_button; lv_obj_t *home_qr_label; lv_obj_t *nodes_panel; - lv_obj_t *node_panel; - lv_obj_t *node_image; - lv_obj_t *node_button; - lv_obj_t *user_name_label; - lv_obj_t *user_name_short_label; - lv_obj_t *battery_label; - lv_obj_t *last_heard_label; - lv_obj_t *signal_label; - lv_obj_t *position_label; - lv_obj_t *position2_label; - lv_obj_t *telemetry1_label; - lv_obj_t *telemetry2_label; lv_obj_t *groups_panel; lv_obj_t *channel_button0; lv_obj_t *channel_label0; diff --git a/generated/ui_320x240/screens.c b/generated/ui_320x240/screens.c index 371b1f868..5b28fc6a6 100644 --- a/generated/ui_320x240/screens.c +++ b/generated/ui_320x240/screens.c @@ -823,159 +823,7 @@ void create_screen_main_screen() { lv_obj_set_style_flex_flow(obj, LV_FLEX_FLOW_COLUMN, LV_PART_MAIN | LV_STATE_DEFAULT); lv_obj_set_style_flex_grow(obj, 0, LV_PART_MAIN | LV_STATE_DEFAULT); lv_obj_set_style_pad_row(obj, 5, LV_PART_MAIN | LV_STATE_DEFAULT); - { - lv_obj_t *parent_obj = obj; - { - // NodePanel - lv_obj_t *obj = lv_obj_create(parent_obj); - objects.node_panel = obj; - lv_obj_set_pos(obj, LV_PCT(0), 0); - lv_obj_set_size(obj, LV_PCT(100), 53); - lv_obj_remove_flag(obj, LV_OBJ_FLAG_SCROLLABLE); - add_style_node_panel_style(obj); - lv_obj_set_style_radius(obj, 10, LV_PART_MAIN | LV_STATE_DEFAULT); - lv_obj_set_style_pad_top(obj, 0, LV_PART_MAIN | LV_STATE_DEFAULT); - lv_obj_set_style_pad_bottom(obj, 0, LV_PART_MAIN | LV_STATE_DEFAULT); - lv_obj_set_style_bg_color(obj, lv_color_hex(0xfffff4), LV_PART_MAIN | LV_STATE_PRESSED); - { - lv_obj_t *parent_obj = obj; - { - // NodeImage - lv_obj_t *obj = lv_image_create(parent_obj); - objects.node_image = obj; - lv_obj_set_pos(obj, -5, 3); - lv_obj_set_size(obj, 32, 32); - lv_image_set_src(obj, &img_node_client_image); - lv_image_set_pivot(obj, 0, 0); - lv_obj_remove_flag(obj, LV_OBJ_FLAG_SCROLLABLE); - lv_obj_set_style_image_recolor(obj, lv_color_hex(0xffffff), LV_PART_MAIN | LV_STATE_DEFAULT); - lv_obj_set_style_align(obj, LV_ALIGN_TOP_LEFT, LV_PART_MAIN | LV_STATE_DEFAULT); - lv_obj_set_style_radius(obj, 6, LV_PART_MAIN | LV_STATE_DEFAULT); - lv_obj_set_style_bg_color(obj, lv_color_hex(0x5d9388), LV_PART_MAIN | LV_STATE_DEFAULT); - lv_obj_set_style_border_color(obj, lv_color_hex(0xff5555), LV_PART_MAIN | LV_STATE_DEFAULT); - lv_obj_set_style_image_recolor_opa(obj, 255, LV_PART_MAIN | LV_STATE_DEFAULT); - lv_obj_set_style_border_width(obj, 3, LV_PART_MAIN | LV_STATE_DEFAULT); - lv_obj_set_style_bg_opa(obj, 255, LV_PART_MAIN | LV_STATE_DEFAULT); - } - { - // NodeButton - lv_obj_t *obj = lv_button_create(parent_obj); - objects.node_button = obj; - lv_obj_set_pos(obj, 0, 0); - lv_obj_set_size(obj, LV_PCT(106), LV_PCT(100)); - add_style_node_button_style(obj); - lv_obj_set_style_shadow_width(obj, 0, LV_PART_MAIN | LV_STATE_DEFAULT); - lv_obj_set_style_align(obj, LV_ALIGN_CENTER, LV_PART_MAIN | LV_STATE_DEFAULT); - lv_obj_set_style_max_height(obj, 132, LV_PART_MAIN | LV_STATE_DEFAULT); - lv_obj_set_style_min_height(obj, 50, LV_PART_MAIN | LV_STATE_DEFAULT); - } - { - // UserNameLabel - lv_obj_t *obj = lv_label_create(parent_obj); - objects.user_name_label = obj; - lv_obj_set_pos(obj, -6, 35); - lv_obj_set_size(obj, LV_PCT(80), LV_SIZE_CONTENT); - lv_label_set_long_mode(obj, LV_LABEL_LONG_SCROLL); - lv_obj_set_style_align(obj, LV_ALIGN_TOP_LEFT, LV_PART_MAIN | LV_STATE_DEFAULT); - lv_label_set_text_static(obj, "Meshtastic"); - } - { - // UserNameShortLabel - lv_obj_t *obj = lv_label_create(parent_obj); - objects.user_name_short_label = obj; - lv_obj_set_pos(obj, 30, 10); - lv_obj_set_size(obj, LV_SIZE_CONTENT, LV_SIZE_CONTENT); - lv_obj_remove_flag(obj, LV_OBJ_FLAG_SCROLLABLE); - lv_obj_set_style_align(obj, LV_ALIGN_TOP_LEFT, LV_PART_MAIN | LV_STATE_DEFAULT); - lv_obj_set_style_text_font(obj, &ui_font_montserrat_14, LV_PART_MAIN | LV_STATE_DEFAULT); - lv_label_set_text_static(obj, "ABCD"); - } - { - // BatteryLabel - lv_obj_t *obj = lv_label_create(parent_obj); - objects.battery_label = obj; - lv_obj_set_pos(obj, 8, 17); - lv_obj_set_size(obj, LV_SIZE_CONTENT, LV_SIZE_CONTENT); - lv_label_set_long_mode(obj, LV_LABEL_LONG_CLIP); - lv_obj_remove_flag(obj, LV_OBJ_FLAG_SCROLLABLE); - lv_obj_set_style_align(obj, LV_ALIGN_TOP_RIGHT, LV_PART_MAIN | LV_STATE_DEFAULT); - lv_obj_set_style_text_align(obj, LV_TEXT_ALIGN_RIGHT, LV_PART_MAIN | LV_STATE_DEFAULT); - lv_label_set_text_static(obj, ""); - } - { - // LastHeardLabel - lv_obj_t *obj = lv_label_create(parent_obj); - objects.last_heard_label = obj; - lv_obj_set_pos(obj, 8, 33); - lv_obj_set_size(obj, LV_SIZE_CONTENT, LV_SIZE_CONTENT); - lv_label_set_long_mode(obj, LV_LABEL_LONG_CLIP); - lv_obj_remove_flag(obj, LV_OBJ_FLAG_SCROLLABLE); - lv_obj_set_style_align(obj, LV_ALIGN_TOP_RIGHT, LV_PART_MAIN | LV_STATE_DEFAULT); - lv_obj_set_style_text_align(obj, LV_TEXT_ALIGN_RIGHT, LV_PART_MAIN | LV_STATE_DEFAULT); - lv_label_set_text_static(obj, ""); - } - { - // SignalLabel - lv_obj_t *obj = lv_label_create(parent_obj); - objects.signal_label = obj; - lv_obj_set_pos(obj, 8, 1); - lv_obj_set_size(obj, LV_SIZE_CONTENT, LV_SIZE_CONTENT); - lv_label_set_long_mode(obj, LV_LABEL_LONG_CLIP); - lv_obj_remove_flag(obj, LV_OBJ_FLAG_SCROLLABLE); - lv_obj_set_style_align(obj, LV_ALIGN_TOP_RIGHT, LV_PART_MAIN | LV_STATE_DEFAULT); - lv_obj_set_style_text_align(obj, LV_TEXT_ALIGN_RIGHT, LV_PART_MAIN | LV_STATE_DEFAULT); - lv_label_set_text_static(obj, ""); - } - { - // PositionLabel - lv_obj_t *obj = lv_label_create(parent_obj); - objects.position_label = obj; - lv_obj_set_pos(obj, -5, 49); - lv_obj_set_size(obj, 120, LV_SIZE_CONTENT); - lv_label_set_long_mode(obj, LV_LABEL_LONG_CLIP); - lv_obj_remove_flag(obj, LV_OBJ_FLAG_SCROLLABLE); - lv_obj_set_style_align(obj, LV_ALIGN_TOP_LEFT, LV_PART_MAIN | LV_STATE_DEFAULT); - lv_obj_set_style_text_color(obj, lv_color_hex(0x05f6cb), LV_PART_MAIN | LV_STATE_DEFAULT); - lv_label_set_text_static(obj, ""); - } - { - // Position2Label - lv_obj_t *obj = lv_label_create(parent_obj); - objects.position2_label = obj; - lv_obj_set_pos(obj, -5, 63); - lv_obj_set_size(obj, 108, LV_SIZE_CONTENT); - lv_label_set_long_mode(obj, LV_LABEL_LONG_SCROLL); - lv_obj_remove_flag(obj, LV_OBJ_FLAG_SCROLLABLE); - lv_obj_set_style_align(obj, LV_ALIGN_TOP_LEFT, LV_PART_MAIN | LV_STATE_DEFAULT); - lv_label_set_text_static(obj, ""); - } - { - // Telemetry1Label - lv_obj_t *obj = lv_label_create(parent_obj); - objects.telemetry1_label = obj; - lv_obj_set_pos(obj, 8, 49); - lv_obj_set_size(obj, 130, LV_SIZE_CONTENT); - lv_label_set_long_mode(obj, LV_LABEL_LONG_CLIP); - lv_obj_remove_flag(obj, LV_OBJ_FLAG_SCROLLABLE); - lv_obj_set_style_align(obj, LV_ALIGN_TOP_RIGHT, LV_PART_MAIN | LV_STATE_DEFAULT); - lv_obj_set_style_text_align(obj, LV_TEXT_ALIGN_RIGHT, LV_PART_MAIN | LV_STATE_DEFAULT); - lv_label_set_text_static(obj, ""); - } - { - // Telemetry2Label - lv_obj_t *obj = lv_label_create(parent_obj); - objects.telemetry2_label = obj; - lv_obj_set_pos(obj, 8, 63); - lv_obj_set_size(obj, 130, LV_SIZE_CONTENT); - lv_label_set_long_mode(obj, LV_LABEL_LONG_CLIP); - lv_obj_remove_flag(obj, LV_OBJ_FLAG_SCROLLABLE); - lv_obj_set_style_align(obj, LV_ALIGN_TOP_RIGHT, LV_PART_MAIN | LV_STATE_DEFAULT); - lv_obj_set_style_text_align(obj, LV_TEXT_ALIGN_RIGHT, LV_PART_MAIN | LV_STATE_DEFAULT); - lv_label_set_text_static(obj, ""); - } - } - } - } + } { // GroupsPanel @@ -3303,7 +3151,7 @@ void create_screen_main_screen() { lv_obj_set_style_pad_bottom(obj, 0, LV_PART_MAIN | LV_STATE_DEFAULT); lv_obj_set_style_bg_opa(obj, 0, LV_PART_MAIN | LV_STATE_DEFAULT); lv_obj_set_style_border_width(obj, 0, LV_PART_MAIN | LV_STATE_DEFAULT); - create_user_widget_ok_cancel_widget(obj, 245); + create_user_widget_ok_cancel_widget(obj, 233); lv_obj_set_style_align(obj, LV_ALIGN_BOTTOM_MID, LV_PART_MAIN | LV_STATE_DEFAULT); } } @@ -3615,7 +3463,7 @@ void create_screen_main_screen() { lv_obj_set_style_pad_bottom(obj, 0, LV_PART_MAIN | LV_STATE_DEFAULT); lv_obj_set_style_bg_opa(obj, 0, LV_PART_MAIN | LV_STATE_DEFAULT); lv_obj_set_style_border_width(obj, 0, LV_PART_MAIN | LV_STATE_DEFAULT); - create_user_widget_ok_cancel_widget(obj, 266); + create_user_widget_ok_cancel_widget(obj, 254); lv_obj_set_style_align(obj, LV_ALIGN_BOTTOM_MID, LV_PART_MAIN | LV_STATE_DEFAULT); } } @@ -3661,7 +3509,7 @@ void create_screen_main_screen() { lv_obj_set_style_pad_bottom(obj, 0, LV_PART_MAIN | LV_STATE_DEFAULT); lv_obj_set_style_bg_opa(obj, 0, LV_PART_MAIN | LV_STATE_DEFAULT); lv_obj_set_style_border_width(obj, 0, LV_PART_MAIN | LV_STATE_DEFAULT); - create_user_widget_ok_cancel_widget(obj, 272); + create_user_widget_ok_cancel_widget(obj, 260); lv_obj_set_style_align(obj, LV_ALIGN_BOTTOM_MID, LV_PART_MAIN | LV_STATE_DEFAULT); } } @@ -3740,7 +3588,7 @@ void create_screen_main_screen() { lv_obj_set_style_pad_bottom(obj, 0, LV_PART_MAIN | LV_STATE_DEFAULT); lv_obj_set_style_bg_opa(obj, 0, LV_PART_MAIN | LV_STATE_DEFAULT); lv_obj_set_style_border_width(obj, 0, LV_PART_MAIN | LV_STATE_DEFAULT); - create_user_widget_ok_cancel_widget(obj, 280); + create_user_widget_ok_cancel_widget(obj, 268); lv_obj_set_style_align(obj, LV_ALIGN_BOTTOM_MID, LV_PART_MAIN | LV_STATE_DEFAULT); } } @@ -3787,7 +3635,7 @@ void create_screen_main_screen() { lv_obj_set_style_pad_bottom(obj, 0, LV_PART_MAIN | LV_STATE_DEFAULT); lv_obj_set_style_bg_opa(obj, 0, LV_PART_MAIN | LV_STATE_DEFAULT); lv_obj_set_style_border_width(obj, 0, LV_PART_MAIN | LV_STATE_DEFAULT); - create_user_widget_ok_cancel_widget(obj, 286); + create_user_widget_ok_cancel_widget(obj, 274); lv_obj_set_style_align(obj, LV_ALIGN_BOTTOM_MID, LV_PART_MAIN | LV_STATE_DEFAULT); } } @@ -3898,7 +3746,7 @@ void create_screen_main_screen() { lv_obj_set_style_pad_bottom(obj, 0, LV_PART_MAIN | LV_STATE_DEFAULT); lv_obj_set_style_bg_opa(obj, 0, LV_PART_MAIN | LV_STATE_DEFAULT); lv_obj_set_style_border_width(obj, 0, LV_PART_MAIN | LV_STATE_DEFAULT); - create_user_widget_ok_cancel_widget(obj, 295); + create_user_widget_ok_cancel_widget(obj, 283); lv_obj_set_style_align(obj, LV_ALIGN_BOTTOM_MID, LV_PART_MAIN | LV_STATE_DEFAULT); } } @@ -3962,7 +3810,7 @@ void create_screen_main_screen() { lv_obj_set_style_pad_bottom(obj, 0, LV_PART_MAIN | LV_STATE_DEFAULT); lv_obj_set_style_bg_opa(obj, 0, LV_PART_MAIN | LV_STATE_DEFAULT); lv_obj_set_style_border_width(obj, 0, LV_PART_MAIN | LV_STATE_DEFAULT); - create_user_widget_ok_cancel_widget(obj, 302); + create_user_widget_ok_cancel_widget(obj, 290); lv_obj_set_style_align(obj, LV_ALIGN_BOTTOM_MID, LV_PART_MAIN | LV_STATE_DEFAULT); } } @@ -4008,7 +3856,7 @@ void create_screen_main_screen() { lv_obj_set_style_pad_bottom(obj, 0, LV_PART_MAIN | LV_STATE_DEFAULT); lv_obj_set_style_bg_opa(obj, 0, LV_PART_MAIN | LV_STATE_DEFAULT); lv_obj_set_style_border_width(obj, 0, LV_PART_MAIN | LV_STATE_DEFAULT); - create_user_widget_ok_cancel_widget(obj, 308); + create_user_widget_ok_cancel_widget(obj, 296); lv_obj_set_style_align(obj, LV_ALIGN_BOTTOM_MID, LV_PART_MAIN | LV_STATE_DEFAULT); } } @@ -4073,7 +3921,7 @@ void create_screen_main_screen() { lv_obj_set_style_pad_bottom(obj, 0, LV_PART_MAIN | LV_STATE_DEFAULT); lv_obj_set_style_bg_opa(obj, 0, LV_PART_MAIN | LV_STATE_DEFAULT); lv_obj_set_style_border_width(obj, 0, LV_PART_MAIN | LV_STATE_DEFAULT); - create_user_widget_ok_cancel_widget(obj, 315); + create_user_widget_ok_cancel_widget(obj, 303); lv_obj_set_style_align(obj, LV_ALIGN_BOTTOM_MID, LV_PART_MAIN | LV_STATE_DEFAULT); } } @@ -4179,7 +4027,7 @@ void create_screen_main_screen() { lv_obj_set_style_pad_bottom(obj, 0, LV_PART_MAIN | LV_STATE_DEFAULT); lv_obj_set_style_bg_opa(obj, 0, LV_PART_MAIN | LV_STATE_DEFAULT); lv_obj_set_style_border_width(obj, 0, LV_PART_MAIN | LV_STATE_DEFAULT); - create_user_widget_ok_cancel_widget(obj, 323); + create_user_widget_ok_cancel_widget(obj, 311); lv_obj_set_style_align(obj, LV_ALIGN_BOTTOM_MID, LV_PART_MAIN | LV_STATE_DEFAULT); } { @@ -4266,7 +4114,7 @@ void create_screen_main_screen() { lv_obj_set_style_pad_bottom(obj, 0, LV_PART_MAIN | LV_STATE_DEFAULT); lv_obj_set_style_bg_opa(obj, 0, LV_PART_MAIN | LV_STATE_DEFAULT); lv_obj_set_style_border_width(obj, 0, LV_PART_MAIN | LV_STATE_DEFAULT); - create_user_widget_ok_cancel_widget(obj, 331); + create_user_widget_ok_cancel_widget(obj, 319); lv_obj_set_style_align(obj, LV_ALIGN_BOTTOM_MID, LV_PART_MAIN | LV_STATE_DEFAULT); } } @@ -4342,7 +4190,7 @@ void create_screen_main_screen() { lv_obj_set_style_pad_bottom(obj, 0, LV_PART_MAIN | LV_STATE_DEFAULT); lv_obj_set_style_bg_opa(obj, 0, LV_PART_MAIN | LV_STATE_DEFAULT); lv_obj_set_style_border_width(obj, 0, LV_PART_MAIN | LV_STATE_DEFAULT); - create_user_widget_ok_cancel_widget(obj, 338); + create_user_widget_ok_cancel_widget(obj, 326); lv_obj_set_style_align(obj, LV_ALIGN_BOTTOM_MID, LV_PART_MAIN | LV_STATE_DEFAULT); } } @@ -4388,7 +4236,7 @@ void create_screen_main_screen() { lv_obj_set_style_pad_bottom(obj, 0, LV_PART_MAIN | LV_STATE_DEFAULT); lv_obj_set_style_bg_opa(obj, 0, LV_PART_MAIN | LV_STATE_DEFAULT); lv_obj_set_style_border_width(obj, 0, LV_PART_MAIN | LV_STATE_DEFAULT); - create_user_widget_ok_cancel_widget(obj, 344); + create_user_widget_ok_cancel_widget(obj, 332); lv_obj_set_style_align(obj, LV_ALIGN_BOTTOM_MID, LV_PART_MAIN | LV_STATE_DEFAULT); } } @@ -4465,7 +4313,7 @@ void create_screen_main_screen() { lv_obj_set_style_pad_bottom(obj, 0, LV_PART_MAIN | LV_STATE_DEFAULT); lv_obj_set_style_bg_opa(obj, 0, LV_PART_MAIN | LV_STATE_DEFAULT); lv_obj_set_style_border_width(obj, 0, LV_PART_MAIN | LV_STATE_DEFAULT); - create_user_widget_ok_cancel_widget(obj, 351); + create_user_widget_ok_cancel_widget(obj, 339); lv_obj_set_style_align(obj, LV_ALIGN_BOTTOM_MID, LV_PART_MAIN | LV_STATE_DEFAULT); } } @@ -4531,7 +4379,7 @@ void create_screen_main_screen() { lv_obj_set_style_pad_bottom(obj, 0, LV_PART_MAIN | LV_STATE_DEFAULT); lv_obj_set_style_bg_opa(obj, 0, LV_PART_MAIN | LV_STATE_DEFAULT); lv_obj_set_style_border_width(obj, 0, LV_PART_MAIN | LV_STATE_DEFAULT); - create_user_widget_ok_cancel_widget(obj, 359); + create_user_widget_ok_cancel_widget(obj, 347); lv_obj_set_style_align(obj, LV_ALIGN_BOTTOM_MID, LV_PART_MAIN | LV_STATE_DEFAULT); } } @@ -4576,7 +4424,7 @@ void create_screen_main_screen() { lv_obj_set_style_pad_bottom(obj, 0, LV_PART_MAIN | LV_STATE_DEFAULT); lv_obj_set_style_bg_opa(obj, 0, LV_PART_MAIN | LV_STATE_DEFAULT); lv_obj_set_style_border_width(obj, 0, LV_PART_MAIN | LV_STATE_DEFAULT); - create_user_widget_ok_cancel_widget(obj, 365); + create_user_widget_ok_cancel_widget(obj, 353); lv_obj_set_style_align(obj, LV_ALIGN_BOTTOM_MID, LV_PART_MAIN | LV_STATE_DEFAULT); } } @@ -4721,7 +4569,7 @@ void create_screen_main_screen() { lv_obj_set_style_pad_bottom(obj, 0, LV_PART_MAIN | LV_STATE_DEFAULT); lv_obj_set_style_bg_opa(obj, 0, LV_PART_MAIN | LV_STATE_DEFAULT); lv_obj_set_style_border_width(obj, 0, LV_PART_MAIN | LV_STATE_DEFAULT); - create_user_widget_ok_cancel_widget(obj, 378); + create_user_widget_ok_cancel_widget(obj, 366); lv_obj_set_style_align(obj, LV_ALIGN_BOTTOM_MID, LV_PART_MAIN | LV_STATE_DEFAULT); } { @@ -6418,7 +6266,7 @@ void create_screen_main_screen() { lv_obj_set_style_pad_bottom(obj, 0, LV_PART_MAIN | LV_STATE_DEFAULT); lv_obj_set_style_bg_opa(obj, 0, LV_PART_MAIN | LV_STATE_DEFAULT); lv_obj_set_style_border_width(obj, 0, LV_PART_MAIN | LV_STATE_DEFAULT); - create_user_widget_ok_cancel_widget(obj, 485); + create_user_widget_ok_cancel_widget(obj, 473); lv_obj_set_style_align(obj, LV_ALIGN_BOTTOM_MID, LV_PART_MAIN | LV_STATE_DEFAULT); } } @@ -6480,24 +6328,24 @@ void create_screen_main_screen() { } void tick_screen_main_screen() { - tick_user_widget_ok_cancel_widget(245); - tick_user_widget_ok_cancel_widget(266); - tick_user_widget_ok_cancel_widget(272); - tick_user_widget_ok_cancel_widget(280); - tick_user_widget_ok_cancel_widget(286); - tick_user_widget_ok_cancel_widget(295); - tick_user_widget_ok_cancel_widget(302); - tick_user_widget_ok_cancel_widget(308); - tick_user_widget_ok_cancel_widget(315); - tick_user_widget_ok_cancel_widget(323); - tick_user_widget_ok_cancel_widget(331); - tick_user_widget_ok_cancel_widget(338); - tick_user_widget_ok_cancel_widget(344); - tick_user_widget_ok_cancel_widget(351); - tick_user_widget_ok_cancel_widget(359); - tick_user_widget_ok_cancel_widget(365); - tick_user_widget_ok_cancel_widget(378); - tick_user_widget_ok_cancel_widget(485); + tick_user_widget_ok_cancel_widget(233); + tick_user_widget_ok_cancel_widget(254); + tick_user_widget_ok_cancel_widget(260); + tick_user_widget_ok_cancel_widget(268); + tick_user_widget_ok_cancel_widget(274); + tick_user_widget_ok_cancel_widget(283); + tick_user_widget_ok_cancel_widget(290); + tick_user_widget_ok_cancel_widget(296); + tick_user_widget_ok_cancel_widget(303); + tick_user_widget_ok_cancel_widget(311); + tick_user_widget_ok_cancel_widget(319); + tick_user_widget_ok_cancel_widget(326); + tick_user_widget_ok_cancel_widget(332); + tick_user_widget_ok_cancel_widget(339); + tick_user_widget_ok_cancel_widget(347); + tick_user_widget_ok_cancel_widget(353); + tick_user_widget_ok_cancel_widget(366); + tick_user_widget_ok_cancel_widget(473); } void create_screen_blank_screen() { diff --git a/generated/ui_320x240/screens.h b/generated/ui_320x240/screens.h index e11e1ad33..d0db20d76 100644 --- a/generated/ui_320x240/screens.h +++ b/generated/ui_320x240/screens.h @@ -139,18 +139,6 @@ typedef struct _objects_t { lv_obj_t *home_qr_button; lv_obj_t *home_qr_label; lv_obj_t *nodes_panel; - lv_obj_t *node_panel; - lv_obj_t *node_image; - lv_obj_t *node_button; - lv_obj_t *user_name_label; - lv_obj_t *user_name_short_label; - lv_obj_t *battery_label; - lv_obj_t *last_heard_label; - lv_obj_t *signal_label; - lv_obj_t *position_label; - lv_obj_t *position2_label; - lv_obj_t *telemetry1_label; - lv_obj_t *telemetry2_label; lv_obj_t *groups_panel; lv_obj_t *channel_button0; lv_obj_t *channel_label0; diff --git a/include/graphics/common/MeshtasticView.h b/include/graphics/common/MeshtasticView.h index 1cabd9747..1b7c40892 100644 --- a/include/graphics/common/MeshtasticView.h +++ b/include/graphics/common/MeshtasticView.h @@ -89,6 +89,8 @@ class MeshtasticView : public DeviceGUI virtual void updatePowerMetrics(uint32_t nodeNum, const meshtastic_PowerMetrics &metrics) {} virtual void updateSignalStrength(uint32_t nodeNum, int32_t rssi, float snr); virtual void updateHopsAway(uint32_t nodeNum, uint8_t hopsAway) {} + virtual void beginNodeListPresentationBatch() {} + virtual void endNodeListPresentationBatch() {} virtual void updateConnectionStatus(const meshtastic_DeviceConnectionStatus &status) {} // methods to update device config @@ -140,14 +142,15 @@ class MeshtasticView : public DeviceGUI virtual void notifyRestoreMessages(int32_t percentage) {} virtual void notifyMessagesRestored(void); - virtual void notifyConnected(const char *info){}; - virtual void notifyDisconnected(const char *info){}; + virtual void notifyConnected(const char *info) {}; + virtual void notifyDisconnected(const char *info) {}; virtual void notifyResync(bool show); virtual void notifyReboot(bool show); virtual void notifyShutdown(void); virtual void showMessagePopup(const char *from); virtual void removeNode(uint32_t nodeNum); + virtual bool hasKnownNodeForPacket(uint32_t nodeNum) const; // local update methods virtual void updateLastHeard(uint32_t nodeNum); diff --git a/include/graphics/common/NodeDiscoverySyncGate.h b/include/graphics/common/NodeDiscoverySyncGate.h new file mode 100644 index 000000000..3007e6c37 --- /dev/null +++ b/include/graphics/common/NodeDiscoverySyncGate.h @@ -0,0 +1,47 @@ +#pragma once + +#include "graphics/common/NodeStore.h" + +#include + +class NodeDiscoverySyncGate +{ + public: + enum class MutationPolicy { Ignore, Defer, PassThrough }; + + static constexpr uint32_t DelayMs = 100; + + MutationPolicy observe(const NodeMutation &mutation, uint32_t nowMs) + { + if (mutation.kind == NodeMutationKind::Unchanged) { + return MutationPolicy::Ignore; + } + if (mutation.kind == NodeMutationKind::Inserted) { + if (!syncPending) { + startedAtMs = nowMs; + } + syncPending = true; + return MutationPolicy::Defer; + } + if (syncPending) { + syncForceRebind = true; + return MutationPolicy::Defer; + } + return MutationPolicy::PassThrough; + } + + bool pending() const { return syncPending; } + bool forceRebind() const { return syncForceRebind; } + bool due(uint32_t nowMs) const { return syncPending && static_cast(nowMs - startedAtMs) >= DelayMs; } + + void consumeFullSync() + { + syncPending = false; + syncForceRebind = false; + } + + private: + bool syncPending = false; + bool syncForceRebind = false; + uint32_t startedAtMs = 0; +}; diff --git a/include/graphics/common/NodeListRowPresentation.h b/include/graphics/common/NodeListRowPresentation.h new file mode 100644 index 000000000..d49f3851e --- /dev/null +++ b/include/graphics/common/NodeListRowPresentation.h @@ -0,0 +1,227 @@ +#pragma once + +#include "fonts.h" +#include "graphics/common/NodeStore.h" +#include "lvgl.h" +#include "meshtastic/mesh.pb.h" +#include "meshtastic/telemetry.pb.h" + +#include +#include +#include +#include +#include +#include + +namespace NodeListRowPresentation +{ +inline std::tuple nodeColors(uint32_t nodeNum) +{ + uint32_t red = (nodeNum & 0xff0000) >> 16; + uint32_t green = (nodeNum & 0xff00) >> 8; + uint32_t blue = nodeNum & 0xff; + while (red + green + blue < 0xF0) { + red += red / 3 + 10; + green += green / 3 + 10; + blue += blue / 3 + 10; + } + return std::make_tuple((red << 16) | (green << 8) | blue, (2 * red + 2 * green + blue) > 600 ? 0x000000 : 0xFFFFFF); +} + +inline bool containsCaseInsensitive(const char *haystack, const char *needle, size_t needleLength) +{ + if (!haystack || !needle || needleLength == 0) { + return false; + } + const char *haystackEnd = haystack + std::strlen(haystack); + auto it = std::search(haystack, haystackEnd, needle, needle + needleLength, [](char left, char right) { + return std::tolower(static_cast(left)) == std::tolower(static_cast(right)); + }); + return it != haystackEnd; +} + +inline bool containsCaseInsensitive(const char *haystack, const char *needle) +{ + return containsCaseInsensitive(haystack, needle, needle ? std::strlen(needle) : 0); +} + +inline void applyNodeImage(lv_obj_t *img, uint32_t nodeNum, const void *source, bool unmessagable, bool resetRecolor) +{ + uint32_t bgColor = 0; + uint32_t fgColor = 0; + std::tie(bgColor, fgColor) = nodeColors(nodeNum); + lv_image_set_src(img, source); + if (unmessagable) { + lv_obj_set_style_border_color(img, lv_color_hex(bgColor), LV_PART_MAIN | LV_STATE_DEFAULT); + lv_obj_set_style_bg_color(img, lv_color_hex(0x202020), LV_PART_MAIN | LV_STATE_DEFAULT); + lv_obj_set_style_image_recolor(img, lv_color_hex(0xff5555), LV_PART_MAIN | LV_STATE_DEFAULT); + lv_obj_set_style_image_recolor_opa(img, 255, LV_PART_MAIN | LV_STATE_DEFAULT); + return; + } + + if (resetRecolor) { + lv_obj_remove_local_style_prop(img, LV_STYLE_IMAGE_RECOLOR, LV_PART_MAIN | LV_STATE_DEFAULT); + } + lv_obj_set_style_bg_color(img, lv_color_hex(bgColor), LV_PART_MAIN | LV_STATE_DEFAULT); + lv_obj_set_style_border_color(img, lv_color_hex(bgColor), LV_PART_MAIN | LV_STATE_DEFAULT); + lv_obj_set_style_image_recolor_opa(img, fgColor ? 0 : 255, LV_PART_MAIN | LV_STATE_DEFAULT); +} + +// Row short-name fallback: names that render narrower than ~4 px on +// ui_font_montserrat_14 are replaced by the low 16 bits of the node id. +inline bool shortNameFallsBackToId(const char *shortName) +{ + if (!shortName) { + return true; + } + const size_t length = std::strlen(shortName); + if (length == 0) { + return true; + } + return lv_txt_get_width(shortName, static_cast(length), &ui_font_montserrat_14, 0) <= 4; +} + +inline void formatShortDisplayName(char *dest, size_t destSize, const char *shortName, NodeId nodeId) +{ + if (destSize == 0) { + return; + } + std::memset(dest, 0, destSize); + if (shortNameFallsBackToId(shortName)) { + std::snprintf(dest, destSize, "%04x", static_cast(nodeId & 0xffff)); + } else { + std::snprintf(dest, destSize, "%s", shortName); + } +} + +// Short-name label composition: the (already fallback resolved) short name is +// padded to four characters and, when both positions are known, followed by a +// second line carrying the flat-earth distance. +inline void formatShortNameWithDistance(char *buffer, size_t bufferSize, const char *shortName, NodeId nodeId, + bool hasOwnPosition, int32_t ownLatitude, int32_t ownLongitude, int32_t latitude, + int32_t longitude, bool metricUnits) +{ + formatShortDisplayName(buffer, bufferSize, shortName, nodeId); + + if (!hasOwnPosition || bufferSize < 6) { + return; + } + + size_t shortLength = 0; + while (shortLength < bufferSize && buffer[shortLength] != '\0') { + ++shortLength; + } + for (size_t i = shortLength; i < 4 && i + 1 < bufferSize; ++i) { + buffer[i] = ' '; + } + + const float dx = 71.5f * 1e-7f * (static_cast(ownLongitude) - static_cast(longitude)); + const float dy = 111.3f * 1e-7f * (static_cast(ownLatitude) - static_cast(latitude)); + const float dist = std::sqrt(dx * dx + dy * dy); + + buffer[4] = '\n'; + if (metricUnits) { + if (dist > 1.0f) { + std::snprintf(&buffer[5], bufferSize - 5, "%.1f km ", dist); + } else { + std::snprintf(&buffer[5], bufferSize - 5, "%u m ", static_cast(std::round(dist * 1000.0f))); + } + } else { + if (dist > 0.1f) { + std::snprintf(&buffer[5], bufferSize - 5, "%.1f mi ", std::round(dist * 0.621371f)); + } else { + std::snprintf(&buffer[5], bufferSize - 5, "%u ft ", static_cast(dist * 3280.84f)); + } + } +} + +// Position detail lines: "%.5f %.5f" coordinates and " MSL"; +// altitude magnitudes of 10000 or more clamp the displayed value to zero. +inline void formatPositionLines(int32_t latitude, int32_t longitude, int32_t altitude, bool metricUnits, char *positionText, + size_t positionTextSize, char *altitudeText, size_t altitudeTextSize) +{ + int32_t shownAltitude = altitude > -10000 && altitude < 10000 ? altitude : 0; + const char *altitudeUnits = "m"; + if (!metricUnits) { + shownAltitude = static_cast(static_cast(shownAltitude) * 3.28084f); + altitudeUnits = "ft"; + } + std::snprintf(positionText, positionTextSize, "%.5f %.5f", latitude * 1e-7, longitude * 1e-7); + std::snprintf(altitudeText, altitudeTextSize, "%d%s MSL", static_cast(shownAltitude), altitudeUnits); +} + +// Environment telemetry lines, including the IAQ chip line. +template +inline void formatTelemetryLines(const Metrics &metrics, bool metricUnits, char *telemetry1Text, size_t telemetry1Size, + char *telemetry2Text, size_t telemetry2Size) +{ + telemetry2Text[0] = '\0'; + if (metricUnits) { + if (static_cast(metrics.relative_humidity) > 0) { + std::snprintf(telemetry1Text, telemetry1Size, "%2.1f°C %d%% %3.1fhPa", metrics.temperature, + static_cast(metrics.relative_humidity), metrics.barometric_pressure); + } else { + std::snprintf(telemetry1Text, telemetry1Size, "%2.1f°C %3.1fhPa", metrics.temperature, metrics.barometric_pressure); + } + } else { + if (static_cast(metrics.relative_humidity) > 0) { + std::snprintf(telemetry1Text, telemetry1Size, "%2.1f°F %d%% %3.1finHg", metrics.temperature * 9 / 5 + 32, + static_cast(metrics.relative_humidity), metrics.barometric_pressure / 33.86f); + } else { + std::snprintf(telemetry1Text, telemetry1Size, "%2.1f°F %3.1finHg", metrics.temperature * 9 / 5 + 32, + metrics.barometric_pressure / 33.86f); + } + } + + if (metrics.iaq > 0 && metrics.iaq < 1000) { + std::snprintf(telemetry2Text, telemetry2Size, "IAQ: %d %.1fV %.1fmA", static_cast(metrics.iaq), metrics.voltage, + metrics.current); + } +} + +// IAQ chip palette; returns {textColor, bgColor}. +inline std::tuple iaqColors(uint32_t iaq) +{ + if (iaq <= 50) { + return {lv_color_hex(0x00000000), lv_color_hex(0x000ce810)}; + } else if (iaq <= 100) { + return {lv_color_hex(0x00000000), lv_color_hex(0x00faf646)}; + } else if (iaq <= 150) { + return {lv_color_hex(0x00000000), lv_color_hex(0x00f98204)}; + } else if (iaq <= 200) { + return {lv_color_hex(0x00000000), lv_color_hex(0x00e42104)}; + } else if (iaq <= 300) { + return {lv_color_hex(0xffffffff), lv_color_hex(0x009b2970)}; + } + return {lv_color_hex(0xffffffff), lv_color_hex(0x001d1414)}; +} + +// Row battery label; levels above 100% are clamped to 100%. +inline void formatBatteryLabel(uint32_t batteryLevel, float voltage, char *buffer, size_t bufferSize) +{ + std::snprintf(buffer, bufferSize, "%u%% %0.2fV", static_cast(std::min(batteryLevel, 100)), voltage); +} + +// Last-heard buckets (minutes/hours/days, empty after 60 days). +// nowLabel carries the localized "now" string so this header stays i18n-macro free. +inline void formatLastHeardLabel(uint32_t lastHeard, uint32_t currentTime, const char *nowLabel, char *buffer, size_t bufferSize) +{ + if (lastHeard == 0) { + buffer[0] = '\0'; + return; + } + + const uint32_t age = currentTime > lastHeard ? currentTime - lastHeard : 0; + if (age < 60) { + std::snprintf(buffer, bufferSize, "%s", nowLabel ? nowLabel : "now"); + } else if (age < 3600) { + std::snprintf(buffer, bufferSize, "%u min", age / 60); + } else if (age < 86400) { + std::snprintf(buffer, bufferSize, "%u h", age / 3600); + } else if (age < 86400U * 60U) { + std::snprintf(buffer, bufferSize, "%u d", age / 86400); + } else { + buffer[0] = '\0'; + } +} +} // namespace NodeListRowPresentation diff --git a/include/graphics/common/NodeStore.h b/include/graphics/common/NodeStore.h new file mode 100644 index 000000000..bfcaafb4b --- /dev/null +++ b/include/graphics/common/NodeStore.h @@ -0,0 +1,135 @@ +#pragma once + +#include "meshtastic/mesh.pb.h" +#include "meshtastic/telemetry.pb.h" + +#include +#include +#include + +#ifndef MAX_NUM_NODES_VIEW +#define MAX_NUM_NODES_VIEW 250 +#endif + +using NodeId = uint32_t; + +struct NodePosition { + bool known = false; + int32_t latitude = 0; + int32_t longitude = 0; + int32_t altitude = 0; + uint32_t satellites = 0; + uint32_t precision = 0; + + bool hasCoordinates() const { return known && (latitude != 0 || longitude != 0); } +}; + +enum class NodeSignalDisplayKind : uint8_t { None, Rssi, Hops }; + +struct NodeUserSummary { + char id[16]{}; + char long_name[40]{}; + char short_name[5]{}; + uint8_t macaddr[6]{}; + meshtastic_HardwareModel hw_model = meshtastic_HardwareModel_UNSET; + meshtastic_Config_DeviceConfig_Role role = meshtastic_Config_DeviceConfig_Role_CLIENT; + bool is_licensed = false; + uint8_t publicKeySize = 0; + uint32_t publicKeyHash = 0; +}; + +struct NodeDeviceMetrics { + uint32_t battery_level = 0; + float voltage = 0.0f; + float channel_utilization = 0.0f; + float air_util_tx = 0.0f; +}; + +struct NodeEnvironmentMetrics { + float temperature = 0.0f; + float relative_humidity = 0.0f; + float barometric_pressure = 0.0f; + float voltage = 0.0f; + float current = 0.0f; + uint16_t iaq = 0; +}; + +struct NodeRecord { + uint64_t recencyOrder = 0; + NodeUserSummary user{}; + NodePosition position{}; + NodeDeviceMetrics deviceMetrics{}; + NodeEnvironmentMetrics environmentMetrics{}; + NodeId id = 0; + uint32_t lastHeard = 0; + int32_t rssi = 0; + float snr = 0; + uint8_t channel = 0; + int8_t hopsAway = -1; + NodeSignalDisplayKind signalDisplay = NodeSignalDisplayKind::None; + bool hasUser = false; + bool hasKey = false; + bool hasBadKey = false; + bool unmessagable = false; + bool viaMqtt = false; + bool recencyPromoted = false; + bool hasDeviceMetrics = false; + bool hasEnvironmentMetrics = false; + bool hasActiveChat = false; +}; + +static_assert(sizeof(NodeRecord) <= 192, "NodeRecord must remain compact for non-PSRAM targets"); + +enum class NodeMutationKind { Inserted, Updated, Removed, Unchanged }; + +enum NodeChangedField : uint32_t { + NodeFieldNone = 0, + NodeFieldUser = 1U << 0, + NodeFieldChannel = 1U << 1, + NodeFieldFlags = 1U << 2, + NodeFieldLastHeard = 1U << 3, + NodeFieldPosition = 1U << 4, + NodeFieldDeviceMetrics = 1U << 5, + NodeFieldEnvironmentMetrics = 1U << 6, + NodeFieldSignal = 1U << 8, + NodeFieldHops = 1U << 9, + NodeFieldActiveChat = 1U << 10, +}; + +struct NodeMutation { + NodeMutationKind kind = NodeMutationKind::Unchanged; + NodeId id = 0; + uint32_t changedFields = NodeFieldNone; +}; + +class NodeStore +{ + public: + using Records = std::unordered_map; + + NodeStore(); + + const NodeRecord *find(NodeId id) const; + const Records &records() const { return nodes; } + size_t size() const { return nodes.size(); } + + NodeMutation upsertUser(NodeId id, uint8_t channel, uint32_t lastHeard, const meshtastic_User &user, bool viaMqtt); + NodeMutation upsertUnknown(NodeId id, uint8_t channel, uint32_t lastHeard, uint8_t role, bool hasKey, bool viaMqtt); + NodeMutation updatePosition(NodeId id, const NodePosition &position); + NodeMutation updateDeviceMetrics(NodeId id, const meshtastic_DeviceMetrics &metrics); + NodeMutation updateEnvironmentMetrics(NodeId id, const meshtastic_EnvironmentMetrics &metrics); + NodeMutation updateSignal(NodeId id, int32_t rssi, float snr); + NodeMutation updateHops(NodeId id, int8_t hopsAway); + NodeMutation updateLastHeard(NodeId id, uint32_t now); + NodeMutation markBadKey(NodeId id); + NodeMutation setActiveChat(NodeId id, bool active); + NodeMutation remove(NodeId id); + + NodeId selectPurgeCandidate(NodeId incoming, NodeId ownNode, uint32_t now) const; + + private: + void touchRecency(NodeRecord &record); + + Records nodes; + uint64_t nextRecencyOrder = 1; +}; diff --git a/include/graphics/common/VisibleNodeIndex.h b/include/graphics/common/VisibleNodeIndex.h new file mode 100644 index 000000000..d7c977b06 --- /dev/null +++ b/include/graphics/common/VisibleNodeIndex.h @@ -0,0 +1,68 @@ +#pragma once + +#include "graphics/common/NodeStore.h" + +#include +#include +#include +#include +#include + +struct NodeListFilter { + bool unknown = false; + bool offline = false; + bool publicKey = false; + uint8_t channel = 0; // 0 = all channels, 1..8 = channel + 1 + int hops = 0; // 0 = all, 1..14 = dropdown index + bool position = false; + bool viaMqtt = false; + std::string name; + uint32_t curTime = 0; + uint32_t secsUntilOffline = 900; // default 15 minutes + + // Own-position context so name matching runs against the rendered + // short-name text, including the id fallback and the distance line. + bool hasOwnPosition = false; + int32_t ownLatitude = 0; + int32_t ownLongitude = 0; + bool metricUnits = true; +}; + +class VisibleNodeIndex +{ + public: + void rebuild(const NodeStore &store, const NodeListFilter &filter, NodeId ownNode); + + const std::vector &ids() const { return visibleIds; } + size_t size() const { return visibleIds.size(); } + bool empty() const { return visibleIds.empty(); } + uint32_t generation() const { return rebuildGeneration; } + uint32_t membershipGeneration() const { return visibleMembershipGeneration; } + + std::optional indexOf(NodeId id) const; + bool contains(NodeId id) const; + +#ifdef UNIT_TEST + void resetContainsCallCountForTesting() const { containsCallCount = 0; } + uint32_t containsCallCountForTesting() const { return containsCallCount; } + uint32_t membershipProbeCountForTesting() const { return membershipProbeCount; } + size_t rebuildScratchCapacityForTesting() const { return rebuildScratch.capacity(); } + const NodeId *membershipStorageForTesting() const { return visibleMembership.data(); } + size_t membershipStorageCapacityForTesting() const { return visibleMembership.size(); } + size_t membershipStorageSizeForTesting() const { return visibleMembershipSize; } +#endif + + static bool isVisible(const NodeRecord &node, const NodeListFilter &filter, NodeId ownNode); + + private: + std::vector visibleIds; + std::vector rebuildScratch; + std::array visibleMembership{}; + size_t visibleMembershipSize = 0; + uint32_t rebuildGeneration = 0; + uint32_t visibleMembershipGeneration = 0; +#ifdef UNIT_TEST + mutable uint32_t containsCallCount = 0; + uint32_t membershipProbeCount = 0; +#endif +}; diff --git a/include/graphics/view/TFT/TFTView_320x240.h b/include/graphics/view/TFT/TFTView_320x240.h index 9d4c7426d..787504a01 100644 --- a/include/graphics/view/TFT/TFTView_320x240.h +++ b/include/graphics/view/TFT/TFTView_320x240.h @@ -1,8 +1,15 @@ #pragma once #include "graphics/common/MeshtasticView.h" +#include "graphics/common/NodeDiscoverySyncGate.h" +#include "graphics/common/NodeStore.h" +#include "graphics/common/VisibleNodeIndex.h" +#include "graphics/view/TFT/VirtualNodeList.h" #include "meshtastic/clientonly.pb.h" +#include +#include #include +#include class MapPanel; @@ -12,7 +19,7 @@ class MapPanel; * Note: due to static callbacks in lvgl this class is modelled as * a singleton with static callback members */ -class TFTView_320x240 : public MeshtasticView +class TFTView_320x240 : public MeshtasticView, private NodeListActionSink { public: void init(IClientBase *client) override; @@ -23,6 +30,7 @@ class TFTView_320x240 : public MeshtasticView void setMyInfo(uint32_t nodeNum) override; void setDeviceMetaData(int hw_model, const char *version, bool has_bluetooth, bool has_wifi, bool has_eth, bool can_shutdown) override; + void addOrUpdateNode(uint32_t nodeNum, uint8_t channel, uint32_t lastHeard, eRole role, bool hasKey, bool viaMqtt) override; void addOrUpdateNode(uint32_t nodeNum, uint8_t channel, uint32_t lastHeard, const meshtastic_User &cfg) override; void addNode(uint32_t nodeNum, uint8_t channel, const char *userShort, const char *userLong, uint32_t lastHeard, eRole role, bool hasKey, bool unmessagable) override; @@ -30,10 +38,10 @@ class TFTView_320x240 : public MeshtasticView void updatePosition(uint32_t nodeNum, int32_t lat, int32_t lon, int32_t alt, uint32_t sats, uint32_t precision) override; void updateMetrics(uint32_t nodeNum, uint32_t bat_level, float voltage, float chUtil, float airUtil) override; void updateEnvironmentMetrics(uint32_t nodeNum, const meshtastic_EnvironmentMetrics &metrics) override; - void updateAirQualityMetrics(uint32_t nodeNum, const meshtastic_AirQualityMetrics &metrics) override; - void updatePowerMetrics(uint32_t nodeNum, const meshtastic_PowerMetrics &metrics) override; void updateSignalStrength(uint32_t nodeNum, int32_t rssi, float snr) override; void updateHopsAway(uint32_t nodeNum, uint8_t hopsAway) override; + void beginNodeListPresentationBatch() override; + void endNodeListPresentationBatch() override; void updateConnectionStatus(const meshtastic_DeviceConnectionStatus &status) override; // methods to update device config @@ -85,6 +93,9 @@ class TFTView_320x240 : public MeshtasticView void newMessage(uint32_t from, uint32_t to, uint8_t ch, const char *msg, uint32_t &msgtime, bool restore = true) override; void restoreMessage(const LogMessage &msg) override; void removeNode(uint32_t nodeNum) override; + bool hasKnownNodeForPacket(uint32_t nodeNum) const override; + + const NodeRecord *nodeRecord(NodeId id) const { return nodeStore.find(id); } enum BasicSettings { eNone, @@ -164,8 +175,6 @@ class TFTView_320x240 : public MeshtasticView virtual void newMessage(uint32_t nodeNum, lv_obj_t *container, uint8_t channel, const char *msg); // create empty message container for node or group channel virtual lv_obj_t *newMessageContainer(uint32_t from, uint32_t to, uint8_t ch); - // filter or highlight node - virtual bool applyNodesFilter(uint32_t nodeNum, bool reset = false); // display message alert popup virtual void messageAlert(const char *alert, bool show); // mark sent message as received @@ -173,7 +182,7 @@ class TFTView_320x240 : public MeshtasticView // set node image based on role virtual void setNodeImage(uint32_t nodeNum, eRole role, bool unmessagable, lv_obj_t *img); // apply filter and count number of filtered nodes - virtual void updateNodesFiltered(bool reset); + virtual void updateNodesFiltered(bool reset, bool forceRebind = false); // set last heard to now, update nodes online virtual void updateLastHeard(uint32_t nodeNum); // update last heard value on all node panels @@ -199,7 +208,6 @@ class TFTView_320x240 : public MeshtasticView // update time display on home screen virtual void updateFreeMem(void); // update distance to other node - virtual void updateDistance(uint32_t nodeNum, int32_t lat, int32_t lon); // show map and load tiles virtual void loadMap(void); // add objects on map @@ -232,7 +240,24 @@ class TFTView_320x240 : public MeshtasticView void disablePanel(lv_obj_t *panel); void setGroupFocus(lv_obj_t *panel); void setInputGroup(void); + void setInputGroup(lv_group_t *group); void setInputButtonLabel(void); + NodeListFilter currentNodeListFilter(void) const; + NodeListRenderContext nodeListRenderContext(void) const; + void publishMapFilter(); + void reconcileVirtualNodeListInputGroup(bool enteringNodeScreen); + bool chatTitleFromModel(uint32_t nodeNum, char *buf, size_t bufSize) const; + void syncVisibleNodeIndex(void); + void syncNodeListPresentation(void); + void syncNodeListPresentation(bool forceRebind); + void syncNodeListPresentation(const NodeMutation &mutation); + void ensureVirtualNodeList(void); + bool mutationCanRefreshVirtualRow(const NodeMutation &mutation) const; + bool refreshVirtualNodePresentation(NodeId id); + void nodeClicked(NodeId id) override; + void nodeLongPressed(NodeId id) override; + void nodeFocusBoundary(bool forward) override; + void nodePositionClicked(NodeId id) override; void updateGroupChannel(uint8_t chId); void backup(uint32_t option); @@ -243,11 +268,23 @@ class TFTView_320x240 : public MeshtasticView void addNodeToTraceRoute(uint32_t nodeNum, lv_obj_t *panel); void purgeNode(uint32_t nodeNum); void removeSpinner(void); + void *traceRouteNodeCallbackUserData(NodeId id) const; void packetDetected(const meshtastic_MeshPacket &p); void writePacketLog(const meshtastic_MeshPacket &p); void updateStatistics(const meshtastic_MeshPacket &p); void updateSignalStrength(int32_t rssi, float snr); int32_t signalStrength2Percent(int32_t rx_rssi, float rx_snr); + void selectNode(NodeId id); + bool nodeIsMessagable(NodeId id) const; + uint8_t nodeChannel(NodeId id) const; + bool nodeHasKey(NodeId id) const; + bool nodeHasBadKey(NodeId id) const; + int8_t nodeHops(NodeId id) const; + const char *nodeDisplayName(NodeId id) const; + const char *nodeShortName(NodeId id) const; + NodePosition nodePosition(NodeId id) const; + NodeId nodePurgeCandidate(NodeId incoming) const; + uint8_t nodeHopLimit(NodeId id, int8_t unknownHops) const; uint32_t preset2val(meshtastic_Config_LoRaConfig_ModemPreset preset); meshtastic_Config_LoRaConfig_ModemPreset val2preset(uint32_t val); @@ -394,7 +431,6 @@ class TFTView_320x240 : public MeshtasticView static void ui_event_positionButton(lv_event_t *e); // animations - static void ui_anim_node_panel_cb(void *var, int32_t v); static void ui_anim_radar_cb(void *var, int32_t r); lv_obj_t *activeButton = nullptr; @@ -407,10 +443,15 @@ class TFTView_320x240 : public MeshtasticView enum BasicSettings activeSettings = eNone; // active settings menu (used to disable other button presses) - static TFTView_320x240 *gui; // singleton pattern - bool screensInitialised; // true if init_screens is completed - uint32_t nodesFiltered; // no. hidden nodes in node list - bool nodesChanged; // true if nodes changed (added or purged) + static TFTView_320x240 *gui; // singleton pattern + bool screensInitialised; // true if init_screens is completed + uint32_t nodesFiltered; // no. hidden nodes in node list + bool nodesChanged; // true if nodes changed (added or purged) + NodeDiscoverySyncGate nodeListDiscoverySync; + uint8_t nodeListPresentationBatchDepth = 0; + bool nodeListPresentationBatchSyncRequested = false; + bool nodeListPresentationBatchForceRebind = false; + NodeId nodeListPresentationBatchRefreshId = 0; bool processingFilter; // indicates that filtering is ongoing bool packetLogEnabled; // display received packets bool detectorRunning; // meshDetector is active @@ -423,11 +464,9 @@ class TFTView_320x240 : public MeshtasticView time_t actTime, uptime, lastHeard; // actual time and uptime; time last heard a node bool hasPosition; // if our position is known int32_t myLatitude, myLongitude; // our current position as reported by firmware - void *topNodeLL; // pointer to topmost button in group ll uint32_t scans; // scanner counter lv_anim_t radar; // radar animation static uint32_t currentNode; // current selected node - static lv_obj_t *currentPanel; // current selected node panel static lv_obj_t *spinnerButton; // start button animation static time_t startTime; // time when start button was pressed static uint32_t pinKeys; // number of keys pressed (lock screen) @@ -456,4 +495,11 @@ class TFTView_320x240 : public MeshtasticView }; meshtastic_DeviceProfile_full db{}; // full copy of the node's configuration db (except nodeinfos) plus ui data -}; \ No newline at end of file + NodeStore nodeStore; + VisibleNodeIndex visibleNodes; + std::unique_ptr virtualNodeList; + uint32_t publishedMapFilterGeneration = 0; + bool mapFilterPublicationValid = false; + bool virtualNodeListInputVisibilityKnown = false; + bool virtualNodeListInputHadVisibleNodes = false; +}; diff --git a/include/graphics/view/TFT/VirtualNodeList.h b/include/graphics/view/TFT/VirtualNodeList.h new file mode 100644 index 000000000..43784fa9a --- /dev/null +++ b/include/graphics/view/TFT/VirtualNodeList.h @@ -0,0 +1,203 @@ +#pragma once + +#include "graphics/common/NodeStore.h" +#include "graphics/common/VisibleNodeIndex.h" +#include "lvgl.h" + +#include +#include +#include +#include + +class NodeListActionSink +{ + public: + virtual void nodeClicked(NodeId id) = 0; + virtual void nodeLongPressed(NodeId id) = 0; + virtual void nodeFocusBoundary(bool forward) {} + virtual void nodePositionClicked(NodeId id) {} + virtual ~NodeListActionSink() = default; +}; + +struct NodeListRenderContext { + NodeId ownNode = 0; + bool hasOwnPosition = false; + int32_t ownLatitude = 0; + int32_t ownLongitude = 0; + bool metricUnits = true; + bool highlightActiveChat = false; + bool highlightPosition = false; + bool highlightTelemetry = false; + bool highlightIaq = false; + char highlightName[64]{}; +}; + +struct ReusableRow { + lv_obj_t *panel = nullptr; + lv_obj_t *img = nullptr; + lv_obj_t *btn = nullptr; + lv_obj_t *lblLong = nullptr; + lv_obj_t *lblShort = nullptr; + lv_obj_t *lblBat = nullptr; + lv_obj_t *lblLh = nullptr; + lv_obj_t *lblSig = nullptr; + lv_obj_t *lblPos1 = nullptr; + lv_obj_t *lblPos2 = nullptr; + lv_obj_t *lblTm1 = nullptr; + lv_obj_t *lblTm2 = nullptr; + char shortText[32]{}; + char longText[40]{}; + char batteryText[32]{}; + char lastHeardText[32]{}; + char signalText[32]{}; + char positionText[48]{}; + char position2Text[32]{}; + char telemetry1Text[64]{}; + char telemetry2Text[48]{}; + NodeId boundId = 0; + NodeId pressedId = 0; + int32_t renderedY = std::numeric_limits::min(); + int32_t renderedHeight = std::numeric_limits::min(); + + // Float-heavy detail strings are reformatted only when their source values + // or unit mode change; recycled rows start invalidated. + bool detailKeysValid = false; + bool detailMetricUnits = true; + bool detailHasBattery = false; + bool detailHasEnvironment = false; + uint32_t detailBatteryLevel = 0; + float detailVoltage = 0; + int32_t detailLatitude = 0; + int32_t detailLongitude = 0; + int32_t detailAltitude = 0; + NodeEnvironmentMetrics detailEnvironment{}; + // Short-name label memoization (fallback + distance line) + NodeId detailShortId = 0; + NodeId detailOwnNode = 0; + char detailShortName[5]{}; + bool detailHasOwnPosition = false; + int32_t detailOwnLatitude = 0; + int32_t detailOwnLongitude = 0; +}; + +class VirtualNodeList +{ + public: + static constexpr size_t MAX_POOL_SIZE = 9; + static constexpr int32_t COLLAPSED_ROW_HEIGHT = 53; + static constexpr int32_t EXPANDED_ROW_HEIGHT = 83; + static constexpr int32_t ROW_GAP = 5; + + VirtualNodeList(lv_obj_t *parent, NodeListActionSink &sink); + ~VirtualNodeList(); + + void sync(const NodeStore &store, const VisibleNodeIndex &index, NodeId expanded = 0, uint32_t currentTime = 0, + const NodeListRenderContext &context = {}, bool forceRebind = false); + void setExpanded(NodeId id); + bool expansionAnimating() const { return expansionAnimationActive; } + void scrollTo(NodeId id, lv_anim_enable_t anim = LV_ANIM_OFF); + void focus(NodeId id); + lv_group_t *navigationGroup() const { return attachedGroup; } +#ifdef UNIT_TEST + int32_t rowHeightForTesting(NodeId id) const { return rowHeight(id); } + int32_t rowYForTesting(size_t index) const { return rowY(index); } + int32_t scrollYForNodeForTesting(NodeId id) const { return scrollYForNode(id); } + NodeId focusedIdForTesting() const { return currentFocusedId(); } + bool detailLabelsHiddenForTesting(NodeId id) const; + bool positionLabelClickableForTesting(NodeId id) const; + bool longLabelsScrollForTesting(NodeId id) const; + void setExpansionProgressForTesting(int32_t progress) { updateExpansionAnimation(progress); } + void finishExpansionForTesting() + { + lv_anim_delete(this, expansionAnimationCallback); + finishExpansionAnimation(); + if (parentPanel) { + lv_obj_update_layout(parentPanel); + } + } + bool expansionAnimationRegisteredForTesting() const + { + return lv_anim_get(const_cast(this), expansionAnimationCallback) != nullptr; + } + uint32_t expandedIndexScanCountForTesting() const { return expandedIndexScanCount; } + uint32_t panelOrderMoveCountForTesting() const { return panelOrderMoveCount; } + uint32_t groupOrderMoveCountForTesting() const { return groupOrderMoveCount; } +#endif + + void refreshVisibleRows(bool force = false, bool rebind = false, bool reorder = true); + bool refreshNode(NodeId id, uint32_t currentTime, const NodeListRenderContext &context); + + private: + struct ScrollAnchor { + NodeId id = 0; + int32_t offset = 0; + }; + + void createRowPool(); + void updateVirtualContentHeight(); + void updateExpandedIndexCache(); + ScrollAnchor captureScrollAnchor() const; + void restoreScrollAnchor(const ScrollAnchor &anchor); + void bindRow(ReusableRow &row, const NodeRecord &record, bool isExpanded); + void applyHighlight(ReusableRow &row, const NodeRecord &record); + bool visibleLastHeardLabelsNeedRefresh(const NodeStore &store, uint32_t nextTime, const NodeListRenderContext &context) const; + int32_t rowHeight(NodeId id) const; + int32_t rowY(size_t index) const; + size_t firstVisibleIndex(int32_t scrollY) const; + bool rowShowsExpandedDetails(NodeId id) const; + int32_t viewportHeight() const; + int32_t scrollYForNode(NodeId id) const; + NodeId currentFocusedId() const; + void restoreFocus(NodeId focusedId, size_t fallbackIndex); + void requestExpanded(NodeId id); + void updateExpansionAnimation(int32_t progress); + void finishExpansionAnimation(); + void attachGroupNavigation(); + void detachGroupNavigation(); + void handleGroupEdge(bool forward); + void noteFocusedButton(lv_obj_t *button); + bool focusAdjacent(NodeId id, int direction); + size_t poolIndexForButton(lv_obj_t *button) const; + + static void scrollEventCallback(lv_event_t *e); + static void rowClickCallback(lv_event_t *e); + static void rowPositionCallback(lv_event_t *e); + static void groupEdgeCallback(lv_group_t *group, bool forward); + static void expansionAnimationCallback(void *var, int32_t progress); + static void expansionAnimationFinished(lv_anim_t *animation); + + lv_obj_t *parentPanel = nullptr; + lv_obj_t *spacer = nullptr; + NodeListActionSink &actionSink; + + const NodeStore *currentStore = nullptr; + const VisibleNodeIndex *currentIndex = nullptr; + NodeId expandedId = 0; + NodeId previousExpandedId = 0; + NodeId pendingExpandedId = 0; + size_t expandedIndex = std::numeric_limits::max(); + size_t previousExpandedIndex = std::numeric_limits::max(); + uint32_t currentTime = 0; + NodeListRenderContext renderContext{}; + + std::vector rowPool; + size_t firstRenderedIndex = std::numeric_limits::max(); + uint32_t lastSyncedIndexGeneration = 0; + bool expansionCacheValid = false; + const VisibleNodeIndex *expansionCacheIndex = nullptr; + uint32_t expansionCacheGeneration = 0; + NodeId expansionCacheExpandedId = 0; + NodeId expansionCachePreviousId = 0; + lv_group_t *attachedGroup = nullptr; + NodeId lastFocusedId = 0; + size_t lastFocusedIndex = std::numeric_limits::max(); + bool rowOrderDirty = false; + bool expansionAnimationActive = false; + bool hasPendingExpandedId = false; + int32_t expansionProgress = 100; +#ifdef UNIT_TEST + mutable uint32_t expandedIndexScanCount = 0; + uint32_t panelOrderMoveCount = 0; + uint32_t groupOrderMoveCount = 0; +#endif +}; diff --git a/source/graphics/TFT/TFTView_320x240.cpp b/source/graphics/TFT/TFTView_320x240.cpp index 29304a755..7f5fa3864 100644 --- a/source/graphics/TFT/TFTView_320x240.cpp +++ b/source/graphics/TFT/TFTView_320x240.cpp @@ -4,6 +4,7 @@ #include "Arduino.h" #include "graphics/common/BatteryLevel.h" #include "graphics/common/LoRaPresets.h" +#include "graphics/common/NodeListRowPresentation.h" #include "graphics/common/Ringtones.h" #include "graphics/common/ViewController.h" #include "graphics/driver/DisplayDriver.h" @@ -56,10 +57,6 @@ fs::FS &fileSystem = LittleFS; #endif #include "graphics/common/SdCard.h" -#ifndef MAX_NUM_NODES_VIEW -#define MAX_NUM_NODES_VIEW 250 -#endif - #ifndef PACKET_LOGS_MAX #define PACKET_LOGS_MAX 200 #endif @@ -80,28 +77,12 @@ constexpr lv_color_t colorDarkRed = LV_COLOR_HEX(0xa70a0a); constexpr lv_color_t colorOrange = LV_COLOR_HEX(0xff8c04); constexpr lv_color_t colorYellow = LV_COLOR_HEX(0xdbd251); constexpr lv_color_t colorBlueGreen = LV_COLOR_HEX(0x05f6cb); -constexpr lv_color_t colorBlue = LV_COLOR_HEX(0x436C70); constexpr lv_color_t colorGray = LV_COLOR_HEX(0x757575); constexpr lv_color_t colorLightGray = LV_COLOR_HEX(0xAAFBFF); constexpr lv_color_t colorMidGray = LV_COLOR_HEX(0x808080); constexpr lv_color_t colorDarkGray = LV_COLOR_HEX(0x303030); constexpr lv_color_t colorMesh = LV_COLOR_HEX(0x67ea94); -// children index of nodepanel lv objects (see addNode) -enum NodePanelIdx { - node_img_idx, - node_btn_idx, - node_lbl_idx, - node_lbs_idx, - node_bat_idx, - node_lh_idx, - node_sig_idx, - node_pos1_idx, - node_pos2_idx, - node_tm1_idx, - node_tm2_idx -}; - enum ScrollDirection { scrollDownLeft = 1, scrollDown = 2, @@ -116,7 +97,6 @@ enum ScrollDirection { extern const char *firmware_version; TFTView_320x240 *TFTView_320x240::gui = nullptr; -lv_obj_t *TFTView_320x240::currentPanel = nullptr; lv_obj_t *TFTView_320x240::spinnerButton = nullptr; uint32_t TFTView_320x240::currentNode = 0; time_t TFTView_320x240::startTime = 0; @@ -124,6 +104,77 @@ uint32_t TFTView_320x240::pinKeys = 0; bool TFTView_320x240::screenLocked = false; bool TFTView_320x240::screenUnlockRequest = false; +void *TFTView_320x240::traceRouteNodeCallbackUserData(NodeId id) const +{ + return reinterpret_cast(static_cast(id)); +} + +void TFTView_320x240::selectNode(NodeId id) +{ + currentNode = id; +} + +bool TFTView_320x240::nodeIsMessagable(NodeId id) const +{ + const NodeRecord *record = nodeStore.find(id); + return record && id != ownNode && !record->unmessagable; +} + +uint8_t TFTView_320x240::nodeChannel(NodeId id) const +{ + const NodeRecord *record = nodeStore.find(id); + return record ? record->channel : 0; +} + +bool TFTView_320x240::nodeHasKey(NodeId id) const +{ + const NodeRecord *record = nodeStore.find(id); + return record && record->hasKey && !record->hasBadKey; +} + +bool TFTView_320x240::nodeHasBadKey(NodeId id) const +{ + const NodeRecord *record = nodeStore.find(id); + return record && record->hasBadKey; +} + +int8_t TFTView_320x240::nodeHops(NodeId id) const +{ + const NodeRecord *record = nodeStore.find(id); + return record ? record->hopsAway : -1; +} + +const char *TFTView_320x240::nodeDisplayName(NodeId id) const +{ + const NodeRecord *record = nodeStore.find(id); + return record ? record->user.long_name : nullptr; +} + +const char *TFTView_320x240::nodeShortName(NodeId id) const +{ + const NodeRecord *record = nodeStore.find(id); + return record ? record->user.short_name : nullptr; +} + +NodePosition TFTView_320x240::nodePosition(NodeId id) const +{ + const NodeRecord *record = nodeStore.find(id); + return record ? record->position : NodePosition{}; +} + +NodeId TFTView_320x240::nodePurgeCandidate(NodeId incoming) const +{ + return nodeStore.selectPurgeCandidate(incoming, ownNode, static_cast(curtime)); +} + +uint8_t TFTView_320x240::nodeHopLimit(NodeId id, int8_t unknownHops) const +{ + int8_t hopsAway = nodeHops(id); + if (hopsAway < 0) + hopsAway = unknownHops; + return hopsAway < db.config.lora.hop_limit ? hopsAway + 1 : hopsAway; +} + TFTView_320x240 *TFTView_320x240::instance(void) { if (!gui) { @@ -143,13 +194,13 @@ TFTView_320x240 *TFTView_320x240::instance(const DisplayDriverConfig &cfg) TFTView_320x240::TFTView_320x240(const DisplayDriverConfig *cfg, DisplayDriver *driver) : MeshtasticView(cfg, driver, new ViewController), screensInitialised(false), nodesFiltered(0), nodesChanged(true), processingFilter(false), packetLogEnabled(false), detectorRunning(false), cardDetected(false), formatSD(false), - packetCounter(0), actTime(0), uptime(0), lastHeard(0), hasPosition(false), myLatitude(0), myLongitude(0), - topNodeLL(nullptr), scans(0), selectedHops(0), chooseNodeSignalScanner(false), chooseNodeTraceRoute(false), qr(nullptr), - db{} + packetCounter(0), actTime(0), uptime(0), lastHeard(0), hasPosition(false), myLatitude(0), myLongitude(0), scans(0), + selectedHops(0), chooseNodeSignalScanner(false), chooseNodeTraceRoute(false), qr(nullptr), db{} { filter.active = false; highlight.active = false; objects.main_screen = nullptr; + selectNode(0); } /** @@ -278,10 +329,6 @@ bool TFTView_320x240::setupUIConfig(const meshtastic_DeviceUIConfig &uiconfig) lv_obj_set_state(objects.nodes_hliaq_switch, LV_STATE_CHECKED, highlight.iaq_switch); lv_textarea_set_text(objects.nodes_hl_name_area, highlight.node_name); - // initialize own node panel - if (ownNode && objects.node_panel) - nodes[ownNode] = objects.node_panel; - // touch screen calibration data uint16_t *parameters = (uint16_t *)db.uiConfig.calibration_data.bytes; if (db.uiConfig.calibration_data.size == 16 && (parameters[0] || parameters[7])) { @@ -428,16 +475,6 @@ void TFTView_320x240::init_screens(void) setInputButtonLabel(); lv_group_focus_obj(objects.home_button); - // remember position of top node panel button for group linked list - lv_ll_t *lv_group_ll = &lv_group_get_default()->obj_ll; - for (lv_obj_t **obj_i = (lv_obj_t **)_lv_ll_get_head(lv_group_ll); obj_i != NULL; - obj_i = (lv_obj_t **)_lv_ll_get_next(lv_group_ll, obj_i)) { - if (*obj_i == objects.node_button) { - topNodeLL = obj_i; - break; - } - } - // user data objects.home_time_button->user_data = (void *)0; objects.home_wlan_button->user_data = (void *)0; @@ -471,6 +508,11 @@ void TFTView_320x240::ui_set_active(lv_obj_t *b, lv_obj_t *p, lv_obj_t *tp) if (activePanel) { lv_obj_add_flag(activePanel, LV_OBJ_FLAG_HIDDEN); + if (activePanel == objects.nodes_panel) { + lv_obj_add_flag(objects.nodes_panel, LV_OBJ_FLAG_HIDDEN); + setInputGroup(); + virtualNodeListInputVisibilityKnown = false; + } if (activePanel == objects.messages_panel) { lv_obj_remove_state(objects.message_input_area, LV_STATE_FOCUSED); if (!lv_obj_has_flag(objects.keyboard, LV_OBJ_FLAG_HIDDEN)) { @@ -494,6 +536,9 @@ void TFTView_320x240::ui_set_active(lv_obj_t *b, lv_obj_t *p, lv_obj_t *tp) } lv_obj_clear_flag(p, LV_OBJ_FLAG_HIDDEN); + if (p == objects.nodes_panel) { + ensureVirtualNodeList(); + } if (tp) { if (activeTopPanel) { @@ -508,7 +553,17 @@ void TFTView_320x240::ui_set_active(lv_obj_t *b, lv_obj_t *p, lv_obj_t *tp) if (activePanel == objects.messages_panel) { lv_group_focus_obj(objects.message_input_area); } else if (inputdriver->hasKeyboardDevice() || inputdriver->hasEncoderDevice()) { - setGroupFocus(activePanel); + if (activePanel == objects.nodes_panel && virtualNodeList) { + reconcileVirtualNodeListInputGroup(true); + if (currentNode && visibleNodes.contains(currentNode)) { + virtualNodeList->focus(currentNode); + } else if (!visibleNodes.ids().empty()) { + virtualNodeList->focus(visibleNodes.ids().front()); + } else { + nodeFocusBoundary(true); + } + } else + setGroupFocus(activePanel); } lv_obj_add_flag(objects.keyboard, LV_OBJ_FLAG_HIDDEN); @@ -753,10 +808,7 @@ void TFTView_320x240::ui_events_init(void) lv_obj_add_event_cb(objects.home_qr_button, this->ui_event_QrButton, LV_EVENT_CLICKED, NULL); lv_obj_add_event_cb(objects.home_cancel_qr_button, this->ui_event_CancelQrButton, LV_EVENT_CLICKED, NULL); - // node and channel buttons - lv_obj_add_event_cb(objects.node_button, ui_event_NodeButton, LV_EVENT_ALL, (void *)ownNode); - - // 8 channel buttons + // channel buttons lv_obj_add_event_cb(objects.channel_button0, ui_event_ChannelButton, LV_EVENT_ALL, (void *)0); lv_obj_add_event_cb(objects.channel_button1, ui_event_ChannelButton, LV_EVENT_ALL, (void *)1); lv_obj_add_event_cb(objects.channel_button2, ui_event_ChannelButton, LV_EVENT_ALL, (void *)2); @@ -1033,7 +1085,7 @@ void TFTView_320x240::ui_event_NodesButton(lv_event_t *e) if (filterNeedsUpdate) { THIS->updateNodesFiltered(true); THIS->updateNodesStatus(); - lv_obj_scroll_to_view(objects.node_panel, LV_ANIM_ON); + lv_obj_scroll_to_y(objects.nodes_panel, 0, LV_ANIM_ON); if (THIS->map) { THIS->map->forceRedraw(true); } @@ -1048,65 +1100,14 @@ void TFTView_320x240::ui_event_NodesButton(lv_event_t *e) void TFTView_320x240::ui_event_NodeButton(lv_event_t *e) { - static bool animRunning = false; - static auto deleted_cb = [](_lv_anim_t *) { animRunning = false; }; - lv_event_code_t event_code = lv_event_get_code(e); - if (event_code == LV_EVENT_CLICKED && !animRunning) { - uint32_t nodeNum = (unsigned long)e->user_data; - if (!nodeNum) // event-handler for own node has value 0 in user_data - nodeNum = THIS->ownNode; - lv_obj_t *panel = THIS->nodes[nodeNum]; - if (currentPanel) { - // create animation to shrink other panel - animRunning = true; - static lv_anim_t a; - int32_t height = lv_obj_get_height(currentPanel); - lv_anim_init(&a); - lv_anim_set_var(&a, currentPanel); - lv_anim_set_values(&a, height, 136 - height); - lv_anim_set_duration(&a, 200); - lv_anim_set_exec_cb(&a, ui_anim_node_panel_cb); - lv_anim_set_path_cb(&a, lv_anim_path_linear); - lv_anim_set_deleted_cb(&a, deleted_cb); - lv_anim_start(&a); - } - if (panel != currentPanel) { - // create animation to enlarge node panel - animRunning = true; - static lv_anim_t a; - int32_t height = lv_obj_get_height(panel); - lv_anim_init(&a); - lv_anim_set_var(&a, panel); - lv_anim_set_values(&a, height, 136 - height); - lv_anim_set_duration(&a, 200); - lv_anim_set_exec_cb(&a, ui_anim_node_panel_cb); - lv_anim_set_path_cb(&a, lv_anim_path_linear); - lv_anim_set_deleted_cb(&a, deleted_cb); - lv_anim_start(&a); - currentPanel = panel; - currentNode = nodeNum; - } else { - currentPanel = nullptr; - currentNode = 0; - } - if (THIS->chooseNodeSignalScanner) { - THIS->chooseNodeSignalScanner = false; - ui_event_signal_scanner(NULL); - // restore previous filter - lv_dropdown_set_selected(objects.nodes_filter_hops_dropdown, THIS->selectedHops); - THIS->updateNodesFiltered(true); - THIS->updateNodesStatus(); - } else if (THIS->chooseNodeTraceRoute) { - THIS->chooseNodeTraceRoute = false; - ui_event_trace_route(NULL); - } - } else if (event_code == LV_EVENT_LONG_PRESSED) { - // set color and text of clicked node - uint32_t nodeNum = (unsigned long)e->user_data; - bool isMessagable = !((unsigned long)(THIS->nodes[nodeNum]->LV_OBJ_IDX(node_img_idx)->user_data) == eRole::unmessagable); - if (nodeNum != THIS->ownNode && isMessagable) - THIS->showMessages(nodeNum); - } + const lv_event_code_t eventCode = lv_event_get_code(e); + uint32_t nodeNum = static_cast(reinterpret_cast(e->user_data)); + if (!nodeNum) + nodeNum = THIS->ownNode; + if (eventCode == LV_EVENT_CLICKED) + THIS->nodeClicked(nodeNum); + else if (eventCode == LV_EVENT_LONG_PRESSED && THIS->nodeIsMessagable(nodeNum)) + THIS->showMessages(nodeNum); } void TFTView_320x240::ui_event_GroupsButton(lv_event_t *e) @@ -1275,7 +1276,7 @@ void TFTView_320x240::ui_event_ChatDelButton(lv_event_t *e) THIS->controller->removeTextMessages(THIS->ownNode, UINT32_MAX, channelOrNode); } else { THIS->eraseChat(channelOrNode); - THIS->applyNodesFilter(channelOrNode); + THIS->syncNodeListPresentation(); THIS->controller->removeTextMessages(THIS->ownNode, channelOrNode, 0); } THIS->activeMsgContainer = objects.messages_container; @@ -2416,38 +2417,38 @@ void TFTView_320x240::ui_event_mapNodeButton(lv_event_t *e) // navigate to node in node list uint32_t nodeNum = (unsigned long)e->user_data; ILOG_DEBUG("map node %08x", nodeNum); - lv_obj_t *panel = THIS->nodes[nodeNum]; - THIS->ui_set_active(objects.nodes_button, objects.nodes_panel, objects.top_nodes_panel); - lv_obj_scroll_to_view(panel, LV_ANIM_ON); - if (panel != currentPanel) - ui_event_NodeButton(e); + if (THIS->nodeRecord(nodeNum)) { + THIS->selectNode(nodeNum); + THIS->ui_set_active(objects.nodes_button, objects.nodes_panel, objects.top_nodes_panel); + THIS->syncNodeListPresentation(); + if (THIS->virtualNodeList) + THIS->virtualNodeList->scrollTo(nodeNum, LV_ANIM_ON); + } } void TFTView_320x240::ui_event_chatNodeButton(lv_event_t *e) { uint32_t nodeNum = (unsigned long)e->user_data; - auto it = THIS->nodes.find(nodeNum); - if (it != THIS->nodes.end()) { - lv_obj_t *panel = it->second; + if (THIS->nodeRecord(nodeNum)) { + THIS->selectNode(nodeNum); THIS->ui_set_active(objects.nodes_button, objects.nodes_panel, objects.top_nodes_panel); - lv_obj_scroll_to_view(panel, LV_ANIM_ON); - if (panel != currentPanel) - ui_event_NodeButton(e); + THIS->syncNodeListPresentation(); + if (THIS->virtualNodeList) + THIS->virtualNodeList->scrollTo(nodeNum, LV_ANIM_ON); } } void TFTView_320x240::ui_event_positionButton(lv_event_t *e) { // navigate to position in map - lv_obj_t *p = (lv_obj_t *)e->user_data; - int32_t lat = (long)p->LV_OBJ_IDX(node_pos1_idx)->user_data; - int32_t lon = (long)p->LV_OBJ_IDX(node_pos2_idx)->user_data; - if (lat && lon) { + uint32_t nodeNum = (unsigned long)e->user_data; + const NodePosition position = THIS->nodePosition(nodeNum); + if (position.hasCoordinates()) { THIS->ui_set_active(objects.map_button, objects.map_panel, objects.top_map_panel); if (!THIS->map) { THIS->loadMap(); } - THIS->map->setScrolledPosition(lat * 1e-7, lon * 1e-7); + THIS->map->setScrolledPosition(position.latitude * 1e-7, position.longitude * 1e-7); } } @@ -2593,6 +2594,7 @@ void TFTView_320x240::loadMap(void) #else map = new MapPanel(objects.raw_map_panel, new AsyncTileService(new URLService())); #endif + mapFilterPublicationValid = false; map->setHomeLocationImage(objects.home_location_image); lv_obj_add_flag(objects.home_location_image, LV_OBJ_FLAG_CLICKABLE); lv_obj_add_event_cb(objects.home_location_image, ui_event_mapNodeButton, LV_EVENT_CLICKED, (void *)ownNode); @@ -2624,12 +2626,10 @@ void TFTView_320x240::loadMap(void) sortedLat.reserve(nodeObjects.size()); sortedLon.reserve(nodeObjects.size()); for (auto it : nodeObjects) { - lv_obj_t *p = nodes[it.first]; - int32_t lat = (long)p->LV_OBJ_IDX(node_pos1_idx)->user_data; - int32_t lon = (long)p->LV_OBJ_IDX(node_pos2_idx)->user_data; - if (lat && lon) { - sortedLat.push_back(lat); - sortedLon.push_back(lon); + const NodePosition position = nodePosition(it.first); + if (position.known && position.latitude && position.longitude) { + sortedLat.push_back(position.latitude); + sortedLon.push_back(position.longitude); } } std::sort(sortedLat.begin(), sortedLat.end()); @@ -2667,10 +2667,8 @@ void TFTView_320x240::loadMap(void) // finally add all node images to the map if (!nodeObjects.empty()) { for (auto it : nodeObjects) { - lv_obj_t *p = nodes[it.first]; - float lat = 1e-7 * (long)p->LV_OBJ_IDX(node_pos1_idx)->user_data; - float lon = 1e-7 * (long)p->LV_OBJ_IDX(node_pos2_idx)->user_data; - map->add(it.first, lat, lon, drawObjectCB); + const NodePosition position = nodePosition(it.first); + map->add(it.first, position.latitude * 1e-7, position.longitude * 1e-7, drawObjectCB); lv_obj_add_flag(it.second, LV_OBJ_FLAG_CLICKABLE); lv_obj_add_event_cb(it.second, ui_event_mapNodeButton, LV_EVENT_CLICKED, (void *)it.first); } @@ -2678,6 +2676,8 @@ void TFTView_320x240::loadMap(void) updateLocationMap(map->getObjectsOnMap()); } + publishMapFilter(); + if (sdCard) { if (!sdCard->isUpdated()) { map->setNoTileImage(&img_no_tile_image); @@ -2781,18 +2781,14 @@ void TFTView_320x240::addOrUpdateMap(uint32_t nodeNum, int32_t lat, int32_t lon) lv_obj_set_style_align(lbl, LV_ALIGN_BOTTOM_MID, LV_PART_MAIN | LV_STATE_DEFAULT); lv_obj_set_style_align(lbl, LV_ALIGN_BOTTOM_MID, LV_PART_MAIN | LV_STATE_DEFAULT); - lv_obj_t *p = nodes[nodeNum]; - lv_label_set_text_fmt(lbl, "%s", lv_label_get_text(p->LV_OBJ_IDX(node_lbs_idx))); - - // position label callback - lv_obj_add_flag(p->LV_OBJ_IDX(node_pos1_idx), LV_OBJ_FLAG_CLICKABLE); - lv_obj_add_event_cb(p->LV_OBJ_IDX(node_pos1_idx), ui_event_positionButton, LV_EVENT_CLICKED, (void *)p); + lv_label_set_text_fmt(lbl, "%s", nodeShortName(nodeNum) ? nodeShortName(nodeNum) : ""); nodeObjects[nodeNum] = img; if (map) { map->add(nodeNum, lat * 1e-7, lon * 1e-7, drawObjectCB); lv_obj_add_flag(img, LV_OBJ_FLAG_CLICKABLE); lv_obj_add_event_cb(img, ui_event_mapNodeButton, LV_EVENT_CLICKED, (void *)nodeNum); + map->update(nodeNum, !visibleNodes.contains(nodeNum)); updateLocationMap(map->getObjectsOnMap()); } } else { @@ -2867,11 +2863,12 @@ void TFTView_320x240::ui_event_mesh_detector_start(_lv_event_t *e) void TFTView_320x240::ui_event_signal_scanner(lv_event_t *e) { - if (currentPanel) { - THIS->setNodeImage(currentNode, (MeshtasticView::eRole)(unsigned long)currentPanel->LV_OBJ_IDX(node_img_idx)->user_data, - false, objects.signal_scanner_node_image); - const char *lbs = lv_label_get_text(currentPanel->LV_OBJ_IDX(node_lbs_idx)); - lv_label_set_text(objects.signal_scanner_node_button_label, lbs); + if (currentNode && THIS->nodeRecord(currentNode)) { + const NodeRecord *record = THIS->nodeRecord(currentNode); + THIS->setNodeImage(currentNode, (MeshtasticView::eRole)record->user.role, record->unmessagable, + objects.signal_scanner_node_image); + const char *lbs = THIS->nodeShortName(currentNode); + lv_label_set_text(objects.signal_scanner_node_button_label, lbs ? lbs : ""); lv_obj_clear_state(objects.signal_scanner_start_button, LV_STATE_DISABLED); } else { lv_label_set_text(objects.signal_scanner_node_button_label, _("choose\nnode")); @@ -2969,12 +2966,12 @@ void TFTView_320x240::ui_event_trace_route(lv_event_t *e) lv_obj_clear_flag(objects.start_button_panel, LV_OBJ_FLAG_HIDDEN); lv_obj_add_flag(objects.hop_routes_panel, LV_OBJ_FLAG_HIDDEN); - if (currentPanel) { - THIS->setNodeImage(THIS->currentNode, - (MeshtasticView::eRole)(unsigned long)currentPanel->LV_OBJ_IDX(node_img_idx)->user_data, false, + if (THIS->currentNode && THIS->nodeRecord(THIS->currentNode)) { + const NodeRecord *record = THIS->nodeRecord(THIS->currentNode); + THIS->setNodeImage(THIS->currentNode, (MeshtasticView::eRole)record->user.role, record->unmessagable, objects.trace_route_to_image); - const char *lbl = lv_label_get_text(currentPanel->LV_OBJ_IDX(node_lbl_idx)); - lv_label_set_text(objects.trace_route_to_button_label, lbl); + const char *lbl = THIS->nodeDisplayName(THIS->currentNode); + lv_label_set_text(objects.trace_route_to_button_label, lbl ? lbl : ""); lv_obj_clear_state(objects.trace_route_start_button, LV_STATE_DISABLED); } else { lv_label_set_text(objects.trace_route_to_button_label, _("choose target node")); @@ -2995,7 +2992,7 @@ void TFTView_320x240::ui_event_trace_route_to(lv_event_t *e) void TFTView_320x240::ui_event_trace_route_start(lv_event_t *e) { if (!spinnerButton) { - if (currentPanel) { + if (currentNode && THIS->nodeRecord(currentNode)) { time(&startTime); lv_obj_t *obj = lv_spinner_create(objects.start_button_panel); spinnerButton = obj; @@ -3006,23 +3003,12 @@ void TFTView_320x240::ui_event_trace_route_start(lv_event_t *e) add_style_spinner_style(obj); lv_label_set_text(objects.trace_route_start_label, "30s"); - // retrieve nodeNum from current node - // FIXME: remove for loop - for (auto &it : THIS->nodes) { - if (it.second == currentPanel) { - uint32_t requestId; - uint32_t to = it.first; - uint8_t ch = (uint8_t)(unsigned long)currentPanel->user_data; - // trial: hoplimit optimization for direct messages - int8_t hopsAway = (signed long)THIS->nodes[to]->LV_OBJ_IDX(node_sig_idx)->user_data; - if (hopsAway < 0) - hopsAway = 5; - uint8_t hopLimit = (hopsAway < THIS->db.config.lora.hop_limit ? hopsAway + 1 : hopsAway); - requestId = THIS->requests.addRequest(to, ResponseHandler::TraceRouteRequest); - THIS->controller->traceRoute(to, ch, hopLimit, requestId); - break; - } - } + uint32_t requestId; + uint32_t to = currentNode; + uint8_t ch = THIS->nodeChannel(to); + uint8_t hopLimit = THIS->nodeHopLimit(to, 5); + requestId = THIS->requests.addRequest(to, ResponseHandler::TraceRouteRequest); + THIS->controller->traceRoute(to, ch, hopLimit, requestId); } } else { // restart @@ -3033,9 +3019,15 @@ void TFTView_320x240::ui_event_trace_route_start(lv_event_t *e) void TFTView_320x240::ui_event_trace_route_node(lv_event_t *e) { // navigate to node in node list - lv_obj_t *panel = (lv_obj_t *)e->user_data; - THIS->ui_set_active(objects.nodes_button, objects.nodes_panel, objects.top_nodes_panel); - lv_obj_scroll_to_view(panel, LV_ANIM_ON); + uint32_t nodeNum = (unsigned long)e->user_data; + if (THIS->nodeRecord(nodeNum)) { + THIS->selectNode(nodeNum); + THIS->ui_set_active(objects.nodes_button, objects.nodes_panel, objects.top_nodes_panel); + THIS->syncNodeListPresentation(); + if (THIS->virtualNodeList) { + THIS->virtualNodeList->scrollTo(nodeNum, LV_ANIM_ON); + } + } } void TFTView_320x240::removeSpinner(void) @@ -3092,21 +3084,22 @@ void TFTView_320x240::packetDetected(const meshtastic_MeshPacket &p) lv_obj_add_flag(objects.detector_radar_panel, LV_OBJ_FLAG_HIDDEN); lv_obj_add_flag(objects.detector_heard_label, LV_OBJ_FLAG_HIDDEN); - setNodeImage(p.from, (MeshtasticView::eRole)(unsigned long)nodes[p.from]->LV_OBJ_IDX(node_img_idx)->user_data, false, - objects.detector_contact_image); - const char *lbl = lv_label_get_text(nodes[p.from]->LV_OBJ_IDX(node_lbl_idx)); + const NodeRecord *record = nodeRecord(p.from); + setNodeImage(p.from, record ? (MeshtasticView::eRole)record->user.role : eRole::unknown, + record && record->unmessagable, objects.detector_contact_image); + const char *lbl = nodeDisplayName(p.from); char from[5]; - char *userShort = (char *)&(nodes[p.from]->LV_OBJ_IDX(node_lbs_idx)->user_data); + const char *userShort = nodeShortName(p.from); int pos = 0; - while (pos < 4 && userShort[pos] != 0) { + while (userShort && pos < 4 && userShort[pos] != 0) { from[pos] = userShort[pos]; pos++; } from[pos] = '\0'; char buf[64]; - lv_snprintf(buf, 64, "%s(%04x)\n%s", from, p.from & 0xffff, lbl); + lv_snprintf(buf, 64, "%s(%04x)\n%s", from, p.from & 0xffff, lbl ? lbl : ""); lv_label_set_text(objects.detector_contact_label, buf); lv_obj_clear_flag(objects.detector_contact_button, LV_OBJ_FLAG_HIDDEN); } else { @@ -3149,9 +3142,9 @@ void TFTView_320x240::writePacketLog(const meshtastic_MeshPacket &p) // get node name from char from[5]; - char *userShort = (char *)&(nodes[p.from]->LV_OBJ_IDX(node_lbs_idx)->user_data); + const char *userShort = nodeShortName(p.from); int pos = 0; - while (pos < 4 && userShort[pos] != 0) { + while (userShort && pos < 4 && userShort[pos] != 0) { from[pos] = userShort[pos]; pos++; } @@ -3354,17 +3347,9 @@ void TFTView_320x240::updateStatistics(const meshtastic_MeshPacket &p) for (auto it2 : stats) { if (it2.id == p.from || move) { buf[0] = '\0'; - auto it = nodes.find(it2.id); // node may have been removed from nodes, so check if still there - if (it != nodes.end() && it->second) { - char *userData = (char *)&(it->second->LV_OBJ_IDX(node_lbs_idx)->user_data); - if (userData) { - buf[0] = userData[0]; - buf[1] = userData[1]; - buf[2] = userData[2]; - buf[3] = userData[3]; - buf[4] = '\0'; - } - } + const char *shortName = nodeShortName(it2.id); + if (shortName) + NodeListRowPresentation::formatShortDisplayName(buf, sizeof(buf), shortName, it2.id); lv_table_set_cell_value(objects.statistics_table, row, 0, buf); sprintf(buf, "%d", it2.tel); @@ -3866,6 +3851,7 @@ void TFTView_320x240::eraseChat(uint32_t channelOrNode) lv_obj_del(messages.at(nodeNum)); messages.erase(nodeNum); chats.erase(nodeNum); + syncNodeListPresentation(nodeStore.setActiveChat(nodeNum, false)); } } @@ -3881,12 +3867,16 @@ void TFTView_320x240::clearChatHistory(void) channelGroup[it.first] = nullptr; } else { lv_obj_delete(messages[it.first]); + nodeStore.setActiveChat(it.first, false); } } chats.clear(); messages.clear(); updateActiveChats(); updateNodesFiltered(true); + // Bulk state change: force visible-row rebinding even when the filtered + // order is unchanged, so active-chat borders clear immediately. + syncNodeListPresentation(true); controller->removeTextMessages(0, 0, 0); } @@ -3904,6 +3894,13 @@ void TFTView_320x240::ui_event_ok(lv_event_t *e) meshtastic_Config_LoRaConfig_RegionCode region = (meshtastic_Config_LoRaConfig_RegionCode)(lv_dropdown_get_selected(objects.setup_region_dropdown) + 1); + uint32_t numChannels = LoRaPresets::getNumChannels(region, THIS->db.config.lora.modem_preset); + // if (numChannels == 0) { + // // region not possible for selected preset, revert + // lv_dropdown_set_selected(objects.settings_region_dropdown, THIS->db.config.lora.region - 1); + // return; + // } + if (region != THIS->db.config.lora.region) { char buf1[10], buf2[30]; lv_dropdown_get_selected_str(objects.setup_region_dropdown, buf1, sizeof(buf1)); @@ -3911,14 +3908,11 @@ void TFTView_320x240::ui_event_ok(lv_event_t *e) lv_label_set_text(objects.basic_settings_region_label, buf2); meshtastic_Config_LoRaConfig &lora = THIS->db.config.lora; - - if (lora.region == meshtastic_Config_LoRaConfig_RegionCode_UNSET && - region == meshtastic_Config_LoRaConfig_RegionCode_US && lora.use_preset && - lora.modem_preset == meshtastic_Config_LoRaConfig_ModemPreset_LONG_FAST) { - lora.modem_preset = meshtastic_Config_LoRaConfig_ModemPreset_LONG_TURBO; + uint32_t defaultSlot = lora.region == meshtastic_Config_LoRaConfig_RegionCode_UNSET ? lora.channel_num : 0; + if (defaultSlot == 0) { + defaultSlot = + LoRaPresets::getDefaultSlot(region, THIS->db.config.lora.modem_preset, THIS->db.channel[0].settings.name); } - uint32_t defaultSlot = LoRaPresets::getDefaultSlot(region, lora.modem_preset, THIS->db.channel[0].settings.name); - uint32_t numChannels = LoRaPresets::getNumChannels(region, lora.modem_preset); lora.region = region; lora.channel_num = (defaultSlot <= numChannels ? defaultSlot : 1); THIS->controller->sendConfig(meshtastic_Config_LoRaConfig{lora}, THIS->ownNode); @@ -3930,8 +3924,6 @@ void TFTView_320x240::ui_event_ok(lv_event_t *e) if (strcmp(userShort, THIS->db.short_name) || strcmp(userLong, THIS->db.long_name)) { lv_snprintf(buf, sizeof(buf), _("User name: %s"), userShort); lv_label_set_text(objects.basic_settings_user_label, buf); - lv_label_set_text(objects.user_name_short_label, userShort); - lv_label_set_text(objects.user_name_label, userLong); strcpy(THIS->db.short_name, userShort); strcpy(THIS->db.long_name, userLong); meshtastic_User user{}; // TODO: don't overwrite is_licensed @@ -3952,8 +3944,6 @@ void TFTView_320x240::ui_event_ok(lv_event_t *e) if (strcmp(userShort, THIS->db.short_name) || strcmp(userLong, THIS->db.long_name)) { lv_snprintf(buf, sizeof(buf), _("User name: %s"), userShort); lv_label_set_text(objects.basic_settings_user_label, buf); - lv_label_set_text(objects.user_name_short_label, userShort); - lv_label_set_text(objects.user_name_label, userLong); strcpy(THIS->db.short_name, userShort); strcpy(THIS->db.long_name, userLong); meshtastic_User user{}; // TODO: don't overwrite is_licensed @@ -4500,11 +4490,6 @@ void TFTView_320x240::ui_event_modem_preset_dropdown(lv_event_t *e) void TFTView_320x240::ui_event_setup_region_dropdown(lv_event_t *e) {} // animations -void TFTView_320x240::ui_anim_node_panel_cb(void *var, int32_t v) -{ - lv_obj_set_height((lv_obj_t *)var, v); -} - void TFTView_320x240::ui_anim_radar_cb(void *var, int32_t r) { lv_img_set_angle(objects.radar_beam, r); @@ -4551,15 +4536,12 @@ void TFTView_320x240::handleAddMessage(char *msg) ch = (uint8_t)channelOrNode; requestId = requests.addRequest(ch, ResponseHandler::TextMessageRequest, (void *)(long)ch, callback); } else { - ch = (uint8_t)(unsigned long)nodes[channelOrNode]->user_data; + ch = nodeChannel(channelOrNode); to = channelOrNode; - usePkc = (unsigned long)nodes[to]->LV_OBJ_IDX(node_bat_idx)->user_data; // hasKey + usePkc = nodeHasKey(to); requestId = requests.addRequest(to, ResponseHandler::TextMessageRequest, (void *)to, callback); // trial: hoplimit optimization for direct text messages - int8_t hopsAway = (signed long)nodes[to]->LV_OBJ_IDX(node_sig_idx)->user_data; - if (hopsAway < 0) - hopsAway = db.config.lora.hop_limit; - hopLimit = (hopsAway < db.config.lora.hop_limit ? hopsAway + 1 : hopsAway); + hopLimit = nodeHopLimit(to, db.config.lora.hop_limit); } // tweak to allow multiple lines in single line text area @@ -4630,220 +4612,30 @@ void TFTView_320x240::addMessage(lv_obj_t *container, uint32_t msgTime, uint32_t void TFTView_320x240::addNode(uint32_t nodeNum, uint8_t ch, const char *userShort, const char *userLong, uint32_t lastHeard, eRole role, bool hasKey, bool unmessagable) { - // lv_obj nodesPanel children | user data (4 bytes) - // ================================================== - // [0]: img | role - // [1]: btn | ll group - // [2]: lbl user long | nodeNum - // [3]: lbl user short | userShort (4 chars) - // [4]: lbl battery | hasKey - // [5]: lbl lastHeard | lastHeard / curtime - // [6]: lbl signal (or hops) | hops away - // [7]: lbl position 1 | lat - // [8]: lbl position 2 | lon - // [9]: lbl telemetry 1 | - // [10]: lbl telemetry 2 | iaq - // panel user_data: ch - - ILOG_DEBUG("addNode(%d): num=0x%08x, lastseen=%d, name=%s(%s), role=%d", nodeCount, nodeNum, lastHeard, userLong, userShort, - role); - while (nodeCount >= MAX_NUM_NODES_VIEW) { - purgeNode(nodeNum); - } - - lv_obj_t *p = lv_obj_create(objects.nodes_panel); - lv_ll_t *lv_group_ll = &lv_group_get_default()->obj_ll; - - p->user_data = (void *)(uint32_t)ch; - nodes[nodeNum] = p; - nodeCount++; - - // NodePanel - lv_obj_set_pos(p, LV_PCT(0), 0); - lv_obj_set_size(p, LV_PCT(100), 53); - lv_obj_set_align(p, LV_ALIGN_CENTER); - lv_obj_set_style_pad_top(p, 0, LV_PART_MAIN | LV_STATE_DEFAULT); - lv_obj_set_style_pad_bottom(p, 0, LV_PART_MAIN | LV_STATE_DEFAULT); - lv_obj_remove_flag(p, lv_obj_flag_t(LV_OBJ_FLAG_CLICKABLE | LV_OBJ_FLAG_PRESS_LOCK | LV_OBJ_FLAG_CLICK_FOCUSABLE | - LV_OBJ_FLAG_GESTURE_BUBBLE | LV_OBJ_FLAG_SNAPPABLE | LV_OBJ_FLAG_SCROLLABLE)); - add_style_node_panel_style(p); - - // NodeImage - lv_obj_t *img = lv_img_create(p); - setNodeImage(nodeNum, role, unmessagable, img); - lv_obj_set_pos(img, -5, 3); - lv_obj_set_size(img, 32, 32); - lv_obj_clear_flag(img, LV_OBJ_FLAG_SCROLLABLE); - lv_obj_set_style_radius(img, 6, LV_PART_MAIN | LV_STATE_DEFAULT); - lv_obj_set_style_bg_opa(img, 255, LV_PART_MAIN | LV_STATE_DEFAULT); - lv_obj_set_style_border_opa(img, 255, LV_PART_MAIN | LV_STATE_DEFAULT); - lv_obj_set_style_border_width(img, 2, LV_PART_MAIN | LV_STATE_DEFAULT); - if (!hasKey) { - lv_obj_set_style_border_color(img, colorRed, LV_PART_MAIN | LV_STATE_DEFAULT); - } - if (unmessagable) { - // node role icon is not clickable and replaced with a cancelled icon - img->user_data = (void *)eRole::unmessagable; - } else { - img->user_data = (void *)role; - } - - // NodeButton - lv_obj_t *nodeButton = lv_btn_create(p); - lv_obj_set_pos(nodeButton, 0, 0); - lv_obj_set_size(nodeButton, LV_PCT(106), LV_PCT(100)); - add_style_node_button_style(nodeButton); - lv_obj_set_align(nodeButton, LV_ALIGN_CENTER); - lv_obj_add_flag(nodeButton, LV_OBJ_FLAG_SCROLL_ON_FOCUS); - lv_obj_set_style_shadow_width(nodeButton, 0, LV_PART_MAIN | LV_STATE_DEFAULT); - lv_obj_set_style_max_height(nodeButton, 132, LV_PART_MAIN | LV_STATE_DEFAULT); - lv_obj_set_style_min_height(nodeButton, 50, LV_PART_MAIN | LV_STATE_DEFAULT); - nodeButton->user_data = _lv_ll_get_tail(lv_group_ll); - - // UserNameLabel - lv_obj_t *ln_lbl = lv_label_create(p); - lv_obj_set_pos(ln_lbl, -5, 35); - lv_obj_set_size(ln_lbl, LV_PCT(80), LV_SIZE_CONTENT); - lv_label_set_long_mode(ln_lbl, LV_LABEL_LONG_SCROLL); - lv_label_set_text(ln_lbl, userLong); - ln_lbl->user_data = (void *)nodeNum; - lv_obj_set_style_align(ln_lbl, LV_ALIGN_TOP_LEFT, LV_PART_MAIN | LV_STATE_DEFAULT); - - // UserNameShortLabel - lv_obj_t *sn_lbl = lv_label_create(p); - lv_obj_set_pos(sn_lbl, 30, 10); - lv_obj_set_size(sn_lbl, LV_SIZE_CONTENT, LV_SIZE_CONTENT); - lv_label_set_long_mode(sn_lbl, LV_LABEL_LONG_WRAP); - lv_obj_set_style_align(sn_lbl, LV_ALIGN_TOP_LEFT, LV_PART_MAIN | LV_STATE_DEFAULT); - lv_obj_set_style_text_font(sn_lbl, &ui_font_montserrat_14, LV_PART_MAIN | LV_STATE_DEFAULT); - // if short name contains only non-printable glyphs replace with short id - if (lv_txt_get_width(userShort, strlen(userShort), &ui_font_montserrat_14, 0) <= 4) { - lv_label_set_text_fmt(sn_lbl, "%04x", nodeNum & 0xffff); - } else { - lv_label_set_text(sn_lbl, userShort); - } - char *modUserShort = lv_label_get_text(sn_lbl); - - // keep a copy of the (4-byte) short name for use in many other widgets - char *userData = (char *)&(sn_lbl->user_data); - userData[0] = modUserShort[0]; - if (userData[0] == 0x00) - userData[0] = ' '; - userData[1] = modUserShort[1]; - if (userData[1] == 0x00) - userData[1] = ' '; - userData[2] = modUserShort[2]; - if (userData[2] == 0x00) - userData[2] = ' '; - userData[3] = modUserShort[3]; - if (userData[3] == 0x00) - userData[3] = ' '; - - // BatteryLabel - lv_obj_t *ui_BatteryLabel = lv_label_create(p); - lv_obj_set_pos(ui_BatteryLabel, 8, 17); - lv_obj_set_size(ui_BatteryLabel, LV_SIZE_CONTENT, LV_SIZE_CONTENT); - lv_obj_set_align(ui_BatteryLabel, LV_ALIGN_TOP_RIGHT); - lv_label_set_text(ui_BatteryLabel, ""); - lv_obj_set_style_text_align(ui_BatteryLabel, LV_TEXT_ALIGN_RIGHT, LV_PART_MAIN | LV_STATE_DEFAULT); - ui_BatteryLabel->user_data = (void *)hasKey; - // LastHeardLabel - lv_obj_t *ui_lastHeardLabel = lv_label_create(p); - lv_obj_set_pos(ui_lastHeardLabel, 8, 33); - lv_obj_set_size(ui_lastHeardLabel, LV_SIZE_CONTENT, LV_SIZE_CONTENT); - lv_obj_set_style_align(ui_lastHeardLabel, LV_ALIGN_TOP_RIGHT, LV_PART_MAIN | LV_STATE_DEFAULT); - lv_label_set_long_mode(ui_lastHeardLabel, LV_LABEL_LONG_CLIP); - - // TODO: devices without actual time will report all nodes as lastseen = now - if (lastHeard) { - lastHeard = std::min(curtime, (time_t)lastHeard); // adapt values too large - - char buf[20]; - bool isOnline = lastHeardToString(lastHeard, buf); - lv_label_set_text(ui_lastHeardLabel, buf); - if (isOnline) { - nodesOnline++; - } - } else { - lv_label_set_text(ui_lastHeardLabel, ""); - } - - lv_obj_set_style_text_align(ui_lastHeardLabel, LV_TEXT_ALIGN_RIGHT, LV_PART_MAIN | LV_STATE_DEFAULT); - ui_lastHeardLabel->user_data = (void *)lastHeard; - // SignalLabel / hopsAway - lv_obj_t *ui_SignalLabel = lv_label_create(p); - lv_obj_set_width(ui_SignalLabel, LV_SIZE_CONTENT); - lv_obj_set_height(ui_SignalLabel, LV_SIZE_CONTENT); - lv_obj_set_pos(ui_SignalLabel, 8, 1); - lv_obj_set_align(ui_SignalLabel, LV_ALIGN_TOP_RIGHT); - lv_label_set_text(ui_SignalLabel, ""); - ui_SignalLabel->user_data = (void *)-1; // TODO viaMqtt; // used for filtering (applyNodesFilter) - // PositionLabel - lv_obj_t *ui_PositionLabel = lv_label_create(p); - lv_obj_set_pos(ui_PositionLabel, -5, 49); - lv_obj_set_size(ui_PositionLabel, 120, LV_SIZE_CONTENT); - lv_label_set_long_mode(ui_PositionLabel, LV_LABEL_LONG_CLIP); - lv_label_set_text(ui_PositionLabel, ""); - lv_obj_set_style_align(ui_PositionLabel, LV_ALIGN_TOP_LEFT, LV_PART_MAIN | LV_STATE_DEFAULT); - lv_obj_set_style_text_color(ui_PositionLabel, colorBlueGreen, LV_PART_MAIN | LV_STATE_DEFAULT); - ui_PositionLabel->user_data = 0; // store latitude - // Position2Label - lv_obj_t *ui_Position2Label = lv_label_create(p); - lv_obj_set_pos(ui_Position2Label, -5, 63); - lv_obj_set_size(ui_Position2Label, 108, LV_SIZE_CONTENT); - lv_label_set_long_mode(ui_Position2Label, LV_LABEL_LONG_SCROLL); - lv_label_set_text(ui_Position2Label, ""); - lv_obj_set_style_align(ui_Position2Label, LV_ALIGN_TOP_LEFT, LV_PART_MAIN | LV_STATE_DEFAULT); - ui_Position2Label->user_data = 0; // store longitude - // Telemetry1Label - lv_obj_t *ui_Telemetry1Label = lv_label_create(p); - lv_obj_set_pos(ui_Telemetry1Label, 8, 49); - lv_obj_set_size(ui_Telemetry1Label, 130, LV_SIZE_CONTENT); - lv_label_set_long_mode(ui_Telemetry1Label, LV_LABEL_LONG_CLIP); - lv_label_set_text(ui_Telemetry1Label, ""); - lv_obj_set_style_align(ui_Telemetry1Label, LV_ALIGN_TOP_RIGHT, LV_PART_MAIN | LV_STATE_DEFAULT); - lv_obj_set_style_text_align(ui_Telemetry1Label, LV_TEXT_ALIGN_RIGHT, LV_PART_MAIN | LV_STATE_DEFAULT); - // Telemetry2Label - lv_obj_t *ui_Telemetry2Label = lv_label_create(p); - lv_obj_set_pos(ui_Telemetry2Label, 8, 63); - lv_obj_set_size(ui_Telemetry2Label, 130, LV_SIZE_CONTENT); - lv_label_set_long_mode(ui_Telemetry2Label, LV_LABEL_LONG_CLIP); - lv_label_set_text(ui_Telemetry2Label, ""); - lv_obj_set_style_align(ui_Telemetry2Label, LV_ALIGN_TOP_RIGHT, LV_PART_MAIN | LV_STATE_DEFAULT); - lv_obj_set_style_text_align(ui_Telemetry2Label, LV_TEXT_ALIGN_RIGHT, LV_PART_MAIN | LV_STATE_DEFAULT); - - // optimisation: hide all 6ix extended labels by default; enable only when set - // lv_obj_add_flag(ui_lastHeardLabel, LV_OBJ_FLAG_HIDDEN); // lastHeard - lv_obj_add_flag(ui_BatteryLabel, LV_OBJ_FLAG_HIDDEN); // Autohide battery - lv_obj_add_flag(ui_SignalLabel, LV_OBJ_FLAG_HIDDEN); // Autohide signal/hops - lv_obj_add_flag(ui_PositionLabel, LV_OBJ_FLAG_HIDDEN); - lv_obj_add_flag(ui_Position2Label, LV_OBJ_FLAG_HIDDEN); - lv_obj_add_flag(ui_Telemetry1Label, LV_OBJ_FLAG_HIDDEN); - lv_obj_add_flag(ui_Telemetry2Label, LV_OBJ_FLAG_HIDDEN); - - lv_obj_add_event_cb(nodeButton, ui_event_NodeButton, LV_EVENT_ALL, (void *)nodeNum); - - // move node into new position within nodePanel - if (lastHeard) { - lv_obj_t **children = objects.nodes_panel->spec_attr->children; - int i = objects.nodes_panel->spec_attr->child_cnt - 1; - while (i > 1) { - if (lastHeard <= (time_t)(children[i - 1]->LV_OBJ_IDX(node_lh_idx)->user_data)) - break; - i--; - } - if (i >= 1 && i < objects.nodes_panel->spec_attr->child_cnt - 1) { - lv_obj_move_to_index(p, i); - // re-arrange the group linked list by moving the new button (now at the tail) into the right position - void *after = children[i + 1]->LV_OBJ_IDX(node_btn_idx)->user_data; - _lv_ll_move_before(lv_group_ll, nodeButton->user_data, after); - } + const uint32_t modelLastHeard = + lastHeard && curtime && lastHeard > static_cast(curtime) ? static_cast(curtime) : lastHeard; + const NodeId purgeCandidate = + !nodeStore.find(nodeNum) && nodeStore.size() >= MAX_NUM_NODES_VIEW ? nodePurgeCandidate(nodeNum) : 0; + if (!nodeStore.find(nodeNum) && nodeStore.size() >= MAX_NUM_NODES_VIEW && !purgeCandidate) { + syncNodeListPresentation(); + return; } - if (!nodesChanged) { - applyNodesFilter(nodeNum); - updateNodesStatus(); + meshtastic_User user = meshtastic_User_init_default; + if (userShort) + std::strncpy(user.short_name, userShort, sizeof(user.short_name) - 1); + if (userLong) + std::strncpy(user.long_name, userLong, sizeof(user.long_name) - 1); + user.role = static_cast(role); + if (hasKey) + user.public_key.size = 32; + user.has_is_unmessagable = true; + user.is_unmessagable = unmessagable; + const NodeMutation mutation = nodeStore.upsertUser(nodeNum, ch, modelLastHeard, user, false); + if (purgeCandidate) { + purgeNode(purgeCandidate); } + syncNodeListPresentation(mutation); } void TFTView_320x240::setMyInfo(uint32_t nodeNum) @@ -4856,14 +4648,44 @@ void TFTView_320x240::setDeviceMetaData(int hw_model, const char *version, bool { } +void TFTView_320x240::addOrUpdateNode(uint32_t nodeNum, uint8_t channel, uint32_t lastHeard, eRole role, bool hasKey, + bool viaMqtt) +{ + NodeMutation mutation{}; + const uint32_t modelLastHeard = + lastHeard && curtime && lastHeard > static_cast(curtime) ? static_cast(curtime) : lastHeard; + const NodeId purgeCandidate = + !nodeStore.find(nodeNum) && nodeStore.size() >= MAX_NUM_NODES_VIEW ? nodePurgeCandidate(nodeNum) : 0; + if (!nodeStore.find(nodeNum) && nodeStore.size() >= MAX_NUM_NODES_VIEW && !purgeCandidate) { + syncNodeListPresentation(); + return; + } + mutation = nodeStore.upsertUnknown(nodeNum, channel, modelLastHeard, static_cast(role), hasKey, viaMqtt); + if (purgeCandidate) { + purgeNode(purgeCandidate); + } + syncNodeListPresentation(mutation); +} + void TFTView_320x240::addOrUpdateNode(uint32_t nodeNum, uint8_t channel, uint32_t lastHeard, const meshtastic_User &cfg) { - if (nodes.find(nodeNum) == nodes.end()) { - addNode(nodeNum, channel, cfg.short_name, cfg.long_name, lastHeard, (MeshtasticView::eRole)cfg.role, - cfg.public_key.size != 0, cfg.has_is_unmessagable && cfg.is_unmessagable); - } else { - updateNode(nodeNum, channel, cfg); + NodeMutation mutation{}; + const auto *existing = nodeStore.find(nodeNum); + const uint32_t incomingLastHeard = + lastHeard && curtime && lastHeard > static_cast(curtime) ? static_cast(curtime) : lastHeard; + const uint32_t modelLastHeard = existing ? existing->lastHeard : incomingLastHeard; + const uint8_t modelChannel = existing ? existing->channel : channel; + const NodeId purgeCandidate = + !nodeStore.find(nodeNum) && nodeStore.size() >= MAX_NUM_NODES_VIEW ? nodePurgeCandidate(nodeNum) : 0; + if (!nodeStore.find(nodeNum) && nodeStore.size() >= MAX_NUM_NODES_VIEW && !purgeCandidate) { + syncNodeListPresentation(); + return; + } + mutation = nodeStore.upsertUser(nodeNum, modelChannel, modelLastHeard, cfg, false); + if (purgeCandidate) { + purgeNode(purgeCandidate); } + syncNodeListPresentation(mutation); } /** @@ -4881,181 +4703,78 @@ void TFTView_320x240::addOrUpdateNode(uint32_t nodeNum, uint8_t channel, uint32_ // eRole role, bool hasKey, bool viaMqtt) void TFTView_320x240::updateNode(uint32_t nodeNum, uint8_t ch, const meshtastic_User &cfg) { + const auto *existing = nodeStore.find(nodeNum); + const uint32_t lastHeard = existing ? existing->lastHeard : static_cast(curtime); + const uint8_t channel = ch < c_max_channels ? ch : (existing ? existing->channel : 0); + const NodeId purgeCandidate = !existing && nodeStore.size() >= MAX_NUM_NODES_VIEW ? nodePurgeCandidate(nodeNum) : 0; + if (!existing && nodeStore.size() >= MAX_NUM_NODES_VIEW && !purgeCandidate) { + syncNodeListPresentation(); + return; + } + const NodeMutation mutation = nodeStore.upsertUser(nodeNum, channel, lastHeard, cfg, false); + if (purgeCandidate) { + purgeNode(purgeCandidate); + } db.user = cfg; - auto it = nodes.find(nodeNum); - if (it != nodes.end() && it->second) { - if (it->first == ownNode) { - // update related settings buttons and store role in image user data - char buf[30]; - lv_snprintf(buf, sizeof(buf), _("User name: %s"), cfg.short_name); - lv_label_set_text(objects.basic_settings_user_label, buf); - - char buf1[30], buf2[40]; - lv_dropdown_set_selected(objects.settings_device_role_dropdown, - role2val(meshtastic_Config_DeviceConfig_Role(cfg.role))); - lv_dropdown_get_selected_str(objects.settings_device_role_dropdown, buf1, sizeof(buf1)); - lv_snprintf(buf2, sizeof(buf2), _("Device Role: %s"), buf1); - lv_label_set_text(objects.basic_settings_role_label, buf2); - - // update DB - strcpy(db.short_name, cfg.short_name); - strcpy(db.long_name, cfg.long_name); - db.config.device.role = cfg.role; - } - lv_label_set_text(it->second->LV_OBJ_IDX(node_lbl_idx), cfg.long_name); - it->second->LV_OBJ_IDX(node_lbl_idx)->user_data = (void *)nodeNum; - lv_label_set_text(it->second->LV_OBJ_IDX(node_lbs_idx), cfg.short_name); - char *userData = (char *)&(it->second->LV_OBJ_IDX(node_lbs_idx)->user_data); - userData[0] = cfg.short_name[0]; - if (userData[0] == 0x00) - userData[0] = ' '; - userData[1] = cfg.short_name[1]; - if (userData[1] == 0x00) - userData[1] = ' '; - userData[2] = cfg.short_name[2]; - if (userData[2] == 0x00) - userData[2] = ' '; - userData[3] = cfg.short_name[3]; - if (userData[3] == 0x00) - userData[3] = ' '; - - setNodeImage(nodeNum, (MeshtasticView::eRole)cfg.role, cfg.has_is_unmessagable && cfg.is_unmessagable, - it->second->LV_OBJ_IDX(node_img_idx)); - - if (cfg.public_key.size != 0) { - // set border color to bg color - lv_color_t color = lv_obj_get_style_bg_color(it->second->LV_OBJ_IDX(node_img_idx), LV_PART_MAIN | LV_STATE_DEFAULT); - lv_obj_set_style_border_color(it->second->LV_OBJ_IDX(node_img_idx), color, LV_PART_MAIN | LV_STATE_DEFAULT); - } else { - lv_obj_set_style_border_color(it->second->LV_OBJ_IDX(node_img_idx), colorRed, LV_PART_MAIN | LV_STATE_DEFAULT); - } - // update chat name - auto ct = chats.find(it->first); - if (ct != chats.end()) { - char buf[64]; - lv_snprintf(buf, sizeof(buf), "%s: %s", lv_label_get_text(it->second->LV_OBJ_IDX(node_lbs_idx)), - lv_label_get_text(it->second->LV_OBJ_IDX(node_lbl_idx))); - lv_label_set_text(ct->second->spec_attr->children[0], buf); - } + if (nodeNum == ownNode) { + char userLabel[30]; + lv_snprintf(userLabel, sizeof(userLabel), _("User name: %s"), cfg.short_name); + lv_label_set_text(objects.basic_settings_user_label, userLabel); + + char roleName[30], roleLabel[40]; + lv_dropdown_set_selected(objects.settings_device_role_dropdown, role2val(meshtastic_Config_DeviceConfig_Role(cfg.role))); + lv_dropdown_get_selected_str(objects.settings_device_role_dropdown, roleName, sizeof(roleName)); + lv_snprintf(roleLabel, sizeof(roleLabel), _("Device Role: %s"), roleName); + lv_label_set_text(objects.basic_settings_role_label, roleLabel); + strcpy(db.short_name, cfg.short_name); + strcpy(db.long_name, cfg.long_name); + db.config.device.role = cfg.role; } + + auto chat = chats.find(nodeNum); + char title[96]; + if (chat != chats.end() && chatTitleFromModel(nodeNum, title, sizeof(title))) + lv_label_set_text(chat->second->spec_attr->children[0], title); + syncNodeListPresentation(mutation); } void TFTView_320x240::updatePosition(uint32_t nodeNum, int32_t lat, int32_t lon, int32_t alt, uint32_t sats, uint32_t precision) { - int32_t altU = abs(alt) < 10000 ? alt : 0; - char units[3] = {}; - if (db.config.display.units == meshtastic_Config_DisplayConfig_DisplayUnits_METRIC) { - units[0] = 'm'; - } else { - units[0] = 'f'; - units[1] = 't'; - altU = int32_t(float(altU) * 3.28084); - } + const NodeMutation mutation = nodeStore.updatePosition(nodeNum, {true, lat, lon, alt, sats, precision}); + int32_t shownAltitude = alt > -10000 && alt < 10000 ? alt : 0; + const bool metric = db.config.display.units == meshtastic_Config_DisplayConfig_DisplayUnits_METRIC; + const char *units = metric ? "m" : "ft"; + if (!metric) + shownAltitude = int32_t(float(shownAltitude) * 3.28084); + if (nodeNum == ownNode) { char buf[64]; - int latSeconds = (int)round(lat * 1e-7 * 3600); - int latDegrees = latSeconds / 3600; - latSeconds = abs(latSeconds % 3600); - int latMinutes = latSeconds / 60; - latSeconds %= 60; - char latLetter = (lat > 0) ? 'N' : 'S'; - - int lonSeconds = (int)round(lon * 1e-7 * 3600); - int lonDegrees = lonSeconds / 3600; - lonSeconds = abs(lonSeconds % 3600); - int lonMinutes = lonSeconds / 60; - lonSeconds %= 60; - char lonLetter = (lon > 0) ? 'E' : 'W'; - + const int latTotal = static_cast(round(lat * 1e-7 * 3600)); + const int lonTotal = static_cast(round(lon * 1e-7 * 3600)); + const int latMinutes = abs(latTotal % 3600) / 60; + const int lonMinutes = abs(lonTotal % 3600) / 60; + const int latSeconds = abs(latTotal % 60); + const int lonSeconds = abs(lonTotal % 60); if (sats) - sprintf(buf, "%c%02i° %2i'%02i\" %u sats\n%c%02i° %2i'%02i\" %d%s", latLetter, abs(latDegrees), latMinutes, - latSeconds, sats, lonLetter, abs(lonDegrees), lonMinutes, lonSeconds, altU, units); + sprintf(buf, "%c%02i° %2i'%02i\" %u sats\n%c%02i° %2i'%02i\" %d%s", lat > 0 ? 'N' : 'S', abs(latTotal / 3600), + latMinutes, latSeconds, sats, lon > 0 ? 'E' : 'W', abs(lonTotal / 3600), lonMinutes, lonSeconds, + shownAltitude, units); else - sprintf(buf, "%c%02i° %2i'%02i\"\n%c%02i° %2i'%02i\" %d%s", latLetter, abs(latDegrees), latMinutes, latSeconds, - lonLetter, abs(lonDegrees), lonMinutes, lonSeconds, altU, units); - + sprintf(buf, "%c%02i° %2i'%02i\"\n%c%02i° %2i'%02i\" %d%s", lat > 0 ? 'N' : 'S', abs(latTotal / 3600), latMinutes, + latSeconds, lon > 0 ? 'E' : 'W', abs(lonTotal / 3600), lonMinutes, lonSeconds, shownAltitude, units); lv_label_set_text(objects.home_location_label, buf); - - if (lat != 0 && lon != 0) { + if (lat != 0 || lon != 0) { hasPosition = true; myLatitude = lat; myLongitude = lon; - - // go through existing node list and update distance - // TODO: need incremental update!? - for (auto &it : nodes) { - if (it.first != ownNode) { - int32_t nlat = (long)it.second->LV_OBJ_IDX(node_pos1_idx)->user_data; - int32_t nlon = (long)it.second->LV_OBJ_IDX(node_pos2_idx)->user_data; - if (nlat != 0 && nlon != 0) { - updateDistance(it.first, nlat, nlon); - } - } - } - // update own location on map if (map) map->setGpsPosition(lat * 1e-7, lon * 1e-7); } - } else { - if (lat != 0 && lon != 0) { - if (hasPosition) { - updateDistance(nodeNum, lat, lon); - } - addOrUpdateMap(nodeNum, lat, lon); - } - } - - if (lat != 0 && lon != 0) { - char buf[32]; - sprintf(buf, "%.5f %.5f", lat * 1e-7, lon * 1e-7); - lv_obj_t *panel = nodes[nodeNum]; - lv_label_set_text(panel->LV_OBJ_IDX(node_pos1_idx), buf); - if (sats) - sprintf(buf, "%d%s MSL %u sats", altU, units, sats); - sprintf(buf, "%d%s MSL", altU, units); - lv_label_set_text(panel->LV_OBJ_IDX(node_pos2_idx), buf); - // store lat/lon in user_data, because we need these values later to calculate the distance to us - panel->LV_OBJ_IDX(node_pos1_idx)->user_data = (void *)lat; - panel->LV_OBJ_IDX(node_pos2_idx)->user_data = (void *)lon; - lv_obj_remove_flag(panel->LV_OBJ_IDX(node_pos1_idx), LV_OBJ_FLAG_HIDDEN); - lv_obj_remove_flag(panel->LV_OBJ_IDX(node_pos2_idx), LV_OBJ_FLAG_HIDDEN); - } - - applyNodesFilter(nodeNum); -} - -void TFTView_320x240::updateDistance(uint32_t nodeNum, int32_t lat, int32_t lon) -{ - // if we know our position then calculate (simple) distance to other node in km - float dx = 71.5 * 1e-7 * (myLongitude - lon); - float dy = 111.3 * 1e-7 * (myLatitude - lat); - float dist = sqrt(dx * dx + dy * dy); - - // add distance to user short field - char buf[32]; - char *userData = (char *)&(nodes[nodeNum]->LV_OBJ_IDX(node_lbs_idx)->user_data); - buf[0] = userData[0]; - buf[1] = userData[1]; - buf[2] = userData[2]; - buf[3] = userData[3]; - buf[4] = '\n'; - - if (db.config.display.units == meshtastic_Config_DisplayConfig_DisplayUnits_METRIC) { - if (dist > 1.0) - sprintf(&buf[5], "%.1f km ", dist); - else - sprintf(&buf[5], "%d m ", (uint32_t)round(dist * 1000)); - } else { - if (dist > 0.1) - sprintf(&buf[5], "%.1f mi ", round(dist * 0.621371)); - else - sprintf(&buf[5], "%d ft ", uint32_t(dist * 3280.84)); + } else if (lat != 0 && lon != 0) { + addOrUpdateMap(nodeNum, lat, lon); } - // we used the userShort label to add the distance, so re-arrange a bit the position - lv_obj_t *userShort = nodes[nodeNum]->LV_OBJ_IDX(node_lbs_idx); - lv_label_set_text(userShort, buf); - lv_obj_set_pos(userShort, 30, -1); + syncNodeListPresentation(mutation); } /** @@ -5069,124 +4788,58 @@ void TFTView_320x240::updateDistance(uint32_t nodeNum, int32_t lat, int32_t lon) */ void TFTView_320x240::updateMetrics(uint32_t nodeNum, uint32_t bat_level, float voltage, float chUtil, float airUtil) { - auto it = nodes.find(nodeNum); - if (it != nodes.end()) { - char buf[48]; - if (it->first == ownNode) { - sprintf(buf, _("Util %0.1f%% Air %0.1f%%"), chUtil, airUtil); - lv_label_set_text(it->second->LV_OBJ_IDX(node_sig_idx), buf); - - // update battery percentage and symbol - if (bat_level != 0 || voltage != 0) { - uint32_t shown_level = std::min(bat_level, (uint32_t)100); - sprintf(buf, "%d%%", shown_level); - bool alert = false; - - BatteryLevel level; - BatteryLevel::Status status = level.calcStatus(bat_level, voltage); - switch (status) { - case BatteryLevel::Plugged: - lv_obj_set_style_bg_image_src(objects.battery_image, &img_battery_plug_image, - LV_PART_MAIN | LV_STATE_DEFAULT); - if (shown_level == 100) - buf[0] = '\0'; - break; - case BatteryLevel::Charging: - lv_obj_set_style_bg_image_src(objects.battery_image, &img_battery_bolt_image, - LV_PART_MAIN | LV_STATE_DEFAULT); - break; - case BatteryLevel::Full: - lv_obj_set_style_bg_image_src(objects.battery_image, &img_battery_full_image, - LV_PART_MAIN | LV_STATE_DEFAULT); - break; - case BatteryLevel::Mid: - lv_obj_set_style_bg_image_src(objects.battery_image, &img_battery_mid_image, LV_PART_MAIN | LV_STATE_DEFAULT); - break; - case BatteryLevel::Low: - lv_obj_set_style_bg_image_src(objects.battery_image, &img_battery_low_image, LV_PART_MAIN | LV_STATE_DEFAULT); - break; - case BatteryLevel::Empty: - lv_obj_set_style_bg_image_src(objects.battery_image, &img_battery_empty_image, - LV_PART_MAIN | LV_STATE_DEFAULT); - break; - case BatteryLevel::Warn: - lv_obj_set_style_bg_image_src(objects.battery_image, &img_battery_empty_warn_image, - LV_PART_MAIN | LV_STATE_DEFAULT); - buf[0] = '\0'; - alert = true; - break; - default: - ILOG_ERROR("unhandled battery level %d", status); - break; - } - Themes::recolorTopLabel(objects.battery_percentage_label, alert); - lv_obj_set_style_bg_image_recolor_opa(objects.battery_image, 255, LV_PART_MAIN | LV_STATE_DEFAULT); - lv_label_set_text(objects.battery_percentage_label, buf); - } - } - - if (bat_level != 0 || voltage != 0) { - bat_level = std::min(bat_level, (uint32_t)100); - sprintf(buf, "%d%% %0.2fV", bat_level, voltage); - lv_label_set_text(it->second->LV_OBJ_IDX(node_bat_idx), buf); - lv_obj_remove_flag(it->second->LV_OBJ_IDX(node_bat_idx), LV_OBJ_FLAG_HIDDEN); + meshtastic_DeviceMetrics metrics = meshtastic_DeviceMetrics_init_default; + metrics.battery_level = bat_level; + metrics.voltage = voltage; + metrics.channel_utilization = chUtil; + metrics.air_util_tx = airUtil; + const NodeMutation mutation = nodeStore.updateDeviceMetrics(nodeNum, metrics); + + if (nodeNum == ownNode && (bat_level != 0 || voltage != 0)) { + char buf[16]; + const uint32_t shownLevel = std::min(bat_level, static_cast(100)); + sprintf(buf, "%d%%", shownLevel); + bool alert = false; + BatteryLevel level; + switch (level.calcStatus(bat_level, voltage)) { + case BatteryLevel::Plugged: + lv_obj_set_style_bg_image_src(objects.battery_image, &img_battery_plug_image, LV_PART_MAIN | LV_STATE_DEFAULT); + if (shownLevel == 100) + buf[0] = '\0'; + break; + case BatteryLevel::Charging: + lv_obj_set_style_bg_image_src(objects.battery_image, &img_battery_bolt_image, LV_PART_MAIN | LV_STATE_DEFAULT); + break; + case BatteryLevel::Full: + lv_obj_set_style_bg_image_src(objects.battery_image, &img_battery_full_image, LV_PART_MAIN | LV_STATE_DEFAULT); + break; + case BatteryLevel::Mid: + lv_obj_set_style_bg_image_src(objects.battery_image, &img_battery_mid_image, LV_PART_MAIN | LV_STATE_DEFAULT); + break; + case BatteryLevel::Low: + lv_obj_set_style_bg_image_src(objects.battery_image, &img_battery_low_image, LV_PART_MAIN | LV_STATE_DEFAULT); + break; + case BatteryLevel::Empty: + lv_obj_set_style_bg_image_src(objects.battery_image, &img_battery_empty_image, LV_PART_MAIN | LV_STATE_DEFAULT); + break; + case BatteryLevel::Warn: + lv_obj_set_style_bg_image_src(objects.battery_image, &img_battery_empty_warn_image, LV_PART_MAIN | LV_STATE_DEFAULT); + buf[0] = '\0'; + alert = true; + break; + default: + break; } + Themes::recolorTopLabel(objects.battery_percentage_label, alert); + lv_obj_set_style_bg_image_recolor_opa(objects.battery_image, 255, LV_PART_MAIN | LV_STATE_DEFAULT); + lv_label_set_text(objects.battery_percentage_label, buf); } + syncNodeListPresentation(mutation); } void TFTView_320x240::updateEnvironmentMetrics(uint32_t nodeNum, const meshtastic_EnvironmentMetrics &metrics) { - auto it = nodes.find(nodeNum); - if (it != nodes.end()) { - char buf[50]; - if (db.config.display.units == meshtastic_Config_DisplayConfig_DisplayUnits_METRIC) { - if ((int)metrics.relative_humidity > 0) { - sprintf(buf, "%2.1f°C %d%% %3.1fhPa", metrics.temperature, (int)metrics.relative_humidity, - metrics.barometric_pressure); - } else { - sprintf(buf, "%2.1f°C %3.1fhPa", metrics.temperature, metrics.barometric_pressure); - } - } else { - if ((int)metrics.relative_humidity > 0) { - sprintf(buf, "%2.1f°F %d%% %3.1finHg", metrics.temperature * 9 / 5 + 32, (int)metrics.relative_humidity, - metrics.barometric_pressure / 33.86f); - } else { - sprintf(buf, "%2.1f°F %3.1finHg", metrics.temperature * 9 / 5 + 32, metrics.barometric_pressure / 33.86f); - } - } - lv_label_set_text(it->second->LV_OBJ_IDX(node_tm1_idx), buf); - lv_obj_remove_flag(it->second->LV_OBJ_IDX(node_tm1_idx), LV_OBJ_FLAG_HIDDEN); - - if (metrics.iaq > 0 && metrics.iaq < 1000) { - sprintf(buf, "IAQ: %d %.1fV %.1fmA", metrics.iaq, metrics.voltage, metrics.current); - lv_label_set_text(it->second->LV_OBJ_IDX(node_tm2_idx), buf); - it->second->LV_OBJ_IDX(node_tm2_idx)->user_data = (void *)(uint32_t)metrics.iaq; - lv_obj_remove_flag(it->second->LV_OBJ_IDX(node_tm2_idx), LV_OBJ_FLAG_HIDDEN); - } - applyNodesFilter(nodeNum); - } -} - -void TFTView_320x240::updateAirQualityMetrics(uint32_t nodeNum, const meshtastic_AirQualityMetrics &metrics) -{ - auto it = nodes.find(nodeNum); - if (it != nodes.end() && it->first != ownNode) { - // TODO - // char buf[32]; - // sprintf(buf, "%d %d", metrics.particles_03um, metrics.pm100_environmental); - // lv_label_set_text(it->second->LV_OBJ_IDX(node_tm2_idx), buf); - } -} - -void TFTView_320x240::updatePowerMetrics(uint32_t nodeNum, const meshtastic_PowerMetrics &metrics) -{ - auto it = nodes.find(nodeNum); - if (it != nodes.end() && it->first != ownNode) { - // TODO - // char buf[32]; - // sprintf(buf, "%0.1fmA %0.2fV", metrics.ch1_current, metrics.ch1_voltage); - // lv_label_set_text(it->second->LV_OBJ_IDX(node_tm2_idx), buf); - } + syncNodeListPresentation(nodeStore.updateEnvironmentMetrics(nodeNum, metrics)); } /** @@ -5194,34 +4847,12 @@ void TFTView_320x240::updatePowerMetrics(uint32_t nodeNum, const meshtastic_Powe */ void TFTView_320x240::updateSignalStrength(uint32_t nodeNum, int32_t rssi, float snr) { - if (nodeNum != ownNode) { - auto it = nodes.find(nodeNum); - if (it != nodes.end()) { - char buf[32]; - if (rssi == 0 && snr == 0.0) { - buf[0] = '\0'; - } else { - sprintf(buf, "rssi: %d snr: %.1f", rssi, snr); - } - lv_label_set_text(it->second->LV_OBJ_IDX(node_sig_idx), buf); - it->second->LV_OBJ_IDX(node_sig_idx)->user_data = 0; - lv_obj_remove_flag(it->second->LV_OBJ_IDX(node_sig_idx), LV_OBJ_FLAG_HIDDEN); - } - } + syncNodeListPresentation(nodeStore.updateSignal(nodeNum, rssi, snr)); } void TFTView_320x240::updateHopsAway(uint32_t nodeNum, uint8_t hopsAway) { - if (nodeNum != ownNode) { - auto it = nodes.find(nodeNum); - if (it != nodes.end()) { - char buf[32]; - sprintf(buf, _("hops: %d"), (int)hopsAway); - lv_label_set_text(it->second->LV_OBJ_IDX(node_sig_idx), buf); - it->second->LV_OBJ_IDX(node_sig_idx)->user_data = (void *)(unsigned long)hopsAway; - lv_obj_remove_flag(it->second->LV_OBJ_IDX(node_sig_idx), LV_OBJ_FLAG_HIDDEN); - } - } + syncNodeListPresentation(nodeStore.updateHops(nodeNum, hopsAway)); } void TFTView_320x240::updateConnectionStatus(const meshtastic_DeviceConnectionStatus &status) @@ -5381,11 +5012,9 @@ void TFTView_320x240::handleResponse(uint32_t from, const uint32_t id, const mes if (req.type == ResponseHandler::TextMessageRequest) { handleTextMessageResponse((unsigned long)req.cookie, id, ack, true); // we probably have a wrong key; mark it as bad and don't use in future - if ((unsigned long)nodes[from]->LV_OBJ_IDX(node_bat_idx)->user_data == 1) { + if (nodeHasKey(from)) { ILOG_DEBUG("public key mismatch"); - nodes[from]->LV_OBJ_IDX(node_bat_idx)->user_data = (void *)2; - lv_obj_set_style_border_color(nodes[from]->LV_OBJ_IDX(node_img_idx), colorRed, - LV_PART_MAIN | LV_STATE_DEFAULT); + syncNodeListPresentation(nodeStore.markBadKey(from)); lv_obj_set_style_bg_image_src(objects.top_messages_node_image, &img_lock_slash_image, LV_PART_MAIN | LV_STATE_DEFAULT); } @@ -5421,7 +5050,7 @@ void TFTView_320x240::scanSignal(uint32_t scanNo) } else { uint32_t requestId; uint32_t to = currentNode; - uint8_t ch = (uint8_t)(unsigned long)currentPanel->user_data; + uint8_t ch = nodeChannel(to); requestId = requests.addRequest(to, ResponseHandler::PositionRequest, (void *)to); controller->requestPosition(to, ch, requestId); objects.signal_scanner_panel->user_data = (void *)requestId; @@ -5491,12 +5120,7 @@ void TFTView_320x240::handleResponse(uint32_t from, uint32_t id, const meshtasti void TFTView_320x240::addNodeToTraceRoute(uint32_t nodeNum, lv_obj_t *panel) { - // check if node exists, and get its panel - lv_obj_t *nodePanel = nullptr; - auto it = nodes.find(nodeNum); - if (it != nodes.end()) { - nodePanel = it->second; - } + const NodeRecord *record = nodeRecord(nodeNum); lv_obj_t *btn = lv_btn_create(panel); // objects.trace_route_to_button = btn; lv_obj_set_pos(btn, 0, 0); @@ -5513,9 +5137,8 @@ void TFTView_320x240::addNodeToTraceRoute(uint32_t nodeNum, lv_obj_t *panel) { { lv_obj_t *img = lv_img_create(btn); - if (nodePanel) { - setNodeImage(nodeNum, (MeshtasticView::eRole)(unsigned long)nodePanel->LV_OBJ_IDX(node_img_idx)->user_data, false, - img); + if (record) { + setNodeImage(nodeNum, (MeshtasticView::eRole)record->user.role, record->unmessagable, img); } else { setNodeImage(0, eRole::unknown, false, img); } @@ -5534,14 +5157,15 @@ void TFTView_320x240::addNodeToTraceRoute(uint32_t nodeNum, lv_obj_t *panel) lv_obj_set_pos(label, 35, 10); lv_obj_set_size(label, LV_PCT(80), LV_SIZE_CONTENT); lv_label_set_long_mode(label, LV_LABEL_LONG_SCROLL); - if (nodePanel) { + if (record) { if (nodeNum != ownNode) { - lv_obj_add_event_cb(btn, ui_event_trace_route_node, LV_EVENT_CLICKED, nodePanel); - lv_label_set_text(label, lv_label_get_text(nodePanel->LV_OBJ_IDX(node_lbs_idx))); + lv_obj_add_event_cb(btn, ui_event_trace_route_node, LV_EVENT_CLICKED, + traceRouteNodeCallbackUserData(nodeNum)); + lv_label_set_text(label, nodeShortName(nodeNum) ? nodeShortName(nodeNum) : ""); if (strlen(lv_label_get_text(label)) >= 5) lv_obj_set_pos(label, 35, -1); } else { - lv_label_set_text(label, lv_label_get_text(nodePanel->LV_OBJ_IDX(node_lbl_idx))); + lv_label_set_text(label, nodeDisplayName(nodeNum) ? nodeDisplayName(nodeNum) : ""); } } else { char buf[20]; @@ -5559,222 +5183,26 @@ void TFTView_320x240::addNodeToTraceRoute(uint32_t nodeNum, lv_obj_t *panel) /** * @brief purge oldest node from node list (and all its memory) - * @param nodeNum node that is being added and already contained in nodes[], so don't remove it! + * @param nodeNum node to remove */ void TFTView_320x240::purgeNode(uint32_t nodeNum) { - if (nodeCount <= 1) - return; - - lv_obj_t **children = objects.nodes_panel->spec_attr->children; - int last = objects.nodes_panel->spec_attr->child_cnt - 1; - int i = last; - -#ifndef ALWAYS_PURGE_OLDEST_NODE - time_t curr_time; -#ifdef ARCH_PORTDUINO - time(&curr_time); -#else - curr_time = actTime; -#endif - // prefer purging older unknown nodes first (but not the brand new ones) - while ((eRole)(long)(children[i]->LV_OBJ_IDX(node_img_idx)->user_data) != eRole::unknown || - curr_time < (time_t)(children[i]->LV_OBJ_IDX(node_lh_idx)->user_data) + 120 || - (unsigned long)(children[i]->LV_OBJ_IDX(node_lbl_idx)->user_data) == nodeNum || - chats.find((unsigned long)(children[i]->LV_OBJ_IDX(node_lbl_idx)->user_data)) != chats.end()) { - if (i < (last + 1) / 5) { // keep 80% named nodes and 20% unknown (not fresh) nodes - i = last; - break; - } - i--; + ILOG_INFO("removing oldest node 0x%08x", nodeNum); + auto message = messages.find(nodeNum); + if (message != messages.end()) { + lv_obj_delete(message->second); + messages.erase(message); } -#endif - lv_obj_t *p = children[i]; - uint32_t oldest = (unsigned long)(p->LV_OBJ_IDX(node_lbl_idx)->user_data); - uint32_t lastHeard = (unsigned long)p->LV_OBJ_IDX(node_lh_idx)->user_data; - if (lastHeard > 0 && (curtime - lastHeard <= secs_until_offline)) - nodesOnline--; - - ILOG_INFO("removing oldest node 0x%08x", oldest); - lv_obj_delete(p); - { - auto it = messages.find(oldest); - if (it != messages.end()) { - lv_obj_delete(it->second); - messages.erase(oldest); - } + auto chat = chats.find(nodeNum); + if (chat != chats.end()) { + lv_obj_delete(chat->second); + chats.erase(chat); + updateActiveChats(); } - - { - auto it = chats.find(oldest); - if (it != chats.end()) { - lv_obj_delete(it->second); - chats.erase(oldest); - updateActiveChats(); - } - } - removeFromMap(oldest); - nodes.erase(oldest); - nodeCount--; - nodesChanged = true; // flag to force re-apply node filter -} - -/** - * @brief apply enabled filters and highlight node - * - * @param nodeNum - * @param reset : set true when filter has changed (to recalculate number of filtered nodes) - * @return true - * @return false - */ -bool TFTView_320x240::applyNodesFilter(uint32_t nodeNum, bool reset) -{ - lv_obj_t *panel = nodes[nodeNum]; - bool hide = false; - if (nodeNum != ownNode /* && filter.active*/) { // TODO - if (lv_obj_has_state(objects.nodes_filter_unknown_switch, LV_STATE_CHECKED)) { - if (lv_img_get_src(panel->LV_OBJ_IDX(node_img_idx)) == &img_circle_question_image) { - hide = true; - } - } - if (lv_obj_has_state(objects.nodes_filter_offline_switch, LV_STATE_CHECKED)) { - time_t lastHeard = (time_t)panel->LV_OBJ_IDX(node_lh_idx)->user_data; - if (lastHeard == 0 || curtime - lastHeard > secs_until_offline) - hide = true; - } - if (lv_obj_has_state(objects.nodes_filter_public_key_switch, LV_STATE_CHECKED)) { - bool hasKey = (unsigned long)panel->LV_OBJ_IDX(node_bat_idx)->user_data == 1; - if (!hasKey) - hide = true; - } - if (lv_dropdown_get_selected(objects.nodes_filter_channel_dropdown) != 0) { - int selected = lv_dropdown_get_selected(objects.nodes_filter_channel_dropdown); - if (selected != 0) { - uint8_t ch = (uint8_t)(unsigned long)panel->user_data; - if (selected - 1 != ch) - hide = true; - } - } - if (lv_dropdown_get_selected(objects.nodes_filter_hops_dropdown) != 0) { - int32_t hopsAway = (signed long)panel->LV_OBJ_IDX(node_sig_idx)->user_data; - int selected = lv_dropdown_get_selected(objects.nodes_filter_hops_dropdown) - 7; - if (hopsAway < 0) - hide = true; - else if (selected <= 0) { - if (hopsAway > -selected) - hide = true; - } else { - if (hopsAway < selected) - hide = true; - } - } -#if 0 - if (lv_obj_has_state(objects.nodes_filter_mqtt_switch, LV_STATE_CHECKED)) { - bool viaMqtt = false; // TODO (unsigned long)panel->LV_OBJ_IDX(node_sig_idx)->user_data; - if (viaMqtt) - hide = true; - } -#endif - if (lv_obj_has_state(objects.nodes_filter_position_switch, LV_STATE_CHECKED)) { - if (lv_label_get_text(panel->LV_OBJ_IDX(node_pos1_idx))[0] == '\0') - hide = true; - } - const char *name = lv_textarea_get_text(objects.nodes_filter_name_area); - if (name[0] != '\0') { - if (name[0] != '!') { // use '!' char to negate search result - if (!strcasestr(lv_label_get_text(panel->LV_OBJ_IDX(node_lbl_idx)), name) && - !strcasestr(lv_label_get_text(panel->LV_OBJ_IDX(node_lbs_idx)), name)) { - hide = true; - } - } else { - if (strcasestr(lv_label_get_text(panel->LV_OBJ_IDX(node_lbl_idx)), &name[1]) || - strcasestr(lv_label_get_text(panel->LV_OBJ_IDX(node_lbs_idx)), &name[1])) { - hide = true; - } - } - } - } - if (hide) { - if (reset || !lv_obj_has_flag(panel, LV_OBJ_FLAG_HIDDEN)) { - lv_obj_add_flag(panel, LV_OBJ_FLAG_HIDDEN); - nodesFiltered++; - } - } else { - lv_obj_clear_flag(panel, LV_OBJ_FLAG_HIDDEN); - } - - // hide node location if filtered - if (map) - map->update(nodeNum, hide); - - bool highlight = false; - if (true /*highlight.active*/) { // TODO - if (lv_obj_has_state(objects.nodes_hl_active_chat_switch, LV_STATE_CHECKED)) { - auto it = chats.find(nodeNum); - if (it != nodes.end()) { - lv_obj_set_style_border_color(panel, colorOrange, LV_PART_MAIN | LV_STATE_DEFAULT); - highlight = true; - } - } - if (lv_obj_has_state(objects.nodes_hl_position_switch, LV_STATE_CHECKED)) { - if (lv_label_get_text(panel->LV_OBJ_IDX(node_pos1_idx))[0] != '\0') { - lv_obj_set_style_border_color(panel, colorBlueGreen, LV_PART_MAIN | LV_STATE_DEFAULT); - highlight = true; - } - } - if (lv_obj_has_state(objects.nodes_hl_telemetry_switch, LV_STATE_CHECKED)) { - if (lv_label_get_text(panel->LV_OBJ_IDX(node_tm1_idx))[0] != '\0') { - lv_obj_set_style_border_color(panel, colorBlue, LV_PART_MAIN | LV_STATE_DEFAULT); - lv_obj_set_style_border_width(panel, 2, LV_PART_MAIN | LV_STATE_DEFAULT); - highlight = true; - } - } - if (lv_obj_has_state(objects.nodes_hliaq_switch, LV_STATE_CHECKED)) { - if (lv_label_get_text(panel->LV_OBJ_IDX(node_tm2_idx))[0] != '\0') { - uint32_t iaq = (unsigned long)panel->LV_OBJ_IDX(node_tm2_idx)->user_data; - // IAQ color code - lv_color_t fg, bg; - if (iaq <= 50) { - fg = lv_color_hex(0x00000000); - bg = lv_color_hex(0x000ce810); - } else if (iaq <= 100) { - fg = lv_color_hex(0x00000000); - bg = lv_color_hex(0x00faf646); - } else if (iaq <= 150) { - fg = lv_color_hex(0x00000000); - bg = lv_color_hex(0x00f98204); - } else if (iaq <= 200) { - fg = lv_color_hex(0x00000000); - bg = lv_color_hex(0x00e42104); - } else if (iaq <= 300) { - fg = lv_color_hex(0xffffffff); - bg = lv_color_hex(0x009b2970); - } else { - fg = lv_color_hex(0xffffffff); - bg = lv_color_hex(0x001d1414); - } - lv_obj_set_style_text_color(panel->LV_OBJ_IDX(node_tm2_idx), fg, LV_PART_MAIN | LV_STATE_DEFAULT); - lv_obj_set_style_bg_color(panel->LV_OBJ_IDX(node_tm2_idx), bg, LV_PART_MAIN | LV_STATE_DEFAULT); - lv_obj_set_style_bg_opa(panel->LV_OBJ_IDX(node_tm2_idx), 255, LV_PART_MAIN | LV_STATE_DEFAULT); - lv_obj_set_style_border_color(panel, bg, LV_PART_MAIN | LV_STATE_DEFAULT); - lv_obj_set_style_border_width(panel, 2, LV_PART_MAIN | LV_STATE_DEFAULT); - highlight = true; - } - } - const char *name = lv_textarea_get_text(objects.nodes_hl_name_area); - if (name[0] != '\0') { - if (strcasestr(lv_label_get_text(panel->LV_OBJ_IDX(node_lbl_idx)), name) || - strcasestr(lv_label_get_text(panel->LV_OBJ_IDX(node_lbs_idx)), name)) { - lv_obj_set_style_border_color(panel, colorMesh, LV_PART_MAIN | LV_STATE_DEFAULT); - highlight = true; - } - } - } - if (!highlight) { - lv_obj_set_style_border_color(panel, colorMidGray, LV_PART_MAIN | LV_STATE_DEFAULT); - lv_obj_set_style_border_width(panel, 1, LV_PART_MAIN | LV_STATE_DEFAULT); - } - return hide; // TODO || filter.active; + removeFromMap(nodeNum); + nodeStore.remove(nodeNum); + if (currentNode == nodeNum) + selectNode(0); } void TFTView_320x240::messageAlert(const char *alert, bool show) @@ -5883,6 +5311,7 @@ void TFTView_320x240::notifyResync(bool show) lv_screen_load_anim(objects.main_screen, LV_SCR_LOAD_ANIM_NONE, 0, 0, false); } } + syncNodeListPresentation(true); } void TFTView_320x240::notifyReboot(bool show) @@ -6519,13 +5948,12 @@ void TFTView_320x240::newMessage(uint32_t from, uint32_t to, uint8_t ch, const c char buf[284]; // 237 + 4 + 40 + 2 + 1 lv_obj_t *container = nullptr; if (to == UINT32_MAX) { // message for group, prepend short name to msg - if (nodes.find(from) == nodes.end()) { + const char *userShort = nodeShortName(from); + if (!userShort) { pos += sprintf(buf, "%04x ", from & 0xffff); } else { - // original short name is held in userData, extract it and add msg - char *userData = (char *)&(nodes[from]->LV_OBJ_IDX(node_lbs_idx)->user_data); - while (pos < 4 && userData[pos] != 0) { - buf[pos] = userData[pos]; + while (pos < 4 && userShort[pos] != 0) { + buf[pos] = userShort[pos]; pos++; } } @@ -6553,7 +5981,7 @@ void TFTView_320x240::newMessage(uint32_t from, uint32_t to, uint8_t ch, const c updateUnreadMessages(); if (activePanel != objects.messages_panel && db.uiConfig.alert_enabled && !db.channel[ch].settings.module_settings.is_muted) { - showMessagePopup(from, to, ch, lv_label_get_text(nodes[from]->LV_OBJ_IDX(node_lbl_idx))); + showMessagePopup(from, to, ch, nodeDisplayName(from) ? nodeDisplayName(from) : ""); } lv_obj_add_flag(container, LV_OBJ_FLAG_HIDDEN); } @@ -6624,7 +6052,7 @@ void TFTView_320x240::restoreMessage(const LogMessage &msg) container = newMessageContainer(msg.from, msg.to, msg.ch); } } else { - if (nodes.find(msg.to) != nodes.end()) { + if (nodeStore.find(msg.to)) { if (msg.trashFlag && chats.find(msg.to) != chats.end()) { ILOG_DEBUG("trashFlag set for node %08x", msg.to); eraseChat(msg.to); @@ -6642,7 +6070,7 @@ void TFTView_320x240::restoreMessage(const LogMessage &msg) lv_obj_add_flag(container, LV_OBJ_FLAG_HIDDEN); addMessage(container, msg.time, 0, (char *)msg.bytes, msg.status); } - } else if (nodes.find(msg.from) != nodes.end()) { + } else if (nodeStore.find(msg.from)) { if (msg.trashFlag && chats.find(msg.from) != chats.end()) { ILOG_DEBUG("trashFlag set for node %08x", msg.from); eraseChat(msg.from); @@ -6686,6 +6114,9 @@ void TFTView_320x240::addChat(uint32_t from, uint32_t to, uint8_t ch) if (it != chats.end()) return; + NodeMutation mutation = index >= c_max_channels ? nodeStore.setActiveChat(index, true) : NodeMutation{}; + syncNodeListPresentation(mutation); + lv_obj_t *chatDelBtn = nullptr; lv_obj_t *parent_obj = objects.chats_panel; @@ -6714,11 +6145,7 @@ void TFTView_320x240::addChat(uint32_t from, uint32_t to, uint8_t ch) if (to == UINT32_MAX || from == 0) { sprintf(buf, "%d: %s", (int)ch, lv_label_get_text(channel[ch])); } else { - auto it = nodes.find(from); - if (it != nodes.end()) { - sprintf(buf, "%s: %s", lv_label_get_text(it->second->LV_OBJ_IDX(node_lbs_idx)), - lv_label_get_text(it->second->LV_OBJ_IDX(node_lbl_idx))); - } else { + if (!chatTitleFromModel(from, buf, sizeof(buf))) { sprintf(buf, "!%08x", from); } } @@ -6762,10 +6189,8 @@ void TFTView_320x240::addChat(uint32_t from, uint32_t to, uint8_t ch) chats[index] = chatBtn; updateActiveChats(); - if (index > c_max_channels) { - if (nodes.find(index) != nodes.end()) - applyNodesFilter(index); - } + if (index > c_max_channels) + syncNodeListPresentation(); lv_obj_add_event_cb(chatBtn, ui_event_ChatButton, LV_EVENT_ALL, (void *)index); lv_obj_add_event_cb(chatDelBtn, ui_event_ChatDelButton, LV_EVENT_CLICKED, (void *)index); @@ -6876,23 +6301,18 @@ void TFTView_320x240::showMessages(uint32_t nodeNum) } activeMsgContainer->user_data = (void *)nodeNum; lv_obj_clear_flag(activeMsgContainer, LV_OBJ_FLAG_HIDDEN); - lv_obj_t *p = nodes[nodeNum]; - if (p) { - lv_label_set_text(objects.top_messages_node_label, lv_label_get_text(p->LV_OBJ_IDX(node_lbl_idx))); + if (nodeStore.find(nodeNum)) { + lv_label_set_text(objects.top_messages_node_label, nodeDisplayName(nodeNum) ? nodeDisplayName(nodeNum) : ""); ui_set_active(objects.messages_button, objects.messages_panel, objects.top_messages_panel); - switch ((unsigned long)p->LV_OBJ_IDX(node_bat_idx)->user_data) { - case 0: - lv_obj_set_style_bg_image_src(objects.top_messages_node_image, &img_lock_channel_image, + if (nodeHasBadKey(nodeNum)) { + lv_obj_set_style_bg_image_src(objects.top_messages_node_image, &img_lock_slash_image, LV_PART_MAIN | LV_STATE_DEFAULT); - break; - case 1: + } else if (nodeHasKey(nodeNum)) { lv_obj_set_style_bg_image_src(objects.top_messages_node_image, &img_lock_secure_image, LV_PART_MAIN | LV_STATE_DEFAULT); - break; - default: - lv_obj_set_style_bg_image_src(objects.top_messages_node_image, &img_lock_slash_image, + } else { + lv_obj_set_style_bg_image_src(objects.top_messages_node_image, &img_lock_channel_image, LV_PART_MAIN | LV_STATE_DEFAULT); - break; } unreadMessages = 0; // TODO: not all messages may be actually read updateUnreadMessages(); @@ -7051,7 +6471,7 @@ void TFTView_320x240::setGroupFocus(lv_obj_t *panel) if (panel == objects.home_panel) { lv_group_focus_obj(objects.home_mail_button); } else if (panel == objects.nodes_panel) { - lv_group_focus_obj(objects.node_button); + reconcileVirtualNodeListInputGroup(true); } else if (panel == objects.groups_panel) { lv_group_focus_obj(objects.channel_button0); } else if (panel == objects.messages_panel) { @@ -7081,13 +6501,19 @@ void TFTView_320x240::setGroupFocus(lv_obj_t *panel) */ void TFTView_320x240::setInputGroup(void) { - lv_group_t *group = lv_group_get_default(); + setInputGroup(lv_group_get_default()); +} - if (group && inputdriver->hasKeyboardDevice()) +void TFTView_320x240::setInputGroup(lv_group_t *group) +{ + if (inputdriver->hasKeyboardDevice()) lv_indev_set_group(inputdriver->getKeyboard(), group); - if (group && inputdriver->hasPointerDevice()) + if (inputdriver->hasPointerDevice()) lv_indev_set_group(inputdriver->getPointer(), group); + + if (inputdriver->hasEncoderDevice()) + lv_indev_set_group(inputdriver->getEncoder(), group); } void TFTView_320x240::setInputButtonLabel(void) @@ -7100,64 +6526,355 @@ void TFTView_320x240::setInputButtonLabel(void) lv_snprintf(label, sizeof(label), _("Input Control: %s/%s"), current_ptr.c_str(), current_kbd.c_str()); lv_label_set_text(objects.basic_settings_input_label, label); } + +NodeListFilter TFTView_320x240::currentNodeListFilter(void) const +{ + NodeListFilter modelFilter; + modelFilter.unknown = lv_obj_has_state(objects.nodes_filter_unknown_switch, LV_STATE_CHECKED); + modelFilter.offline = lv_obj_has_state(objects.nodes_filter_offline_switch, LV_STATE_CHECKED); + modelFilter.publicKey = lv_obj_has_state(objects.nodes_filter_public_key_switch, LV_STATE_CHECKED); + modelFilter.channel = static_cast(lv_dropdown_get_selected(objects.nodes_filter_channel_dropdown)); + modelFilter.hops = static_cast(lv_dropdown_get_selected(objects.nodes_filter_hops_dropdown)); + modelFilter.position = lv_obj_has_state(objects.nodes_filter_position_switch, LV_STATE_CHECKED); + modelFilter.viaMqtt = lv_obj_has_state(objects.nodes_filter_mqtt_switch, LV_STATE_CHECKED); + const char *name = lv_textarea_get_text(objects.nodes_filter_name_area); + if (name) { + modelFilter.name = name; + } + modelFilter.curTime = static_cast(curtime); + modelFilter.secsUntilOffline = secs_until_offline; + modelFilter.hasOwnPosition = hasPosition; + modelFilter.ownLatitude = myLatitude; + modelFilter.ownLongitude = myLongitude; + modelFilter.metricUnits = db.config.display.units == meshtastic_Config_DisplayConfig_DisplayUnits_METRIC; + return modelFilter; +} + +NodeListRenderContext TFTView_320x240::nodeListRenderContext(void) const +{ + NodeListRenderContext context; + context.ownNode = ownNode; + context.hasOwnPosition = hasPosition; + context.ownLatitude = myLatitude; + context.ownLongitude = myLongitude; + context.metricUnits = db.config.display.units == meshtastic_Config_DisplayConfig_DisplayUnits_METRIC; + context.highlightActiveChat = lv_obj_has_state(objects.nodes_hl_active_chat_switch, LV_STATE_CHECKED); + context.highlightPosition = lv_obj_has_state(objects.nodes_hl_position_switch, LV_STATE_CHECKED); + context.highlightTelemetry = lv_obj_has_state(objects.nodes_hl_telemetry_switch, LV_STATE_CHECKED); + context.highlightIaq = lv_obj_has_state(objects.nodes_hliaq_switch, LV_STATE_CHECKED); + std::snprintf(context.highlightName, sizeof(context.highlightName), "%s", lv_textarea_get_text(objects.nodes_hl_name_area)); + return context; +} + +// Chat entry title resolved from the node model, for nodes without a row panel. +bool TFTView_320x240::chatTitleFromModel(uint32_t nodeNum, char *buf, size_t bufSize) const +{ + const auto *record = nodeStore.find(nodeNum); + if (!record) { + return false; + } + char renderedShort[32]; + if (nodeNum != ownNode && hasPosition && record->position.hasCoordinates()) { + NodeListRowPresentation::formatShortNameWithDistance( + renderedShort, sizeof(renderedShort), record->user.short_name, nodeNum, hasPosition, myLatitude, myLongitude, + record->position.latitude, record->position.longitude, + db.config.display.units == meshtastic_Config_DisplayConfig_DisplayUnits_METRIC); + } else { + NodeListRowPresentation::formatShortDisplayName(renderedShort, sizeof(renderedShort), record->user.short_name, nodeNum); + } + snprintf(buf, bufSize, "%s: %s", renderedShort, record->user.long_name); + return true; +} + +void TFTView_320x240::syncVisibleNodeIndex(void) +{ + visibleNodes.rebuild(nodeStore, currentNodeListFilter(), ownNode); + publishMapFilter(); +} + +void TFTView_320x240::publishMapFilter() +{ + if (!map || (mapFilterPublicationValid && publishedMapFilterGeneration == visibleNodes.membershipGeneration())) { + return; + } + + for (const auto &entry : nodeObjects) { + map->update(entry.first, !visibleNodes.contains(entry.first)); + } + publishedMapFilterGeneration = visibleNodes.membershipGeneration(); + mapFilterPublicationValid = true; +} + +void TFTView_320x240::reconcileVirtualNodeListInputGroup(bool enteringNodeScreen) +{ + if (!virtualNodeList) { + return; + } + + const bool hasVisibleNodes = !visibleNodes.empty(); + if (!enteringNodeScreen && virtualNodeListInputVisibilityKnown && virtualNodeListInputHadVisibleNodes == hasVisibleNodes) { + return; + } + + if (hasVisibleNodes) { + setInputGroup(virtualNodeList->navigationGroup()); + } else { + setInputGroup(); + } + virtualNodeListInputVisibilityKnown = true; + virtualNodeListInputHadVisibleNodes = hasVisibleNodes; +} + +void TFTView_320x240::syncNodeListPresentation(void) +{ + syncNodeListPresentation(false); +} + +void TFTView_320x240::beginNodeListPresentationBatch() +{ + ++nodeListPresentationBatchDepth; +} + +void TFTView_320x240::endNodeListPresentationBatch() +{ + if (!nodeListPresentationBatchDepth || --nodeListPresentationBatchDepth) { + return; + } + + const bool syncRequested = nodeListPresentationBatchSyncRequested; + const bool forceRebind = nodeListPresentationBatchForceRebind; + const NodeId refreshId = nodeListPresentationBatchRefreshId; + nodeListPresentationBatchSyncRequested = false; + nodeListPresentationBatchForceRebind = false; + nodeListPresentationBatchRefreshId = 0; + if (syncRequested) { + syncNodeListPresentation(forceRebind); + if (refreshId && !forceRebind) { + refreshVirtualNodePresentation(refreshId); + } + } else if (refreshId) { + refreshVirtualNodePresentation(refreshId); + } +} + +void TFTView_320x240::syncNodeListPresentation(bool forceRebind) +{ + if (nodeListPresentationBatchDepth) { + nodeListPresentationBatchSyncRequested = true; + nodeListPresentationBatchForceRebind = nodeListPresentationBatchForceRebind || forceRebind; + return; + } + forceRebind = forceRebind || nodeListDiscoverySync.forceRebind(); + syncVisibleNodeIndex(); + if (currentNode && !nodeStore.find(currentNode)) { + selectNode(0); + } + nodeCount = static_cast(nodeStore.size()); + nodesFiltered = static_cast(nodeCount - visibleNodes.size()); + nodesOnline = 0; + for (const auto &entry : nodeStore.records()) { + const uint32_t lastHeard = entry.first == ownNode ? static_cast(curtime) : entry.second.lastHeard; + const uint32_t effectiveLastHeard = std::min(lastHeard, static_cast(curtime)); + if (effectiveLastHeard && static_cast(curtime) - effectiveLastHeard <= secs_until_offline) { + nodesOnline++; + } + } + updateNodesStatus(); + ensureVirtualNodeList(); + if (virtualNodeList) { + virtualNodeList->sync(nodeStore, visibleNodes, currentNode, static_cast(curtime), nodeListRenderContext(), + forceRebind); + if (activePanel == objects.nodes_panel) { + reconcileVirtualNodeListInputGroup(false); + } + } + nodesChanged = false; + nodeListDiscoverySync.consumeFullSync(); +} + +void TFTView_320x240::syncNodeListPresentation(const NodeMutation &mutation) +{ + const auto policy = nodeListDiscoverySync.observe(mutation, lv_tick_get()); + if (policy == NodeDiscoverySyncGate::MutationPolicy::Ignore) { + return; + } + if (mutation.kind == NodeMutationKind::Inserted) { + nodesChanged = true; + return; + } + if (policy == NodeDiscoverySyncGate::MutationPolicy::Defer) { + return; + } + if (nodeListPresentationBatchDepth) { + if (mutation.kind == NodeMutationKind::Updated && mutation.id) { + if (nodeListPresentationBatchRefreshId && nodeListPresentationBatchRefreshId != mutation.id) { + nodeListPresentationBatchSyncRequested = true; + nodeListPresentationBatchForceRebind = true; + return; + } + nodeListPresentationBatchRefreshId = mutation.id; + if (!mutationCanRefreshVirtualRow(mutation)) { + nodeListPresentationBatchSyncRequested = true; + } + return; + } + nodeListPresentationBatchSyncRequested = true; + nodeListPresentationBatchForceRebind = true; + return; + } + if (mutationCanRefreshVirtualRow(mutation)) { + refreshVirtualNodePresentation(mutation.id); + return; + } + syncNodeListPresentation(); + if (mutation.kind == NodeMutationKind::Updated && mutation.id) { + refreshVirtualNodePresentation(mutation.id); + } +} + +bool TFTView_320x240::mutationCanRefreshVirtualRow(const NodeMutation &mutation) const +{ + if (mutation.kind != NodeMutationKind::Updated || mutation.id == 0) { + return false; + } + + uint32_t contentFields = NodeFieldSignal | NodeFieldDeviceMetrics | NodeFieldEnvironmentMetrics | NodeFieldActiveChat; + const int hopsFilter = static_cast(lv_dropdown_get_selected(objects.nodes_filter_hops_dropdown)); + if (!hopsFilter) { + contentFields |= NodeFieldHops; + } + + return (mutation.changedFields & ~contentFields) == 0; +} + +bool TFTView_320x240::refreshVirtualNodePresentation(NodeId id) +{ + if (!virtualNodeList) { + return false; + } + return virtualNodeList->refreshNode(id, static_cast(curtime), nodeListRenderContext()); +} + +void TFTView_320x240::ensureVirtualNodeList(void) +{ + if (virtualNodeList) { + return; + } + if (!objects.nodes_panel) { + return; + } + + virtualNodeList.reset(new VirtualNodeList(objects.nodes_panel, *this)); +} + +void TFTView_320x240::nodeClicked(NodeId id) +{ + if (!id || !nodeStore.find(id)) { + return; + } + + if (currentNode == id) { + selectNode(0); + } else { + selectNode(id); + } + + if (chooseNodeSignalScanner) { + chooseNodeSignalScanner = false; + ui_event_signal_scanner(nullptr); + lv_dropdown_set_selected(objects.nodes_filter_hops_dropdown, selectedHops); + updateNodesFiltered(true); + updateNodesStatus(); + } else if (chooseNodeTraceRoute) { + chooseNodeTraceRoute = false; + ui_event_trace_route(nullptr); + } else { + if (virtualNodeList) { + virtualNodeList->setExpanded(currentNode); + } + } +} + +void TFTView_320x240::nodeLongPressed(NodeId id) +{ + if (nodeIsMessagable(id)) { + showMessages(id); + } +} + +void TFTView_320x240::nodeFocusBoundary(bool forward) +{ + (void)forward; + setInputGroup(); + if (activeButton) { + lv_group_focus_obj(activeButton); + } +} + +void TFTView_320x240::nodePositionClicked(NodeId id) +{ + const NodePosition position = nodePosition(id); + if (position.known && position.latitude && position.longitude) { + ui_set_active(objects.map_button, objects.map_panel, objects.top_map_panel); + if (!map) { + loadMap(); + } + map->setScrolledPosition(position.latitude * 1e-7, position.longitude * 1e-7); + } +} // -------- helpers -------- void TFTView_320x240::removeNode(uint32_t nodeNum) { - auto it = nodes.find(nodeNum); - if (it != nodes.end()) { - } + syncNodeListPresentation(nodeStore.remove(nodeNum)); +} + +bool TFTView_320x240::hasKnownNodeForPacket(uint32_t nodeNum) const +{ + return nodeStore.find(nodeNum) != nullptr; } void TFTView_320x240::setNodeImage(uint32_t nodeNum, eRole role, bool unmessagable, lv_obj_t *img) { - uint32_t bgColor, fgColor; - std::tie(bgColor, fgColor) = nodeColor(nodeNum); + const void *source = &img_node_client_image; if (unmessagable) { - lv_image_set_src(img, &img_unmessagable_image); - lv_obj_set_style_border_color(img, lv_color_hex(bgColor), LV_PART_MAIN | LV_STATE_DEFAULT); - lv_obj_set_style_bg_color(img, lv_color_hex(0x202020), LV_PART_MAIN | LV_STATE_DEFAULT); - lv_obj_set_style_img_recolor(img, lv_color_hex(0xFF5555), LV_PART_MAIN | LV_STATE_DEFAULT); - lv_obj_set_style_img_recolor_opa(img, 255, LV_PART_MAIN | LV_STATE_DEFAULT); - return; + source = &img_unmessagable_image; } else { switch (role) { case client: case client_mute: case client_hidden: case tak: { - lv_image_set_src(img, &img_node_client_image); + source = &img_node_client_image; break; } case router_client: { - lv_image_set_src(img, &img_top_nodes_image); + source = &img_top_nodes_image; break; } case repeater: case router: case router_late: { - lv_image_set_src(img, &img_node_router_image); + source = &img_node_router_image; break; } case tracker: case sensor: case lost_and_found: case tak_tracker: { - lv_image_set_src(img, &img_node_sensor_image); + source = &img_node_sensor_image; break; } case unknown: { - lv_image_set_src(img, &img_circle_question_image); + source = &img_circle_question_image; break; } default: - lv_image_set_src(img, &img_node_client_image); + source = &img_node_client_image; break; } } - lv_obj_set_style_bg_color(img, lv_color_hex(bgColor), LV_PART_MAIN | LV_STATE_DEFAULT); - lv_obj_set_style_border_color(img, lv_color_hex(bgColor), LV_PART_MAIN | LV_STATE_DEFAULT); - lv_obj_set_style_img_recolor_opa(img, fgColor ? 0 : 255, LV_PART_MAIN | LV_STATE_DEFAULT); + NodeListRowPresentation::applyNodeImage(img, nodeNum, source, unmessagable, false); } void TFTView_320x240::updateNodesStatus(void) @@ -7179,25 +6896,13 @@ void TFTView_320x240::updateNodesStatus(void) * @param reset indicates to start update from beginning of node list otherwise * continue with iterator position or skip if done */ -void TFTView_320x240::updateNodesFiltered(bool reset) +void TFTView_320x240::updateNodesFiltered(bool reset, bool forceRebind) { - static auto it = nodes.begin(); - if (reset || nodesChanged) { - nodesFiltered = 0; - nodesChanged = false; - processingFilter = true; - it = nodes.begin(); - } - - for (int i = 0; i < 10 && it != nodes.end(); i++) { - applyNodesFilter(it->first, true); - it++; - } - - if (it == nodes.end()) { - processingFilter = false; - } - updateNodesStatus(); + if (!reset && !nodesChanged && !processingFilter) + return; + nodesChanged = false; + processingFilter = false; + syncNodeListPresentation(forceRebind); } /** @@ -7207,27 +6912,7 @@ void TFTView_320x240::updateNodesFiltered(bool reset) */ void TFTView_320x240::updateLastHeard(uint32_t nodeNum) { - auto it = nodes.find(nodeNum); - if (it != nodes.end() && it->second) { - time_t lastHeard = (time_t)it->second->LV_OBJ_IDX(node_lh_idx)->user_data; - it->second->LV_OBJ_IDX(node_lh_idx)->user_data = (void *)curtime; - lv_label_set_text(it->second->LV_OBJ_IDX(node_lh_idx), _("now")); - if (it->first != ownNode) { - if (lastHeard > 0 && curtime - lastHeard >= secs_until_offline) { - nodesOnline++; - applyNodesFilter(nodeNum); - updateNodesStatus(); - } - // move to top position - lv_obj_move_to_index(it->second, 1); - - // re-arrange the group linked list i.e. move the node after the top position - lv_ll_t *lv_group_ll = &lv_group_get_default()->obj_ll; - void *act = it->second->LV_OBJ_IDX(node_btn_idx)->user_data; - if (lv_group_ll && act) - _lv_ll_move_before(lv_group_ll, act, _lv_ll_get_next(lv_group_ll, topNodeLL)); - } - } + syncNodeListPresentation(nodeStore.updateLastHeard(nodeNum, curtime)); } /** @@ -7236,26 +6921,9 @@ void TFTView_320x240::updateLastHeard(uint32_t nodeNum) */ void TFTView_320x240::updateAllLastHeard(void) { - uint16_t online = 0; - time_t lastHeard; - for (auto it : nodes) { - char buf[32]; - if (it.first == ownNode) { // own node is always now, so do update - lastHeard = curtime; - it.second->LV_OBJ_IDX(node_lh_idx)->user_data = (void *)lastHeard; - } else { - lastHeard = (time_t)it.second->LV_OBJ_IDX(node_lh_idx)->user_data; - } - if (lastHeard) { - bool isOnline = lastHeardToString(lastHeard, buf); - lv_label_set_text(it.second->LV_OBJ_IDX(node_lh_idx), buf); - if (isOnline) - online++; - } - } - nodesOnline = online; - updateNodesFiltered(true); - updateNodesStatus(); + if (ownNode) + nodeStore.updateLastHeard(ownNode, curtime); + syncNodeListPresentation(); } void TFTView_320x240::updateUnreadMessages(void) @@ -7280,13 +6948,15 @@ void TFTView_320x240::updateTime(void) { char buf[80]; time_t curr_time; +#ifdef ARCH_PORTDUINO time(&curr_time); - if (!VALID_TIME(curr_time)) - curr_time = actTime; +#else + curr_time = actTime; +#endif + tm *curr_tm = localtime(&curr_time); int len = 0; - tm *curr_tm = localtime(&curr_time); - if (VALID_TIME(curr_time) && (unsigned long)objects.home_time_button->user_data == 0 && curr_tm) { + if (VALID_TIME(curr_time) && (unsigned long)objects.home_time_button->user_data == 0) { if (db.config.display.use_12h_clock) { len = strftime(buf, 40, "%I:%M:%S %p\n%a %d-%b-%g", curr_tm); } else { @@ -7556,6 +7226,7 @@ void TFTView_320x240::task_handler(void) MeshtasticView::task_handler(); if (screensInitialised) { + if (map && activePanel == objects.map_panel) map->task_handler(); @@ -7612,8 +7283,9 @@ void TFTView_320x240::task_handler(void) } } } - if (processingFilter || nodesChanged) { - updateNodesFiltered(nodesChanged); + const bool discoverySyncDue = !nodeListDiscoverySync.pending() || nodeListDiscoverySync.due(lv_tick_get()); + if ((processingFilter || nodesChanged) && discoverySyncDue) { + updateNodesFiltered(nodesChanged, nodeListDiscoverySync.forceRebind()); } } } diff --git a/source/graphics/common/MeshtasticView.cpp b/source/graphics/common/MeshtasticView.cpp index 92a584c25..12a16473b 100644 --- a/source/graphics/common/MeshtasticView.cpp +++ b/source/graphics/common/MeshtasticView.cpp @@ -1,4 +1,5 @@ #include "graphics/common/MeshtasticView.h" +#include "graphics/common/NodeListRowPresentation.h" #include "graphics/common/ViewController.h" #include "graphics/driver/DisplayDriver.h" #include "lv_i18n.h" @@ -101,18 +102,21 @@ void MeshtasticView::showMessagePopup(const char *from) {} void MeshtasticView::updateLastHeard(uint32_t nodeNum) {} +bool MeshtasticView::hasKnownNodeForPacket(uint32_t nodeNum) const +{ + return nodes.find(nodeNum) != nodes.end(); +} + void MeshtasticView::packetReceived(const meshtastic_MeshPacket &p) { // if there's a message from a node we don't know (yet), create it with defaults - auto it = nodes.find(p.from); - if (it == nodes.end()) { - MeshtasticView::addOrUpdateNode(p.from, p.channel, 0, eRole::unknown, false, false); + if (!hasKnownNodeForPacket(p.from)) { + addOrUpdateNode(p.from, p.channel, 0, eRole::unknown, false, false); updateLastHeard(p.from); } if (p.to != ownNode && p.to != 0xffffffff) { - auto it = nodes.find(p.to); - if (it == nodes.end()) { - MeshtasticView::addOrUpdateNode(p.to, p.channel, 0, eRole::unknown, false, false); + if (!hasKnownNodeForPacket(p.to)) { + addOrUpdateNode(p.to, p.channel, 0, eRole::unknown, false, false); updateLastHeard(p.to); } } @@ -129,16 +133,7 @@ void MeshtasticView::removeNode(uint32_t nodeNum) {} */ std::tuple MeshtasticView::nodeColor(uint32_t nodeNum) { - uint32_t red = (nodeNum & 0xff0000) >> 16; - uint32_t green = (nodeNum & 0xff00) >> 8; - uint32_t blue = (nodeNum & 0xff); - while (red + green + blue < 0xF0) { - red += red / 3 + 10; - green += green / 3 + 10; - blue += blue / 3 + 10; - } - - return std::make_tuple((red << 16) | (green << 8) | blue, (2 * red + 2 * green + blue) > 600 ? 0x000000 : 0xFFFFFF); + return NodeListRowPresentation::nodeColors(nodeNum); } /** diff --git a/source/graphics/common/NodeStore.cpp b/source/graphics/common/NodeStore.cpp new file mode 100644 index 000000000..157e07998 --- /dev/null +++ b/source/graphics/common/NodeStore.cpp @@ -0,0 +1,375 @@ +#include "graphics/common/NodeStore.h" + +#include "mesh-pb-constants.h" + +#include +#include + +namespace +{ +constexpr uint32_t purgeFreshnessSeconds = 120; + +uint32_t effectiveLastHeard(const NodeRecord &record, uint32_t now) +{ + return record.lastHeard > now ? now : record.lastHeard; +} + +uint32_t publicKeyHash(const meshtastic_User &user) +{ + uint32_t hash = 2166136261U; + for (pb_size_t i = 0; i < user.public_key.size; ++i) { + hash ^= user.public_key.bytes[i]; + hash *= 16777619U; + } + return hash; +} + +NodeUserSummary summarizeUser(const meshtastic_User &user) +{ + NodeUserSummary summary{}; + std::snprintf(summary.id, sizeof(summary.id), "%s", user.id); + std::snprintf(summary.long_name, sizeof(summary.long_name), "%s", user.long_name); + std::snprintf(summary.short_name, sizeof(summary.short_name), "%s", user.short_name); + std::memcpy(summary.macaddr, user.macaddr, sizeof(summary.macaddr)); + summary.hw_model = user.hw_model; + summary.role = user.role; + summary.is_licensed = user.is_licensed; + summary.publicKeySize = static_cast(user.public_key.size); + summary.publicKeyHash = publicKeyHash(user); + return summary; +} + +bool sameUser(const NodeUserSummary &left, const NodeUserSummary &right) +{ + return std::strcmp(left.id, right.id) == 0 && std::strcmp(left.long_name, right.long_name) == 0 && + std::strcmp(left.short_name, right.short_name) == 0 && + std::memcmp(left.macaddr, right.macaddr, sizeof(left.macaddr)) == 0 && left.hw_model == right.hw_model && + left.is_licensed == right.is_licensed && left.role == right.role && left.publicKeySize == right.publicKeySize && + left.publicKeyHash == right.publicKeyHash; +} + +bool samePosition(const NodePosition &left, const NodePosition &right) +{ + return left.known == right.known && left.latitude == right.latitude && left.longitude == right.longitude && + left.altitude == right.altitude && left.satellites == right.satellites && left.precision == right.precision; +} + +bool samePublicKey(const NodeUserSummary &left, const NodeUserSummary &right) +{ + return left.publicKeySize == right.publicKeySize && left.publicKeyHash == right.publicKeyHash; +} + +NodeDeviceMetrics summarizeDeviceMetrics(const meshtastic_DeviceMetrics &metrics) +{ + return {metrics.battery_level, metrics.voltage, metrics.channel_utilization, metrics.air_util_tx}; +} + +NodeEnvironmentMetrics summarizeEnvironmentMetrics(const meshtastic_EnvironmentMetrics &metrics) +{ + return {metrics.temperature, metrics.relative_humidity, metrics.barometric_pressure, + metrics.voltage, metrics.current, metrics.iaq}; +} + +bool sameDeviceMetrics(const NodeDeviceMetrics &left, const NodeDeviceMetrics &right) +{ + return left.battery_level == right.battery_level && left.voltage == right.voltage && + left.channel_utilization == right.channel_utilization && left.air_util_tx == right.air_util_tx; +} + +bool sameEnvironmentMetrics(const NodeEnvironmentMetrics &left, const NodeEnvironmentMetrics &right) +{ + return left.temperature == right.temperature && left.relative_humidity == right.relative_humidity && + left.barometric_pressure == right.barometric_pressure && left.voltage == right.voltage && + left.current == right.current && left.iaq == right.iaq; +} + +NodeMutation unchanged(NodeId id) +{ + return {NodeMutationKind::Unchanged, id, NodeFieldNone}; +} + +NodeMutation updated(NodeId id, uint32_t fields) +{ + return {fields == NodeFieldNone ? NodeMutationKind::Unchanged : NodeMutationKind::Updated, id, fields}; +} +} // namespace + +NodeStore::NodeStore() +{ + nodes.reserve(MAX_NUM_NODES_VIEW); +} + +const NodeRecord *NodeStore::find(NodeId id) const +{ + const auto it = nodes.find(id); + return it == nodes.end() ? nullptr : &it->second; +} + +void NodeStore::touchRecency(NodeRecord &record) +{ + record.recencyPromoted = true; + record.recencyOrder = nextRecencyOrder++; +} + +NodeMutation NodeStore::upsertUser(NodeId id, uint8_t channel, uint32_t lastHeard, const meshtastic_User &user, bool viaMqtt) +{ + auto [it, inserted] = nodes.try_emplace(id); + auto &record = it->second; + uint32_t changed = NodeFieldNone; + const NodeUserSummary userSummary = summarizeUser(user); + const bool keyChanged = record.hasUser && !samePublicKey(record.user, userSummary); + + if (inserted) { + record.id = id; + record.recencyOrder = nextRecencyOrder++; + changed = NodeFieldUser | NodeFieldFlags | NodeFieldLastHeard; + if (channel < MAX_NUM_CHANNELS) { + changed |= NodeFieldChannel; + } + } + if (!record.hasUser || !sameUser(record.user, userSummary)) { + record.hasUser = true; + record.user = userSummary; + changed |= NodeFieldUser; + } + if (channel < MAX_NUM_CHANNELS) { + if (record.channel != channel) { + record.channel = channel; + changed |= NodeFieldChannel; + } + } else if (inserted) { + // New record with invalid channel keeps default 0 without dirtying. + record.channel = 0; + } + const bool hasKey = user.public_key.size != 0; + const bool unmessagable = user.has_is_unmessagable && user.is_unmessagable; + const bool keyPresenceChanged = record.hasKey != hasKey; + if (keyPresenceChanged || record.unmessagable != unmessagable || record.viaMqtt != viaMqtt || keyChanged) { + record.hasKey = hasKey; + if (keyPresenceChanged || keyChanged) { + record.hasBadKey = false; + } + record.unmessagable = unmessagable; + record.viaMqtt = viaMqtt; + changed |= NodeFieldFlags; + } + if (record.lastHeard != lastHeard) { + record.lastHeard = lastHeard; + changed |= NodeFieldLastHeard; + } + + return inserted ? NodeMutation{NodeMutationKind::Inserted, id, changed} : updated(id, changed); +} + +NodeMutation NodeStore::upsertUnknown(NodeId id, uint8_t channel, uint32_t lastHeard, uint8_t role, bool hasKey, bool viaMqtt) +{ + meshtastic_User fallback = meshtastic_User_init_default; + std::snprintf(fallback.short_name, sizeof(fallback.short_name), "%04x", id & 0xffff); + std::snprintf(fallback.long_name, sizeof(fallback.long_name), "Meshtastic %s", fallback.short_name); + fallback.role = static_cast(role); + fallback.hw_model = meshtastic_HardwareModel_UNSET; + const NodeUserSummary fallbackSummary = summarizeUser(fallback); + + auto [it, inserted] = nodes.try_emplace(id); + auto &record = it->second; + uint32_t changed = NodeFieldNone; + + if (inserted) { + record.id = id; + record.recencyOrder = nextRecencyOrder++; + changed = NodeFieldUser | NodeFieldFlags | NodeFieldLastHeard; + if (channel < MAX_NUM_CHANNELS) { + changed |= NodeFieldChannel; + } + } + if (record.hasUser || !sameUser(record.user, fallbackSummary)) { + record.hasUser = false; + record.user = fallbackSummary; + changed |= NodeFieldUser; + } + if (inserted && channel < MAX_NUM_CHANNELS && record.channel != channel) { + record.channel = channel; + changed |= NodeFieldChannel; + } else if (inserted && channel >= MAX_NUM_CHANNELS) { + record.channel = 0; + } + if (record.hasKey != hasKey || record.unmessagable || record.viaMqtt != viaMqtt) { + record.hasKey = hasKey; + record.hasBadKey = false; + record.unmessagable = false; + record.viaMqtt = viaMqtt; + changed |= NodeFieldFlags; + } + if (inserted && record.lastHeard != lastHeard) { + record.lastHeard = lastHeard; + changed |= NodeFieldLastHeard; + } + + return inserted ? NodeMutation{NodeMutationKind::Inserted, id, changed} : updated(id, changed); +} + +NodeMutation NodeStore::updatePosition(NodeId id, const NodePosition &position) +{ + auto it = nodes.find(id); + if (it == nodes.end() || !position.hasCoordinates() || samePosition(it->second.position, position)) + return unchanged(id); + it->second.position = position; + return updated(id, NodeFieldPosition); +} + +NodeMutation NodeStore::updateDeviceMetrics(NodeId id, const meshtastic_DeviceMetrics &metrics) +{ + auto it = nodes.find(id); + if (it == nodes.end()) + return unchanged(id); + auto retainedMetrics = summarizeDeviceMetrics(metrics); + if (it->second.hasDeviceMetrics && retainedMetrics.battery_level == 0 && retainedMetrics.voltage == 0.0f) { + retainedMetrics.battery_level = it->second.deviceMetrics.battery_level; + retainedMetrics.voltage = it->second.deviceMetrics.voltage; + } + if (it->second.hasDeviceMetrics && sameDeviceMetrics(it->second.deviceMetrics, retainedMetrics)) + return unchanged(id); + it->second.hasDeviceMetrics = true; + it->second.deviceMetrics = retainedMetrics; + return updated(id, NodeFieldDeviceMetrics); +} + +NodeMutation NodeStore::updateEnvironmentMetrics(NodeId id, const meshtastic_EnvironmentMetrics &metrics) +{ + auto it = nodes.find(id); + if (it == nodes.end()) + return unchanged(id); + auto retainedMetrics = summarizeEnvironmentMetrics(metrics); + if (it->second.hasEnvironmentMetrics && (retainedMetrics.iaq == 0 || retainedMetrics.iaq >= 1000) && + it->second.environmentMetrics.iaq > 0 && it->second.environmentMetrics.iaq < 1000) { + retainedMetrics.iaq = it->second.environmentMetrics.iaq; + retainedMetrics.voltage = it->second.environmentMetrics.voltage; + retainedMetrics.current = it->second.environmentMetrics.current; + } + if (it->second.hasEnvironmentMetrics && sameEnvironmentMetrics(it->second.environmentMetrics, retainedMetrics)) + return unchanged(id); + it->second.hasEnvironmentMetrics = true; + it->second.environmentMetrics = retainedMetrics; + return updated(id, NodeFieldEnvironmentMetrics); +} + +NodeMutation NodeStore::updateSignal(NodeId id, int32_t rssi, float snr) +{ + auto it = nodes.find(id); + if (it == nodes.end()) + return unchanged(id); + const bool changed = it->second.rssi != rssi || it->second.snr != snr || it->second.hopsAway != 0 || + it->second.signalDisplay != NodeSignalDisplayKind::Rssi; + it->second.rssi = rssi; + it->second.snr = snr; + it->second.hopsAway = 0; + it->second.signalDisplay = NodeSignalDisplayKind::Rssi; + if (!changed) + return unchanged(id); + return updated(id, NodeFieldSignal | NodeFieldHops); +} + +NodeMutation NodeStore::updateHops(NodeId id, int8_t hopsAway) +{ + auto it = nodes.find(id); + if (it == nodes.end()) + return unchanged(id); + const bool changed = it->second.hopsAway != hopsAway || it->second.signalDisplay != NodeSignalDisplayKind::Hops; + it->second.hopsAway = hopsAway; + it->second.signalDisplay = NodeSignalDisplayKind::Hops; + if (!changed) + return unchanged(id); + return updated(id, NodeFieldHops); +} + +NodeMutation NodeStore::updateLastHeard(NodeId id, uint32_t now) +{ + auto it = nodes.find(id); + if (it == nodes.end()) + return unchanged(id); + if (it->second.lastHeard == now) { + touchRecency(it->second); + return updated(id, NodeFieldLastHeard); + } + it->second.lastHeard = now; + touchRecency(it->second); + return updated(id, NodeFieldLastHeard); +} + +NodeMutation NodeStore::markBadKey(NodeId id) +{ + auto it = nodes.find(id); + if (it == nodes.end() || it->second.hasBadKey) + return unchanged(id); + it->second.hasBadKey = true; + return updated(id, NodeFieldFlags); +} + +NodeMutation NodeStore::setActiveChat(NodeId id, bool active) +{ + auto it = nodes.find(id); + if (it == nodes.end() || it->second.hasActiveChat == active) + return unchanged(id); + it->second.hasActiveChat = active; + return updated(id, NodeFieldActiveChat); +} + +NodeMutation NodeStore::remove(NodeId id) +{ + if (nodes.erase(id) == 0) + return unchanged(id); + return {NodeMutationKind::Removed, id, NodeFieldNone}; +} + +NodeId NodeStore::selectPurgeCandidate(NodeId incoming, NodeId ownNode, uint32_t now) const +{ + if (nodes.size() <= 1) + return 0; + + const auto older = [now](const NodeRecord *left, const NodeRecord *right) { + const uint32_t leftHeard = effectiveLastHeard(*left, now); + const uint32_t rightHeard = effectiveLastHeard(*right, now); + if (leftHeard != rightHeard) + return leftHeard < rightHeard; + if (left->recencyPromoted != right->recencyPromoted) + return !left->recencyPromoted; + if (left->recencyOrder != right->recencyOrder) + return left->recencyPromoted ? left->recencyOrder < right->recencyOrder : left->recencyOrder > right->recencyOrder; + return left->id < right->id; + }; + + const auto removable = [incoming, ownNode](const NodeRecord &record) { + return record.id != incoming && record.id != ownNode && !record.hasActiveChat; + }; + const auto staleUnknown = [now](const NodeRecord &record) { + return !record.hasUser && now >= record.lastHeard && now - record.lastHeard >= purgeFreshnessSeconds; + }; + + const NodeRecord *oldestRemovable = nullptr; + const NodeRecord *oldestStaleUnknown = nullptr; + for (const auto &[id, record] : nodes) { + if (removable(record)) { + if (!oldestRemovable || older(&record, oldestRemovable)) { + oldestRemovable = &record; + } + if (staleUnknown(record) && (!oldestStaleUnknown || older(&record, oldestStaleUnknown))) { + oldestStaleUnknown = &record; + } + } + } + + if (oldestStaleUnknown) { + const size_t preferredPopulation = (nodes.size() * 4 + 4) / 5; + size_t staleUnknownRank = 1; + for (const auto &[id, record] : nodes) { + if (older(&record, oldestStaleUnknown)) { + ++staleUnknownRank; + } + } + if (staleUnknownRank <= preferredPopulation) { + return oldestStaleUnknown->id; + } + } + + return oldestRemovable ? oldestRemovable->id : 0; +} diff --git a/source/graphics/common/ViewController.cpp b/source/graphics/common/ViewController.cpp index 451aa1911..57e8a45d0 100644 --- a/source/graphics/common/ViewController.cpp +++ b/source/graphics/common/ViewController.cpp @@ -16,6 +16,19 @@ fs::FS &persistentFS = LittleFS; const size_t DATA_PAYLOAD_LEN = meshtastic_Constants_DATA_PAYLOAD_LEN; constexpr const char *logDir = "/messages"; +namespace +{ +class NodeListPresentationBatch +{ + public: + explicit NodeListPresentationBatch(MeshtasticView &view) : view(view) { view.beginNodeListPresentationBatch(); } + ~NodeListPresentationBatch() { view.endNodeListPresentationBatch(); } + + private: + MeshtasticView &view; +}; +} // namespace + /** * @brief mediate between GUI view and client interface * @@ -710,6 +723,7 @@ bool ViewController::handleFromRadio(const meshtastic_FromRadio &from) break; } case meshtastic_FromRadio_node_info_tag: { + NodeListPresentationBatch presentationBatch(*view); const meshtastic_NodeInfo &node = from.node_info; if (node.has_user) { view->addOrUpdateNode(node.num, node.channel, node.last_heard, node.user); @@ -911,6 +925,7 @@ bool ViewController::handleFromRadio(const meshtastic_FromRadio &from) bool ViewController::packetReceived(const meshtastic_MeshPacket &p) { + NodeListPresentationBatch presentationBatch(*view); ILOG_DEBUG("received packet from 0x%08x, id=0x%08x, portnum=%u", p.from, p.id, p.decoded.portnum); view->packetReceived(p); diff --git a/source/graphics/common/VisibleNodeIndex.cpp b/source/graphics/common/VisibleNodeIndex.cpp new file mode 100644 index 000000000..bd0cb2472 --- /dev/null +++ b/source/graphics/common/VisibleNodeIndex.cpp @@ -0,0 +1,242 @@ +#include "graphics/common/VisibleNodeIndex.h" + +#include "graphics/common/NodeListRowPresentation.h" + +#include +#include +#include + +namespace +{ + +bool rankedBefore(const NodeStore &store, uint32_t now, NodeId ownNode, NodeId a, NodeId b) +{ + if (a != b) { + if (a == ownNode && ownNode != 0) { + return true; + } + if (b == ownNode && ownNode != 0) { + return false; + } + } + const auto *recA = store.find(a); + const auto *recB = store.find(b); + uint32_t lhA = recA ? recA->lastHeard : 0; + uint32_t lhB = recB ? recB->lastHeard : 0; + if (now != 0) { + lhA = lhA > now ? now : lhA; + lhB = lhB > now ? now : lhB; + } + if (lhA != lhB) { + return lhA > lhB; + } + uint64_t recencyA = recA ? recA->recencyOrder : 0; + uint64_t recencyB = recB ? recB->recencyOrder : 0; + const bool promotedA = recA && recA->recencyPromoted; + const bool promotedB = recB && recB->recencyPromoted; + if (promotedA != promotedB) { + return promotedA; + } + if (recencyA != recencyB) { + return promotedA ? recencyA > recencyB : recencyA < recencyB; + } + return a < b; +} + +} // namespace + +bool VisibleNodeIndex::isVisible(const NodeRecord &node, const NodeListFilter &filter, NodeId ownNode) +{ + // Own node is never hidden by filters in MUI + if (node.id == ownNode && ownNode != 0) { + return true; + } + + // Filter Unknown: hide if no user data or unknown + if (filter.unknown) { + if (!node.hasUser) { + return false; + } + } + + // Filter Offline: hide if lastHeard is 0 or older than secsUntilOffline + if (filter.offline) { + if (node.lastHeard == 0) { + return false; + } + if (filter.curTime > 0 && filter.curTime >= node.lastHeard) { + if ((filter.curTime - node.lastHeard) > filter.secsUntilOffline) { + return false; + } + } + } + + // Filter Public Key: hide when there is no usable key; a marked bad key + // no longer counts, matching the row presentation contract. + if (filter.publicKey) { + if (!node.hasKey || node.hasBadKey) { + return false; + } + } + + // Filter Channel: dropdown index 0 = all, >0 = channel index (selected - 1) + if (filter.channel != 0) { + uint8_t targetChannel = filter.channel - 1; + if (node.channel != targetChannel) { + return false; + } + } + + // Filter Hops: dropdown index 0 = all + if (filter.hops != 0) { + int selected = filter.hops - 7; + if (node.hopsAway < 0) { + return false; + } + if (selected <= 0) { + if (node.hopsAway > -selected) { + return false; + } + } else { + if (node.hopsAway < selected) { + return false; + } + } + } + + // The MQTT filter stays disabled: viaMqtt never affects visibility. + (void)filter.viaMqtt; + + // Filter Position: require known coordinates + if (filter.position) { + if (!node.position.hasCoordinates()) { + return false; + } + } + + // Filter Name: match against the rendered short-name text, including the + // id fallback and the distance line. + if (!filter.name.empty()) { + auto matchesAny = [&](const std::string &query) { + if (NodeListRowPresentation::containsCaseInsensitive(node.user.long_name, query.c_str(), query.size())) { + return true; + } + char renderedShort[48]; + if (filter.hasOwnPosition && node.position.hasCoordinates() && node.id != ownNode) { + NodeListRowPresentation::formatShortNameWithDistance( + renderedShort, sizeof(renderedShort), node.user.short_name, node.id, filter.hasOwnPosition, + filter.ownLatitude, filter.ownLongitude, node.position.latitude, node.position.longitude, filter.metricUnits); + } else { + NodeListRowPresentation::formatShortDisplayName(renderedShort, sizeof(renderedShort), node.user.short_name, + node.id); + } + return NodeListRowPresentation::containsCaseInsensitive(renderedShort, query.c_str(), query.size()); + }; + + if (filter.name[0] != '!') { + if (!matchesAny(filter.name)) { + return false; + } + } else { + // An empty negation matches every name and therefore hides the row. + const char *negated = filter.name.c_str() + 1; + const size_t negatedLength = filter.name.size() - 1; + if (negatedLength == 0 || + NodeListRowPresentation::containsCaseInsensitive(node.user.long_name, negated, negatedLength)) { + return false; + } + char renderedShort[48]; + if (filter.hasOwnPosition && node.position.hasCoordinates() && node.id != ownNode) { + NodeListRowPresentation::formatShortNameWithDistance( + renderedShort, sizeof(renderedShort), node.user.short_name, node.id, filter.hasOwnPosition, + filter.ownLatitude, filter.ownLongitude, node.position.latitude, node.position.longitude, filter.metricUnits); + } else { + NodeListRowPresentation::formatShortDisplayName(renderedShort, sizeof(renderedShort), node.user.short_name, + node.id); + } + if (NodeListRowPresentation::containsCaseInsensitive(renderedShort, negated, negatedLength)) { + return false; + } + } + } + + return true; +} + +void VisibleNodeIndex::rebuild(const NodeStore &store, const NodeListFilter &filter, NodeId ownNode) +{ + std::vector &next = rebuildScratch; + next.clear(); + next.reserve(visibleMembership.size()); + + for (const auto &pair : store.records()) { + const auto &record = pair.second; + if (isVisible(record, filter, ownNode)) { + if (next.size() < visibleMembership.size()) { + next.push_back(record.id); + } else { + auto worst = + std::max_element(next.begin(), next.end(), [&store, now = filter.curTime, ownNode](NodeId a, NodeId b) { + return rankedBefore(store, now, ownNode, a, b); + }); + if (worst != next.end() && rankedBefore(store, filter.curTime, ownNode, record.id, *worst)) { + *worst = record.id; + } + } + } + } + +#ifdef UNIT_TEST + membershipProbeCount = 0; +#endif + assert(next.size() <= visibleMembership.size()); + std::sort(next.begin(), next.end()); + + bool membershipChanged = next.size() != visibleMembershipSize; + if (!membershipChanged) { + for (size_t i = 0; i < next.size(); ++i) { +#ifdef UNIT_TEST + ++membershipProbeCount; +#endif + if (next[i] != visibleMembership[i]) { + membershipChanged = true; + break; + } + } + } + if (membershipChanged) { + std::copy(next.begin(), next.end(), visibleMembership.begin()); + visibleMembershipSize = next.size(); + ++visibleMembershipGeneration; + } + + // Sort by effective lastHeard descending, then newest same-second mutation + // first. The own node keeps the first position regardless of freshness. + std::sort(next.begin(), next.end(), + [&store, now = filter.curTime, ownNode](NodeId a, NodeId b) { return rankedBefore(store, now, ownNode, a, b); }); + + // Only publish (and bump the generation for) genuinely changed orders so + // redundant syncs can skip their refresh work downstream. + if (next != visibleIds) { + visibleIds = next; + ++rebuildGeneration; + } +} + +std::optional VisibleNodeIndex::indexOf(NodeId id) const +{ + for (size_t i = 0; i < visibleIds.size(); ++i) { + if (visibleIds[i] == id) { + return i; + } + } + return std::nullopt; +} + +bool VisibleNodeIndex::contains(NodeId id) const +{ +#ifdef UNIT_TEST + ++containsCallCount; +#endif + return std::binary_search(visibleMembership.begin(), visibleMembership.begin() + visibleMembershipSize, id); +} diff --git a/source/graphics/view/TFT/VirtualNodeList.cpp b/source/graphics/view/TFT/VirtualNodeList.cpp new file mode 100644 index 000000000..ad06a7fd1 --- /dev/null +++ b/source/graphics/view/TFT/VirtualNodeList.cpp @@ -0,0 +1,1355 @@ +#include "graphics/view/TFT/VirtualNodeList.h" + +#include "core/lv_group_private.h" +#include "fonts.h" +#include "graphics/common/NodeListRowPresentation.h" +#include "images.h" +#include "lv_i18n.h" +#include "styles.h" + +#include +#include +#include +#include +#include +#include +#include + +namespace +{ +VirtualNodeList *activeGroupNavigationList = nullptr; +const lv_color_t highlightOrange = lv_color_hex(0xff8c04); +const lv_color_t highlightBlueGreen = lv_color_hex(0x05f6cb); +const lv_color_t highlightBlue = lv_color_hex(0x436c70); +const lv_color_t highlightMidGray = lv_color_hex(0x808080); +const lv_color_t highlightMesh = lv_color_hex(0x67ea94); + +size_t rowPoolSizeForViewport(lv_obj_t *parentPanel) +{ + lv_obj_update_layout(parentPanel); + const int32_t contentHeight = lv_obj_get_height(parentPanel) - lv_obj_get_style_pad_top(parentPanel, LV_PART_MAIN) - + lv_obj_get_style_pad_bottom(parentPanel, LV_PART_MAIN); + const size_t visibleRows = + static_cast(std::max(0, contentHeight) + VirtualNodeList::COLLAPSED_ROW_HEIGHT - 1) / + (VirtualNodeList::COLLAPSED_ROW_HEIGHT + VirtualNodeList::ROW_GAP); + return std::clamp(visibleRows + 1, size_t{1}, VirtualNodeList::MAX_POOL_SIZE); +} + +template void setRowText(lv_obj_t *label, char (&storage)[Size], const char *text) +{ + std::snprintf(storage, Size, "%s", text ? text : ""); + lv_label_set_text_static(label, storage); +} + +void formatLastHeard(uint32_t lastHeard, uint32_t currentTime, char *buffer, size_t bufferSize) +{ + NodeListRowPresentation::formatLastHeardLabel(lastHeard, currentTime, _("now"), buffer, bufferSize); +} + +void formatShortName(const NodeRecord &record, const NodeListRenderContext &context, char *buffer, size_t bufferSize) +{ + // Remote rows carry a distance line only when both positions are known. + if (!context.hasOwnPosition || record.id == context.ownNode || !record.position.hasCoordinates()) { + NodeListRowPresentation::formatShortDisplayName(buffer, bufferSize, record.user.short_name, record.id); + return; + } + NodeListRowPresentation::formatShortNameWithDistance( + buffer, bufferSize, record.user.short_name, record.id, context.hasOwnPosition, context.ownLatitude, context.ownLongitude, + record.position.latitude, record.position.longitude, context.metricUnits); +} + +void setHidden(lv_obj_t *obj, bool hidden) +{ + if (hidden) { + lv_obj_add_flag(obj, LV_OBJ_FLAG_HIDDEN); + } else { + lv_obj_remove_flag(obj, LV_OBJ_FLAG_HIDDEN); + } +} + +void clearRowBinding(ReusableRow &row) +{ + row.boundId = 0; + row.pressedId = 0; + row.detailKeysValid = false; + if (row.panel) { + lv_obj_set_user_data(row.panel, nullptr); + } + if (row.btn) { + lv_obj_set_user_data(row.btn, nullptr); + } + if (row.lblPos1) { + lv_obj_set_user_data(row.lblPos1, nullptr); + } +} + +void moveGroupButtonToHead(lv_group_t *group, lv_obj_t *button) +{ + if (!group || !button) { + return; + } + + lv_obj_t **buttonNode = nullptr; + lv_obj_t **head = static_cast(lv_ll_get_head(&group->obj_ll)); + if (head && *head == button) { + return; + } + + for (void *nodePtr = lv_ll_get_head(&group->obj_ll); nodePtr; nodePtr = lv_ll_get_next(&group->obj_ll, nodePtr)) { + auto **node = static_cast(nodePtr); + if (*node == button) { + buttonNode = node; + break; + } + } + + if (buttonNode && head) { + lv_ll_move_before(&group->obj_ll, buttonNode, head); + } +} + +void moveGroupButtonToTail(lv_group_t *group, lv_obj_t *button) +{ + if (!group || !button) { + return; + } + + auto **tail = static_cast(lv_ll_get_tail(&group->obj_ll)); + if (tail && *tail == button) { + return; + } + + for (void *nodePtr = lv_ll_get_head(&group->obj_ll); nodePtr; nodePtr = lv_ll_get_next(&group->obj_ll, nodePtr)) { + auto **node = static_cast(nodePtr); + if (*node == button) { + lv_ll_move_before(&group->obj_ll, node, nullptr); + return; + } + } +} + +void setRoleImage(const NodeRecord &record, lv_obj_t *img) +{ + const void *source = &img_node_client_image; + if (record.unmessagable) { + source = &img_unmessagable_image; + } else { + switch (record.user.role) { + case meshtastic_Config_DeviceConfig_Role_ROUTER: + case meshtastic_Config_DeviceConfig_Role_REPEATER: + case meshtastic_Config_DeviceConfig_Role_ROUTER_LATE: + source = &img_node_router_image; + break; + case meshtastic_Config_DeviceConfig_Role_ROUTER_CLIENT: + source = &img_top_nodes_image; + break; + case meshtastic_Config_DeviceConfig_Role_TRACKER: + case meshtastic_Config_DeviceConfig_Role_SENSOR: + case meshtastic_Config_DeviceConfig_Role_LOST_AND_FOUND: + case meshtastic_Config_DeviceConfig_Role_TAK_TRACKER: + source = &img_node_sensor_image; + break; + case meshtastic_Config_DeviceConfig_Role_CLIENT: + case meshtastic_Config_DeviceConfig_Role_CLIENT_MUTE: + case meshtastic_Config_DeviceConfig_Role_CLIENT_HIDDEN: + case meshtastic_Config_DeviceConfig_Role_TAK: + source = &img_node_client_image; + break; + default: + source = record.hasUser ? &img_node_client_image : &img_circle_question_image; + break; + } + } + NodeListRowPresentation::applyNodeImage(img, record.id, source, record.unmessagable, true); +} +} // namespace + +VirtualNodeList::VirtualNodeList(lv_obj_t *parent, NodeListActionSink &sink) : parentPanel(parent), actionSink(sink) +{ + if (parentPanel) { + lv_obj_add_event_cb(parentPanel, scrollEventCallback, LV_EVENT_SCROLL, this); + lv_obj_add_event_cb(parentPanel, scrollEventCallback, LV_EVENT_SCROLL_END, this); + createRowPool(); + attachGroupNavigation(); + } +} + +VirtualNodeList::~VirtualNodeList() +{ + lv_anim_delete(this, expansionAnimationCallback); + detachGroupNavigation(); + if (parentPanel) { + lv_obj_remove_event_cb_with_user_data(parentPanel, scrollEventCallback, this); + } + if (spacer) { + lv_obj_delete(spacer); + spacer = nullptr; + } + for (auto &row : rowPool) { + if (row.panel) { + lv_obj_delete(row.panel); + row.panel = nullptr; + } + } + rowPool.clear(); +} + +void VirtualNodeList::createRowPool() +{ + if (!parentPanel) { + return; + } + + // Disable flex layout on parent so rows use absolute virtual coordinates + lv_obj_set_style_layout(parentPanel, LV_LAYOUT_NONE, LV_PART_MAIN | LV_STATE_DEFAULT); + + // Create spacer for virtual scroll height + spacer = lv_obj_create(parentPanel); + lv_obj_set_size(spacer, 1, 1); + lv_obj_set_pos(spacer, 0, 0); + lv_obj_remove_flag(spacer, static_cast(LV_OBJ_FLAG_CLICKABLE | LV_OBJ_FLAG_SCROLLABLE)); + lv_obj_set_style_opa(spacer, 0, LV_PART_MAIN | LV_STATE_DEFAULT); + lv_obj_set_style_border_width(spacer, 0, LV_PART_MAIN | LV_STATE_DEFAULT); + lv_obj_set_style_pad_all(spacer, 0, LV_PART_MAIN | LV_STATE_DEFAULT); + + rowPool.resize(rowPoolSizeForViewport(parentPanel)); + + for (size_t i = 0; i < rowPool.size(); ++i) { + auto &row = rowPool[i]; + + row.panel = lv_obj_create(parentPanel); + lv_obj_set_pos(row.panel, 0, 0); + lv_obj_set_size(row.panel, lv_pct(100), COLLAPSED_ROW_HEIGHT); + lv_obj_set_align(row.panel, LV_ALIGN_TOP_LEFT); + lv_obj_set_style_pad_top(row.panel, 0, LV_PART_MAIN | LV_STATE_DEFAULT); + lv_obj_set_style_pad_bottom(row.panel, 0, LV_PART_MAIN | LV_STATE_DEFAULT); + lv_obj_remove_flag(row.panel, static_cast(LV_OBJ_FLAG_SCROLLABLE | LV_OBJ_FLAG_GESTURE_BUBBLE)); + add_style_node_panel_style(row.panel); + + row.img = lv_image_create(row.panel); + lv_obj_set_pos(row.img, -5, 3); + lv_obj_set_size(row.img, 32, 32); + lv_image_set_src(row.img, &img_node_client_image); + lv_image_set_pivot(row.img, 0, 0); + lv_obj_clear_flag(row.img, LV_OBJ_FLAG_SCROLLABLE); + lv_obj_set_style_align(row.img, LV_ALIGN_TOP_LEFT, LV_PART_MAIN | LV_STATE_DEFAULT); + lv_obj_set_style_radius(row.img, 6, LV_PART_MAIN | LV_STATE_DEFAULT); + lv_obj_set_style_image_recolor_opa(row.img, 255, LV_PART_MAIN | LV_STATE_DEFAULT); + lv_obj_set_style_bg_color(row.img, lv_color_hex(0x5d9388), LV_PART_MAIN | LV_STATE_DEFAULT); + lv_obj_set_style_bg_opa(row.img, 255, LV_PART_MAIN | LV_STATE_DEFAULT); + lv_obj_set_style_border_color(row.img, lv_color_hex(0xff5555), LV_PART_MAIN | LV_STATE_DEFAULT); + lv_obj_set_style_border_opa(row.img, 255, LV_PART_MAIN | LV_STATE_DEFAULT); + lv_obj_set_style_border_width(row.img, 2, LV_PART_MAIN | LV_STATE_DEFAULT); + + row.btn = lv_button_create(row.panel); + lv_obj_set_pos(row.btn, 0, 0); + lv_obj_set_size(row.btn, lv_pct(106), lv_pct(100)); + lv_obj_set_align(row.btn, LV_ALIGN_CENTER); + add_style_node_button_style(row.btn); + lv_obj_set_style_shadow_width(row.btn, 0, LV_PART_MAIN | LV_STATE_DEFAULT); + lv_obj_set_style_max_height(row.btn, 132, LV_PART_MAIN | LV_STATE_DEFAULT); + lv_obj_set_style_min_height(row.btn, 50, LV_PART_MAIN | LV_STATE_DEFAULT); + lv_obj_add_flag(row.btn, LV_OBJ_FLAG_SCROLL_ON_FOCUS); + lv_obj_add_event_cb(row.btn, rowClickCallback, LV_EVENT_ALL, this); + + row.lblLong = lv_label_create(row.panel); + lv_obj_set_pos(row.lblLong, -5, 35); + lv_obj_set_size(row.lblLong, lv_pct(80), LV_SIZE_CONTENT); + lv_label_set_long_mode(row.lblLong, LV_LABEL_LONG_SCROLL); + lv_obj_set_style_align(row.lblLong, LV_ALIGN_TOP_LEFT, LV_PART_MAIN | LV_STATE_DEFAULT); + + row.lblShort = lv_label_create(row.panel); + lv_obj_set_pos(row.lblShort, 30, 10); + lv_obj_set_size(row.lblShort, LV_SIZE_CONTENT, LV_SIZE_CONTENT); + lv_label_set_long_mode(row.lblShort, LV_LABEL_LONG_WRAP); + lv_obj_remove_flag(row.lblShort, LV_OBJ_FLAG_SCROLLABLE); + lv_obj_set_style_align(row.lblShort, LV_ALIGN_TOP_LEFT, LV_PART_MAIN | LV_STATE_DEFAULT); + lv_obj_set_style_text_font(row.lblShort, &ui_font_montserrat_14, LV_PART_MAIN | LV_STATE_DEFAULT); + + row.lblBat = lv_label_create(row.panel); + lv_obj_set_pos(row.lblBat, 8, 17); + lv_obj_set_size(row.lblBat, LV_SIZE_CONTENT, LV_SIZE_CONTENT); + lv_label_set_long_mode(row.lblBat, LV_LABEL_LONG_CLIP); + lv_obj_remove_flag(row.lblBat, LV_OBJ_FLAG_SCROLLABLE); + lv_obj_set_style_align(row.lblBat, LV_ALIGN_TOP_RIGHT, LV_PART_MAIN | LV_STATE_DEFAULT); + lv_obj_set_style_text_align(row.lblBat, LV_TEXT_ALIGN_RIGHT, LV_PART_MAIN | LV_STATE_DEFAULT); + + row.lblLh = lv_label_create(row.panel); + lv_obj_set_pos(row.lblLh, 8, 33); + lv_obj_set_size(row.lblLh, LV_SIZE_CONTENT, LV_SIZE_CONTENT); + lv_label_set_long_mode(row.lblLh, LV_LABEL_LONG_CLIP); + lv_obj_remove_flag(row.lblLh, LV_OBJ_FLAG_SCROLLABLE); + lv_obj_set_style_align(row.lblLh, LV_ALIGN_TOP_RIGHT, LV_PART_MAIN | LV_STATE_DEFAULT); + lv_obj_set_style_text_align(row.lblLh, LV_TEXT_ALIGN_RIGHT, LV_PART_MAIN | LV_STATE_DEFAULT); + + row.lblSig = lv_label_create(row.panel); + lv_obj_set_width(row.lblSig, LV_SIZE_CONTENT); + lv_obj_set_height(row.lblSig, LV_SIZE_CONTENT); + lv_obj_set_pos(row.lblSig, 8, 1); + lv_obj_remove_flag(row.lblSig, LV_OBJ_FLAG_SCROLLABLE); + lv_obj_set_style_align(row.lblSig, LV_ALIGN_TOP_RIGHT, LV_PART_MAIN | LV_STATE_DEFAULT); + lv_obj_set_style_text_align(row.lblSig, LV_TEXT_ALIGN_RIGHT, LV_PART_MAIN | LV_STATE_DEFAULT); + + row.lblPos1 = lv_label_create(row.panel); + lv_obj_set_pos(row.lblPos1, -5, 49); + lv_obj_set_size(row.lblPos1, 120, LV_SIZE_CONTENT); + lv_label_set_long_mode(row.lblPos1, LV_LABEL_LONG_CLIP); + lv_obj_remove_flag(row.lblPos1, LV_OBJ_FLAG_SCROLLABLE); + lv_obj_set_style_align(row.lblPos1, LV_ALIGN_TOP_LEFT, LV_PART_MAIN | LV_STATE_DEFAULT); + lv_obj_set_style_text_color(row.lblPos1, lv_color_hex(0x05f6cb), LV_PART_MAIN | LV_STATE_DEFAULT); + lv_obj_add_event_cb(row.lblPos1, rowPositionCallback, LV_EVENT_CLICKED, this); + lv_obj_add_flag(row.lblPos1, LV_OBJ_FLAG_HIDDEN); + + row.lblPos2 = lv_label_create(row.panel); + lv_obj_set_pos(row.lblPos2, -5, 63); + lv_obj_set_size(row.lblPos2, 108, LV_SIZE_CONTENT); + lv_label_set_long_mode(row.lblPos2, LV_LABEL_LONG_SCROLL); + lv_obj_remove_flag(row.lblPos2, LV_OBJ_FLAG_SCROLLABLE); + lv_obj_set_style_align(row.lblPos2, LV_ALIGN_TOP_LEFT, LV_PART_MAIN | LV_STATE_DEFAULT); + lv_obj_add_flag(row.lblPos2, LV_OBJ_FLAG_HIDDEN); + + row.lblTm1 = lv_label_create(row.panel); + lv_obj_set_pos(row.lblTm1, 8, 49); + lv_obj_set_size(row.lblTm1, 130, LV_SIZE_CONTENT); + lv_label_set_long_mode(row.lblTm1, LV_LABEL_LONG_CLIP); + lv_obj_set_style_align(row.lblTm1, LV_ALIGN_TOP_RIGHT, LV_PART_MAIN | LV_STATE_DEFAULT); + lv_obj_set_style_text_align(row.lblTm1, LV_TEXT_ALIGN_RIGHT, LV_PART_MAIN | LV_STATE_DEFAULT); + lv_obj_add_flag(row.lblTm1, LV_OBJ_FLAG_HIDDEN); + + row.lblTm2 = lv_label_create(row.panel); + lv_obj_set_pos(row.lblTm2, 8, 63); + lv_obj_set_size(row.lblTm2, 130, LV_SIZE_CONTENT); + lv_label_set_long_mode(row.lblTm2, LV_LABEL_LONG_CLIP); + lv_obj_set_style_align(row.lblTm2, LV_ALIGN_TOP_RIGHT, LV_PART_MAIN | LV_STATE_DEFAULT); + lv_obj_set_style_text_align(row.lblTm2, LV_TEXT_ALIGN_RIGHT, LV_PART_MAIN | LV_STATE_DEFAULT); + lv_obj_add_flag(row.lblTm2, LV_OBJ_FLAG_HIDDEN); + + lv_label_set_text_static(row.lblShort, row.shortText); + lv_label_set_text_static(row.lblLong, row.longText); + lv_label_set_text_static(row.lblBat, row.batteryText); + lv_label_set_text_static(row.lblLh, row.lastHeardText); + lv_label_set_text_static(row.lblSig, row.signalText); + lv_label_set_text_static(row.lblPos1, row.positionText); + lv_label_set_text_static(row.lblPos2, row.position2Text); + lv_label_set_text_static(row.lblTm1, row.telemetry1Text); + lv_label_set_text_static(row.lblTm2, row.telemetry2Text); + + lv_obj_add_flag(row.panel, LV_OBJ_FLAG_HIDDEN); + } +} + +void VirtualNodeList::attachGroupNavigation() +{ + attachedGroup = lv_group_create(); + if (!attachedGroup) { + return; + } + + activeGroupNavigationList = this; + lv_group_set_wrap(attachedGroup, false); + lv_group_set_edge_cb(attachedGroup, groupEdgeCallback); +} + +void VirtualNodeList::detachGroupNavigation() +{ + if (attachedGroup && activeGroupNavigationList == this) { + activeGroupNavigationList = nullptr; + } + if (attachedGroup) { + for (auto &row : rowPool) { + if (row.btn) { + lv_group_remove_obj(row.btn); + } + } + lv_group_delete(attachedGroup); + } + attachedGroup = nullptr; +} + +void VirtualNodeList::updateVirtualContentHeight() +{ + if (!currentIndex) { + return; + } + + const auto &ids = currentIndex->ids(); + + if (spacer) { + const int32_t height = rowY(ids.size()); + lv_obj_set_size(spacer, 1, height > 0 ? height : 1); + lv_obj_set_pos(spacer, 0, 0); + } +} + +void VirtualNodeList::updateExpandedIndexCache() +{ + // The scan reruns only when the expanded ids or the index actually changed. + if (expansionCacheValid && expansionCacheIndex == currentIndex && + expansionCacheGeneration == (currentIndex ? currentIndex->generation() : 0) && expansionCacheExpandedId == expandedId && + expansionCachePreviousId == previousExpandedId) { + return; + } + + expandedIndex = std::numeric_limits::max(); + previousExpandedIndex = std::numeric_limits::max(); + + if (!currentIndex) { + return; + } + + const auto cacheIndex = [this](NodeId id) { + if (!id) { + return std::numeric_limits::max(); + } +#ifdef UNIT_TEST + ++expandedIndexScanCount; +#endif + const auto position = currentIndex->indexOf(id); + return position.value_or(std::numeric_limits::max()); + }; + + expandedIndex = cacheIndex(expandedId); + if (previousExpandedId != expandedId) { + previousExpandedIndex = cacheIndex(previousExpandedId); + } + + expansionCacheValid = true; + expansionCacheIndex = currentIndex; + expansionCacheGeneration = currentIndex->generation(); + expansionCacheExpandedId = expandedId; + expansionCachePreviousId = previousExpandedId; +} + +VirtualNodeList::ScrollAnchor VirtualNodeList::captureScrollAnchor() const +{ + if (!parentPanel) { + return {}; + } + + const int32_t scrollY = std::max(0, static_cast(lv_obj_get_scroll_y(parentPanel))); + if (scrollY == 0) { + return {}; + } + const ReusableRow *anchorRow = nullptr; + for (const auto &row : rowPool) { + if (!row.boundId || !row.panel || lv_obj_has_flag(row.panel, LV_OBJ_FLAG_HIDDEN) || row.renderedY > scrollY) { + continue; + } + if (!anchorRow || row.renderedY > anchorRow->renderedY) { + anchorRow = &row; + } + } + + if (!anchorRow) { + return {}; + } + return {anchorRow->boundId, scrollY - anchorRow->renderedY}; +} + +void VirtualNodeList::restoreScrollAnchor(const ScrollAnchor &anchor) +{ + if (!parentPanel || !currentIndex || !anchor.id) { + return; + } + + const auto position = currentIndex->indexOf(anchor.id); + if (position.has_value()) { + lv_obj_scroll_to_y(parentPanel, rowY(position.value()) + anchor.offset, LV_ANIM_OFF); + } +} + +void VirtualNodeList::sync(const NodeStore &store, const VisibleNodeIndex &index, NodeId expanded, uint32_t now, + const NodeListRenderContext &context, bool forceRebind) +{ + const uint32_t nextTime = now != 0 ? now : static_cast(std::time(nullptr)); + const bool indexChanged = currentIndex != &index || lastSyncedIndexGeneration != index.generation(); + const bool timeChanged = currentTime != nextTime && visibleLastHeardLabelsNeedRefresh(store, nextTime, context); + const bool contextChanged = + renderContext.ownNode != context.ownNode || renderContext.hasOwnPosition != context.hasOwnPosition || + renderContext.ownLatitude != context.ownLatitude || renderContext.ownLongitude != context.ownLongitude || + renderContext.metricUnits != context.metricUnits || renderContext.highlightActiveChat != context.highlightActiveChat || + renderContext.highlightPosition != context.highlightPosition || + renderContext.highlightTelemetry != context.highlightTelemetry || renderContext.highlightIaq != context.highlightIaq || + std::strcmp(renderContext.highlightName, context.highlightName) != 0; + const ScrollAnchor scrollAnchor = indexChanged ? captureScrollAnchor() : ScrollAnchor{}; + const NodeId focusedId = currentFocusedId(); + const size_t fallbackFocusIndex = lastFocusedIndex; + currentStore = &store; + currentIndex = &index; + requestExpanded(expanded); + updateExpandedIndexCache(); + currentTime = nextTime; + renderContext = context; + + const bool rebindVisibleRows = forceRebind || timeChanged || contextChanged; + updateVirtualContentHeight(); + if (indexChanged) { + restoreScrollAnchor(scrollAnchor); + } + refreshVisibleRows(indexChanged || rebindVisibleRows, rebindVisibleRows); + restoreFocus(focusedId, fallbackFocusIndex); + lastSyncedIndexGeneration = index.generation(); +} + +bool VirtualNodeList::visibleLastHeardLabelsNeedRefresh(const NodeStore &store, uint32_t nextTime, + const NodeListRenderContext &context) const +{ + if (currentTime == 0) { + return false; + } + + for (const auto &row : rowPool) { + if (!row.boundId || !row.panel || lv_obj_has_flag(row.panel, LV_OBJ_FLAG_HIDDEN)) { + continue; + } + const NodeRecord *record = store.find(row.boundId); + if (!record) { + continue; + } + const uint32_t effectiveLastHeard = record->id != 0 && record->id == context.ownNode ? nextTime : record->lastHeard; + char nextText[sizeof(row.lastHeardText)]{}; + formatLastHeard(effectiveLastHeard, nextTime, nextText, sizeof(nextText)); + if (std::strcmp(nextText, row.lastHeardText) != 0) { + return true; + } + } + return false; +} + +void VirtualNodeList::setExpanded(NodeId id) +{ + requestExpanded(id); +} + +int32_t VirtualNodeList::rowHeight(NodeId id) const +{ + if (!expansionAnimationActive) { + return id == expandedId ? EXPANDED_ROW_HEIGHT : COLLAPSED_ROW_HEIGHT; + } + if (id == expandedId && expandedId != 0) { + return COLLAPSED_ROW_HEIGHT + (EXPANDED_ROW_HEIGHT - COLLAPSED_ROW_HEIGHT) * expansionProgress / 100; + } + if (id == previousExpandedId && previousExpandedId != 0) { + return EXPANDED_ROW_HEIGHT - (EXPANDED_ROW_HEIGHT - COLLAPSED_ROW_HEIGHT) * expansionProgress / 100; + } + return COLLAPSED_ROW_HEIGHT; +} + +int32_t VirtualNodeList::rowY(size_t index) const +{ + if (!currentIndex) { + return 0; + } + + const auto &ids = currentIndex->ids(); + const size_t count = ids.size(); + int32_t y = static_cast(index * (COLLAPSED_ROW_HEIGHT + ROW_GAP)); + if (index == count && count > 0) { + y -= ROW_GAP; + } + + const auto addExpandedDelta = [&](NodeId id, size_t position) { + if (!id || position == std::numeric_limits::max()) { + return; + } + if (position < index) { + y += rowHeight(id) - COLLAPSED_ROW_HEIGHT; + } + }; + addExpandedDelta(expandedId, expandedIndex); + if (previousExpandedId != expandedId) { + addExpandedDelta(previousExpandedId, previousExpandedIndex); + } + return y; +} + +size_t VirtualNodeList::firstVisibleIndex(int32_t scrollY) const +{ + if (!currentIndex || currentIndex->ids().empty()) { + return 0; + } + + const size_t count = currentIndex->ids().size(); + size_t first = 0; + size_t last = count; + while (first < last) { + const size_t middle = first + (last - first) / 2; + if (rowY(middle + 1) <= scrollY) { + first = middle + 1; + } else { + last = middle; + } + } + return first; +} + +bool VirtualNodeList::rowShowsExpandedDetails(NodeId id) const +{ + return id != 0 && (id == expandedId || (expansionAnimationActive && id == previousExpandedId)); +} + +int32_t VirtualNodeList::viewportHeight() const +{ + if (!parentPanel) { + return 0; + } + return std::max(0, lv_obj_get_height(parentPanel) - lv_obj_get_style_pad_top(parentPanel, LV_PART_MAIN) - + lv_obj_get_style_pad_bottom(parentPanel, LV_PART_MAIN)); +} + +int32_t VirtualNodeList::scrollYForNode(NodeId id) const +{ + if (!currentIndex || !parentPanel) { + return 0; + } + const auto optIdx = currentIndex->indexOf(id); + if (!optIdx.has_value()) { + return std::max(0, lv_obj_get_scroll_y(parentPanel)); + } + + const int32_t scrollY = std::max(0, lv_obj_get_scroll_y(parentPanel)); + const int32_t top = rowY(optIdx.value()); + const int32_t bottom = top + rowHeight(id); + const int32_t height = viewportHeight(); + if (top < scrollY) { + return top; + } + if (height > 0 && bottom > scrollY + height) { + return bottom - height; + } + return scrollY; +} + +NodeId VirtualNodeList::currentFocusedId() const +{ + if (!attachedGroup) { + return 0; + } + lv_obj_t *focused = lv_group_get_focused(attachedGroup); + const size_t poolIndex = poolIndexForButton(focused); + if (poolIndex >= rowPool.size()) { + return 0; + } + return rowPool[poolIndex].boundId; +} + +void VirtualNodeList::restoreFocus(NodeId focusedId, size_t fallbackIndex) +{ + if (!focusedId || !currentIndex || !attachedGroup || currentIndex->empty()) { + return; + } + + NodeId targetId = focusedId; + if (!currentIndex->contains(targetId)) { + const auto &ids = currentIndex->ids(); + if (fallbackIndex >= ids.size()) { + fallbackIndex = ids.size() - 1; + } + targetId = ids[fallbackIndex]; + } + focus(targetId); +} + +void VirtualNodeList::requestExpanded(NodeId id) +{ + if (expansionAnimationActive) { + if (id != expandedId) { + pendingExpandedId = id; + hasPendingExpandedId = true; + } else { + pendingExpandedId = 0; + hasPendingExpandedId = false; + } + updateExpandedIndexCache(); + updateVirtualContentHeight(); + refreshVisibleRows(true, false, false); + return; + } + + if (id == expandedId) { + return; + } + + hasPendingExpandedId = false; + previousExpandedId = expandedId; + expandedId = id; + expansionProgress = 0; + expansionAnimationActive = true; + updateExpandedIndexCache(); + + lv_anim_t animation; + lv_anim_init(&animation); + lv_anim_set_var(&animation, this); + lv_anim_set_values(&animation, 0, 100); + lv_anim_set_duration(&animation, 200); + lv_anim_set_path_cb(&animation, lv_anim_path_linear); + lv_anim_set_exec_cb(&animation, expansionAnimationCallback); + lv_anim_set_completed_cb(&animation, expansionAnimationFinished); + lv_anim_start(&animation); + + updateVirtualContentHeight(); + refreshVisibleRows(true, true); +} + +void VirtualNodeList::updateExpansionAnimation(int32_t progress) +{ + expansionProgress = progress; + updateVirtualContentHeight(); + refreshVisibleRows(true, false, false); +} + +void VirtualNodeList::finishExpansionAnimation() +{ + expansionProgress = 100; + expansionAnimationActive = false; + previousExpandedId = 0; + updateExpandedIndexCache(); + + if (hasPendingExpandedId) { + const NodeId nextExpandedId = pendingExpandedId; + hasPendingExpandedId = false; + if (nextExpandedId != expandedId) { + requestExpanded(nextExpandedId); + return; + } + } + + updateVirtualContentHeight(); + refreshVisibleRows(true, true); +} + +void VirtualNodeList::scrollTo(NodeId id, lv_anim_enable_t anim) +{ + if (!currentIndex || !parentPanel) { + return; + } + const auto optIdx = currentIndex->indexOf(id); + if (!optIdx.has_value()) { + return; + } + + lv_obj_scroll_to_y(parentPanel, scrollYForNode(id), anim); + refreshVisibleRows(); +} + +void VirtualNodeList::focus(NodeId id) +{ + scrollTo(id, LV_ANIM_OFF); + for (auto &row : rowPool) { + if (row.boundId == id && !lv_obj_has_flag(row.panel, LV_OBJ_FLAG_HIDDEN)) { + lv_group_focus_obj(row.btn); + noteFocusedButton(row.btn); + break; + } + } +} + +size_t VirtualNodeList::poolIndexForButton(lv_obj_t *button) const +{ + for (size_t i = 0; i < rowPool.size(); ++i) { + if (rowPool[i].btn == button) { + return i; + } + } + return rowPool.size(); +} + +void VirtualNodeList::noteFocusedButton(lv_obj_t *button) +{ + const size_t poolIndex = poolIndexForButton(button); + if (poolIndex >= rowPool.size()) { + return; + } + + lastFocusedId = rowPool[poolIndex].boundId; + if (currentIndex) { + lastFocusedIndex = currentIndex->indexOf(lastFocusedId).value_or(std::numeric_limits::max()); + } +} + +bool VirtualNodeList::focusAdjacent(NodeId id, int direction) +{ + if (!currentIndex || direction == 0) { + return false; + } + const auto current = currentIndex->indexOf(id); + if (!current.has_value()) { + return false; + } + + const auto &ids = currentIndex->ids(); + const auto target = static_cast(current.value()) + direction; + if (target < 0 || target >= static_cast(ids.size())) { + return false; + } + + focus(ids[static_cast(target)]); + return true; +} + +void VirtualNodeList::handleGroupEdge(bool forward) +{ + NodeId focusedId = lastFocusedId; + if (attachedGroup) { + lv_obj_t *focused = lv_group_get_focused(attachedGroup); + const size_t poolIndex = poolIndexForButton(focused); + if (poolIndex < rowPool.size() && rowPool[poolIndex].boundId != 0) { + focusedId = rowPool[poolIndex].boundId; + lastFocusedId = focusedId; + } + } + + if (focusedId == 0) { + actionSink.nodeFocusBoundary(forward); + return; + } + + const bool moved = focusAdjacent(focusedId, forward ? 1 : -1); + if (!moved) { + actionSink.nodeFocusBoundary(forward); + } +} + +void VirtualNodeList::bindRow(ReusableRow &row, const NodeRecord &record, bool isExpanded) +{ + row.boundId = record.id; + lv_obj_set_user_data(row.panel, reinterpret_cast(static_cast(record.id))); + lv_obj_set_user_data(row.btn, reinterpret_cast(static_cast(record.id))); + lv_obj_set_user_data(row.lblPos1, reinterpret_cast(static_cast(record.id))); + + setRoleImage(record, row.img); + if (!record.hasKey || record.hasBadKey) { + lv_obj_set_style_border_color(row.img, lv_color_hex(0xff5555), LV_PART_MAIN | LV_STATE_DEFAULT); + } else if (!record.unmessagable) { + lv_obj_set_style_border_color(row.img, lv_obj_get_style_bg_color(row.img, LV_PART_MAIN), LV_PART_MAIN | LV_STATE_DEFAULT); + } + + const bool shortTextChanged = + !row.detailKeysValid || row.detailShortId != record.id || row.detailOwnNode != renderContext.ownNode || + std::memcmp(row.detailShortName, record.user.short_name, sizeof(row.detailShortName)) != 0 || + row.detailHasOwnPosition != renderContext.hasOwnPosition || row.detailOwnLatitude != renderContext.ownLatitude || + row.detailOwnLongitude != renderContext.ownLongitude || row.detailLatitude != record.position.latitude || + row.detailLongitude != record.position.longitude || row.detailMetricUnits != renderContext.metricUnits; + if (shortTextChanged) { + formatShortName(record, renderContext, row.shortText, sizeof(row.shortText)); + lv_label_set_text_static(row.lblShort, row.shortText); + lv_obj_set_pos(row.lblShort, 30, std::strchr(row.shortText, '\n') ? -1 : 10); + row.detailShortId = record.id; + row.detailOwnNode = renderContext.ownNode; + std::memcpy(row.detailShortName, record.user.short_name, sizeof(row.detailShortName)); + row.detailHasOwnPosition = renderContext.hasOwnPosition; + row.detailOwnLatitude = renderContext.ownLatitude; + row.detailOwnLongitude = renderContext.ownLongitude; + } + setRowText(row.lblLong, row.longText, record.user.long_name); + + const bool hasBattery = + record.hasDeviceMetrics && (record.deviceMetrics.battery_level != 0 || record.deviceMetrics.voltage != 0.0f); + if (!row.detailKeysValid || row.detailHasBattery != hasBattery || + (hasBattery && + (row.detailBatteryLevel != record.deviceMetrics.battery_level || row.detailVoltage != record.deviceMetrics.voltage))) { + if (hasBattery) { + NodeListRowPresentation::formatBatteryLabel(record.deviceMetrics.battery_level, record.deviceMetrics.voltage, + row.batteryText, sizeof(row.batteryText)); + } else { + row.batteryText[0] = '\0'; + } + lv_label_set_text_static(row.lblBat, row.batteryText); + row.detailHasBattery = hasBattery; + row.detailBatteryLevel = record.deviceMetrics.battery_level; + row.detailVoltage = record.deviceMetrics.voltage; + } + + // The own-node row always reports "now"; ordering is pinned by the visible + // index even as the model last-heard timestamp continues to update. + const uint32_t effectiveLastHeard = record.id != 0 && record.id == renderContext.ownNode ? currentTime : record.lastHeard; + formatLastHeard(effectiveLastHeard, currentTime, row.lastHeardText, sizeof(row.lastHeardText)); + lv_label_set_text_static(row.lblLh, row.lastHeardText); + + if (record.id == renderContext.ownNode && record.hasDeviceMetrics) { + std::snprintf(row.signalText, sizeof(row.signalText), _("Util %0.1f%% Air %0.1f%%"), + record.deviceMetrics.channel_utilization, record.deviceMetrics.air_util_tx); + } else if (record.signalDisplay == NodeSignalDisplayKind::Hops && record.hopsAway >= 0) { + std::snprintf(row.signalText, sizeof(row.signalText), _("hops: %d"), static_cast(record.hopsAway)); + } else if (record.signalDisplay == NodeSignalDisplayKind::Rssi && (record.rssi != 0 || record.snr != 0.0f)) { + std::snprintf(row.signalText, sizeof(row.signalText), "rssi: %d snr: %.1f", static_cast(record.rssi), record.snr); + } else { + row.signalText[0] = '\0'; + } + lv_label_set_text_static(row.lblSig, ""); + lv_label_set_text_static(row.lblSig, row.signalText); + lv_obj_set_width(row.lblSig, LV_SIZE_CONTENT); + lv_obj_set_height(row.lblSig, LV_SIZE_CONTENT); + + const bool showPosition = record.position.hasCoordinates(); + const bool positionChanged = !row.detailKeysValid || row.detailLatitude != record.position.latitude || + row.detailLongitude != record.position.longitude || + row.detailAltitude != record.position.altitude || + row.detailMetricUnits != renderContext.metricUnits; + if (positionChanged) { + if (showPosition) { + NodeListRowPresentation::formatPositionLines(record.position.latitude, record.position.longitude, + record.position.altitude, renderContext.metricUnits, row.positionText, + sizeof(row.positionText), row.position2Text, sizeof(row.position2Text)); + } else { + row.positionText[0] = '\0'; + row.position2Text[0] = '\0'; + } + lv_label_set_text_static(row.lblPos1, row.positionText); + lv_label_set_text_static(row.lblPos2, row.position2Text); + row.detailLatitude = record.position.latitude; + row.detailLongitude = record.position.longitude; + row.detailAltitude = record.position.altitude; + } + if (isExpanded && showPosition && record.id != renderContext.ownNode) { + lv_obj_add_flag(row.lblPos1, LV_OBJ_FLAG_CLICKABLE); + } else { + lv_obj_remove_flag(row.lblPos1, LV_OBJ_FLAG_CLICKABLE); + } + setHidden(row.lblPos1, !isExpanded || !showPosition); + setHidden(row.lblPos2, !isExpanded || !showPosition); + + const bool showTelemetry1 = record.hasEnvironmentMetrics; + const auto &env = record.environmentMetrics; + const bool environmentChanged = + !row.detailKeysValid || row.detailHasEnvironment != showTelemetry1 || + row.detailMetricUnits != renderContext.metricUnits || + (showTelemetry1 && + (row.detailEnvironment.temperature != env.temperature || + row.detailEnvironment.relative_humidity != env.relative_humidity || + row.detailEnvironment.barometric_pressure != env.barometric_pressure || row.detailEnvironment.iaq != env.iaq || + row.detailEnvironment.voltage != env.voltage || row.detailEnvironment.current != env.current)); + if (environmentChanged) { + if (showTelemetry1) { + NodeListRowPresentation::formatTelemetryLines(record.environmentMetrics, renderContext.metricUnits, + row.telemetry1Text, sizeof(row.telemetry1Text), row.telemetry2Text, + sizeof(row.telemetry2Text)); + } else { + row.telemetry1Text[0] = '\0'; + row.telemetry2Text[0] = '\0'; + } + lv_label_set_text_static(row.lblTm1, row.telemetry1Text); + lv_label_set_text_static(row.lblTm2, row.telemetry2Text); + row.detailHasEnvironment = showTelemetry1; + if (showTelemetry1) { + row.detailEnvironment = env; + } + } + setHidden(row.lblTm1, !isExpanded || !showTelemetry1); + const bool showTelemetry2 = + record.hasEnvironmentMetrics && record.environmentMetrics.iaq > 0 && record.environmentMetrics.iaq < 1000; + setHidden(row.lblTm2, !isExpanded || !showTelemetry2); + + row.detailMetricUnits = renderContext.metricUnits; + row.detailKeysValid = true; + + applyHighlight(row, record); +} + +void VirtualNodeList::applyHighlight(ReusableRow &row, const NodeRecord &record) +{ + lv_obj_set_style_border_width(row.panel, 1, LV_PART_MAIN | LV_STATE_DEFAULT); + bool highlighted = false; + if (renderContext.highlightActiveChat && record.hasActiveChat) { + lv_obj_set_style_border_color(row.panel, highlightOrange, LV_PART_MAIN | LV_STATE_DEFAULT); + highlighted = true; + } + if (renderContext.highlightPosition && record.position.hasCoordinates()) { + lv_obj_set_style_border_color(row.panel, highlightBlueGreen, LV_PART_MAIN | LV_STATE_DEFAULT); + highlighted = true; + } + if (renderContext.highlightTelemetry && record.hasEnvironmentMetrics) { + lv_obj_set_style_border_color(row.panel, highlightBlue, LV_PART_MAIN | LV_STATE_DEFAULT); + lv_obj_set_style_border_width(row.panel, 2, LV_PART_MAIN | LV_STATE_DEFAULT); + highlighted = true; + } + if (renderContext.highlightIaq && record.hasEnvironmentMetrics && record.environmentMetrics.iaq > 0 && + record.environmentMetrics.iaq < 1000) { + const uint32_t iaq = record.environmentMetrics.iaq; + const auto [iaqTextColor, iaqBgColor] = NodeListRowPresentation::iaqColors(iaq); + lv_obj_set_style_border_color(row.panel, iaqBgColor, LV_PART_MAIN | LV_STATE_DEFAULT); + lv_obj_set_style_border_width(row.panel, 2, LV_PART_MAIN | LV_STATE_DEFAULT); + lv_obj_set_style_text_color(row.lblTm2, iaqTextColor, LV_PART_MAIN | LV_STATE_DEFAULT); + lv_obj_set_style_bg_color(row.lblTm2, iaqBgColor, LV_PART_MAIN | LV_STATE_DEFAULT); + lv_obj_set_style_bg_opa(row.lblTm2, LV_OPA_COVER, LV_PART_MAIN | LV_STATE_DEFAULT); + highlighted = true; + } else { + lv_obj_set_style_bg_opa(row.lblTm2, LV_OPA_TRANSP, LV_PART_MAIN | LV_STATE_DEFAULT); + lv_obj_remove_local_style_prop(row.lblTm2, LV_STYLE_TEXT_COLOR, LV_PART_MAIN | LV_STATE_DEFAULT); + lv_obj_remove_local_style_prop(row.lblTm2, LV_STYLE_BG_COLOR, LV_PART_MAIN | LV_STATE_DEFAULT); + } + // Name highlight matches the rendered short-name text, including the id + // fallback and the distance line. + const char *name = renderContext.highlightName; + if (name[0] != '\0' && (NodeListRowPresentation::containsCaseInsensitive(record.user.long_name, name) || + NodeListRowPresentation::containsCaseInsensitive(row.shortText, name))) { + lv_obj_set_style_border_color(row.panel, highlightMesh, LV_PART_MAIN | LV_STATE_DEFAULT); + highlighted = true; + } + if (!highlighted) { + lv_obj_set_style_border_color(row.panel, highlightMidGray, LV_PART_MAIN | LV_STATE_DEFAULT); + lv_obj_set_style_border_width(row.panel, 1, LV_PART_MAIN | LV_STATE_DEFAULT); + } +} + +void VirtualNodeList::refreshVisibleRows(bool force, bool rebind, bool reorder) +{ + if (!parentPanel || !currentIndex || !currentStore) { + return; + } + + const auto &ids = currentIndex->ids(); + if (ids.empty()) { + for (auto &row : rowPool) { + lv_obj_add_flag(row.panel, LV_OBJ_FLAG_HIDDEN); + if (reorder && attachedGroup && row.btn && lv_obj_get_group(row.btn) == attachedGroup) { + lv_group_remove_obj(row.btn); + } + clearRowBinding(row); + } + firstRenderedIndex = std::numeric_limits::max(); + rowOrderDirty = false; + return; + } + + int32_t scrollY = lv_obj_get_scroll_y(parentPanel); + if (scrollY < 0) { + scrollY = 0; + } + + size_t firstIdx = firstVisibleIndex(scrollY); + + // Buffer 1 row of overscan above if possible + if (firstIdx > 0) { + firstIdx--; + } + if (!ids.empty() && firstIdx >= ids.size()) { + firstIdx = ids.size() - 1; + } + const size_t previousFirstRenderedIndex = firstRenderedIndex; + if (!force && previousFirstRenderedIndex == firstIdx) { + if (!rebind && !(reorder && rowOrderDirty)) { + return; + } + if (!rowOrderDirty) { + reorder = false; + } + } else if (!reorder) { + rowOrderDirty = true; + } + firstRenderedIndex = firstIdx; + + constexpr size_t unusedRow = std::numeric_limits::max(); + const size_t visibleCount = std::min(rowPool.size(), ids.size() - firstIdx); + std::array assignedRows{}; + std::array usedRows{}; + assignedRows.fill(unusedRow); + + for (size_t slot = 0; slot < visibleCount; ++slot) { + const NodeId id = ids[firstIdx + slot]; + for (size_t rowIndex = 0; rowIndex < rowPool.size(); ++rowIndex) { + if (!usedRows[rowIndex] && rowPool[rowIndex].boundId == id) { + assignedRows[slot] = rowIndex; + usedRows[rowIndex] = true; + break; + } + } + } + + for (size_t slot = 0; slot < visibleCount; ++slot) { + if (assignedRows[slot] != unusedRow) { + continue; + } + for (size_t rowIndex = 0; rowIndex < rowPool.size(); ++rowIndex) { + if (!usedRows[rowIndex]) { + assignedRows[slot] = rowIndex; + usedRows[rowIndex] = true; + break; + } + } + } + + for (size_t slot = 0; slot < visibleCount; ++slot) { + const size_t rowIndex = assignedRows[slot]; + if (rowIndex == unusedRow) { + continue; + } + const size_t nodeIdx = firstIdx + slot; + const NodeId id = ids[nodeIdx]; + auto &row = rowPool[rowIndex]; + const auto *rec = currentStore->find(id); + if (rec) { + if (rebind || row.boundId != id) { + bindRow(row, *rec, rowShowsExpandedDetails(id)); + } + const int32_t height = rowHeight(id); + const int32_t y = rowY(nodeIdx); + if (row.renderedHeight != height) { + lv_obj_set_height(row.panel, height); + row.renderedHeight = height; + } + if (row.renderedY != y) { + lv_obj_set_y(row.panel, y); + row.renderedY = y; + } + lv_obj_remove_flag(row.panel, LV_OBJ_FLAG_HIDDEN); + } else { + lv_obj_add_flag(row.panel, LV_OBJ_FLAG_HIDDEN); + if (reorder && attachedGroup && row.btn && lv_obj_get_group(row.btn) == attachedGroup) { + lv_group_remove_obj(row.btn); + } + clearRowBinding(row); + } + } + + for (size_t rowIndex = 0; rowIndex < rowPool.size(); ++rowIndex) { + if (usedRows[rowIndex]) { + continue; + } + auto &row = rowPool[rowIndex]; + lv_obj_add_flag(row.panel, LV_OBJ_FLAG_HIDDEN); + if (reorder && attachedGroup && row.btn && lv_obj_get_group(row.btn) == attachedGroup) { + lv_group_remove_obj(row.btn); + } + clearRowBinding(row); + } + + if (!reorder) { + return; + } + rowOrderDirty = false; + + if (attachedGroup) { + for (size_t rowIndex = 0; rowIndex < rowPool.size(); ++rowIndex) { + auto &row = rowPool[rowIndex]; + if (usedRows[rowIndex]) { + if (row.btn && lv_obj_get_group(row.btn) != attachedGroup) { + lv_group_add_obj(attachedGroup, row.btn); + } + } else if (row.btn && lv_obj_get_group(row.btn) == attachedGroup) { + lv_group_remove_obj(row.btn); + } + } + } + + const bool canRotateRows = + !force && !rebind && visibleCount == rowPool.size() && previousFirstRenderedIndex != std::numeric_limits::max(); + const size_t forwardRows = canRotateRows && firstIdx > previousFirstRenderedIndex ? firstIdx - previousFirstRenderedIndex : 0; + const size_t backwardRows = + canRotateRows && previousFirstRenderedIndex > firstIdx ? previousFirstRenderedIndex - firstIdx : 0; + + if (forwardRows > 0 && forwardRows < visibleCount) { + for (size_t slot = visibleCount - forwardRows; slot < visibleCount; ++slot) { + const size_t rowIndex = assignedRows[slot]; + lv_obj_move_to_index(rowPool[rowIndex].panel, static_cast(visibleCount)); +#ifdef UNIT_TEST + ++panelOrderMoveCount; +#endif + moveGroupButtonToTail(attachedGroup, rowPool[rowIndex].btn); +#ifdef UNIT_TEST + ++groupOrderMoveCount; +#endif + } + } else if (backwardRows > 0 && backwardRows < visibleCount) { + for (size_t slot = backwardRows; slot > 0; --slot) { + const size_t rowIndex = assignedRows[slot - 1]; + lv_obj_move_to_index(rowPool[rowIndex].panel, 1); +#ifdef UNIT_TEST + ++panelOrderMoveCount; +#endif + moveGroupButtonToHead(attachedGroup, rowPool[rowIndex].btn); +#ifdef UNIT_TEST + ++groupOrderMoveCount; +#endif + } + } else { + if (spacer) { + lv_obj_move_to_index(spacer, 0); +#ifdef UNIT_TEST + ++panelOrderMoveCount; +#endif + } + for (size_t slot = 0; slot < visibleCount; ++slot) { + const size_t rowIndex = assignedRows[slot]; + if (rowIndex != unusedRow && rowPool[rowIndex].panel) { + lv_obj_move_to_index(rowPool[rowIndex].panel, static_cast(slot + 1)); +#ifdef UNIT_TEST + ++panelOrderMoveCount; +#endif + } + } + for (size_t slot = visibleCount; slot > 0; --slot) { + const size_t rowIndex = assignedRows[slot - 1]; + if (rowIndex != unusedRow && rowPool[rowIndex].btn) { + moveGroupButtonToHead(attachedGroup, rowPool[rowIndex].btn); +#ifdef UNIT_TEST + ++groupOrderMoveCount; +#endif + } + } + } +#ifdef UNIT_TEST + lv_obj_update_layout(parentPanel); +#endif +} + +bool VirtualNodeList::refreshNode(NodeId id, uint32_t now, const NodeListRenderContext &context) +{ + if (!id || !currentStore) { + return false; + } + + currentTime = now != 0 ? now : static_cast(std::time(nullptr)); + renderContext = context; + + const auto *rec = currentStore->find(id); + if (!rec) { + return false; + } + + for (size_t index = 0; index < rowPool.size(); ++index) { + auto &row = rowPool[index]; + if (row.boundId == id && row.panel && !lv_obj_has_flag(row.panel, LV_OBJ_FLAG_HIDDEN)) { + bindRow(row, *rec, rowShowsExpandedDetails(id)); + const int32_t height = rowHeight(id); + const auto position = currentIndex ? currentIndex->indexOf(id) : std::nullopt; + const int32_t y = position.has_value() ? rowY(position.value()) : lv_obj_get_y(row.panel); + if (row.renderedHeight != height) { + lv_obj_set_height(row.panel, height); + row.renderedHeight = height; + } + if (row.renderedY != y) { + lv_obj_set_y(row.panel, y); + row.renderedY = y; + } + return true; + } + } + + return false; +} + +void VirtualNodeList::scrollEventCallback(lv_event_t *e) +{ + auto *self = static_cast(lv_event_get_user_data(e)); + if (self) { + self->refreshVisibleRows(false, false, lv_event_get_code(e) == LV_EVENT_SCROLL_END); + } +} + +void VirtualNodeList::rowClickCallback(lv_event_t *e) +{ + auto *self = static_cast(lv_event_get_user_data(e)); + auto *btn = static_cast(lv_event_get_target(e)); + if (!self || !btn) { + return; + } + + ReusableRow *row = nullptr; + for (auto &candidate : self->rowPool) { + if (candidate.btn == btn) { + row = &candidate; + break; + } + } + if (!row) { + return; + } + + const lv_event_code_t code = lv_event_get_code(e); + if (code == LV_EVENT_PRESSED) { + row->pressedId = row->boundId; + } else if (code == LV_EVENT_PRESS_LOST || code == LV_EVENT_CANCEL) { + row->pressedId = 0; + } else if (code == LV_EVENT_CLICKED) { + const NodeId id = row->pressedId ? row->pressedId : row->boundId; + row->pressedId = 0; + if (id != 0 && !self->expansionAnimating()) { + self->actionSink.nodeClicked(id); + } + } else if (code == LV_EVENT_LONG_PRESSED) { + const NodeId id = row->pressedId ? row->pressedId : row->boundId; + row->pressedId = 0; + if (id != 0) { + self->actionSink.nodeLongPressed(id); + } + } else if (code == LV_EVENT_FOCUSED) { + const NodeId id = row->boundId; + if (id != 0) { + self->noteFocusedButton(btn); + } + } +} + +#ifdef UNIT_TEST +bool VirtualNodeList::detailLabelsHiddenForTesting(NodeId id) const +{ + for (const auto &row : rowPool) { + if (row.boundId == id) { + return lv_obj_has_flag(row.lblPos1, LV_OBJ_FLAG_HIDDEN) && lv_obj_has_flag(row.lblPos2, LV_OBJ_FLAG_HIDDEN) && + lv_obj_has_flag(row.lblTm1, LV_OBJ_FLAG_HIDDEN) && lv_obj_has_flag(row.lblTm2, LV_OBJ_FLAG_HIDDEN); + } + } + return false; +} + +bool VirtualNodeList::positionLabelClickableForTesting(NodeId id) const +{ + for (const auto &row : rowPool) { + if (row.boundId == id) { + return lv_obj_has_flag(row.lblPos1, LV_OBJ_FLAG_CLICKABLE); + } + } + return false; +} + +bool VirtualNodeList::longLabelsScrollForTesting(NodeId id) const +{ + for (const auto &row : rowPool) { + if (row.boundId == id) { + return lv_label_get_long_mode(row.lblLong) == LV_LABEL_LONG_SCROLL && + lv_label_get_long_mode(row.lblPos1) == LV_LABEL_LONG_CLIP && + lv_label_get_long_mode(row.lblPos2) == LV_LABEL_LONG_SCROLL; + } + } + return false; +} +#endif + +void VirtualNodeList::rowPositionCallback(lv_event_t *e) +{ + auto *self = static_cast(lv_event_get_user_data(e)); + auto *label = static_cast(lv_event_get_target(e)); + if (!self || !label) { + return; + } + + const auto id = static_cast(reinterpret_cast(lv_obj_get_user_data(label))); + if (id != 0 && lv_event_get_code(e) == LV_EVENT_CLICKED) { + self->actionSink.nodePositionClicked(id); + } +} + +void VirtualNodeList::groupEdgeCallback(lv_group_t *group, bool forward) +{ + (void)group; + if (activeGroupNavigationList) { + activeGroupNavigationList->handleGroupEdge(forward); + } +} + +void VirtualNodeList::expansionAnimationCallback(void *var, int32_t progress) +{ + auto *self = static_cast(var); + if (self) { + self->updateExpansionAnimation(progress); + } +} + +void VirtualNodeList::expansionAnimationFinished(lv_anim_t *animation) +{ + auto *self = static_cast(animation->var); + if (self) { + self->finishExpansionAnimation(); + } +} diff --git a/studio/240x320/TFTView_240x320.eez-project b/studio/240x320/TFTView_240x320.eez-project index 1719df568..6a4c228b5 100644 --- a/studio/240x320/TFTView_240x320.eez-project +++ b/studio/240x320/TFTView_240x320.eez-project @@ -3281,585 +3281,7 @@ "topUnit": "%", "widthUnit": "%", "heightUnit": "%", - "children": [ - { - "objID": "f1d0863a-8923-40eb-c758-de09d73c5cdb", - "type": "LVGLPanelWidget", - "left": 0, - "top": 0, - "width": 100, - "height": 53, - "customInputs": [], - "customOutputs": [], - "style": { - "objID": "5f16ee5d-f403-436b-dbf2-299ac94cc407", - "useStyle": "default", - "conditionalStyles": [], - "childStyles": [] - }, - "hiddenInEditor": true, - "timeline": [], - "eventHandlers": [], - "identifier": "NodePanel", - "leftUnit": "%", - "topUnit": "px", - "widthUnit": "%", - "heightUnit": "px", - "children": [ - { - "objID": "260f10d6-992a-43c5-c5c4-113e5ee43b3d", - "type": "LVGLImageWidget", - "left": -5, - "top": 3, - "width": 32, - "height": 32, - "customInputs": [], - "customOutputs": [], - "style": { - "objID": "37fc8254-20cb-4046-bec5-144071b8d54e", - "useStyle": "default", - "conditionalStyles": [], - "childStyles": [] - }, - "hiddenInEditor": true, - "timeline": [], - "eventHandlers": [], - "identifier": "NodeImage", - "leftUnit": "px", - "topUnit": "px", - "widthUnit": "px", - "heightUnit": "px", - "children": [], - "widgetFlags": "ADV_HITTEST|CLICK_FOCUSABLE|GESTURE_BUBBLE|PRESS_LOCK|SCROLL_CHAIN_HOR|SCROLL_CHAIN_VER|SCROLL_ELASTIC|SCROLL_MOMENTUM|SCROLL_WITH_ARROW|SNAPPABLE", - "hiddenFlag": false, - "hiddenFlagType": "literal", - "clickableFlagType": "literal", - "checkedStateType": "literal", - "disabledStateType": "literal", - "states": "", - "localStyles": { - "objID": "c5c0b450-3e29-4e6c-e221-0ccfd9381084", - "definition": { - "MAIN": { - "DEFAULT": { - "img_recolor": "#ffffff", - "align": "TOP_LEFT", - "radius": 6, - "bg_color": "#5d9388", - "border_color": "FF5555", - "img_recolor_opa": 255, - "border_width": 3, - "bg_opa": 255 - } - } - } - }, - "groupIndex": 0, - "image": "node_client_image", - "setPivot": true, - "pivotX": 0, - "pivotY": 0, - "zoom": 256, - "angle": 0, - "innerAlign": "CENTER", - "sizeMode": "VIRTUAL" - }, - { - "objID": "a9a4bad3-0104-4a24-8e1a-c61bff58a53f", - "type": "LVGLButtonWidget", - "left": 0, - "top": 0, - "width": 106, - "height": 100, - "customInputs": [], - "customOutputs": [], - "style": { - "objID": "51d87a4d-496a-41fd-d8a3-9e65e4daee03", - "useStyle": "default", - "conditionalStyles": [], - "childStyles": [] - }, - "hiddenInEditor": true, - "timeline": [], - "eventHandlers": [], - "identifier": "NodeButton", - "leftUnit": "px", - "topUnit": "px", - "widthUnit": "%", - "heightUnit": "%", - "children": [], - "widgetFlags": "CLICK_FOCUSABLE|GESTURE_BUBBLE|PRESS_LOCK|SCROLL_CHAIN_HOR|SCROLL_CHAIN_VER|SCROLL_ELASTIC|SCROLL_MOMENTUM|SCROLL_ON_FOCUS|SCROLL_WITH_ARROW|SNAPPABLE", - "hiddenFlagType": "literal", - "clickableFlag": true, - "clickableFlagType": "literal", - "checkedStateType": "literal", - "disabledStateType": "literal", - "states": "", - "useStyle": "NodeButtonStyle", - "localStyles": { - "objID": "75c60d7e-f1fc-4401-8aee-5d566fde5287", - "definition": { - "MAIN": { - "DEFAULT": { - "shadow_width": 0, - "align": "CENTER", - "max_height": 132, - "min_height": 50 - } - } - } - }, - "groupIndex": 0 - }, - { - "objID": "b9efe8d9-1a96-4cf4-b7c6-0d00472c03b5", - "type": "LVGLLabelWidget", - "left": -6, - "top": 35, - "width": 80, - "height": 17, - "customInputs": [], - "customOutputs": [], - "style": { - "objID": "e6a36a8a-3f26-46eb-e284-417e0d61840a", - "useStyle": "default", - "conditionalStyles": [], - "childStyles": [] - }, - "hiddenInEditor": true, - "timeline": [], - "eventHandlers": [], - "identifier": "UserNameLabel", - "leftUnit": "px", - "topUnit": "px", - "widthUnit": "%", - "heightUnit": "content", - "children": [], - "widgetFlags": "CLICK_FOCUSABLE|GESTURE_BUBBLE|PRESS_LOCK|SCROLL_CHAIN_HOR|SCROLL_CHAIN_VER|SCROLL_ELASTIC|SCROLL_MOMENTUM|SCROLL_WITH_ARROW|SNAPPABLE|SCROLLABLE", - "hiddenFlagType": "literal", - "clickableFlagType": "literal", - "checkedStateType": "literal", - "disabledStateType": "literal", - "states": "", - "localStyles": { - "objID": "433a3bfe-ff3d-4ce2-bb78-8a5f55fe750d", - "definition": { - "MAIN": { - "DEFAULT": { - "align": "TOP_LEFT" - } - } - } - }, - "groupIndex": 0, - "text": "Meshtastic", - "textType": "literal", - "longMode": "SCROLL", - "recolor": false - }, - { - "objID": "2df840ee-bd79-4ce1-d706-e8a1cb50401b", - "type": "LVGLLabelWidget", - "left": 30, - "top": 10, - "width": 43, - "height": 19, - "customInputs": [], - "customOutputs": [], - "style": { - "objID": "497653f6-72c5-4a2f-f4bb-6b1313b959ab", - "useStyle": "default", - "conditionalStyles": [], - "childStyles": [] - }, - "hiddenInEditor": true, - "timeline": [], - "eventHandlers": [], - "identifier": "UserNameShortLabel", - "leftUnit": "px", - "topUnit": "px", - "widthUnit": "content", - "heightUnit": "content", - "children": [], - "widgetFlags": "CLICK_FOCUSABLE|GESTURE_BUBBLE|PRESS_LOCK|SCROLL_CHAIN_HOR|SCROLL_CHAIN_VER|SCROLL_ELASTIC|SCROLL_MOMENTUM|SCROLL_WITH_ARROW|SNAPPABLE", - "hiddenFlagType": "literal", - "clickableFlagType": "literal", - "checkedStateType": "literal", - "disabledStateType": "literal", - "states": "", - "localStyles": { - "objID": "ecb59583-b918-4350-f769-6261e669bdde", - "definition": { - "MAIN": { - "DEFAULT": { - "align": "TOP_LEFT", - "text_font": "montserrat_14" - } - } - } - }, - "groupIndex": 0, - "text": "ABCD", - "textType": "literal", - "longMode": "WRAP", - "recolor": false - }, - { - "objID": "1ba48a4e-3e33-4a01-b9b4-ada03eb70a78", - "type": "LVGLLabelWidget", - "left": 8, - "top": 17, - "width": 0, - "height": 17, - "customInputs": [], - "customOutputs": [], - "style": { - "objID": "1e941f41-2c34-4a0b-da8a-5cf5dcafd1b2", - "useStyle": "default", - "conditionalStyles": [], - "childStyles": [] - }, - "hiddenInEditor": true, - "timeline": [], - "eventHandlers": [], - "identifier": "BatteryLabel", - "leftUnit": "px", - "topUnit": "px", - "widthUnit": "content", - "heightUnit": "content", - "children": [], - "widgetFlags": "CLICK_FOCUSABLE|GESTURE_BUBBLE|PRESS_LOCK|SCROLL_CHAIN_HOR|SCROLL_CHAIN_VER|SCROLL_ELASTIC|SCROLL_MOMENTUM|SCROLL_WITH_ARROW|SNAPPABLE", - "hiddenFlagType": "literal", - "clickableFlagType": "literal", - "checkedStateType": "literal", - "disabledStateType": "literal", - "states": "", - "localStyles": { - "objID": "07608b94-e375-42ea-ec81-e1673e268aea", - "definition": { - "MAIN": { - "DEFAULT": { - "align": "TOP_RIGHT", - "text_align": "RIGHT" - } - } - } - }, - "groupIndex": 0, - "text": "", - "textType": "literal", - "longMode": "CLIP", - "recolor": false - }, - { - "objID": "f393db90-4ef4-4b03-ab7e-c2d37d10901b", - "type": "LVGLLabelWidget", - "left": 8, - "top": 33, - "width": 0, - "height": 17, - "customInputs": [], - "customOutputs": [], - "style": { - "objID": "6296d3e7-b1de-4e71-e00e-4d1bfa91d26e", - "useStyle": "default", - "conditionalStyles": [], - "childStyles": [] - }, - "hiddenInEditor": true, - "timeline": [], - "eventHandlers": [], - "identifier": "LastHeardLabel", - "leftUnit": "px", - "topUnit": "px", - "widthUnit": "content", - "heightUnit": "content", - "children": [], - "widgetFlags": "CLICK_FOCUSABLE|GESTURE_BUBBLE|PRESS_LOCK|SCROLL_CHAIN_HOR|SCROLL_CHAIN_VER|SCROLL_ELASTIC|SCROLL_MOMENTUM|SCROLL_WITH_ARROW|SNAPPABLE", - "hiddenFlagType": "literal", - "clickableFlagType": "literal", - "checkedStateType": "literal", - "disabledStateType": "literal", - "states": "", - "localStyles": { - "objID": "c26bbd5b-2e62-43b1-dffa-e54a1b28bd61", - "definition": { - "MAIN": { - "DEFAULT": { - "align": "TOP_RIGHT", - "text_align": "RIGHT" - } - } - } - }, - "groupIndex": 0, - "text": "", - "textType": "literal", - "longMode": "CLIP", - "recolor": false - }, - { - "objID": "67873566-79d8-40b7-c7da-44e386340bf0", - "type": "LVGLLabelWidget", - "left": 8, - "top": 1, - "width": 0, - "height": 17, - "customInputs": [], - "customOutputs": [], - "style": { - "objID": "1161f8a8-501e-46c5-fa71-07c5502a8647", - "useStyle": "default", - "conditionalStyles": [], - "childStyles": [] - }, - "hiddenInEditor": true, - "timeline": [], - "eventHandlers": [], - "identifier": "SignalLabel", - "leftUnit": "px", - "topUnit": "px", - "widthUnit": "content", - "heightUnit": "content", - "children": [], - "widgetFlags": "CLICK_FOCUSABLE|GESTURE_BUBBLE|PRESS_LOCK|SCROLL_CHAIN_HOR|SCROLL_CHAIN_VER|SCROLL_ELASTIC|SCROLL_MOMENTUM|SCROLL_WITH_ARROW|SNAPPABLE", - "hiddenFlagType": "literal", - "clickableFlagType": "literal", - "checkedStateType": "literal", - "disabledStateType": "literal", - "states": "", - "localStyles": { - "objID": "56c6afaa-610c-4e66-f517-b176fffe01d4", - "definition": { - "MAIN": { - "DEFAULT": { - "align": "TOP_RIGHT", - "text_align": "RIGHT" - } - } - } - }, - "groupIndex": 0, - "text": "", - "textType": "literal", - "longMode": "CLIP", - "recolor": false - }, - { - "objID": "f3b76765-4ccf-44d6-eecb-ab7709d6b284", - "type": "LVGLLabelWidget", - "left": -5, - "top": 49, - "width": 120, - "height": 17, - "customInputs": [], - "customOutputs": [], - "style": { - "objID": "502f22a8-31b5-4365-f471-4f4f25465a57", - "useStyle": "default", - "conditionalStyles": [], - "childStyles": [] - }, - "hiddenInEditor": true, - "timeline": [], - "eventHandlers": [], - "identifier": "PositionLabel", - "leftUnit": "px", - "topUnit": "px", - "widthUnit": "px", - "heightUnit": "content", - "children": [], - "widgetFlags": "CLICK_FOCUSABLE|GESTURE_BUBBLE|PRESS_LOCK|SCROLL_CHAIN_HOR|SCROLL_CHAIN_VER|SCROLL_ELASTIC|SCROLL_MOMENTUM|SCROLL_WITH_ARROW|SNAPPABLE", - "hiddenFlagType": "literal", - "clickableFlagType": "literal", - "checkedStateType": "literal", - "disabledStateType": "literal", - "states": "", - "localStyles": { - "objID": "24f3db86-021b-41c5-902f-bfa32f846266", - "definition": { - "MAIN": { - "DEFAULT": { - "align": "TOP_LEFT", - "text_color": "#05f6cb" - } - } - } - }, - "groupIndex": 0, - "text": "", - "textType": "literal", - "longMode": "CLIP", - "recolor": false - }, - { - "objID": "388a7080-ce7d-43d5-c330-203d649738ec", - "type": "LVGLLabelWidget", - "left": -5, - "top": 63, - "width": 108, - "height": 17, - "customInputs": [], - "customOutputs": [], - "style": { - "objID": "f84e7e3f-0f1d-48dc-b2fb-d97c40bb24da", - "useStyle": "default", - "conditionalStyles": [], - "childStyles": [] - }, - "hiddenInEditor": true, - "timeline": [], - "eventHandlers": [], - "identifier": "Position2Label", - "leftUnit": "px", - "topUnit": "px", - "widthUnit": "px", - "heightUnit": "content", - "children": [], - "widgetFlags": "CLICK_FOCUSABLE|GESTURE_BUBBLE|PRESS_LOCK|SCROLL_CHAIN_HOR|SCROLL_CHAIN_VER|SCROLL_ELASTIC|SCROLL_MOMENTUM|SCROLL_WITH_ARROW|SNAPPABLE", - "hiddenFlagType": "literal", - "clickableFlagType": "literal", - "checkedStateType": "literal", - "disabledStateType": "literal", - "states": "", - "localStyles": { - "objID": "b9fe785d-406a-4826-eb80-263700601da7", - "definition": { - "MAIN": { - "DEFAULT": { - "align": "TOP_LEFT" - } - } - } - }, - "groupIndex": 0, - "text": "", - "textType": "literal", - "longMode": "SCROLL", - "recolor": false - }, - { - "objID": "353c8ddd-3fac-4d8b-dda4-9efb03e90cb0", - "type": "LVGLLabelWidget", - "left": 8, - "top": 49, - "width": 130, - "height": 17, - "customInputs": [], - "customOutputs": [], - "style": { - "objID": "be56a2c1-6ddd-449b-bd78-87508e292329", - "useStyle": "default", - "conditionalStyles": [], - "childStyles": [] - }, - "hiddenInEditor": true, - "timeline": [], - "eventHandlers": [], - "identifier": "Telemetry1Label", - "leftUnit": "px", - "topUnit": "px", - "widthUnit": "px", - "heightUnit": "content", - "children": [], - "widgetFlags": "CLICK_FOCUSABLE|GESTURE_BUBBLE|PRESS_LOCK|SCROLL_CHAIN_HOR|SCROLL_CHAIN_VER|SCROLL_ELASTIC|SCROLL_MOMENTUM|SCROLL_WITH_ARROW|SNAPPABLE", - "hiddenFlagType": "literal", - "clickableFlagType": "literal", - "checkedStateType": "literal", - "disabledStateType": "literal", - "states": "", - "localStyles": { - "objID": "690017db-cfd0-43c4-dfbe-96d42d7a7550", - "definition": { - "MAIN": { - "DEFAULT": { - "align": "TOP_RIGHT", - "text_align": "RIGHT" - } - } - } - }, - "groupIndex": 0, - "text": "", - "textType": "literal", - "longMode": "CLIP", - "recolor": false - }, - { - "objID": "b912e446-6bee-4911-95d1-e50031307882", - "type": "LVGLLabelWidget", - "left": 8, - "top": 63, - "width": 130, - "height": 17, - "customInputs": [], - "customOutputs": [], - "style": { - "objID": "4fd54599-4993-43e3-b3d6-ae3fee165a2a", - "useStyle": "default", - "conditionalStyles": [], - "childStyles": [] - }, - "hiddenInEditor": true, - "timeline": [], - "eventHandlers": [], - "identifier": "Telemetry2Label", - "leftUnit": "px", - "topUnit": "px", - "widthUnit": "px", - "heightUnit": "content", - "children": [], - "widgetFlags": "CLICK_FOCUSABLE|GESTURE_BUBBLE|PRESS_LOCK|SCROLL_CHAIN_HOR|SCROLL_CHAIN_VER|SCROLL_ELASTIC|SCROLL_MOMENTUM|SCROLL_WITH_ARROW|SNAPPABLE", - "hiddenFlagType": "literal", - "clickableFlagType": "literal", - "checkedStateType": "literal", - "disabledStateType": "literal", - "states": "", - "localStyles": { - "objID": "464972f9-ef4f-4fc0-c4b1-a09716251bc4", - "definition": { - "MAIN": { - "DEFAULT": { - "align": "TOP_RIGHT", - "text_align": "RIGHT" - } - } - } - }, - "groupIndex": 0, - "text": "", - "textType": "literal", - "longMode": "CLIP", - "recolor": false - } - ], - "widgetFlags": "CLICK_FOCUSABLE|GESTURE_BUBBLE|PRESS_LOCK|SCROLL_CHAIN_HOR|SCROLL_CHAIN_VER|SCROLL_ELASTIC|SCROLL_MOMENTUM|SCROLL_WITH_ARROW|SNAPPABLE", - "hiddenFlag": false, - "hiddenFlagType": "literal", - "clickableFlag": true, - "clickableFlagType": "literal", - "checkedStateType": "literal", - "disabledStateType": "literal", - "states": "", - "useStyle": "NodePanelStyle", - "localStyles": { - "objID": "755a5e29-37bf-4568-8be3-fa5f8d638d52", - "definition": { - "MAIN": { - "DEFAULT": { - "radius": 10, - "pad_top": 0, - "pad_bottom": 0 - }, - "PRESSED": { - "bg_color": "#fffff4" - } - } - } - }, - "groupIndex": 0 - } - ], + "children": [], "widgetFlags": "PRESS_LOCK|CLICK_FOCUSABLE|SNAPPABLE|SCROLL_CHAIN|SCROLLABLE|SCROLL_WITH_ARROW|SCROLL_MOMENTUM|SCROLL_ELASTIC", "hiddenFlag": true, "hiddenFlagType": "literal", diff --git a/studio/320x240/TFT320x240.eez-project b/studio/320x240/TFT320x240.eez-project index 8ac69a8c8..317957afb 100644 --- a/studio/320x240/TFT320x240.eez-project +++ b/studio/320x240/TFT320x240.eez-project @@ -2879,594 +2879,7 @@ "topUnit": "%", "widthUnit": "%", "heightUnit": "%", - "children": [ - { - "objID": "22955f34-0b85-433d-d13f-2ceedc723a64", - "type": "LVGLPanelWidget", - "left": 0, - "top": 0, - "width": 100, - "height": 53, - "customInputs": [], - "customOutputs": [], - "style": { - "objID": "2855c043-8b7f-49e4-ee80-e88a72e01b0c", - "useStyle": "default", - "conditionalStyles": [], - "childStyles": [] - }, - "hiddenInEditor": true, - "timeline": [], - "eventHandlers": [], - "identifier": "NodePanel", - "leftUnit": "%", - "topUnit": "px", - "widthUnit": "%", - "heightUnit": "px", - "children": [ - { - "objID": "9e44b0c6-7846-44b9-b4e1-aa2e52746bee", - "type": "LVGLImageWidget", - "left": -5, - "top": 3, - "width": 32, - "height": 32, - "customInputs": [], - "customOutputs": [], - "style": { - "objID": "0f13fb72-917f-4aa1-9088-ce2a0138a527", - "useStyle": "default", - "conditionalStyles": [], - "childStyles": [] - }, - "hiddenInEditor": true, - "timeline": [], - "eventHandlers": [], - "identifier": "NodeImage", - "leftUnit": "px", - "topUnit": "px", - "widthUnit": "px", - "heightUnit": "px", - "children": [], - "widgetFlags": "ADV_HITTEST|CLICK_FOCUSABLE|GESTURE_BUBBLE|PRESS_LOCK|SCROLL_CHAIN_HOR|SCROLL_CHAIN_VER|SCROLL_ELASTIC|SCROLL_MOMENTUM|SCROLL_WITH_ARROW|SNAPPABLE", - "hiddenFlag": false, - "hiddenFlagType": "literal", - "clickableFlagType": "literal", - "checkedStateType": "literal", - "disabledStateType": "literal", - "states": "", - "localStyles": { - "objID": "01b76791-617d-4e00-fbc0-7d45d538815e", - "definition": { - "MAIN": { - "DEFAULT": { - "img_recolor": "#ffffff", - "align": "TOP_LEFT", - "radius": 6, - "bg_color": "#5d9388", - "border_color": "FF5555", - "img_recolor_opa": 255, - "border_width": 3, - "bg_opa": 255 - } - } - } - }, - "groupIndex": 0, - "image": "node_client_image", - "setPivot": true, - "pivotX": 0, - "pivotY": 0, - "zoom": 256, - "angle": 0, - "innerAlign": "CENTER", - "sizeMode": "VIRTUAL" - }, - { - "objID": "235e95db-5597-43fd-b076-44b4b2f9b011", - "type": "LVGLButtonWidget", - "left": 0, - "top": 0, - "width": 106, - "height": 100, - "customInputs": [], - "customOutputs": [], - "style": { - "objID": "c539fcf0-8cb9-4045-b60c-b229ce1cec1d", - "useStyle": "default", - "conditionalStyles": [], - "childStyles": [] - }, - "hiddenInEditor": true, - "timeline": [], - "eventHandlers": [], - "identifier": "NodeButton", - "leftUnit": "px", - "topUnit": "px", - "widthUnit": "%", - "heightUnit": "%", - "children": [], - "widgetFlags": "CLICK_FOCUSABLE|GESTURE_BUBBLE|PRESS_LOCK|SCROLL_CHAIN_HOR|SCROLL_CHAIN_VER|SCROLL_ELASTIC|SCROLL_MOMENTUM|SCROLL_ON_FOCUS|SCROLL_WITH_ARROW|SNAPPABLE", - "hiddenFlagType": "literal", - "clickableFlag": true, - "clickableFlagType": "literal", - "checkedStateType": "literal", - "disabledStateType": "literal", - "states": "", - "useStyle": "NodeButtonStyle", - "localStyles": { - "objID": "192163fb-30f4-4134-affd-bd716effe140", - "definition": { - "MAIN": { - "DEFAULT": { - "shadow_width": 0, - "align": "CENTER", - "max_height": 132, - "min_height": 50 - } - } - } - }, - "groupIndex": 0 - }, - { - "objID": "04199aaa-ccc0-49dd-8bf9-09009f977774", - "type": "LVGLLabelWidget", - "left": -6, - "top": 35, - "width": 80, - "height": 17, - "customInputs": [], - "customOutputs": [], - "style": { - "objID": "dac1f3c7-4af6-4f10-f1e7-1abfaf097259", - "useStyle": "default", - "conditionalStyles": [], - "childStyles": [] - }, - "hiddenInEditor": true, - "timeline": [], - "eventHandlers": [], - "identifier": "UserNameLabel", - "leftUnit": "px", - "topUnit": "px", - "widthUnit": "%", - "heightUnit": "content", - "children": [], - "widgetFlags": "CLICK_FOCUSABLE|GESTURE_BUBBLE|PRESS_LOCK|SCROLL_CHAIN_HOR|SCROLL_CHAIN_VER|SCROLL_ELASTIC|SCROLL_MOMENTUM|SCROLL_WITH_ARROW|SNAPPABLE|SCROLLABLE", - "hiddenFlagType": "literal", - "clickableFlagType": "literal", - "checkedStateType": "literal", - "disabledStateType": "literal", - "states": "", - "localStyles": { - "objID": "5e7d5762-4cab-4637-ed49-e893be465513", - "definition": { - "MAIN": { - "DEFAULT": { - "align": "TOP_LEFT" - } - } - } - }, - "groupIndex": 0, - "text": "Meshtastic", - "textType": "literal", - "longMode": "SCROLL", - "recolor": false, - "useStaticText": true - }, - { - "objID": "0b4e8877-3367-4737-b454-062aca89d8b7", - "type": "LVGLLabelWidget", - "left": 30, - "top": 10, - "width": 43, - "height": 19, - "customInputs": [], - "customOutputs": [], - "style": { - "objID": "ee33c396-1e80-46da-e79f-42eecacb188e", - "useStyle": "default", - "conditionalStyles": [], - "childStyles": [] - }, - "hiddenInEditor": true, - "timeline": [], - "eventHandlers": [], - "identifier": "UserNameShortLabel", - "leftUnit": "px", - "topUnit": "px", - "widthUnit": "content", - "heightUnit": "content", - "children": [], - "widgetFlags": "CLICK_FOCUSABLE|GESTURE_BUBBLE|PRESS_LOCK|SCROLL_CHAIN_HOR|SCROLL_CHAIN_VER|SCROLL_ELASTIC|SCROLL_MOMENTUM|SCROLL_WITH_ARROW|SNAPPABLE", - "hiddenFlagType": "literal", - "clickableFlagType": "literal", - "checkedStateType": "literal", - "disabledStateType": "literal", - "states": "", - "localStyles": { - "objID": "5b010c17-25ce-4e47-9aaf-effa62dc9206", - "definition": { - "MAIN": { - "DEFAULT": { - "align": "TOP_LEFT", - "text_font": "montserrat_14" - } - } - } - }, - "groupIndex": 0, - "text": "ABCD", - "textType": "literal", - "longMode": "WRAP", - "recolor": false, - "useStaticText": true - }, - { - "objID": "f2a27cf6-9eb2-46e7-bf86-939133949886", - "type": "LVGLLabelWidget", - "left": 8, - "top": 17, - "width": 0, - "height": 17, - "customInputs": [], - "customOutputs": [], - "style": { - "objID": "fa7f7abc-0822-4613-bd4e-5c35cd6076e5", - "useStyle": "default", - "conditionalStyles": [], - "childStyles": [] - }, - "hiddenInEditor": true, - "timeline": [], - "eventHandlers": [], - "identifier": "BatteryLabel", - "leftUnit": "px", - "topUnit": "px", - "widthUnit": "content", - "heightUnit": "content", - "children": [], - "widgetFlags": "CLICK_FOCUSABLE|GESTURE_BUBBLE|PRESS_LOCK|SCROLL_CHAIN_HOR|SCROLL_CHAIN_VER|SCROLL_ELASTIC|SCROLL_MOMENTUM|SCROLL_WITH_ARROW|SNAPPABLE", - "hiddenFlagType": "literal", - "clickableFlagType": "literal", - "checkedStateType": "literal", - "disabledStateType": "literal", - "states": "", - "localStyles": { - "objID": "0d967e69-b091-494d-abf1-5e553e0bfd70", - "definition": { - "MAIN": { - "DEFAULT": { - "align": "TOP_RIGHT", - "text_align": "RIGHT" - } - } - } - }, - "groupIndex": 0, - "text": "", - "textType": "literal", - "longMode": "CLIP", - "recolor": false, - "useStaticText": true - }, - { - "objID": "315f954f-04b9-4481-ebbd-8fa17e5f1006", - "type": "LVGLLabelWidget", - "left": 8, - "top": 33, - "width": 0, - "height": 17, - "customInputs": [], - "customOutputs": [], - "style": { - "objID": "13350ef6-b6dd-4918-f8d3-21a1de7ab0f5", - "useStyle": "default", - "conditionalStyles": [], - "childStyles": [] - }, - "hiddenInEditor": true, - "timeline": [], - "eventHandlers": [], - "identifier": "LastHeardLabel", - "leftUnit": "px", - "topUnit": "px", - "widthUnit": "content", - "heightUnit": "content", - "children": [], - "widgetFlags": "CLICK_FOCUSABLE|GESTURE_BUBBLE|PRESS_LOCK|SCROLL_CHAIN_HOR|SCROLL_CHAIN_VER|SCROLL_ELASTIC|SCROLL_MOMENTUM|SCROLL_WITH_ARROW|SNAPPABLE", - "hiddenFlagType": "literal", - "clickableFlagType": "literal", - "checkedStateType": "literal", - "disabledStateType": "literal", - "states": "", - "localStyles": { - "objID": "2a2d1b55-fec2-4945-8189-b9f8ad0fbe85", - "definition": { - "MAIN": { - "DEFAULT": { - "align": "TOP_RIGHT", - "text_align": "RIGHT" - } - } - } - }, - "groupIndex": 0, - "text": "", - "textType": "literal", - "longMode": "CLIP", - "recolor": false, - "useStaticText": true - }, - { - "objID": "13be50d4-e30a-42de-c52d-b5891ec30d80", - "type": "LVGLLabelWidget", - "left": 8, - "top": 1, - "width": 0, - "height": 17, - "customInputs": [], - "customOutputs": [], - "style": { - "objID": "fcbcb51e-d7e5-43b5-cc21-4d3d91cba45c", - "useStyle": "default", - "conditionalStyles": [], - "childStyles": [] - }, - "hiddenInEditor": true, - "timeline": [], - "eventHandlers": [], - "identifier": "SignalLabel", - "leftUnit": "px", - "topUnit": "px", - "widthUnit": "content", - "heightUnit": "content", - "children": [], - "widgetFlags": "CLICK_FOCUSABLE|GESTURE_BUBBLE|PRESS_LOCK|SCROLL_CHAIN_HOR|SCROLL_CHAIN_VER|SCROLL_ELASTIC|SCROLL_MOMENTUM|SCROLL_WITH_ARROW|SNAPPABLE", - "hiddenFlagType": "literal", - "clickableFlagType": "literal", - "checkedStateType": "literal", - "disabledStateType": "literal", - "states": "", - "localStyles": { - "objID": "9eb24a84-0c48-4c80-99d3-346355e10b73", - "definition": { - "MAIN": { - "DEFAULT": { - "align": "TOP_RIGHT", - "text_align": "RIGHT" - } - } - } - }, - "groupIndex": 0, - "text": "", - "textType": "literal", - "longMode": "CLIP", - "recolor": false, - "useStaticText": true - }, - { - "objID": "628221d8-ba5b-479f-9449-63ca2b351225", - "type": "LVGLLabelWidget", - "left": -5, - "top": 49, - "width": 120, - "height": 17, - "customInputs": [], - "customOutputs": [], - "style": { - "objID": "27604f3a-a723-497b-e295-608b7da0eaf8", - "useStyle": "default", - "conditionalStyles": [], - "childStyles": [] - }, - "hiddenInEditor": true, - "timeline": [], - "eventHandlers": [], - "identifier": "PositionLabel", - "leftUnit": "px", - "topUnit": "px", - "widthUnit": "px", - "heightUnit": "content", - "children": [], - "widgetFlags": "CLICK_FOCUSABLE|GESTURE_BUBBLE|PRESS_LOCK|SCROLL_CHAIN_HOR|SCROLL_CHAIN_VER|SCROLL_ELASTIC|SCROLL_MOMENTUM|SCROLL_WITH_ARROW|SNAPPABLE", - "hiddenFlagType": "literal", - "clickableFlagType": "literal", - "checkedStateType": "literal", - "disabledStateType": "literal", - "states": "", - "localStyles": { - "objID": "ec11df2a-4e6d-40ee-cc44-aa3e279e1944", - "definition": { - "MAIN": { - "DEFAULT": { - "align": "TOP_LEFT", - "text_color": "#05f6cb" - } - } - } - }, - "groupIndex": 0, - "text": "", - "textType": "literal", - "longMode": "CLIP", - "recolor": false, - "useStaticText": true - }, - { - "objID": "ed14fc01-37e5-46be-def3-0412abaffd2f", - "type": "LVGLLabelWidget", - "left": -5, - "top": 63, - "width": 108, - "height": 17, - "customInputs": [], - "customOutputs": [], - "style": { - "objID": "e73f2aa8-40de-4b16-f115-cc32a58673b4", - "useStyle": "default", - "conditionalStyles": [], - "childStyles": [] - }, - "hiddenInEditor": true, - "timeline": [], - "eventHandlers": [], - "identifier": "Position2Label", - "leftUnit": "px", - "topUnit": "px", - "widthUnit": "px", - "heightUnit": "content", - "children": [], - "widgetFlags": "CLICK_FOCUSABLE|GESTURE_BUBBLE|PRESS_LOCK|SCROLL_CHAIN_HOR|SCROLL_CHAIN_VER|SCROLL_ELASTIC|SCROLL_MOMENTUM|SCROLL_WITH_ARROW|SNAPPABLE", - "hiddenFlagType": "literal", - "clickableFlagType": "literal", - "checkedStateType": "literal", - "disabledStateType": "literal", - "states": "", - "localStyles": { - "objID": "346ebd58-1e3c-4e26-c9f7-5a4e5f8f1c76", - "definition": { - "MAIN": { - "DEFAULT": { - "align": "TOP_LEFT" - } - } - } - }, - "groupIndex": 0, - "text": "", - "textType": "literal", - "longMode": "SCROLL", - "recolor": false, - "useStaticText": true - }, - { - "objID": "5da1ee6e-f9f2-4750-adc2-15142f57ca9c", - "type": "LVGLLabelWidget", - "left": 8, - "top": 49, - "width": 130, - "height": 17, - "customInputs": [], - "customOutputs": [], - "style": { - "objID": "a7a712b0-8140-4e00-82c5-1df152ff4396", - "useStyle": "default", - "conditionalStyles": [], - "childStyles": [] - }, - "hiddenInEditor": true, - "timeline": [], - "eventHandlers": [], - "identifier": "Telemetry1Label", - "leftUnit": "px", - "topUnit": "px", - "widthUnit": "px", - "heightUnit": "content", - "children": [], - "widgetFlags": "CLICK_FOCUSABLE|GESTURE_BUBBLE|PRESS_LOCK|SCROLL_CHAIN_HOR|SCROLL_CHAIN_VER|SCROLL_ELASTIC|SCROLL_MOMENTUM|SCROLL_WITH_ARROW|SNAPPABLE", - "hiddenFlagType": "literal", - "clickableFlagType": "literal", - "checkedStateType": "literal", - "disabledStateType": "literal", - "states": "", - "localStyles": { - "objID": "df0efa67-828e-4cae-9176-dedf25814ff3", - "definition": { - "MAIN": { - "DEFAULT": { - "align": "TOP_RIGHT", - "text_align": "RIGHT" - } - } - } - }, - "groupIndex": 0, - "text": "", - "textType": "literal", - "longMode": "CLIP", - "recolor": false, - "useStaticText": true - }, - { - "objID": "fefed3a9-68b9-44f4-ad64-e683fcba6aaf", - "type": "LVGLLabelWidget", - "left": 8, - "top": 63, - "width": 130, - "height": 17, - "customInputs": [], - "customOutputs": [], - "style": { - "objID": "51a7b11b-1bc3-48e0-8736-51d002936031", - "useStyle": "default", - "conditionalStyles": [], - "childStyles": [] - }, - "hiddenInEditor": true, - "timeline": [], - "eventHandlers": [], - "identifier": "Telemetry2Label", - "leftUnit": "px", - "topUnit": "px", - "widthUnit": "px", - "heightUnit": "content", - "children": [], - "widgetFlags": "CLICK_FOCUSABLE|GESTURE_BUBBLE|PRESS_LOCK|SCROLL_CHAIN_HOR|SCROLL_CHAIN_VER|SCROLL_ELASTIC|SCROLL_MOMENTUM|SCROLL_WITH_ARROW|SNAPPABLE", - "hiddenFlagType": "literal", - "clickableFlagType": "literal", - "checkedStateType": "literal", - "disabledStateType": "literal", - "states": "", - "localStyles": { - "objID": "c2dee63c-4cd4-42b6-dc09-8d3ac047743b", - "definition": { - "MAIN": { - "DEFAULT": { - "align": "TOP_RIGHT", - "text_align": "RIGHT" - } - } - } - }, - "groupIndex": 0, - "text": "", - "textType": "literal", - "longMode": "CLIP", - "recolor": false, - "useStaticText": true - } - ], - "widgetFlags": "CLICK_FOCUSABLE|GESTURE_BUBBLE|PRESS_LOCK|SCROLL_CHAIN_HOR|SCROLL_CHAIN_VER|SCROLL_ELASTIC|SCROLL_MOMENTUM|SCROLL_WITH_ARROW|SNAPPABLE", - "hiddenFlag": false, - "hiddenFlagType": "literal", - "clickableFlag": true, - "clickableFlagType": "literal", - "checkedStateType": "literal", - "disabledStateType": "literal", - "states": "", - "useStyle": "NodePanelStyle", - "localStyles": { - "objID": "65d97282-bc44-4520-eea5-76e11b1081eb", - "definition": { - "MAIN": { - "DEFAULT": { - "radius": 10, - "pad_top": 0, - "pad_bottom": 0 - }, - "PRESSED": { - "bg_color": "#fffff4" - } - } - } - }, - "groupIndex": 0 - } - ], + "children": [], "widgetFlags": "PRESS_LOCK|CLICK_FOCUSABLE|SNAPPABLE|SCROLL_CHAIN|SCROLLABLE|SCROLL_WITH_ARROW|SCROLL_MOMENTUM|SCROLL_ELASTIC", "hiddenFlag": true, "hiddenFlagType": "literal", diff --git a/tests/test_NodeDiscoverySyncGate.cpp b/tests/test_NodeDiscoverySyncGate.cpp new file mode 100644 index 000000000..2670500a7 --- /dev/null +++ b/tests/test_NodeDiscoverySyncGate.cpp @@ -0,0 +1,57 @@ +#include "graphics/common/NodeDiscoverySyncGate.h" +#include "graphics/common/NodeStore.h" + +#include + +namespace +{ +NodeMutation inserted(NodeId id) +{ + return {NodeMutationKind::Inserted, id, NodeFieldNone}; +} + +NodeMutation updated(NodeId id) +{ + return {NodeMutationKind::Updated, id, NodeFieldUser}; +} +} // namespace + +TEST_CASE("node discovery sync gate keeps the first insert deadline") +{ + NodeDiscoverySyncGate gate; + + CHECK(gate.observe(inserted(1), 1000) == NodeDiscoverySyncGate::MutationPolicy::Defer); + CHECK(gate.pending()); + CHECK_FALSE(gate.due(1099)); + + CHECK(gate.observe(inserted(2), 1050) == NodeDiscoverySyncGate::MutationPolicy::Defer); + CHECK_FALSE(gate.due(1099)); + CHECK(gate.due(1100)); +} + +TEST_CASE("node discovery sync gate requests forced rebind for later changed mutations") +{ + NodeDiscoverySyncGate gate; + + CHECK(gate.observe(inserted(1), 1000) == NodeDiscoverySyncGate::MutationPolicy::Defer); + CHECK(gate.observe(updated(1), 1010) == NodeDiscoverySyncGate::MutationPolicy::Defer); + + CHECK(gate.pending()); + CHECK(gate.forceRebind()); + CHECK(gate.due(1100)); +} + +TEST_CASE("node discovery sync gate consumes pending state after full sync") +{ + NodeDiscoverySyncGate gate; + + CHECK(gate.observe(inserted(1), 1000) == NodeDiscoverySyncGate::MutationPolicy::Defer); + CHECK(gate.observe(updated(1), 1010) == NodeDiscoverySyncGate::MutationPolicy::Defer); + + gate.consumeFullSync(); + + CHECK_FALSE(gate.pending()); + CHECK_FALSE(gate.forceRebind()); + CHECK_FALSE(gate.due(1100)); + CHECK(gate.observe(updated(1), 1110) == NodeDiscoverySyncGate::MutationPolicy::PassThrough); +} diff --git a/tests/test_NodeStore.cpp b/tests/test_NodeStore.cpp new file mode 100644 index 000000000..3448f8961 --- /dev/null +++ b/tests/test_NodeStore.cpp @@ -0,0 +1,498 @@ +#include "graphics/common/MeshtasticView.h" +#include "graphics/common/NodeStore.h" + +#include + +#include +#include +#include +#include + +namespace +{ +constexpr auto unknownRole = static_cast(15); +thread_local bool countAllocations = false; +thread_local size_t allocationCount = 0; + +struct ScopedAllocationCounter { + ScopedAllocationCounter() + { + allocationCount = 0; + countAllocations = true; + } + + ~ScopedAllocationCounter() { countAllocations = false; } + + size_t count() const { return allocationCount; } +}; + +meshtastic_User makeUser(const char *shortName, const char *longName, + meshtastic_Config_DeviceConfig_Role role = meshtastic_Config_DeviceConfig_Role_CLIENT) +{ + meshtastic_User user = meshtastic_User_init_default; + std::snprintf(user.short_name, sizeof(user.short_name), "%s", shortName); + std::snprintf(user.long_name, sizeof(user.long_name), "%s", longName); + user.role = role; + return user; +} +} // namespace + +void *operator new(std::size_t size) +{ + if (countAllocations) { + ++allocationCount; + } + void *ptr = std::malloc(size ? size : 1); + if (!ptr) { + throw std::bad_alloc(); + } + return ptr; +} + +void operator delete(void *ptr) noexcept +{ + std::free(ptr); +} + +void operator delete(void *ptr, std::size_t) noexcept +{ + std::free(ptr); +} + +TEST_CASE("node store retains typed user fields without LVGL") +{ + NodeStore store; + auto user = makeUser("ABCD", "Alpha", meshtastic_Config_DeviceConfig_Role_ROUTER); + user.public_key.size = 1; + user.public_key.bytes[0] = 0x42; + user.has_is_unmessagable = true; + user.is_unmessagable = true; + + const auto mutation = store.upsertUser(0x12345678, 2, 100, user, true); + + CHECK(mutation.kind == NodeMutationKind::Inserted); + CHECK(mutation.id == 0x12345678); + REQUIRE(store.find(0x12345678) != nullptr); + const auto &record = *store.find(0x12345678); + CHECK(record.channel == 2); + CHECK(record.lastHeard == 100); + CHECK(record.hasUser); + CHECK(record.user.role == meshtastic_Config_DeviceConfig_Role_ROUTER); + CHECK(record.user.publicKeySize == 1); + CHECK(record.user.publicKeyHash != 0); + CHECK(record.hasKey); + CHECK(record.unmessagable); + CHECK(record.viaMqtt); + CHECK(store.size() == 1); + CHECK(store.records().size() == 1); +} + +TEST_CASE("node record stores compact presentation fields") +{ + CHECK(sizeof(NodeRecord) <= 192); + CHECK(sizeof(NodeRecord) * MAX_NUM_NODES_VIEW <= 48000); +} + +TEST_CASE("node store creates the current fallback identity for an unknown node") +{ + NodeStore store; + + const auto mutation = store.upsertUnknown(0x1234abcd, 4, 250, meshtastic_Config_DeviceConfig_Role_REPEATER, false, true); + + CHECK(mutation.kind == NodeMutationKind::Inserted); + REQUIRE(store.find(0x1234abcd) != nullptr); + const auto &record = *store.find(0x1234abcd); + CHECK_FALSE(record.hasUser); + CHECK(record.channel == 4); + CHECK(record.lastHeard == 250); + CHECK(record.user.short_name == doctest::String("abcd")); + CHECK(record.user.long_name == doctest::String("Meshtastic abcd")); + CHECK(record.user.role == meshtastic_Config_DeviceConfig_Role_REPEATER); + CHECK(record.user.hw_model == meshtastic_HardwareModel_UNSET); + CHECK_FALSE(record.hasKey); + CHECK(record.viaMqtt); +} + +TEST_CASE("node store reports unchanged and updated duplicate user mutations") +{ + NodeStore store; + auto user = makeUser("ALPH", "Alpha"); + store.upsertUser(1, 0, 100, user, false); + + CHECK(store.upsertUser(1, 0, 100, user, false).kind == NodeMutationKind::Unchanged); + + user.role = meshtastic_Config_DeviceConfig_Role_SENSOR; + const auto mutation = store.upsertUser(1, 3, 200, user, true); + CHECK(mutation.kind == NodeMutationKind::Updated); + REQUIRE(store.find(1) != nullptr); + CHECK(store.find(1)->channel == 3); + CHECK(store.find(1)->lastHeard == 200); + CHECK(store.find(1)->user.role == meshtastic_Config_DeviceConfig_Role_SENSOR); + CHECK(store.find(1)->viaMqtt); +} + +TEST_CASE("node store updates position telemetry and radio fields on an existing node") +{ + NodeStore store; + store.upsertUnknown(7, 0, 10, meshtastic_Config_DeviceConfig_Role_CLIENT, false, false); + + NodePosition position{true, 123456789, -987654321, 1234, 9, 22}; + meshtastic_DeviceMetrics device = meshtastic_DeviceMetrics_init_default; + device.has_battery_level = true; + device.battery_level = 78; + meshtastic_EnvironmentMetrics environment = meshtastic_EnvironmentMetrics_init_default; + environment.has_temperature = true; + environment.temperature = 22.5f; + + CHECK(store.updatePosition(7, position).kind == NodeMutationKind::Updated); + CHECK(store.updatePosition(7, position).kind == NodeMutationKind::Unchanged); + CHECK(store.updateDeviceMetrics(7, device).kind == NodeMutationKind::Updated); + CHECK(store.updateEnvironmentMetrics(7, environment).kind == NodeMutationKind::Updated); + CHECK(store.updateSignal(7, -87, 6.25f).kind == NodeMutationKind::Updated); + CHECK(store.updateHops(7, 3).kind == NodeMutationKind::Updated); + CHECK(store.updateLastHeard(7, 999).kind == NodeMutationKind::Updated); + CHECK(store.setActiveChat(7, true).kind == NodeMutationKind::Updated); + + REQUIRE(store.find(7) != nullptr); + const auto &record = *store.find(7); + CHECK(record.position.latitude == 123456789); + CHECK(record.position.longitude == -987654321); + CHECK(record.position.altitude == 1234); + CHECK(record.position.satellites == 9); + CHECK(record.position.precision == 22); + CHECK(record.hasDeviceMetrics); + CHECK(record.deviceMetrics.battery_level == 78); + CHECK(record.hasEnvironmentMetrics); + CHECK(record.environmentMetrics.temperature == doctest::Approx(22.5f)); + CHECK(record.rssi == -87); + CHECK(record.snr == doctest::Approx(6.25f)); + CHECK(record.hopsAway == 3); + CHECK(record.lastHeard == 999); + CHECK(record.hasActiveChat); +} + +TEST_CASE("node store retains channel and last-heard metadata when an unknown update fills identity") +{ + NodeStore store; + constexpr NodeId nodeId = 0x1a2b3c4d; + store.upsertUnknown(nodeId, 3, 100, meshtastic_Config_DeviceConfig_Role_CLIENT, false, false); + + const NodeMutation mutation = store.upsertUnknown(nodeId, 7, 200, meshtastic_Config_DeviceConfig_Role_ROUTER, true, true); + + CHECK(mutation.kind == NodeMutationKind::Updated); + REQUIRE(store.find(nodeId) != nullptr); + CHECK(store.find(nodeId)->channel == 3); + CHECK(store.find(nodeId)->lastHeard == 100); + CHECK(store.find(nodeId)->user.role == meshtastic_Config_DeviceConfig_Role_ROUTER); + CHECK(store.find(nodeId)->hasKey); + CHECK(store.find(nodeId)->viaMqtt); +} + +TEST_CASE("node store preserves legacy-visible position and battery fields across incomplete updates") +{ + NodeStore store; + store.upsertUnknown(7, 0, 10, meshtastic_Config_DeviceConfig_Role_CLIENT, false, false); + + const NodePosition validPosition{true, 123456789, -987654321, 1234, 9, 22}; + CHECK(store.updatePosition(7, validPosition).kind == NodeMutationKind::Updated); + CHECK(store.updatePosition(7, {true, 0, 0, 0, 0, 0}).kind == NodeMutationKind::Unchanged); + CHECK(store.find(7)->position.latitude == validPosition.latitude); + CHECK(store.find(7)->position.longitude == validPosition.longitude); + + meshtastic_DeviceMetrics metrics = meshtastic_DeviceMetrics_init_default; + metrics.has_battery_level = true; + metrics.battery_level = 78; + metrics.has_voltage = true; + metrics.voltage = 4.12f; + metrics.has_channel_utilization = true; + metrics.channel_utilization = 12.5f; + metrics.has_air_util_tx = true; + metrics.air_util_tx = 3.2f; + CHECK(store.updateDeviceMetrics(7, metrics).kind == NodeMutationKind::Updated); + + meshtastic_DeviceMetrics zeroBattery = meshtastic_DeviceMetrics_init_default; + zeroBattery.has_channel_utilization = true; + zeroBattery.channel_utilization = 0.0f; + zeroBattery.has_air_util_tx = true; + zeroBattery.air_util_tx = 0.0f; + CHECK(store.updateDeviceMetrics(7, zeroBattery).kind == NodeMutationKind::Updated); + REQUIRE(store.find(7) != nullptr); + CHECK(store.find(7)->deviceMetrics.battery_level == 78); + CHECK(store.find(7)->deviceMetrics.voltage == doctest::Approx(4.12f)); + CHECK(store.find(7)->deviceMetrics.channel_utilization == doctest::Approx(0.0f)); + CHECK(store.find(7)->deviceMetrics.air_util_tx == doctest::Approx(0.0f)); +} + +TEST_CASE("node store accepts positions on the equator and prime meridian") +{ + NodeStore store; + store.upsertUnknown(7, 0, 10, meshtastic_Config_DeviceConfig_Role_CLIENT, false, false); + + CHECK(store.updatePosition(7, {true, 0, -987654321, 1234, 9, 22}).kind == NodeMutationKind::Updated); + CHECK(store.find(7)->position.latitude == 0); + CHECK(store.find(7)->position.longitude == -987654321); + + CHECK(store.updatePosition(7, {true, 123456789, 0, 1234, 9, 22}).kind == NodeMutationKind::Updated); + CHECK(store.find(7)->position.latitude == 123456789); + CHECK(store.find(7)->position.longitude == 0); + + CHECK(store.updatePosition(7, {true, 0, 0, 1234, 9, 22}).kind == NodeMutationKind::Unchanged); +} + +TEST_CASE("node store treats RSSI after hops as direct and records bad PKI keys") +{ + NodeStore store; + store.upsertUser(7, 0, 10, makeUser("NODE", "Node"), false); + + store.updateHops(7, 4); + auto signal = store.updateSignal(7, -87, 6.25f); + REQUIRE(store.find(7) != nullptr); + CHECK((signal.changedFields & NodeFieldHops) != 0U); + CHECK(store.find(7)->hopsAway == 0); + CHECK(store.find(7)->signalDisplay == NodeSignalDisplayKind::Rssi); + + CHECK(store.markBadKey(7).kind == NodeMutationKind::Updated); + CHECK(store.find(7)->hasBadKey); + CHECK(store.markBadKey(7).kind == NodeMutationKind::Unchanged); +} + +TEST_CASE("node store clears a bad-key marker when a replacement public key arrives") +{ + NodeStore store; + auto user = makeUser("NODE", "Node"); + user.public_key.size = 32; + user.public_key.bytes[0] = 0x11; + store.upsertUser(7, 0, 10, user, false); + store.markBadKey(7); + + user.public_key.bytes[0] = 0x22; + const NodeMutation mutation = store.upsertUser(7, 0, 10, user, false); + + CHECK(mutation.kind == NodeMutationKind::Updated); + CHECK((mutation.changedFields & NodeFieldFlags) != 0U); + CHECK_FALSE(store.find(7)->hasBadKey); +} + +TEST_CASE("node store retains a bad-key marker when same-key flags change") +{ + NodeStore store; + auto user = makeUser("NODE", "Node"); + user.public_key.size = 32; + user.public_key.bytes[0] = 0x11; + store.upsertUser(7, 0, 10, user, false); + store.markBadKey(7); + + user.has_is_unmessagable = true; + user.is_unmessagable = true; + store.upsertUser(7, 0, 10, user, false); + REQUIRE(store.find(7) != nullptr); + CHECK(store.find(7)->hasBadKey); + + store.upsertUser(7, 0, 10, user, true); + CHECK(store.find(7)->hasBadKey); +} + +TEST_CASE("node store field updates do not create incomplete nodes") +{ + NodeStore store; + const NodePosition position{true, 1, 2, 3, 4, 5}; + + CHECK(store.updatePosition(42, position).kind == NodeMutationKind::Unchanged); + CHECK(store.updateSignal(42, -90, 1.0f).kind == NodeMutationKind::Unchanged); + CHECK(store.setActiveChat(42, true).kind == NodeMutationKind::Unchanged); + CHECK(store.find(42) == nullptr); + CHECK(store.size() == 0); +} + +TEST_CASE("node store removes records with explicit mutation results") +{ + NodeStore store; + store.upsertUnknown(9, 0, 0, meshtastic_Config_DeviceConfig_Role_CLIENT, false, false); + + CHECK(store.remove(9).kind == NodeMutationKind::Removed); + CHECK(store.find(9) == nullptr); + CHECK(store.remove(9).kind == NodeMutationKind::Unchanged); +} + +TEST_CASE("node store purge candidate prefers a stale unknown from the oldest population") +{ + constexpr uint32_t now = 100000; + constexpr NodeId incoming = 0x99; + constexpr NodeId ownNode = 0x01; + constexpr NodeId oldestKnown = 0x10; + constexpr NodeId oldestEligibleUnknown = 0x20; + NodeStore store; + + store.upsertUser(oldestKnown, 0, now - 10000, makeUser("KNWN", "Known"), false); + store.upsertUnknown(ownNode, 0, now - 9000, meshtastic_Config_DeviceConfig_Role_CLIENT, false, false); + store.upsertUnknown(0x30, 0, now - 8000, static_cast(unknownRole), false, false); + store.setActiveChat(0x30, true); + store.upsertUnknown(incoming, 0, now - 7000, static_cast(unknownRole), false, false); + store.upsertUnknown(oldestEligibleUnknown, 0, now - 5000, static_cast(unknownRole), false, false); + store.upsertUnknown(0x40, 0, now - 30, static_cast(unknownRole), false, false); + + CHECK(store.selectPurgeCandidate(incoming, ownNode, now) == oldestEligibleUnknown); +} + +TEST_CASE("node store purge candidate falls back to the oldest removable node") +{ + constexpr uint32_t now = 100000; + NodeStore store; + store.upsertUser(0x10, 0, now - 10000, makeUser("OLD", "Old known"), false); + store.upsertUnknown(0x20, 0, now - 30, static_cast(unknownRole), false, false); + + CHECK(store.selectPurgeCandidate(0x99, 0x01, now) == 0x10); +} + +TEST_CASE("node store purge candidate keeps retained legacy same-timestamp ordering and protection") +{ + constexpr uint32_t now = 1000; + NodeStore store; + + store.upsertUnknown(0x30000000, 0, 900, static_cast(unknownRole), false, false); + store.upsertUnknown(0x10000000, 0, 900, static_cast(unknownRole), false, false); + store.upsertUnknown(0x20000000, 0, 900, static_cast(unknownRole), false, false); + + CHECK(store.selectPurgeCandidate(0x40000000, 0, now) == 0x20000000); + + store.setActiveChat(0x20000000, true); + CHECK(store.selectPurgeCandidate(0x40000000, 0, now) == 0x10000000); +} + +TEST_CASE("node store purge candidate limits stale-unknown preference to the oldest population") +{ + constexpr uint32_t now = 1000; + NodeStore store; + + store.upsertUser(0x10000000, 0, 100, makeUser("OLD", "Old Named"), false); + store.upsertUser(0x20000000, 0, 200, makeUser("MID", "Middle Named"), false); + store.upsertUser(0x30000000, 0, 300, makeUser("NEW", "New Named"), false); + store.upsertUser(0x40000000, 0, 400, makeUser("NWR", "Newer Named"), false); + store.upsertUnknown(0x50000000, 0, 800, static_cast(unknownRole), false, false); + + CHECK(store.selectPurgeCandidate(0x60000000, 0, now) == 0x10000000); +} + +TEST_CASE("node store purge candidate includes the stale unknown at the preferred-population boundary") +{ + constexpr uint32_t now = 10000; + NodeStore store; + + for (uint32_t i = 0; i < 7; ++i) { + store.upsertUser(0x10000000U + i, 0, 100U + i, makeUser("OLD", "Old Named"), false); + } + store.upsertUnknown(0x20000000U, 0, 107U, static_cast(unknownRole), false, false); + store.upsertUser(0x30000000U, 0, 108U, makeUser("NEW", "New Named"), false); + store.upsertUser(0x40000000U, 0, 109U, makeUser("NEW", "Newest Named"), false); + + CHECK(store.selectPurgeCandidate(0x50000000U, 0, now) == 0x20000000U); +} + +TEST_CASE("node store purge candidate selection does not allocate at the 250-node limit") +{ + constexpr uint32_t now = 10000; + constexpr NodeId expected = 0x10000000U; + NodeStore store; + + for (uint32_t i = 0; i < 250; ++i) { + store.upsertUnknown(expected + i, 0, 1000U + i, static_cast(unknownRole), false, false); + } + + NodeId candidate = 0; + size_t allocations = 0; + { + ScopedAllocationCounter counter; + candidate = store.selectPurgeCandidate(0x20000000U, 0, now); + allocations = counter.count(); + } + + CHECK(candidate == expected); + CHECK(allocations == 0); +} + +TEST_CASE("node store purge candidate does not ignore records above the 250-node limit") +{ + constexpr uint32_t now = 10000; + NodeStore store; + + for (uint32_t i = 0; i < 300; ++i) { + store.upsertUnknown(0x10000000U + i, 0, 1000U + i, static_cast(unknownRole), false, false); + } + + size_t seen = 0; + NodeId expected = 0; + for (const auto &[id, record] : store.records()) { + if (seen++ < 250) { + store.setActiveChat(id, true); + } else if (!expected || record.lastHeard < store.find(expected)->lastHeard) { + expected = id; + } + } + REQUIRE(expected != 0); + store.updateLastHeard(expected, 1); + + CHECK(store.selectPurgeCandidate(0x40000000U, 0, now) == expected); +} + +TEST_CASE("node store purge candidate returns zero when every node is protected") +{ + constexpr uint32_t now = 100000; + constexpr NodeId incoming = 0x99; + constexpr NodeId ownNode = 0x01; + NodeStore store; + store.upsertUnknown(ownNode, 0, now - 5000, static_cast(unknownRole), false, false); + store.upsertUnknown(incoming, 0, now - 4000, static_cast(unknownRole), false, false); + store.upsertUnknown(0x30, 0, now - 3000, static_cast(unknownRole), false, false); + store.setActiveChat(0x30, true); + + CHECK(store.selectPurgeCandidate(incoming, ownNode, now) == 0); +} + +TEST_CASE("node store retains channel validity on invalid NodeInfo updates") +{ + NodeStore store; + constexpr NodeId existingId = 0x1234; + constexpr NodeId newId = 0x5678; + + // Existing record keeps its channel when refreshed with sentinel value. + store.upsertUser(existingId, 2, 100, makeUser("ABCD", "Alpha"), false); + const auto invalidExisting = store.upsertUser(existingId, c_max_channels, 100, makeUser("EFGH", "Beta"), false); + REQUIRE(store.find(existingId) != nullptr); + CHECK(store.find(existingId)->channel == 2); + CHECK((invalidExisting.changedFields & NodeFieldChannel) == 0U); + CHECK(std::string(store.find(existingId)->user.long_name) == "Beta"); + + // New record with sentinel channel defaults to 0 without dirtying channel. + const auto invalidNew = store.upsertUser(newId, c_max_channels, 100, makeUser("WXYZ", "Gamma"), false); + REQUIRE(store.find(newId) != nullptr); + CHECK(store.find(newId)->channel == 0); + CHECK((invalidNew.changedFields & NodeFieldChannel) == 0U); +} + +TEST_CASE("node store normalizes invalid channels in upsertUnknown as in upsertUser") +{ + NodeStore store; + constexpr NodeId id = 0x9999; + // Insert unknown with invalid channel retains default 0 + const auto inserted = store.upsertUnknown(id, c_max_channels, 100, 0, false, false); + REQUIRE(store.find(id) != nullptr); + CHECK(store.find(id)->channel == 0); + CHECK((inserted.changedFields & NodeFieldChannel) == 0U); + + // Unknown refresh retains channel even with valid value (consistent with + // existing retain test above) and invalid preserves. + const auto retained = store.upsertUnknown(id, 3, 100, 0, false, false); + CHECK(store.find(id)->channel == 0); + CHECK((retained.changedFields & NodeFieldChannel) == 0U); + const auto invalidRefresh = store.upsertUnknown(id, c_max_channels, 100, 0, false, false); + CHECK(store.find(id)->channel == 0); + CHECK((invalidRefresh.changedFields & NodeFieldChannel) == 0U); +} + +TEST_CASE("node store never purges its sole record") +{ + NodeStore store; + store.upsertUnknown(7, 0, 1, static_cast(unknownRole), false, false); + + CHECK(store.selectPurgeCandidate(99, 1, 1000) == 0); +} diff --git a/tests/test_VirtualNodeList.cpp b/tests/test_VirtualNodeList.cpp new file mode 100644 index 000000000..3d008f7dd --- /dev/null +++ b/tests/test_VirtualNodeList.cpp @@ -0,0 +1,193 @@ +#include "graphics/view/TFT/VirtualNodeList.h" + +#include + +#include + +namespace +{ +struct RecordingSink : NodeListActionSink { + NodeId clicked = 0; + NodeId longPressed = 0; + NodeId positionClicked = 0; + bool hitBoundary = false; + + void nodeClicked(NodeId id) override { clicked = id; } + void nodeLongPressed(NodeId id) override { longPressed = id; } + void nodeFocusBoundary(bool) override { hitBoundary = true; } + void nodePositionClicked(NodeId id) override { positionClicked = id; } +}; + +void ensureLvgl() +{ + static bool initialized = false; + if (initialized) { + return; + } + lv_init(); + lv_display_create(320, 240); + initialized = true; +} + +meshtastic_User makeUser(NodeId id, const char *name) +{ + meshtastic_User user = meshtastic_User_init_default; + std::snprintf(user.short_name, sizeof(user.short_name), "%04x", static_cast(id & 0xffff)); + std::snprintf(user.long_name, sizeof(user.long_name), "%s", name); + user.role = meshtastic_Config_DeviceConfig_Role_CLIENT; + return user; +} + +lv_obj_t *makeParent(lv_obj_t *screen) +{ + auto *parent = lv_obj_create(screen); + lv_obj_set_size(parent, 180, VirtualNodeList::COLLAPSED_ROW_HEIGHT * 2 + VirtualNodeList::ROW_GAP); + lv_obj_set_style_pad_all(parent, 0, LV_PART_MAIN | LV_STATE_DEFAULT); + lv_obj_set_scroll_dir(parent, LV_DIR_VER); + return parent; +} + +void addNode(NodeStore &store, NodeId id, uint32_t lastHeard, const char *name) +{ + store.upsertUser(id, 0, lastHeard, makeUser(id, name), false); +} +} // namespace + +TEST_CASE("virtual node list keeps focused node identity across reorder") +{ + ensureLvgl(); + auto *screen = lv_obj_create(nullptr); + auto *parent = makeParent(screen); + RecordingSink sink; + NodeStore store; + VisibleNodeIndex index; + NodeListFilter filter; + filter.curTime = 1000; + + addNode(store, 0x1111, 300, "Alpha"); + addNode(store, 0x2222, 200, "Bravo"); + addNode(store, 0x3333, 100, "Charlie"); + index.rebuild(store, filter, 0); + { + VirtualNodeList list(parent, sink); + list.sync(store, index, 0, filter.curTime); + list.focus(0x2222); + REQUIRE(list.focusedIdForTesting() == 0x2222); + + store.updateLastHeard(0x3333, 400); + index.rebuild(store, filter, 0); + list.sync(store, index, 0, filter.curTime); + + CHECK(list.focusedIdForTesting() == 0x2222); + } + lv_obj_delete(screen); +} + +TEST_CASE("virtual node list moves removed focus to nearest visible row") +{ + ensureLvgl(); + auto *screen = lv_obj_create(nullptr); + auto *parent = makeParent(screen); + RecordingSink sink; + NodeStore store; + VisibleNodeIndex index; + NodeListFilter filter; + filter.curTime = 1000; + + addNode(store, 0x1111, 300, "Alpha"); + addNode(store, 0x2222, 200, "Bravo"); + addNode(store, 0x3333, 100, "Charlie"); + index.rebuild(store, filter, 0); + { + VirtualNodeList list(parent, sink); + list.sync(store, index, 0, filter.curTime); + list.focus(0x2222); + + store.remove(0x2222); + index.rebuild(store, filter, 0); + list.sync(store, index, 0, filter.curTime); + + CHECK(list.focusedIdForTesting() == 0x3333); + } + lv_obj_delete(screen); +} + +TEST_CASE("virtual node list uses minimal focus scroll and hides collapsed details") +{ + ensureLvgl(); + auto *screen = lv_obj_create(nullptr); + auto *parent = makeParent(screen); + RecordingSink sink; + NodeStore store; + VisibleNodeIndex index; + NodeListFilter filter; + filter.curTime = 1000; + + addNode(store, 0x1111, 400, "Alpha with a long visible name"); + addNode(store, 0x2222, 300, "Bravo"); + addNode(store, 0x3333, 200, "Charlie"); + addNode(store, 0x4444, 100, "Delta"); + store.updatePosition(0x1111, {true, 377749000, -1224194000, 42, 5, 0}); + meshtastic_EnvironmentMetrics env = meshtastic_EnvironmentMetrics_init_default; + env.temperature = 21.5f; + env.barometric_pressure = 1013.2f; + env.iaq = 80; + store.updateEnvironmentMetrics(0x1111, env); + index.rebuild(store, filter, 0); + { + VirtualNodeList list(parent, sink); + list.sync(store, index, 0, filter.curTime); + + CHECK(list.detailLabelsHiddenForTesting(0x1111)); + CHECK_FALSE(list.positionLabelClickableForTesting(0x1111)); + CHECK(list.longLabelsScrollForTesting(0x1111)); + CHECK(list.scrollYForNodeForTesting(0x1111) == 0); + CHECK(list.scrollYForNodeForTesting(0x3333) > 0); + CHECK(list.scrollYForNodeForTesting(0x3333) < list.rowYForTesting(2)); + + list.setExpanded(0x1111); + list.finishExpansionForTesting(); + CHECK_FALSE(list.detailLabelsHiddenForTesting(0x1111)); + CHECK(list.positionLabelClickableForTesting(0x1111)); + } + + lv_obj_delete(screen); +} + +TEST_CASE("virtual node list repairs navigation order after deferred boundary scroll") +{ + ensureLvgl(); + auto *screen = lv_obj_create(nullptr); + auto *parent = makeParent(screen); + RecordingSink sink; + NodeStore store; + VisibleNodeIndex index; + NodeListFilter filter; + filter.curTime = 1000; + + addNode(store, 0x1111, 500, "Alpha"); + addNode(store, 0x2222, 400, "Bravo"); + addNode(store, 0x3333, 300, "Charlie"); + addNode(store, 0x4444, 200, "Delta"); + addNode(store, 0x5555, 100, "Echo"); + index.rebuild(store, filter, 0); + { + VirtualNodeList list(parent, sink); + list.sync(store, index, 0, filter.curTime); + const uint32_t baselineGroupMoves = list.groupOrderMoveCountForTesting(); + + lv_obj_scroll_to_y(parent, list.rowYForTesting(2), LV_ANIM_OFF); + list.refreshVisibleRows(false, false, false); + CHECK(list.groupOrderMoveCountForTesting() == baselineGroupMoves); + + list.refreshVisibleRows(false, false, true); + CHECK(list.groupOrderMoveCountForTesting() > baselineGroupMoves); + + const uint32_t repairedGroupMoves = list.groupOrderMoveCountForTesting(); + lv_obj_scroll_to_y(parent, list.rowYForTesting(2) + 1, LV_ANIM_OFF); + list.refreshVisibleRows(false, false, true); + CHECK(list.groupOrderMoveCountForTesting() == repairedGroupMoves); + } + + lv_obj_delete(screen); +} diff --git a/tests/test_VisibleNodeIndex.cpp b/tests/test_VisibleNodeIndex.cpp new file mode 100644 index 000000000..89e2833ea --- /dev/null +++ b/tests/test_VisibleNodeIndex.cpp @@ -0,0 +1,394 @@ +#include "doctest.h" +#include "graphics/common/MeshtasticView.h" +#include "graphics/common/VisibleNodeIndex.h" + +#include + +namespace +{ + +meshtastic_User makeUser(const char *id, const char *longName, const char *shortName, + meshtastic_Config_DeviceConfig_Role role = meshtastic_Config_DeviceConfig_Role_CLIENT, + bool withKey = true) +{ + meshtastic_User u{}; + snprintf(u.id, sizeof(u.id), "%s", id); + snprintf(u.long_name, sizeof(u.long_name), "%s", longName); + snprintf(u.short_name, sizeof(u.short_name), "%s", shortName); + u.role = role; + if (withKey) { + u.public_key.size = 32; + memset(u.public_key.bytes, 0x42, 32); + } + return u; +} + +NodeStore fixtureStore() +{ + NodeStore store; + // Node 1: Alpha (recent, channel 0, hops 0, with key, with position) + store.upsertUser(0x1111, 0, 1000, makeUser("!00001111", "Alpha Node", "ALPH"), false); + NodePosition pos1{true, 377749000, -1224194000, 10, 8, 1}; + store.updatePosition(0x1111, pos1); + store.updateHops(0x1111, 0); + + // Node 2: Bravo (older, channel 1, hops 2, unknown/fallback, no key) + store.upsertUnknown(0x2222, 1, 500, 0, false, false); + store.updateHops(0x2222, 2); + + // Node 3: Charlie (offline / lastHeard=0, channel 0, hops 1, with key) + store.upsertUser(0x3333, 0, 0, makeUser("!00003333", "Charlie Station", "CHAR"), false); + store.updateHops(0x3333, 1); + + // Node 4: Delta (medium age, channel 0, hops -1 / direct, with key) + store.upsertUser(0x4444, 0, 800, makeUser("!00004444", "Delta Gateway", "DELT"), false); + + // Node 5: Own Node (always included regardless of filters) + store.upsertUser(0x9999, 0, 1000, makeUser("!00009999", "My Self Node", "SELF"), false); + + return store; +} + +} // namespace + +TEST_CASE("visible index applies current filters and newest-first ordering") +{ + NodeStore store = fixtureStore(); + VisibleNodeIndex index; + NodeListFilter filter; + filter.curTime = 1000; + filter.secsUntilOffline = 300; // nodes older than 700 are offline + NodeId ownNode = 0x9999; + + SUBCASE("no filter: sorted newest first, then NodeId ascending") + { + index.rebuild(store, filter, ownNode); + // Order expected: own node 0x9999 pinned first, then + // 0x1111 (1000), 0x4444 (800), 0x2222 (500), 0x3333 (0) + const auto &ids = index.ids(); + REQUIRE(ids.size() == 5); + CHECK(ids[0] == 0x9999); + CHECK(ids[1] == 0x1111); + CHECK(ids[2] == 0x4444); + CHECK(ids[3] == 0x2222); + CHECK(ids[4] == 0x3333); + } + + SUBCASE("filter unknown: excludes 0x2222 (unknown)") + { + filter.unknown = true; + index.rebuild(store, filter, ownNode); + CHECK(index.ids() == std::vector{0x9999, 0x1111, 0x4444, 0x3333}); + } + + SUBCASE("filter offline: excludes 0x2222 (500 < 700) and 0x3333 (0)") + { + filter.offline = true; + index.rebuild(store, filter, ownNode); + CHECK(index.ids() == std::vector{0x9999, 0x1111, 0x4444}); + } + + SUBCASE("filter public key: excludes 0x2222 (hasKey=false)") + { + filter.publicKey = true; + index.rebuild(store, filter, ownNode); + CHECK(index.ids() == std::vector{0x9999, 0x1111, 0x4444, 0x3333}); + } + + SUBCASE("filter channel: channel 1 (dropdown selected=2 -> channel=1) selects only 0x2222 plus ownNode") + { + filter.channel = 2; // channel index 1 + index.rebuild(store, filter, ownNode); + CHECK(index.ids() == std::vector{0x9999, 0x2222}); + } + + SUBCASE("filter position: requires known coordinates, selects 0x1111 plus ownNode") + { + filter.position = true; + index.rebuild(store, filter, ownNode); + CHECK(index.ids() == std::vector{0x9999, 0x1111}); + } + + SUBCASE("filter mqtt: remains disabled for legacy-compatible parity") + { + store.upsertUser(0x5555, 0, 950, makeUser("!00005555", "MQTT Node", "MQTT"), true); + filter.viaMqtt = true; + index.rebuild(store, filter, ownNode); + CHECK(index.ids() == std::vector{0x9999, 0x1111, 0x5555, 0x4444, 0x2222, 0x3333}); + } + + SUBCASE("filter hops: hops <= 0 (dropdown 7) selects direct nodes (0x1111)") + { + filter.hops = 7; // 7 - 7 = 0 -> hopsAway <= 0 + index.rebuild(store, filter, ownNode); + CHECK(index.contains(0x1111)); + CHECK_FALSE(index.contains(0x2222)); // hops 2 + CHECK_FALSE(index.contains(0x3333)); // hops 1 + } + + SUBCASE("filter hops: hops >= 2 (dropdown 9) selects 0x2222") + { + filter.hops = 9; // 9 - 7 = 2 -> hopsAway >= 2 + index.rebuild(store, filter, ownNode); + CHECK(index.contains(0x2222)); + CHECK_FALSE(index.contains(0x1111)); // hops 0 + CHECK_FALSE(index.contains(0x3333)); // hops 1 + } + + SUBCASE("filter name: case-insensitive search matching 'alp'") + { + filter.name = "alp"; + index.rebuild(store, filter, ownNode); + CHECK(index.ids() == std::vector{0x9999, 0x1111}); // 0x1111 matches, ownNode always kept + } + + SUBCASE("filter name: search matching hex node ID of unknown node '2222'") + { + filter.name = "2222"; + index.rebuild(store, filter, ownNode); + CHECK(index.contains(0x2222)); + CHECK_FALSE(index.contains(0x1111)); + } + + SUBCASE("filter name: displayed unknown short ID matches but decimal and full hex IDs do not") + { + store.upsertUnknown(0x1234abcd, 0, 900, MeshtasticView::unknown, false, false); + + filter.name = "abcd"; + index.rebuild(store, filter, ownNode); + CHECK(index.contains(0x1234abcd)); + + filter.name = "1234abcd"; + index.rebuild(store, filter, ownNode); + CHECK_FALSE(index.contains(0x1234abcd)); + + filter.name = "305441741"; + index.rebuild(store, filter, ownNode); + CHECK_FALSE(index.contains(0x1234abcd)); + } + + SUBCASE("filter name negation: bare '!' hides every non-own node") + { + filter.name = "!"; + index.rebuild(store, filter, ownNode); + CHECK(index.ids() == std::vector{ownNode}); + } + + SUBCASE("filter name negation: '!' excludes matches") + { + filter.name = "!alpha"; + index.rebuild(store, filter, ownNode); + // Excludes 0x1111, keeps others + CHECK(std::find(index.ids().begin(), index.ids().end(), 0x1111) == index.ids().end()); + CHECK(index.contains(0x4444)); + CHECK(index.contains(0x9999)); + } +} + +TEST_CASE("visible index preserves stable selection lookup after last-heard reorder") +{ + NodeStore store; + store.upsertUser(0x1111, 0, 500, makeUser("!00001111", "Alpha", "A"), false); + store.upsertUser(0x2222, 0, 300, makeUser("!00002222", "Bravo", "B"), false); + store.upsertUser(0x3333, 0, 100, makeUser("!00003333", "Charlie", "C"), false); + + VisibleNodeIndex index; + NodeListFilter filter; + index.rebuild(store, filter, 0); + + REQUIRE(index.ids().size() == 3); + CHECK(index.indexOf(0x2222) == std::optional(1)); + + // Update Bravo to be newest + store.updateLastHeard(0x2222, 900); + index.rebuild(store, filter, 0); + + CHECK(index.indexOf(0x2222) == std::optional(0)); + CHECK(index.indexOf(0x1111) == std::optional(1)); + CHECK(index.indexOf(0x3333) == std::optional(2)); + CHECK(index.indexOf(0x9999) == std::nullopt); +} + +TEST_CASE("visible index membership detection stays linear during a recency-only reorder") +{ + NodeStore store; + constexpr size_t nodeCount = 128; + for (size_t i = 0; i < nodeCount; ++i) { + const NodeId id = static_cast(0x1000 + i); + store.upsertUser(id, 0, static_cast(nodeCount - i), makeUser("!00001000", "Node", "NODE"), false); + } + + VisibleNodeIndex index; + NodeListFilter filter; + index.rebuild(store, filter, 0); + const uint32_t membershipGeneration = index.membershipGeneration(); + + store.updateLastHeard(0x107f, 1000); + index.rebuild(store, filter, 0); + + CHECK(index.ids().front() == 0x107f); + CHECK(index.membershipGeneration() == membershipGeneration); + CHECK(index.membershipProbeCountForTesting() == nodeCount); +} + +TEST_CASE("visible index membership cache keeps fixed storage at the 250-node limit") +{ + NodeStore store; + constexpr size_t nodeCount = 250; + for (size_t i = 0; i < nodeCount; ++i) { + const NodeId id = static_cast(0x1000 + i); + store.upsertUser(id, 0, static_cast(nodeCount - i), makeUser("!00001000", "Node", "NODE"), false); + } + + VisibleNodeIndex index; + NodeListFilter filter; + index.rebuild(store, filter, 0); + const auto *storage = index.membershipStorageForTesting(); + + REQUIRE(index.membershipStorageCapacityForTesting() == nodeCount); + REQUIRE(index.membershipStorageSizeForTesting() == nodeCount); + + store.remove(0x10f9); + store.upsertUser(0x2000, 0, 1, makeUser("!00002000", "Replacement", "REPL"), false); + index.rebuild(store, filter, 0); + + CHECK(index.membershipStorageForTesting() == storage); + CHECK(index.membershipStorageCapacityForTesting() == nodeCount); + CHECK(index.membershipStorageSizeForTesting() == nodeCount); + CHECK(index.contains(0x2000)); + CHECK_FALSE(index.contains(0x10f9)); +} + +TEST_CASE("visible index selects the top-ranked 250 nodes before enforcing membership capacity") +{ + NodeStore store; + constexpr NodeId ownNode = 0x7000; + constexpr NodeId lowestPeer = 0x1000; + constexpr size_t peerCount = 250; + + store.upsertUser(ownNode, 0, 1, makeUser("!00007000", "Own Node", "OWN"), false); + for (size_t i = 0; i < peerCount; ++i) { + const NodeId id = static_cast(lowestPeer + i); + store.upsertUser(id, 0, static_cast(1000 + i), makeUser("!00001000", "Peer Node", "PEER"), false); + } + + VisibleNodeIndex index; + NodeListFilter filter; + index.rebuild(store, filter, ownNode); + + CHECK(index.membershipStorageSizeForTesting() == 250); + CHECK(index.membershipStorageCapacityForTesting() == 250); + CHECK(index.ids().size() == 250); + CHECK(index.rebuildScratchCapacityForTesting() == 250); + REQUIRE(index.ids().size() == 250); + CHECK(index.ids().front() == ownNode); + CHECK_FALSE(index.contains(lowestPeer)); + CHECK(index.indexOf(lowestPeer) == std::nullopt); + for (size_t i = 1; i < peerCount; ++i) { + const NodeId id = static_cast(lowestPeer + i); + CHECK(index.contains(id)); + CHECK(index.indexOf(id).has_value()); + } +} + +TEST_CASE("visible index promotes same-second last-heard refreshes ahead of older ties") +{ + NodeStore store; + store.upsertUser(0x9000, 0, 500, makeUser("!00009000", "High Id", "HIGH"), false); + store.upsertUser(0x1000, 0, 500, makeUser("!00001000", "Low Id", "LOW"), false); + + VisibleNodeIndex index; + NodeListFilter filter; + index.rebuild(store, filter, 0); + REQUIRE(index.ids().size() == 2); + CHECK(index.ids()[0] == 0x9000); + CHECK(index.ids()[1] == 0x1000); + + store.updateLastHeard(0x9000, 500); + index.rebuild(store, filter, 0); + + CHECK(index.ids()[0] == 0x9000); + CHECK(index.ids()[1] == 0x1000); +} + +TEST_CASE("visible index clamps future last-heard timestamps before ordering") +{ + NodeStore store; + store.upsertUser(0x9000, 0, 2000, makeUser("!00009000", "Future High Id", "FUTR"), false); + store.upsertUser(0x1000, 0, 1000, makeUser("!00001000", "Current Low Id", "CURR"), false); + + VisibleNodeIndex index; + NodeListFilter filter; + filter.curTime = 1000; + index.rebuild(store, filter, 0); + + REQUIRE(index.ids().size() == 2); + CHECK(index.ids()[0] == 0x9000); + CHECK(index.ids()[1] == 0x1000); +} + +TEST_CASE("visible index keeps reusable scratch capacity after changed rebuilds") +{ + NodeStore store; + store.upsertUser(0x1111, 0, 300, makeUser("!00001111", "Alpha", "A"), false); + store.upsertUser(0x2222, 0, 200, makeUser("!00002222", "Bravo", "B"), false); + store.upsertUser(0x3333, 0, 100, makeUser("!00003333", "Charlie", "C"), false); + + VisibleNodeIndex index; + NodeListFilter filter; + index.rebuild(store, filter, 0); + const size_t reusableCapacity = index.ids().capacity(); + REQUIRE(reusableCapacity >= 3); + + store.updateLastHeard(0x3333, 400); + index.rebuild(store, filter, 0); + + CHECK(index.ids() == std::vector{0x3333, 0x1111, 0x2222}); + CHECK(index.rebuildScratchCapacityForTesting() >= reusableCapacity); +} + +TEST_CASE("visible index public-key filtering tracks key validity transitions") +{ + NodeStore store; + store.upsertUser(0x1111, 0, 900, makeUser("!00001111", "Keyed Node", "KEY"), false); + VisibleNodeIndex index; + NodeListFilter filter; + filter.publicKey = true; + + index.rebuild(store, filter, 0); + CHECK(index.contains(0x1111)); + + // Key loss drops membership. + meshtastic_User keyless{}; + std::strcpy(keyless.short_name, "KEY"); + std::strcpy(keyless.long_name, "Keyed Node"); + store.upsertUser(0x1111, 0, 900, keyless, false); + index.rebuild(store, filter, 0); + CHECK_FALSE(index.contains(0x1111)); + + // Bad-key marking also drops membership while the record still has a key. + store.upsertUser(0x1111, 0, 900, makeUser("!00001111", "Keyed Node", "KEY"), false); + store.markBadKey(0x1111); + index.rebuild(store, filter, 0); + CHECK_FALSE(index.contains(0x1111)); +} + +TEST_CASE("visible index treats fully-zero coordinates as unknown position") +{ + NodeStore store; + store.upsertUser(0x2222, 0, 800, makeUser("!00002222", "Null Island", "NULL"), false); + store.updatePosition(0x2222, {true, 0, 0, 0, 0, 0}); + NodeListFilter filter; + filter.position = true; + VisibleNodeIndex index; + index.rebuild(store, filter, 0); + CHECK_FALSE(index.contains(0x2222)); + // Documented edge: a single-zero coordinate still counts as positioned at + // the model layer, though the panel renderer requires both axes non-zero. +} + +TEST_CASE("visible index capacity shares MAX_NUM_NODES_VIEW") +{ + VisibleNodeIndex index; + CHECK(index.membershipStorageCapacityForTesting() == MAX_NUM_NODES_VIEW); +} From ecfefc8a3f3a15a513c924403033b689f8386783 Mon Sep 17 00:00:00 2001 From: Benjamin Faershtein <119711889+RCGV1@users.noreply.github.com> Date: Fri, 28 Aug 2026 21:38:29 -0700 Subject: [PATCH 02/15] fix(MUI): preserve virtual node list parity --- CMakeLists.txt | 4 + .../graphics/common/NodeListRowPresentation.h | 2 +- include/graphics/common/NodeStore.h | 10 ++ include/graphics/view/TFT/TFTView_320x240.h | 2 + include/graphics/view/TFT/VirtualNodeList.h | 2 + source/graphics/TFT/TFTView_320x240.cpp | 13 ++- source/graphics/common/NodeStore.cpp | 49 +++++++++ source/graphics/view/TFT/VirtualNodeList.cpp | 49 ++++++--- tests/test_NodeStore.cpp | 42 ++++++++ tests/test_VirtualNodeList.cpp | 99 +++++++++++++++++++ 10 files changed, 258 insertions(+), 14 deletions(-) diff --git a/CMakeLists.txt b/CMakeLists.txt index ecd2ff13c..fc6cd2480 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -88,7 +88,11 @@ if(ENABLE_DOCTESTS) ${CMAKE_CURRENT_SOURCE_DIR}/locale ${CMAKE_CURRENT_SOURCE_DIR}/portduino ${CMAKE_CURRENT_SOURCE_DIR}/generated/${GENERATED_VIEW} + ${GENERATED_FILES_DIR} + ${CMAKE_CURRENT_BINARY_DIR} + ${nanopb_SOURCE_DIR} ) + add_dependencies(tests Protobufs) set_target_properties(tests PROPERTIES RUNTIME_OUTPUT_DIRECTORY ${CMAKE_BINARY_DIR}/bin) add_test(NAME tests COMMAND tests) endif() diff --git a/include/graphics/common/NodeListRowPresentation.h b/include/graphics/common/NodeListRowPresentation.h index d49f3851e..22f1e4535 100644 --- a/include/graphics/common/NodeListRowPresentation.h +++ b/include/graphics/common/NodeListRowPresentation.h @@ -128,7 +128,7 @@ inline void formatShortNameWithDistance(char *buffer, size_t bufferSize, const c } } else { if (dist > 0.1f) { - std::snprintf(&buffer[5], bufferSize - 5, "%.1f mi ", std::round(dist * 0.621371f)); + std::snprintf(&buffer[5], bufferSize - 5, "%.1f mi ", dist * 0.621371f); } else { std::snprintf(&buffer[5], bufferSize - 5, "%u ft ", static_cast(dist * 3280.84f)); } diff --git a/include/graphics/common/NodeStore.h b/include/graphics/common/NodeStore.h index bfcaafb4b..6dff143bc 100644 --- a/include/graphics/common/NodeStore.h +++ b/include/graphics/common/NodeStore.h @@ -54,12 +54,17 @@ struct NodeEnvironmentMetrics { uint16_t iaq = 0; }; +struct NodeAirQualityMetrics { + uint16_t pm25_standard = 0; +}; + struct NodeRecord { uint64_t recencyOrder = 0; NodeUserSummary user{}; NodePosition position{}; NodeDeviceMetrics deviceMetrics{}; NodeEnvironmentMetrics environmentMetrics{}; + NodeAirQualityMetrics airQualityMetrics{}; NodeId id = 0; uint32_t lastHeard = 0; int32_t rssi = 0; @@ -75,6 +80,7 @@ struct NodeRecord { bool recencyPromoted = false; bool hasDeviceMetrics = false; bool hasEnvironmentMetrics = false; + bool hasAirQualityMetrics = false; bool hasActiveChat = false; }; @@ -91,9 +97,11 @@ enum NodeChangedField : uint32_t { NodeFieldPosition = 1U << 4, NodeFieldDeviceMetrics = 1U << 5, NodeFieldEnvironmentMetrics = 1U << 6, + NodeFieldAirQualityMetrics = 1U << 7, NodeFieldSignal = 1U << 8, NodeFieldHops = 1U << 9, NodeFieldActiveChat = 1U << 10, + NodeFieldPowerMetrics = 1U << 11, }; struct NodeMutation { @@ -118,6 +126,8 @@ class NodeStore NodeMutation updatePosition(NodeId id, const NodePosition &position); NodeMutation updateDeviceMetrics(NodeId id, const meshtastic_DeviceMetrics &metrics); NodeMutation updateEnvironmentMetrics(NodeId id, const meshtastic_EnvironmentMetrics &metrics); + NodeMutation updateAirQualityMetrics(NodeId id, const meshtastic_AirQualityMetrics &metrics); + NodeMutation updatePowerMetrics(NodeId id, const meshtastic_PowerMetrics &metrics); NodeMutation updateSignal(NodeId id, int32_t rssi, float snr); NodeMutation updateHops(NodeId id, int8_t hopsAway); NodeMutation updateLastHeard(NodeId id, uint32_t now); diff --git a/include/graphics/view/TFT/TFTView_320x240.h b/include/graphics/view/TFT/TFTView_320x240.h index 787504a01..4510817ba 100644 --- a/include/graphics/view/TFT/TFTView_320x240.h +++ b/include/graphics/view/TFT/TFTView_320x240.h @@ -38,6 +38,8 @@ class TFTView_320x240 : public MeshtasticView, private NodeListActionSink void updatePosition(uint32_t nodeNum, int32_t lat, int32_t lon, int32_t alt, uint32_t sats, uint32_t precision) override; void updateMetrics(uint32_t nodeNum, uint32_t bat_level, float voltage, float chUtil, float airUtil) override; void updateEnvironmentMetrics(uint32_t nodeNum, const meshtastic_EnvironmentMetrics &metrics) override; + void updateAirQualityMetrics(uint32_t nodeNum, const meshtastic_AirQualityMetrics &metrics) override; + void updatePowerMetrics(uint32_t nodeNum, const meshtastic_PowerMetrics &metrics) override; void updateSignalStrength(uint32_t nodeNum, int32_t rssi, float snr) override; void updateHopsAway(uint32_t nodeNum, uint8_t hopsAway) override; void beginNodeListPresentationBatch() override; diff --git a/include/graphics/view/TFT/VirtualNodeList.h b/include/graphics/view/TFT/VirtualNodeList.h index 43784fa9a..5b2a9ebe7 100644 --- a/include/graphics/view/TFT/VirtualNodeList.h +++ b/include/graphics/view/TFT/VirtualNodeList.h @@ -106,6 +106,8 @@ class VirtualNodeList bool detailLabelsHiddenForTesting(NodeId id) const; bool positionLabelClickableForTesting(NodeId id) const; bool longLabelsScrollForTesting(NodeId id) const; + const char *shortTextForTesting(NodeId id) const; + size_t rowPoolSizeForTesting() const { return rowPool.size(); } void setExpansionProgressForTesting(int32_t progress) { updateExpansionAnimation(progress); } void finishExpansionForTesting() { diff --git a/source/graphics/TFT/TFTView_320x240.cpp b/source/graphics/TFT/TFTView_320x240.cpp index 7f5fa3864..5f2d52488 100644 --- a/source/graphics/TFT/TFTView_320x240.cpp +++ b/source/graphics/TFT/TFTView_320x240.cpp @@ -4842,6 +4842,16 @@ void TFTView_320x240::updateEnvironmentMetrics(uint32_t nodeNum, const meshtasti syncNodeListPresentation(nodeStore.updateEnvironmentMetrics(nodeNum, metrics)); } +void TFTView_320x240::updateAirQualityMetrics(uint32_t nodeNum, const meshtastic_AirQualityMetrics &metrics) +{ + syncNodeListPresentation(nodeStore.updateAirQualityMetrics(nodeNum, metrics)); +} + +void TFTView_320x240::updatePowerMetrics(uint32_t nodeNum, const meshtastic_PowerMetrics &metrics) +{ + syncNodeListPresentation(nodeStore.updatePowerMetrics(nodeNum, metrics)); +} + /** * update signal strength for direct neighbors */ @@ -6738,7 +6748,8 @@ bool TFTView_320x240::mutationCanRefreshVirtualRow(const NodeMutation &mutation) return false; } - uint32_t contentFields = NodeFieldSignal | NodeFieldDeviceMetrics | NodeFieldEnvironmentMetrics | NodeFieldActiveChat; + uint32_t contentFields = NodeFieldSignal | NodeFieldDeviceMetrics | NodeFieldEnvironmentMetrics | NodeFieldAirQualityMetrics | + NodeFieldPowerMetrics | NodeFieldActiveChat; const int hopsFilter = static_cast(lv_dropdown_get_selected(objects.nodes_filter_hops_dropdown)); if (!hopsFilter) { contentFields |= NodeFieldHops; diff --git a/source/graphics/common/NodeStore.cpp b/source/graphics/common/NodeStore.cpp index 157e07998..025d53437 100644 --- a/source/graphics/common/NodeStore.cpp +++ b/source/graphics/common/NodeStore.cpp @@ -70,6 +70,11 @@ NodeEnvironmentMetrics summarizeEnvironmentMetrics(const meshtastic_EnvironmentM metrics.voltage, metrics.current, metrics.iaq}; } +NodeAirQualityMetrics summarizeAirQualityMetrics(const meshtastic_AirQualityMetrics &metrics) +{ + return {static_cast(metrics.pm25_standard > UINT16_MAX ? UINT16_MAX : metrics.pm25_standard)}; +} + bool sameDeviceMetrics(const NodeDeviceMetrics &left, const NodeDeviceMetrics &right) { return left.battery_level == right.battery_level && left.voltage == right.voltage && @@ -83,6 +88,11 @@ bool sameEnvironmentMetrics(const NodeEnvironmentMetrics &left, const NodeEnviro left.current == right.current && left.iaq == right.iaq; } +bool sameAirQualityMetrics(const NodeAirQualityMetrics &left, const NodeAirQualityMetrics &right) +{ + return left.pm25_standard == right.pm25_standard; +} + NodeMutation unchanged(NodeId id) { return {NodeMutationKind::Unchanged, id, NodeFieldNone}; @@ -240,6 +250,12 @@ NodeMutation NodeStore::updateEnvironmentMetrics(NodeId id, const meshtastic_Env if (it == nodes.end()) return unchanged(id); auto retainedMetrics = summarizeEnvironmentMetrics(metrics); + if (!metrics.has_voltage && it->second.environmentMetrics.voltage != 0.0f) { + retainedMetrics.voltage = it->second.environmentMetrics.voltage; + } + if (!metrics.has_current && it->second.environmentMetrics.current != 0.0f) { + retainedMetrics.current = it->second.environmentMetrics.current; + } if (it->second.hasEnvironmentMetrics && (retainedMetrics.iaq == 0 || retainedMetrics.iaq >= 1000) && it->second.environmentMetrics.iaq > 0 && it->second.environmentMetrics.iaq < 1000) { retainedMetrics.iaq = it->second.environmentMetrics.iaq; @@ -253,6 +269,39 @@ NodeMutation NodeStore::updateEnvironmentMetrics(NodeId id, const meshtastic_Env return updated(id, NodeFieldEnvironmentMetrics); } +NodeMutation NodeStore::updateAirQualityMetrics(NodeId id, const meshtastic_AirQualityMetrics &metrics) +{ + auto it = nodes.find(id); + if (it == nodes.end()) + return unchanged(id); + const auto retainedMetrics = summarizeAirQualityMetrics(metrics); + if (it->second.hasAirQualityMetrics && sameAirQualityMetrics(it->second.airQualityMetrics, retainedMetrics)) + return unchanged(id); + it->second.hasAirQualityMetrics = true; + it->second.airQualityMetrics = retainedMetrics; + return updated(id, NodeFieldAirQualityMetrics); +} + +NodeMutation NodeStore::updatePowerMetrics(NodeId id, const meshtastic_PowerMetrics &metrics) +{ + auto it = nodes.find(id); + if (it == nodes.end() || (!metrics.has_ch1_voltage && !metrics.has_ch1_current)) + return unchanged(id); + + auto retainedMetrics = it->second.environmentMetrics; + if (metrics.has_ch1_voltage) { + retainedMetrics.voltage = metrics.ch1_voltage; + } + if (metrics.has_ch1_current) { + retainedMetrics.current = metrics.ch1_current; + } + if (sameEnvironmentMetrics(it->second.environmentMetrics, retainedMetrics)) + return unchanged(id); + + it->second.environmentMetrics = retainedMetrics; + return updated(id, it->second.hasEnvironmentMetrics ? NodeFieldEnvironmentMetrics : NodeFieldPowerMetrics); +} + NodeMutation NodeStore::updateSignal(NodeId id, int32_t rssi, float snr) { auto it = nodes.find(id); diff --git a/source/graphics/view/TFT/VirtualNodeList.cpp b/source/graphics/view/TFT/VirtualNodeList.cpp index ad06a7fd1..f539c0cc3 100644 --- a/source/graphics/view/TFT/VirtualNodeList.cpp +++ b/source/graphics/view/TFT/VirtualNodeList.cpp @@ -29,12 +29,24 @@ size_t rowPoolSizeForViewport(lv_obj_t *parentPanel) lv_obj_update_layout(parentPanel); const int32_t contentHeight = lv_obj_get_height(parentPanel) - lv_obj_get_style_pad_top(parentPanel, LV_PART_MAIN) - lv_obj_get_style_pad_bottom(parentPanel, LV_PART_MAIN); + if (contentHeight <= 0) { + return 1; + } + constexpr int32_t rowPitch = VirtualNodeList::COLLAPSED_ROW_HEIGHT + VirtualNodeList::ROW_GAP; const size_t visibleRows = - static_cast(std::max(0, contentHeight) + VirtualNodeList::COLLAPSED_ROW_HEIGHT - 1) / - (VirtualNodeList::COLLAPSED_ROW_HEIGHT + VirtualNodeList::ROW_GAP); + static_cast(contentHeight + VirtualNodeList::ROW_GAP + rowPitch - 1) / static_cast(rowPitch); return std::clamp(visibleRows + 1, size_t{1}, VirtualNodeList::MAX_POOL_SIZE); } +bool sameRenderContext(const NodeListRenderContext &left, const NodeListRenderContext &right) +{ + return left.ownNode == right.ownNode && left.hasOwnPosition == right.hasOwnPosition && + left.ownLatitude == right.ownLatitude && left.ownLongitude == right.ownLongitude && + left.metricUnits == right.metricUnits && left.highlightActiveChat == right.highlightActiveChat && + left.highlightPosition == right.highlightPosition && left.highlightTelemetry == right.highlightTelemetry && + left.highlightIaq == right.highlightIaq && std::strcmp(left.highlightName, right.highlightName) == 0; +} + template void setRowText(lv_obj_t *label, char (&storage)[Size], const char *text) { std::snprintf(storage, Size, "%s", text ? text : ""); @@ -464,13 +476,7 @@ void VirtualNodeList::sync(const NodeStore &store, const VisibleNodeIndex &index const uint32_t nextTime = now != 0 ? now : static_cast(std::time(nullptr)); const bool indexChanged = currentIndex != &index || lastSyncedIndexGeneration != index.generation(); const bool timeChanged = currentTime != nextTime && visibleLastHeardLabelsNeedRefresh(store, nextTime, context); - const bool contextChanged = - renderContext.ownNode != context.ownNode || renderContext.hasOwnPosition != context.hasOwnPosition || - renderContext.ownLatitude != context.ownLatitude || renderContext.ownLongitude != context.ownLongitude || - renderContext.metricUnits != context.metricUnits || renderContext.highlightActiveChat != context.highlightActiveChat || - renderContext.highlightPosition != context.highlightPosition || - renderContext.highlightTelemetry != context.highlightTelemetry || renderContext.highlightIaq != context.highlightIaq || - std::strcmp(renderContext.highlightName, context.highlightName) != 0; + const bool contextChanged = !sameRenderContext(renderContext, context); const ScrollAnchor scrollAnchor = indexChanged ? captureScrollAnchor() : ScrollAnchor{}; const NodeId focusedId = currentFocusedId(); const size_t fallbackFocusIndex = lastFocusedIndex; @@ -1099,6 +1105,8 @@ void VirtualNodeList::refreshVisibleRows(bool force, bool rebind, bool reorder) lv_group_remove_obj(row.btn); } clearRowBinding(row); + usedRows[rowIndex] = false; + assignedRows[slot] = unusedRow; } } @@ -1132,8 +1140,10 @@ void VirtualNodeList::refreshVisibleRows(bool force, bool rebind, bool reorder) } } - const bool canRotateRows = - !force && !rebind && visibleCount == rowPool.size() && previousFirstRenderedIndex != std::numeric_limits::max(); + const bool allRowsAssigned = std::all_of(assignedRows.begin(), assignedRows.begin() + visibleCount, + [unusedRow](size_t rowIndex) { return rowIndex != unusedRow; }); + const bool canRotateRows = allRowsAssigned && !force && !rebind && visibleCount == rowPool.size() && + previousFirstRenderedIndex != std::numeric_limits::max(); const size_t forwardRows = canRotateRows && firstIdx > previousFirstRenderedIndex ? firstIdx - previousFirstRenderedIndex : 0; const size_t backwardRows = canRotateRows && previousFirstRenderedIndex > firstIdx ? previousFirstRenderedIndex - firstIdx : 0; @@ -1200,12 +1210,17 @@ bool VirtualNodeList::refreshNode(NodeId id, uint32_t now, const NodeListRenderC } currentTime = now != 0 ? now : static_cast(std::time(nullptr)); - renderContext = context; + const bool contextChanged = !sameRenderContext(renderContext, context); const auto *rec = currentStore->find(id); if (!rec) { return false; } + if (contextChanged) { + renderContext = context; + refreshVisibleRows(true, true); + return true; + } for (size_t index = 0; index < rowPool.size(); ++index) { auto &row = rowPool[index]; @@ -1314,6 +1329,16 @@ bool VirtualNodeList::longLabelsScrollForTesting(NodeId id) const } return false; } + +const char *VirtualNodeList::shortTextForTesting(NodeId id) const +{ + for (const auto &row : rowPool) { + if (row.boundId == id && row.panel && !lv_obj_has_flag(row.panel, LV_OBJ_FLAG_HIDDEN)) { + return row.shortText; + } + } + return ""; +} #endif void VirtualNodeList::rowPositionCallback(lv_event_t *e) diff --git a/tests/test_NodeStore.cpp b/tests/test_NodeStore.cpp index 3448f8961..dcaa91382 100644 --- a/tests/test_NodeStore.cpp +++ b/tests/test_NodeStore.cpp @@ -143,11 +143,15 @@ TEST_CASE("node store updates position telemetry and radio fields on an existing meshtastic_EnvironmentMetrics environment = meshtastic_EnvironmentMetrics_init_default; environment.has_temperature = true; environment.temperature = 22.5f; + meshtastic_AirQualityMetrics air = meshtastic_AirQualityMetrics_init_default; + air.has_pm25_standard = true; + air.pm25_standard = 17; CHECK(store.updatePosition(7, position).kind == NodeMutationKind::Updated); CHECK(store.updatePosition(7, position).kind == NodeMutationKind::Unchanged); CHECK(store.updateDeviceMetrics(7, device).kind == NodeMutationKind::Updated); CHECK(store.updateEnvironmentMetrics(7, environment).kind == NodeMutationKind::Updated); + CHECK(store.updateAirQualityMetrics(7, air).kind == NodeMutationKind::Updated); CHECK(store.updateSignal(7, -87, 6.25f).kind == NodeMutationKind::Updated); CHECK(store.updateHops(7, 3).kind == NodeMutationKind::Updated); CHECK(store.updateLastHeard(7, 999).kind == NodeMutationKind::Updated); @@ -164,6 +168,8 @@ TEST_CASE("node store updates position telemetry and radio fields on an existing CHECK(record.deviceMetrics.battery_level == 78); CHECK(record.hasEnvironmentMetrics); CHECK(record.environmentMetrics.temperature == doctest::Approx(22.5f)); + CHECK(record.hasAirQualityMetrics); + CHECK(record.airQualityMetrics.pm25_standard == 17); CHECK(record.rssi == -87); CHECK(record.snr == doctest::Approx(6.25f)); CHECK(record.hopsAway == 3); @@ -171,6 +177,42 @@ TEST_CASE("node store updates position telemetry and radio fields on an existing CHECK(record.hasActiveChat); } +TEST_CASE("node store carries power telemetry into IAQ environment rows without full protobuf storage") +{ + NodeStore store; + store.upsertUnknown(7, 0, 10, meshtastic_Config_DeviceConfig_Role_CLIENT, false, false); + + meshtastic_PowerMetrics power = meshtastic_PowerMetrics_init_default; + power.has_ch1_voltage = true; + power.ch1_voltage = 4.21f; + power.has_ch1_current = true; + power.ch1_current = 12.5f; + CHECK(store.updatePowerMetrics(7, power).kind == NodeMutationKind::Updated); + + meshtastic_EnvironmentMetrics environment = meshtastic_EnvironmentMetrics_init_default; + environment.has_temperature = true; + environment.temperature = 22.5f; + environment.has_barometric_pressure = true; + environment.barometric_pressure = 1013.2f; + environment.has_iaq = true; + environment.iaq = 88; + CHECK(store.updateEnvironmentMetrics(7, environment).kind == NodeMutationKind::Updated); + + REQUIRE(store.find(7) != nullptr); + const auto &record = *store.find(7); + CHECK(record.environmentMetrics.iaq == 88); + CHECK(record.environmentMetrics.voltage == doctest::Approx(4.21f)); + CHECK(record.environmentMetrics.current == doctest::Approx(12.5f)); + + environment.has_voltage = true; + environment.voltage = 0.0f; + environment.has_current = true; + environment.current = 0.0f; + CHECK(store.updateEnvironmentMetrics(7, environment).kind == NodeMutationKind::Updated); + CHECK(store.find(7)->environmentMetrics.voltage == doctest::Approx(0.0f)); + CHECK(store.find(7)->environmentMetrics.current == doctest::Approx(0.0f)); +} + TEST_CASE("node store retains channel and last-heard metadata when an unknown update fills identity") { NodeStore store; diff --git a/tests/test_VirtualNodeList.cpp b/tests/test_VirtualNodeList.cpp index 3d008f7dd..d919bb1eb 100644 --- a/tests/test_VirtualNodeList.cpp +++ b/tests/test_VirtualNodeList.cpp @@ -1,8 +1,10 @@ +#include "graphics/common/NodeListRowPresentation.h" #include "graphics/view/TFT/VirtualNodeList.h" #include #include +#include namespace { @@ -47,12 +49,44 @@ lv_obj_t *makeParent(lv_obj_t *screen) return parent; } +lv_obj_t *makeParentWithHeight(lv_obj_t *screen, int32_t height) +{ + auto *parent = lv_obj_create(screen); + lv_obj_set_size(parent, 180, height); + lv_obj_set_style_pad_all(parent, 0, LV_PART_MAIN | LV_STATE_DEFAULT); + lv_obj_set_scroll_dir(parent, LV_DIR_VER); + return parent; +} + void addNode(NodeStore &store, NodeId id, uint32_t lastHeard, const char *name) { store.upsertUser(id, 0, lastHeard, makeUser(id, name), false); } } // namespace +TEST_CASE("node row formats imperial mile distance with one decimal before rounding") +{ + char text[32]{}; + + NodeListRowPresentation::formatShortNameWithDistance(text, sizeof(text), nullptr, 0x1111, true, 0, 0, 179695, 0, false); + + CHECK(std::strstr(text, "1.2 mi") != nullptr); +} + +TEST_CASE("virtual node list allocates enough rows for viewport pitch and overscan") +{ + ensureLvgl(); + auto *screen = lv_obj_create(nullptr); + auto *parent = makeParentWithHeight(screen, VirtualNodeList::COLLAPSED_ROW_HEIGHT * 2 + VirtualNodeList::ROW_GAP * 2 + 1); + RecordingSink sink; + { + VirtualNodeList list(parent, sink); + + CHECK(list.rowPoolSizeForTesting() == 4); + } + lv_obj_delete(screen); +} + TEST_CASE("virtual node list keeps focused node identity across reorder") { ensureLvgl(); @@ -191,3 +225,68 @@ TEST_CASE("virtual node list repairs navigation order after deferred boundary sc lv_obj_delete(screen); } + +TEST_CASE("virtual node list removes missing records from navigation group during stale index refresh") +{ + ensureLvgl(); + auto *screen = lv_obj_create(nullptr); + auto *parent = makeParent(screen); + RecordingSink sink; + NodeStore store; + VisibleNodeIndex index; + NodeListFilter filter; + filter.curTime = 1000; + + addNode(store, 0x1111, 300, "Alpha"); + addNode(store, 0x2222, 200, "Bravo"); + addNode(store, 0x3333, 100, "Charlie"); + index.rebuild(store, filter, 0); + { + VirtualNodeList list(parent, sink); + list.sync(store, index, 0, filter.curTime); + REQUIRE(lv_group_get_obj_count(list.navigationGroup()) == 3); + + store.remove(0x2222); + list.refreshVisibleRows(true, false, true); + + CHECK(lv_group_get_obj_count(list.navigationGroup()) == 2); + } + + lv_obj_delete(screen); +} + +TEST_CASE("virtual node list rebinds all visible rows when refresh node changes render context") +{ + ensureLvgl(); + auto *screen = lv_obj_create(nullptr); + auto *parent = makeParent(screen); + RecordingSink sink; + NodeStore store; + VisibleNodeIndex index; + NodeListFilter filter; + filter.curTime = 1000; + + addNode(store, 0x1111, 300, "Alpha"); + addNode(store, 0x2222, 200, "Bravo"); + store.updatePosition(0x1111, {true, 179695, 0, 0, 0, 0}); + store.updatePosition(0x2222, {true, 269542, 0, 0, 0, 0}); + index.rebuild(store, filter, 0); + { + NodeListRenderContext context; + context.hasOwnPosition = true; + context.metricUnits = true; + + VirtualNodeList list(parent, sink); + list.sync(store, index, 0, filter.curTime, context); + REQUIRE(std::strstr(list.shortTextForTesting(0x1111), "km") != nullptr); + REQUIRE(std::strstr(list.shortTextForTesting(0x2222), "km") != nullptr); + + context.metricUnits = false; + REQUIRE(list.refreshNode(0x1111, filter.curTime, context)); + + CHECK(std::strstr(list.shortTextForTesting(0x1111), "mi") != nullptr); + CHECK(std::strstr(list.shortTextForTesting(0x2222), "mi") != nullptr); + } + + lv_obj_delete(screen); +} From 52000708a21dd600fa73e380bc9a9c441f5617f4 Mon Sep 17 00:00:00 2001 From: Benjamin Faershtein <119711889+RCGV1@users.noreply.github.com> Date: Fri, 28 Aug 2026 21:41:04 -0700 Subject: [PATCH 03/15] test(MUI): include C string helpers --- tests/test_VisibleNodeIndex.cpp | 1 + 1 file changed, 1 insertion(+) diff --git a/tests/test_VisibleNodeIndex.cpp b/tests/test_VisibleNodeIndex.cpp index 89e2833ea..becc49ce3 100644 --- a/tests/test_VisibleNodeIndex.cpp +++ b/tests/test_VisibleNodeIndex.cpp @@ -3,6 +3,7 @@ #include "graphics/common/VisibleNodeIndex.h" #include +#include namespace { From 1ca454d2ec8adc05826ccf0443c609414d4cc8d3 Mon Sep 17 00:00:00 2001 From: Benjamin Faershtein <119711889+RCGV1@users.noreply.github.com> Date: Fri, 28 Aug 2026 21:44:14 -0700 Subject: [PATCH 04/15] fix(MUI): rotate partially filled row pools --- source/graphics/view/TFT/VirtualNodeList.cpp | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/source/graphics/view/TFT/VirtualNodeList.cpp b/source/graphics/view/TFT/VirtualNodeList.cpp index f539c0cc3..3bb557208 100644 --- a/source/graphics/view/TFT/VirtualNodeList.cpp +++ b/source/graphics/view/TFT/VirtualNodeList.cpp @@ -1142,8 +1142,8 @@ void VirtualNodeList::refreshVisibleRows(bool force, bool rebind, bool reorder) const bool allRowsAssigned = std::all_of(assignedRows.begin(), assignedRows.begin() + visibleCount, [unusedRow](size_t rowIndex) { return rowIndex != unusedRow; }); - const bool canRotateRows = allRowsAssigned && !force && !rebind && visibleCount == rowPool.size() && - previousFirstRenderedIndex != std::numeric_limits::max(); + const bool canRotateRows = + allRowsAssigned && !force && !rebind && previousFirstRenderedIndex != std::numeric_limits::max(); const size_t forwardRows = canRotateRows && firstIdx > previousFirstRenderedIndex ? firstIdx - previousFirstRenderedIndex : 0; const size_t backwardRows = canRotateRows && previousFirstRenderedIndex > firstIdx ? previousFirstRenderedIndex - firstIdx : 0; From dda898ae486a10cae2c036e89a2ecce9e52c9347 Mon Sep 17 00:00:00 2001 From: Benjamin Faershtein <119711889+RCGV1@users.noreply.github.com> Date: Fri, 28 Aug 2026 21:47:35 -0700 Subject: [PATCH 05/15] test(MUI): respect synchronous scroll completion --- tests/test_VirtualNodeList.cpp | 5 ----- 1 file changed, 5 deletions(-) diff --git a/tests/test_VirtualNodeList.cpp b/tests/test_VirtualNodeList.cpp index d919bb1eb..e9446efb5 100644 --- a/tests/test_VirtualNodeList.cpp +++ b/tests/test_VirtualNodeList.cpp @@ -211,15 +211,10 @@ TEST_CASE("virtual node list repairs navigation order after deferred boundary sc const uint32_t baselineGroupMoves = list.groupOrderMoveCountForTesting(); lv_obj_scroll_to_y(parent, list.rowYForTesting(2), LV_ANIM_OFF); - list.refreshVisibleRows(false, false, false); - CHECK(list.groupOrderMoveCountForTesting() == baselineGroupMoves); - - list.refreshVisibleRows(false, false, true); CHECK(list.groupOrderMoveCountForTesting() > baselineGroupMoves); const uint32_t repairedGroupMoves = list.groupOrderMoveCountForTesting(); lv_obj_scroll_to_y(parent, list.rowYForTesting(2) + 1, LV_ANIM_OFF); - list.refreshVisibleRows(false, false, true); CHECK(list.groupOrderMoveCountForTesting() == repairedGroupMoves); } From be672fdff9ca1148659858758aaa3a2e70de5fc4 Mon Sep 17 00:00:00 2001 From: Benjamin Faershtein <119711889+RCGV1@users.noreply.github.com> Date: Fri, 28 Aug 2026 21:54:31 -0700 Subject: [PATCH 06/15] fix(MUI): size row pool for scroll phase --- source/graphics/view/TFT/VirtualNodeList.cpp | 3 +-- tests/test_VirtualNodeList.cpp | 14 ++++++++++++++ 2 files changed, 15 insertions(+), 2 deletions(-) diff --git a/source/graphics/view/TFT/VirtualNodeList.cpp b/source/graphics/view/TFT/VirtualNodeList.cpp index 3bb557208..2bffca413 100644 --- a/source/graphics/view/TFT/VirtualNodeList.cpp +++ b/source/graphics/view/TFT/VirtualNodeList.cpp @@ -33,8 +33,7 @@ size_t rowPoolSizeForViewport(lv_obj_t *parentPanel) return 1; } constexpr int32_t rowPitch = VirtualNodeList::COLLAPSED_ROW_HEIGHT + VirtualNodeList::ROW_GAP; - const size_t visibleRows = - static_cast(contentHeight + VirtualNodeList::ROW_GAP + rowPitch - 1) / static_cast(rowPitch); + const size_t visibleRows = static_cast(contentHeight + rowPitch * 2 - 2) / static_cast(rowPitch); return std::clamp(visibleRows + 1, size_t{1}, VirtualNodeList::MAX_POOL_SIZE); } diff --git a/tests/test_VirtualNodeList.cpp b/tests/test_VirtualNodeList.cpp index e9446efb5..dcfb299cd 100644 --- a/tests/test_VirtualNodeList.cpp +++ b/tests/test_VirtualNodeList.cpp @@ -87,6 +87,20 @@ TEST_CASE("virtual node list allocates enough rows for viewport pitch and oversc lv_obj_delete(screen); } +TEST_CASE("virtual node list covers the worst scroll phase") +{ + ensureLvgl(); + auto *screen = lv_obj_create(nullptr); + auto *parent = makeParentWithHeight(screen, 206); + RecordingSink sink; + { + VirtualNodeList list(parent, sink); + + CHECK(list.rowPoolSizeForTesting() == 6); + } + lv_obj_delete(screen); +} + TEST_CASE("virtual node list keeps focused node identity across reorder") { ensureLvgl(); From 9158ca05d56eba7dd59afc97354c68205475de12 Mon Sep 17 00:00:00 2001 From: Benjamin Faershtein <119711889+RCGV1@users.noreply.github.com> Date: Fri, 28 Aug 2026 22:19:49 -0700 Subject: [PATCH 07/15] fix(MUI): ignore incomplete telemetry updates --- source/graphics/common/NodeStore.cpp | 2 +- source/graphics/view/TFT/VirtualNodeList.cpp | 2 +- tests/test_NodeStore.cpp | 5 +++++ tests/test_VirtualNodeList.cpp | 4 ++-- 4 files changed, 9 insertions(+), 4 deletions(-) diff --git a/source/graphics/common/NodeStore.cpp b/source/graphics/common/NodeStore.cpp index 025d53437..721b15dfe 100644 --- a/source/graphics/common/NodeStore.cpp +++ b/source/graphics/common/NodeStore.cpp @@ -272,7 +272,7 @@ NodeMutation NodeStore::updateEnvironmentMetrics(NodeId id, const meshtastic_Env NodeMutation NodeStore::updateAirQualityMetrics(NodeId id, const meshtastic_AirQualityMetrics &metrics) { auto it = nodes.find(id); - if (it == nodes.end()) + if (it == nodes.end() || !metrics.has_pm25_standard) return unchanged(id); const auto retainedMetrics = summarizeAirQualityMetrics(metrics); if (it->second.hasAirQualityMetrics && sameAirQualityMetrics(it->second.airQualityMetrics, retainedMetrics)) diff --git a/source/graphics/view/TFT/VirtualNodeList.cpp b/source/graphics/view/TFT/VirtualNodeList.cpp index 2bffca413..e1f447756 100644 --- a/source/graphics/view/TFT/VirtualNodeList.cpp +++ b/source/graphics/view/TFT/VirtualNodeList.cpp @@ -1100,7 +1100,7 @@ void VirtualNodeList::refreshVisibleRows(bool force, bool rebind, bool reorder) lv_obj_remove_flag(row.panel, LV_OBJ_FLAG_HIDDEN); } else { lv_obj_add_flag(row.panel, LV_OBJ_FLAG_HIDDEN); - if (reorder && attachedGroup && row.btn && lv_obj_get_group(row.btn) == attachedGroup) { + if (attachedGroup && row.btn && lv_obj_get_group(row.btn) == attachedGroup) { lv_group_remove_obj(row.btn); } clearRowBinding(row); diff --git a/tests/test_NodeStore.cpp b/tests/test_NodeStore.cpp index dcaa91382..adc9fd731 100644 --- a/tests/test_NodeStore.cpp +++ b/tests/test_NodeStore.cpp @@ -170,6 +170,11 @@ TEST_CASE("node store updates position telemetry and radio fields on an existing CHECK(record.environmentMetrics.temperature == doctest::Approx(22.5f)); CHECK(record.hasAirQualityMetrics); CHECK(record.airQualityMetrics.pm25_standard == 17); + + meshtastic_AirQualityMetrics missingAir = meshtastic_AirQualityMetrics_init_default; + CHECK(store.updateAirQualityMetrics(7, missingAir).kind == NodeMutationKind::Unchanged); + CHECK(store.find(7)->hasAirQualityMetrics); + CHECK(store.find(7)->airQualityMetrics.pm25_standard == 17); CHECK(record.rssi == -87); CHECK(record.snr == doctest::Approx(6.25f)); CHECK(record.hopsAway == 3); diff --git a/tests/test_VirtualNodeList.cpp b/tests/test_VirtualNodeList.cpp index dcfb299cd..2b3274480 100644 --- a/tests/test_VirtualNodeList.cpp +++ b/tests/test_VirtualNodeList.cpp @@ -235,7 +235,7 @@ TEST_CASE("virtual node list repairs navigation order after deferred boundary sc lv_obj_delete(screen); } -TEST_CASE("virtual node list removes missing records from navigation group during stale index refresh") +TEST_CASE("virtual node list removes missing records from navigation group during scroll refresh") { ensureLvgl(); auto *screen = lv_obj_create(nullptr); @@ -256,7 +256,7 @@ TEST_CASE("virtual node list removes missing records from navigation group durin REQUIRE(lv_group_get_obj_count(list.navigationGroup()) == 3); store.remove(0x2222); - list.refreshVisibleRows(true, false, true); + list.refreshVisibleRows(true, false, false); CHECK(lv_group_get_obj_count(list.navigationGroup()) == 2); } From 924265ac38ad6199ad158c9b712ac03f34d34f53 Mon Sep 17 00:00:00 2001 From: Benjamin Faershtein <119711889+RCGV1@users.noreply.github.com> Date: Sat, 29 Aug 2026 10:11:45 -0700 Subject: [PATCH 08/15] fix(MUI): preserve partial telemetry updates --- include/graphics/common/MeshtasticView.h | 1 + include/graphics/view/TFT/TFTView_320x240.h | 1 + source/graphics/TFT/TFTView_320x240.cpp | 38 ++++++--- source/graphics/common/MeshtasticView.cpp | 2 + source/graphics/common/NodeStore.cpp | 77 ++++++++++++------ source/graphics/common/ViewController.cpp | 7 +- tests/test_NodeStore.cpp | 90 +++++++++++++++++++++ 7 files changed, 176 insertions(+), 40 deletions(-) diff --git a/include/graphics/common/MeshtasticView.h b/include/graphics/common/MeshtasticView.h index 1b7c40892..7676f8719 100644 --- a/include/graphics/common/MeshtasticView.h +++ b/include/graphics/common/MeshtasticView.h @@ -84,6 +84,7 @@ class MeshtasticView : public DeviceGUI virtual void updateNode(uint32_t nodeNum, uint8_t channel, const meshtastic_User &cfg); virtual void updatePosition(uint32_t nodeNum, int32_t lat, int32_t lon, int32_t alt, uint32_t sats, uint32_t precision); virtual void updateMetrics(uint32_t nodeNum, uint32_t bat_level, float voltage, float chUtil, float airUtil); + virtual void updateMetrics(uint32_t nodeNum, const meshtastic_DeviceMetrics &metrics); virtual void updateEnvironmentMetrics(uint32_t nodeNum, const meshtastic_EnvironmentMetrics &metrics) {} virtual void updateAirQualityMetrics(uint32_t nodeNum, const meshtastic_AirQualityMetrics &metrics) {} virtual void updatePowerMetrics(uint32_t nodeNum, const meshtastic_PowerMetrics &metrics) {} diff --git a/include/graphics/view/TFT/TFTView_320x240.h b/include/graphics/view/TFT/TFTView_320x240.h index 4510817ba..27164acfa 100644 --- a/include/graphics/view/TFT/TFTView_320x240.h +++ b/include/graphics/view/TFT/TFTView_320x240.h @@ -37,6 +37,7 @@ class TFTView_320x240 : public MeshtasticView, private NodeListActionSink void updateNode(uint32_t nodeNum, uint8_t channel, const meshtastic_User &cfg) override; void updatePosition(uint32_t nodeNum, int32_t lat, int32_t lon, int32_t alt, uint32_t sats, uint32_t precision) override; void updateMetrics(uint32_t nodeNum, uint32_t bat_level, float voltage, float chUtil, float airUtil) override; + void updateMetrics(uint32_t nodeNum, const meshtastic_DeviceMetrics &metrics) override; void updateEnvironmentMetrics(uint32_t nodeNum, const meshtastic_EnvironmentMetrics &metrics) override; void updateAirQualityMetrics(uint32_t nodeNum, const meshtastic_AirQualityMetrics &metrics) override; void updatePowerMetrics(uint32_t nodeNum, const meshtastic_PowerMetrics &metrics) override; diff --git a/source/graphics/TFT/TFTView_320x240.cpp b/source/graphics/TFT/TFTView_320x240.cpp index 5f2d52488..426559de3 100644 --- a/source/graphics/TFT/TFTView_320x240.cpp +++ b/source/graphics/TFT/TFTView_320x240.cpp @@ -2627,7 +2627,7 @@ void TFTView_320x240::loadMap(void) sortedLon.reserve(nodeObjects.size()); for (auto it : nodeObjects) { const NodePosition position = nodePosition(it.first); - if (position.known && position.latitude && position.longitude) { + if (position.hasCoordinates()) { sortedLat.push_back(position.latitude); sortedLon.push_back(position.longitude); } @@ -4741,7 +4741,8 @@ void TFTView_320x240::updateNode(uint32_t nodeNum, uint8_t ch, const meshtastic_ void TFTView_320x240::updatePosition(uint32_t nodeNum, int32_t lat, int32_t lon, int32_t alt, uint32_t sats, uint32_t precision) { - const NodeMutation mutation = nodeStore.updatePosition(nodeNum, {true, lat, lon, alt, sats, precision}); + const NodePosition position{true, lat, lon, alt, sats, precision}; + const NodeMutation mutation = nodeStore.updatePosition(nodeNum, position); int32_t shownAltitude = alt > -10000 && alt < 10000 ? alt : 0; const bool metric = db.config.display.units == meshtastic_Config_DisplayConfig_DisplayUnits_METRIC; const char *units = metric ? "m" : "ft"; @@ -4764,14 +4765,14 @@ void TFTView_320x240::updatePosition(uint32_t nodeNum, int32_t lat, int32_t lon, sprintf(buf, "%c%02i° %2i'%02i\"\n%c%02i° %2i'%02i\" %d%s", lat > 0 ? 'N' : 'S', abs(latTotal / 3600), latMinutes, latSeconds, lon > 0 ? 'E' : 'W', abs(lonTotal / 3600), lonMinutes, lonSeconds, shownAltitude, units); lv_label_set_text(objects.home_location_label, buf); - if (lat != 0 || lon != 0) { + if (position.hasCoordinates()) { hasPosition = true; myLatitude = lat; myLongitude = lon; if (map) map->setGpsPosition(lat * 1e-7, lon * 1e-7); } - } else if (lat != 0 && lon != 0) { + } else if (position.hasCoordinates()) { addOrUpdateMap(nodeNum, lat, lon); } syncNodeListPresentation(mutation); @@ -4789,19 +4790,28 @@ void TFTView_320x240::updatePosition(uint32_t nodeNum, int32_t lat, int32_t lon, void TFTView_320x240::updateMetrics(uint32_t nodeNum, uint32_t bat_level, float voltage, float chUtil, float airUtil) { meshtastic_DeviceMetrics metrics = meshtastic_DeviceMetrics_init_default; + metrics.has_battery_level = true; metrics.battery_level = bat_level; + metrics.has_voltage = true; metrics.voltage = voltage; + metrics.has_channel_utilization = true; metrics.channel_utilization = chUtil; + metrics.has_air_util_tx = true; metrics.air_util_tx = airUtil; + updateMetrics(nodeNum, metrics); +} + +void TFTView_320x240::updateMetrics(uint32_t nodeNum, const meshtastic_DeviceMetrics &metrics) +{ const NodeMutation mutation = nodeStore.updateDeviceMetrics(nodeNum, metrics); - if (nodeNum == ownNode && (bat_level != 0 || voltage != 0)) { + if (nodeNum == ownNode && (metrics.battery_level != 0 || metrics.voltage != 0)) { char buf[16]; - const uint32_t shownLevel = std::min(bat_level, static_cast(100)); + const uint32_t shownLevel = std::min(metrics.battery_level, static_cast(100)); sprintf(buf, "%d%%", shownLevel); bool alert = false; BatteryLevel level; - switch (level.calcStatus(bat_level, voltage)) { + switch (level.calcStatus(metrics.battery_level, metrics.voltage)) { case BatteryLevel::Plugged: lv_obj_set_style_bg_image_src(objects.battery_image, &img_battery_plug_image, LV_PART_MAIN | LV_STATE_DEFAULT); if (shownLevel == 100) @@ -5340,13 +5350,17 @@ void TFTView_320x240::notifyShutdown(void) void TFTView_320x240::blankScreen(bool enable) { ILOG_DEBUG("%s screen (%s)", enable ? "blank" : "unblank", screenLocked ? "locked" : "timeout"); - if (enable) + if (enable) { + setInputGroup(); lv_screen_load_anim(objects.blank_screen, LV_SCR_LOAD_ANIM_FADE_OUT, 1000, 0, false); - else { + } else { if (objects.main_screen) lv_screen_load_anim(objects.main_screen, LV_SCR_LOAD_ANIM_NONE, 0, 0, false); else lv_screen_load_anim(objects.boot_screen, LV_SCR_LOAD_ANIM_NONE, 0, 0, false); + if (objects.nodes_panel && activePanel == objects.nodes_panel) { + reconcileVirtualNodeListInputGroup(true); + } } } @@ -5354,6 +5368,7 @@ void TFTView_320x240::screenSaving(bool enabled) { if (enabled) { // overlay main screen with blank screen to prevent accidentally pressing buttons + setInputGroup(); lv_screen_load_anim(objects.blank_screen, LV_SCR_LOAD_ANIM_FADE_OUT, 0, 0, false); lv_group_focus_obj(objects.blank_screen_button); screenLocked = true; @@ -5370,6 +5385,9 @@ void TFTView_320x240::screenSaving(bool enabled) ui_event_cancel(&e); } screenLocked = false; + if (objects.nodes_panel && activePanel == objects.nodes_panel) { + reconcileVirtualNodeListInputGroup(true); + } } else { ILOG_DEBUG("showing boot screen"); lv_screen_load_anim(objects.boot_screen, LV_SCR_LOAD_ANIM_NONE, 0, 0, false); @@ -6825,7 +6843,7 @@ void TFTView_320x240::nodeFocusBoundary(bool forward) void TFTView_320x240::nodePositionClicked(NodeId id) { const NodePosition position = nodePosition(id); - if (position.known && position.latitude && position.longitude) { + if (position.hasCoordinates()) { ui_set_active(objects.map_button, objects.map_panel, objects.top_map_panel); if (!map) { loadMap(); diff --git a/source/graphics/common/MeshtasticView.cpp b/source/graphics/common/MeshtasticView.cpp index 12a16473b..d3667ac80 100644 --- a/source/graphics/common/MeshtasticView.cpp +++ b/source/graphics/common/MeshtasticView.cpp @@ -84,6 +84,8 @@ void MeshtasticView::updatePosition(uint32_t nodeNum, int32_t lat, int32_t lon, void MeshtasticView::updateMetrics(uint32_t nodeNum, uint32_t bat_level, float voltage, float chUtil, float airUtil) {} +void MeshtasticView::updateMetrics(uint32_t nodeNum, const meshtastic_DeviceMetrics &metrics) {} + void MeshtasticView::updateSignalStrength(uint32_t nodeNum, int32_t rssi, float snr) {} void MeshtasticView::notifyMessagesRestored(void) diff --git a/source/graphics/common/NodeStore.cpp b/source/graphics/common/NodeStore.cpp index 721b15dfe..f37e1f490 100644 --- a/source/graphics/common/NodeStore.cpp +++ b/source/graphics/common/NodeStore.cpp @@ -59,15 +59,58 @@ bool samePublicKey(const NodeUserSummary &left, const NodeUserSummary &right) return left.publicKeySize == right.publicKeySize && left.publicKeyHash == right.publicKeyHash; } -NodeDeviceMetrics summarizeDeviceMetrics(const meshtastic_DeviceMetrics &metrics) +bool hasDeviceMetricsFields(const meshtastic_DeviceMetrics &metrics) { - return {metrics.battery_level, metrics.voltage, metrics.channel_utilization, metrics.air_util_tx}; + return metrics.has_battery_level || metrics.has_voltage || metrics.has_channel_utilization || metrics.has_air_util_tx; } -NodeEnvironmentMetrics summarizeEnvironmentMetrics(const meshtastic_EnvironmentMetrics &metrics) +bool hasEnvironmentMetricsFields(const meshtastic_EnvironmentMetrics &metrics) { - return {metrics.temperature, metrics.relative_humidity, metrics.barometric_pressure, - metrics.voltage, metrics.current, metrics.iaq}; + return metrics.has_temperature || metrics.has_relative_humidity || metrics.has_barometric_pressure || metrics.has_voltage || + metrics.has_current || metrics.has_iaq; +} + +NodeDeviceMetrics summarizeDeviceMetrics(const meshtastic_DeviceMetrics &metrics, const NodeDeviceMetrics &previous) +{ + NodeDeviceMetrics summary = previous; + if (metrics.has_battery_level) { + summary.battery_level = metrics.battery_level; + } + if (metrics.has_voltage) { + summary.voltage = metrics.voltage; + } + if (metrics.has_channel_utilization) { + summary.channel_utilization = metrics.channel_utilization; + } + if (metrics.has_air_util_tx) { + summary.air_util_tx = metrics.air_util_tx; + } + return summary; +} + +NodeEnvironmentMetrics summarizeEnvironmentMetrics(const meshtastic_EnvironmentMetrics &metrics, + const NodeEnvironmentMetrics &previous) +{ + NodeEnvironmentMetrics summary = previous; + if (metrics.has_temperature) { + summary.temperature = metrics.temperature; + } + if (metrics.has_relative_humidity) { + summary.relative_humidity = metrics.relative_humidity; + } + if (metrics.has_barometric_pressure) { + summary.barometric_pressure = metrics.barometric_pressure; + } + if (metrics.has_voltage) { + summary.voltage = metrics.voltage; + } + if (metrics.has_current) { + summary.current = metrics.current; + } + if (metrics.has_iaq) { + summary.iaq = metrics.iaq; + } + return summary; } NodeAirQualityMetrics summarizeAirQualityMetrics(const meshtastic_AirQualityMetrics &metrics) @@ -230,13 +273,9 @@ NodeMutation NodeStore::updatePosition(NodeId id, const NodePosition &position) NodeMutation NodeStore::updateDeviceMetrics(NodeId id, const meshtastic_DeviceMetrics &metrics) { auto it = nodes.find(id); - if (it == nodes.end()) + if (it == nodes.end() || !hasDeviceMetricsFields(metrics)) return unchanged(id); - auto retainedMetrics = summarizeDeviceMetrics(metrics); - if (it->second.hasDeviceMetrics && retainedMetrics.battery_level == 0 && retainedMetrics.voltage == 0.0f) { - retainedMetrics.battery_level = it->second.deviceMetrics.battery_level; - retainedMetrics.voltage = it->second.deviceMetrics.voltage; - } + auto retainedMetrics = summarizeDeviceMetrics(metrics, it->second.deviceMetrics); if (it->second.hasDeviceMetrics && sameDeviceMetrics(it->second.deviceMetrics, retainedMetrics)) return unchanged(id); it->second.hasDeviceMetrics = true; @@ -247,21 +286,9 @@ NodeMutation NodeStore::updateDeviceMetrics(NodeId id, const meshtastic_DeviceMe NodeMutation NodeStore::updateEnvironmentMetrics(NodeId id, const meshtastic_EnvironmentMetrics &metrics) { auto it = nodes.find(id); - if (it == nodes.end()) + if (it == nodes.end() || !hasEnvironmentMetricsFields(metrics)) return unchanged(id); - auto retainedMetrics = summarizeEnvironmentMetrics(metrics); - if (!metrics.has_voltage && it->second.environmentMetrics.voltage != 0.0f) { - retainedMetrics.voltage = it->second.environmentMetrics.voltage; - } - if (!metrics.has_current && it->second.environmentMetrics.current != 0.0f) { - retainedMetrics.current = it->second.environmentMetrics.current; - } - if (it->second.hasEnvironmentMetrics && (retainedMetrics.iaq == 0 || retainedMetrics.iaq >= 1000) && - it->second.environmentMetrics.iaq > 0 && it->second.environmentMetrics.iaq < 1000) { - retainedMetrics.iaq = it->second.environmentMetrics.iaq; - retainedMetrics.voltage = it->second.environmentMetrics.voltage; - retainedMetrics.current = it->second.environmentMetrics.current; - } + auto retainedMetrics = summarizeEnvironmentMetrics(metrics, it->second.environmentMetrics); if (it->second.hasEnvironmentMetrics && sameEnvironmentMetrics(it->second.environmentMetrics, retainedMetrics)) return unchanged(id); it->second.hasEnvironmentMetrics = true; diff --git a/source/graphics/common/ViewController.cpp b/source/graphics/common/ViewController.cpp index 57e8a45d0..eb1c7c293 100644 --- a/source/graphics/common/ViewController.cpp +++ b/source/graphics/common/ViewController.cpp @@ -736,8 +736,7 @@ bool ViewController::handleFromRadio(const meshtastic_FromRadio &from) node.position.precision_bits); } if (node.has_device_metrics) { - view->updateMetrics(node.num, node.device_metrics.battery_level, node.device_metrics.voltage, - node.device_metrics.channel_utilization, node.device_metrics.air_util_tx); + view->updateMetrics(node.num, node.device_metrics); } break; } @@ -995,9 +994,7 @@ bool ViewController::packetReceived(const meshtastic_MeshPacket &p) if (pb_decode_from_bytes(p.decoded.payload.bytes, p.decoded.payload.size, &meshtastic_Telemetry_msg, &telemetry)) { switch (telemetry.which_variant) { case meshtastic_Telemetry_device_metrics_tag: { - meshtastic_DeviceMetrics &metrics = telemetry.variant.device_metrics; - view->updateMetrics(p.from, metrics.battery_level, metrics.voltage, metrics.channel_utilization, - metrics.air_util_tx); + view->updateMetrics(p.from, telemetry.variant.device_metrics); break; } case meshtastic_Telemetry_environment_metrics_tag: { diff --git a/tests/test_NodeStore.cpp b/tests/test_NodeStore.cpp index adc9fd731..c80e50292 100644 --- a/tests/test_NodeStore.cpp +++ b/tests/test_NodeStore.cpp @@ -270,6 +270,96 @@ TEST_CASE("node store preserves legacy-visible position and battery fields acros CHECK(store.find(7)->deviceMetrics.air_util_tx == doctest::Approx(0.0f)); } +TEST_CASE("node store preserves absent device metrics fields and accepts explicit zero values") +{ + NodeStore store; + store.upsertUnknown(7, 0, 10, meshtastic_Config_DeviceConfig_Role_CLIENT, false, false); + + CHECK(store.updateDeviceMetrics(7, meshtastic_DeviceMetrics_init_default).kind == NodeMutationKind::Unchanged); + CHECK_FALSE(store.find(7)->hasDeviceMetrics); + + meshtastic_DeviceMetrics metrics = meshtastic_DeviceMetrics_init_default; + metrics.has_battery_level = true; + metrics.battery_level = 78; + metrics.has_voltage = true; + metrics.voltage = 4.12f; + CHECK(store.updateDeviceMetrics(7, metrics).kind == NodeMutationKind::Updated); + + meshtastic_DeviceMetrics missingBatteryAndVoltage = meshtastic_DeviceMetrics_init_default; + missingBatteryAndVoltage.has_channel_utilization = true; + missingBatteryAndVoltage.channel_utilization = 1.5f; + CHECK(store.updateDeviceMetrics(7, missingBatteryAndVoltage).kind == NodeMutationKind::Updated); + REQUIRE(store.find(7) != nullptr); + CHECK(store.find(7)->deviceMetrics.battery_level == 78); + CHECK(store.find(7)->deviceMetrics.voltage == doctest::Approx(4.12f)); + CHECK(store.find(7)->deviceMetrics.channel_utilization == doctest::Approx(1.5f)); + + meshtastic_DeviceMetrics explicitZeros = meshtastic_DeviceMetrics_init_default; + explicitZeros.has_battery_level = true; + explicitZeros.battery_level = 0; + explicitZeros.has_voltage = true; + explicitZeros.voltage = 0.0f; + CHECK(store.updateDeviceMetrics(7, explicitZeros).kind == NodeMutationKind::Updated); + CHECK(store.find(7)->deviceMetrics.battery_level == 0); + CHECK(store.find(7)->deviceMetrics.voltage == doctest::Approx(0.0f)); + CHECK(store.find(7)->deviceMetrics.channel_utilization == doctest::Approx(1.5f)); +} + +TEST_CASE("node store preserves absent environment metrics fields and accepts explicit zero values") +{ + NodeStore store; + store.upsertUnknown(7, 0, 10, meshtastic_Config_DeviceConfig_Role_CLIENT, false, false); + + CHECK(store.updateEnvironmentMetrics(7, meshtastic_EnvironmentMetrics_init_default).kind == NodeMutationKind::Unchanged); + CHECK_FALSE(store.find(7)->hasEnvironmentMetrics); + + meshtastic_EnvironmentMetrics metrics = meshtastic_EnvironmentMetrics_init_default; + metrics.has_temperature = true; + metrics.temperature = 22.5f; + metrics.has_relative_humidity = true; + metrics.relative_humidity = 48.0f; + metrics.has_barometric_pressure = true; + metrics.barometric_pressure = 1013.2f; + metrics.has_voltage = true; + metrics.voltage = 4.25f; + metrics.has_current = true; + metrics.current = 12.5f; + metrics.has_iaq = true; + metrics.iaq = 88; + CHECK(store.updateEnvironmentMetrics(7, metrics).kind == NodeMutationKind::Updated); + + meshtastic_EnvironmentMetrics missingPowerAndIaq = meshtastic_EnvironmentMetrics_init_default; + missingPowerAndIaq.has_temperature = true; + missingPowerAndIaq.temperature = 23.0f; + CHECK(store.updateEnvironmentMetrics(7, missingPowerAndIaq).kind == NodeMutationKind::Updated); + REQUIRE(store.find(7) != nullptr); + CHECK(store.find(7)->environmentMetrics.temperature == doctest::Approx(23.0f)); + CHECK(store.find(7)->environmentMetrics.relative_humidity == doctest::Approx(48.0f)); + CHECK(store.find(7)->environmentMetrics.barometric_pressure == doctest::Approx(1013.2f)); + CHECK(store.find(7)->environmentMetrics.voltage == doctest::Approx(4.25f)); + CHECK(store.find(7)->environmentMetrics.current == doctest::Approx(12.5f)); + CHECK(store.find(7)->environmentMetrics.iaq == 88); + + meshtastic_EnvironmentMetrics explicitZeros = meshtastic_EnvironmentMetrics_init_default; + explicitZeros.has_relative_humidity = true; + explicitZeros.relative_humidity = 0.0f; + explicitZeros.has_barometric_pressure = true; + explicitZeros.barometric_pressure = 0.0f; + explicitZeros.has_voltage = true; + explicitZeros.voltage = 0.0f; + explicitZeros.has_current = true; + explicitZeros.current = 0.0f; + explicitZeros.has_iaq = true; + explicitZeros.iaq = 0; + CHECK(store.updateEnvironmentMetrics(7, explicitZeros).kind == NodeMutationKind::Updated); + CHECK(store.find(7)->environmentMetrics.temperature == doctest::Approx(23.0f)); + CHECK(store.find(7)->environmentMetrics.relative_humidity == doctest::Approx(0.0f)); + CHECK(store.find(7)->environmentMetrics.barometric_pressure == doctest::Approx(0.0f)); + CHECK(store.find(7)->environmentMetrics.voltage == doctest::Approx(0.0f)); + CHECK(store.find(7)->environmentMetrics.current == doctest::Approx(0.0f)); + CHECK(store.find(7)->environmentMetrics.iaq == 0); +} + TEST_CASE("node store accepts positions on the equator and prime meridian") { NodeStore store; From 3d915d050709da12806488009f656c403007442f Mon Sep 17 00:00:00 2001 From: Benjamin Faershtein <119711889+RCGV1@users.noreply.github.com> Date: Sat, 29 Aug 2026 10:22:30 -0700 Subject: [PATCH 09/15] fix(MUI): restore input after screen unlock --- source/graphics/TFT/TFTView_320x240.cpp | 3 +++ 1 file changed, 3 insertions(+) diff --git a/source/graphics/TFT/TFTView_320x240.cpp b/source/graphics/TFT/TFTView_320x240.cpp index 426559de3..7c06af632 100644 --- a/source/graphics/TFT/TFTView_320x240.cpp +++ b/source/graphics/TFT/TFTView_320x240.cpp @@ -2314,6 +2314,9 @@ void TFTView_320x240::ui_event_pin_screen_button(lv_event_t *e) screenLocked = false; lv_obj_clear_flag(objects.tab_page_basic_settings, LV_OBJ_FLAG_HIDDEN); lv_screen_load_anim(objects.main_screen, LV_SCR_LOAD_ANIM_FADE_IN, 100, 0, false); + if (objects.nodes_panel && activePanel == objects.nodes_panel) { + reconcileVirtualNodeListInputGroup(true); + } lv_label_set_text(objects.lock_screen_digits_label, hidden[pinKeys]); } } From 224667f393e172c4d2ab5f749516065fffc123be Mon Sep 17 00:00:00 2001 From: Benjamin Faershtein <119711889+RCGV1@users.noreply.github.com> Date: Sat, 29 Aug 2026 10:23:37 -0700 Subject: [PATCH 10/15] fix(MUI): bind unlock input restoration to view --- source/graphics/TFT/TFTView_320x240.cpp | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/source/graphics/TFT/TFTView_320x240.cpp b/source/graphics/TFT/TFTView_320x240.cpp index 7c06af632..624b6cdb8 100644 --- a/source/graphics/TFT/TFTView_320x240.cpp +++ b/source/graphics/TFT/TFTView_320x240.cpp @@ -2314,8 +2314,8 @@ void TFTView_320x240::ui_event_pin_screen_button(lv_event_t *e) screenLocked = false; lv_obj_clear_flag(objects.tab_page_basic_settings, LV_OBJ_FLAG_HIDDEN); lv_screen_load_anim(objects.main_screen, LV_SCR_LOAD_ANIM_FADE_IN, 100, 0, false); - if (objects.nodes_panel && activePanel == objects.nodes_panel) { - reconcileVirtualNodeListInputGroup(true); + if (objects.nodes_panel && THIS->activePanel == objects.nodes_panel) { + THIS->reconcileVirtualNodeListInputGroup(true); } lv_label_set_text(objects.lock_screen_digits_label, hidden[pinKeys]); } From f44f2c06e51067443251da376b74c833d8ebef20 Mon Sep 17 00:00:00 2001 From: mverch67 Date: Sat, 29 Aug 2026 20:43:01 +0200 Subject: [PATCH 11/15] replace deprecated lv_text_get_width() --- include/graphics/common/NodeListRowPresentation.h | 4 +++- source/graphics/TFT/TFTView_320x240.cpp | 8 ++++++-- 2 files changed, 9 insertions(+), 3 deletions(-) diff --git a/include/graphics/common/NodeListRowPresentation.h b/include/graphics/common/NodeListRowPresentation.h index 22f1e4535..a96e26682 100644 --- a/include/graphics/common/NodeListRowPresentation.h +++ b/include/graphics/common/NodeListRowPresentation.h @@ -78,7 +78,9 @@ inline bool shortNameFallsBackToId(const char *shortName) if (length == 0) { return true; } - return lv_txt_get_width(shortName, static_cast(length), &ui_font_montserrat_14, 0) <= 4; + lv_point_t size; + lv_text_get_size(&size, shortName, &ui_font_montserrat_14, 0, 0, LV_COORD_MAX, LV_TEXT_FLAG_NONE); + return size.x <= 4; } inline void formatShortDisplayName(char *dest, size_t destSize, const char *shortName, NodeId nodeId) diff --git a/source/graphics/TFT/TFTView_320x240.cpp b/source/graphics/TFT/TFTView_320x240.cpp index 624b6cdb8..c105d7933 100644 --- a/source/graphics/TFT/TFTView_320x240.cpp +++ b/source/graphics/TFT/TFTView_320x240.cpp @@ -4585,7 +4585,9 @@ void TFTView_320x240::addMessage(lv_obj_t *container, uint32_t msgTime, uint32_t lv_obj_t *textLabel = lv_label_create(hiddenPanel); // calculate expected size of text bubble, to make it look nicer - lv_coord_t width = lv_txt_get_width(buf, strlen(buf), &ui_font_montserrat_12, 0); + lv_point_t size; + lv_text_get_size(&size, buf, &ui_font_montserrat_14, 0, 0, LV_COORD_MAX, LV_TEXT_FLAG_NONE); + lv_coord_t width = size.x; lv_obj_set_width(textLabel, std::max(std::min(width, 200) + 10, 40)); lv_obj_set_height(textLabel, LV_SIZE_CONTENT); lv_obj_set_y(textLabel, 0); @@ -6048,7 +6050,9 @@ void TFTView_320x240::newMessage(uint32_t nodeNum, lv_obj_t *container, uint8_t lv_obj_t *msgLabel = lv_label_create(hiddenPanel); // calculate expected size of text bubble, to make it look nicer - lv_coord_t width = lv_txt_get_width(msg, strlen(msg), &ui_font_montserrat_14, 0); + lv_point_t size; + lv_text_get_size(&size, msg, &ui_font_montserrat_14, 0, 0, LV_COORD_MAX, LV_TEXT_FLAG_NONE); + lv_coord_t width = size.x; lv_obj_set_width(msgLabel, std::max(std::min((int32_t)(width), 160) + 10, 40)); lv_obj_set_height(msgLabel, LV_SIZE_CONTENT); lv_obj_set_align(msgLabel, LV_ALIGN_LEFT_MID); From 3f4c675e70fe82d3c95abdd398d1027e57c5a304 Mon Sep 17 00:00:00 2001 From: Benjamin Faershtein <119711889+RCGV1@users.noreply.github.com> Date: Sat, 29 Aug 2026 12:31:56 -0700 Subject: [PATCH 12/15] fix(MUI): measure outgoing chat bubbles with rendered font --- source/graphics/TFT/TFTView_320x240.cpp | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/source/graphics/TFT/TFTView_320x240.cpp b/source/graphics/TFT/TFTView_320x240.cpp index c105d7933..b7c59c20f 100644 --- a/source/graphics/TFT/TFTView_320x240.cpp +++ b/source/graphics/TFT/TFTView_320x240.cpp @@ -4586,7 +4586,7 @@ void TFTView_320x240::addMessage(lv_obj_t *container, uint32_t msgTime, uint32_t lv_obj_t *textLabel = lv_label_create(hiddenPanel); // calculate expected size of text bubble, to make it look nicer lv_point_t size; - lv_text_get_size(&size, buf, &ui_font_montserrat_14, 0, 0, LV_COORD_MAX, LV_TEXT_FLAG_NONE); + lv_text_get_size(&size, buf, &ui_font_montserrat_12, 0, 0, LV_COORD_MAX, LV_TEXT_FLAG_NONE); lv_coord_t width = size.x; lv_obj_set_width(textLabel, std::max(std::min(width, 200) + 10, 40)); lv_obj_set_height(textLabel, LV_SIZE_CONTENT); From 7c67278fb94dc5833827f16d567cb78f504eeda2 Mon Sep 17 00:00:00 2001 From: mverch67 Date: Sun, 30 Aug 2026 00:49:25 +0200 Subject: [PATCH 13/15] fix size calculation chat message font 14 (pixel) --- source/graphics/TFT/TFTView_320x240.cpp | 8 +++----- 1 file changed, 3 insertions(+), 5 deletions(-) diff --git a/source/graphics/TFT/TFTView_320x240.cpp b/source/graphics/TFT/TFTView_320x240.cpp index b7c59c20f..39b0ff338 100644 --- a/source/graphics/TFT/TFTView_320x240.cpp +++ b/source/graphics/TFT/TFTView_320x240.cpp @@ -4586,9 +4586,8 @@ void TFTView_320x240::addMessage(lv_obj_t *container, uint32_t msgTime, uint32_t lv_obj_t *textLabel = lv_label_create(hiddenPanel); // calculate expected size of text bubble, to make it look nicer lv_point_t size; - lv_text_get_size(&size, buf, &ui_font_montserrat_12, 0, 0, LV_COORD_MAX, LV_TEXT_FLAG_NONE); - lv_coord_t width = size.x; - lv_obj_set_width(textLabel, std::max(std::min(width, 200) + 10, 40)); + lv_text_get_size(&size, buf, &ui_font_montserrat_14, 0, 0, LV_COORD_MAX, LV_TEXT_FLAG_NONE); + lv_obj_set_width(textLabel, std::max(std::min(size.x, 200) + 10, 40)); lv_obj_set_height(textLabel, LV_SIZE_CONTENT); lv_obj_set_y(textLabel, 0); lv_obj_set_align(textLabel, LV_ALIGN_RIGHT_MID); @@ -6052,8 +6051,7 @@ void TFTView_320x240::newMessage(uint32_t nodeNum, lv_obj_t *container, uint8_t // calculate expected size of text bubble, to make it look nicer lv_point_t size; lv_text_get_size(&size, msg, &ui_font_montserrat_14, 0, 0, LV_COORD_MAX, LV_TEXT_FLAG_NONE); - lv_coord_t width = size.x; - lv_obj_set_width(msgLabel, std::max(std::min((int32_t)(width), 160) + 10, 40)); + lv_obj_set_width(msgLabel, std::max(std::min((int32_t)(size.x), 160) + 10, 40)); lv_obj_set_height(msgLabel, LV_SIZE_CONTENT); lv_obj_set_align(msgLabel, LV_ALIGN_LEFT_MID); lv_label_set_text(msgLabel, msg); From 44d4e5bfffc777c31f80e67dcec1c32d6c00b813 Mon Sep 17 00:00:00 2001 From: mverch67 Date: Sun, 30 Aug 2026 02:00:30 +0200 Subject: [PATCH 14/15] allow enough rows for larger screens (and make it configurable for specific devices) --- include/graphics/view/TFT/VirtualNodeList.h | 1 - source/graphics/view/TFT/VirtualNodeList.cpp | 6 +++++- 2 files changed, 5 insertions(+), 2 deletions(-) diff --git a/include/graphics/view/TFT/VirtualNodeList.h b/include/graphics/view/TFT/VirtualNodeList.h index 5b2a9ebe7..600b29b5b 100644 --- a/include/graphics/view/TFT/VirtualNodeList.h +++ b/include/graphics/view/TFT/VirtualNodeList.h @@ -83,7 +83,6 @@ struct ReusableRow { class VirtualNodeList { public: - static constexpr size_t MAX_POOL_SIZE = 9; static constexpr int32_t COLLAPSED_ROW_HEIGHT = 53; static constexpr int32_t EXPANDED_ROW_HEIGHT = 83; static constexpr int32_t ROW_GAP = 5; diff --git a/source/graphics/view/TFT/VirtualNodeList.cpp b/source/graphics/view/TFT/VirtualNodeList.cpp index e1f447756..9c9e21323 100644 --- a/source/graphics/view/TFT/VirtualNodeList.cpp +++ b/source/graphics/view/TFT/VirtualNodeList.cpp @@ -15,6 +15,10 @@ #include #include +#ifndef MAX_POOL_SIZE +#define MAX_POOL_SIZE 25 +#endif + namespace { VirtualNodeList *activeGroupNavigationList = nullptr; @@ -34,7 +38,7 @@ size_t rowPoolSizeForViewport(lv_obj_t *parentPanel) } constexpr int32_t rowPitch = VirtualNodeList::COLLAPSED_ROW_HEIGHT + VirtualNodeList::ROW_GAP; const size_t visibleRows = static_cast(contentHeight + rowPitch * 2 - 2) / static_cast(rowPitch); - return std::clamp(visibleRows + 1, size_t{1}, VirtualNodeList::MAX_POOL_SIZE); + return std::clamp(visibleRows + 1, size_t{1}, size_t{MAX_POOL_SIZE}); } bool sameRenderContext(const NodeListRenderContext &left, const NodeListRenderContext &right) From 7483fd6773d242ca0cec57692a4d14f89538e94f Mon Sep 17 00:00:00 2001 From: Benjamin Faershtein <119711889+RCGV1@users.noreply.github.com> Date: Sun, 30 Aug 2026 02:20:57 -0700 Subject: [PATCH 15/15] fix(MUI): preserve unknown restored chat peers --- source/graphics/TFT/TFTView_320x240.cpp | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/source/graphics/TFT/TFTView_320x240.cpp b/source/graphics/TFT/TFTView_320x240.cpp index 39b0ff338..899dd9e74 100644 --- a/source/graphics/TFT/TFTView_320x240.cpp +++ b/source/graphics/TFT/TFTView_320x240.cpp @@ -6095,7 +6095,7 @@ void TFTView_320x240::restoreMessage(const LogMessage &msg) } } else { ILOG_DEBUG("to node 0x%08x not in db", msg.to); - MeshtasticView::addOrUpdateNode(msg.to, msg.ch, 0, eRole::unknown, false, false); + addOrUpdateNode(msg.to, msg.ch, 0, eRole::unknown, false, false); } } if (container) { @@ -6118,7 +6118,7 @@ void TFTView_320x240::restoreMessage(const LogMessage &msg) if (msg.to != UINT32_MAX) { // from node not in db ILOG_DEBUG("from node 0x%08x not in db", msg.from); - MeshtasticView::addOrUpdateNode(msg.from, msg.ch, 0, eRole::unknown, false, false); + addOrUpdateNode(msg.from, msg.ch, 0, eRole::unknown, false, false); } else { ILOG_DEBUG("from node 0x%08x not in db and no need to insert", msg.from); pos += sprintf(buf, "%04x ", msg.from & 0xffff);