#include #include #include #include "freertos/FreeRTOS.h" #include "freertos/task.h" #include "esp_lcd_panel_io.h" #include "esp_lcd_panel_vendor.h" #include "esp_lcd_panel_ops.h" #include "driver/gpio.h" #include "driver/spi_master.h" #include "esp_err.h" #include "esp_log.h" #include "esp_timer.h" #include "lvgl.h" #include "esp_lvgl_port.h" #include "gui.h" #include "gpio.h" #include "keypad.h" #include "bubble.h" #include "system.h" #include "bt_app.h" #define DEVKIT #undef DEVKIT static const char *TAG = "gui"; #define UNLOCK() lvgl_port_unlock() #define LOCK() lvgl_port_lock(0) // LCD Pin Configuration #define LCD_PIXEL_CLOCK_HZ (5 * 1000 * 1000) #define LCD_SPI_HOST SPI2_HOST #define PIN_NUM_nON 26 // ST7735S properties #define LCD_H_RES 160 #define LCD_V_RES 80 #define LCD_CMD_BITS 8 #define LCD_PARAM_BITS 8 #define LCD_COLOR_SPACE ESP_LCD_COLOR_SPACE_RGB #define LCD_BITS_PER_PIXEL 16 static esp_lcd_panel_handle_t panel_handle = NULL; esp_lcd_panel_io_handle_t io_handle = NULL; static lv_disp_t *disp = NULL; static lv_obj_t *imu_label = NULL; // Label for IMU data static lv_style_t style_mono8; typedef struct { int selected; int count; lv_obj_t *obj; } menu_context_t; static void gui_task(void *pvParameters); static void createBubble(lv_obj_t * scr); static void build_scrollable_menu(void); static void currentFocusIndex(menu_context_t *ctx); static lv_obj_t * addMenuItem(lv_obj_t *page, const char *text); static lv_obj_t* create_menu_container(void); static void show_bt_device_list(void); static void show_volume_control(void); static lv_obj_t* create_volume_page(void); static void update_volume_display(int volume); static void ensure_menu_styles(void); // Menu stack management functions static void menu_stack_push(lv_obj_t *page); static lv_obj_t* menu_stack_pop(void); static void menu_stack_clear(void); static bool menu_stack_is_empty(void); static void menu_go_back(void); #define MAX_ITEMS 10 #define VISIBLE_ITEMS 3 #define MENU_MAX_STRING_LENGTH 30 static const char *menu_items[MAX_ITEMS] = { "V-Moda Crossfade", "Item 2", "Item 3", "Item 4", "Item 5", "Item 6", "Item 7", "Item 8", "Item 9", "Item 10" }; static lv_obj_t *list; static lv_obj_t *buttons[MAX_ITEMS]; static int selected_index = 0; static lv_obj_t *_bubble = NULL; static lv_obj_t * _menu = NULL; static lv_obj_t *_currentPage = NULL; static GuiMode_t _mode = GUI_BUBBLE; // Menu navigation stack #define MAX_MENU_STACK_SIZE 8 typedef struct { lv_obj_t *pages[MAX_MENU_STACK_SIZE]; int count; } menu_stack_t; static menu_stack_t _menuStack = {0}; /* 1. Prepare a default (unfocused) style */ static lv_style_t _styleUnfocusedBtn; /* 2. Prepare a focus style */ static lv_style_t _styleFocusedBtn; static menu_context_t _menuContext; // Hide *all* headers/back buttons that LVGL may show for this menu static void menu_hide_headers(lv_obj_t *menu) { if (!menu) return; // Main header (normal menus) lv_obj_t *mh = lv_menu_get_main_header(menu); if (mh) { lv_obj_add_flag(mh, LV_OBJ_FLAG_HIDDEN); lv_obj_set_height(mh, 0); } lv_obj_t *mh_back = lv_menu_get_main_header_back_button(menu); if (mh_back) { lv_obj_add_flag(mh_back, LV_OBJ_FLAG_HIDDEN); lv_obj_set_width(mh_back, 0); lv_obj_set_height(mh_back, 0); } // Sidebar header (if you ever use sidebar mode) lv_obj_t *sh = lv_menu_get_sidebar_header(menu); if (sh) { lv_obj_add_flag(sh, LV_OBJ_FLAG_HIDDEN); lv_obj_set_height(sh, 0); } lv_obj_t *sh_back = lv_menu_get_sidebar_header_back_button(menu); if (sh_back) { lv_obj_add_flag(sh_back, LV_OBJ_FLAG_HIDDEN); lv_obj_set_width(sh_back, 0); lv_obj_set_height(sh_back, 0); } } static bool notify_lvgl_flush_ready(void *user_ctx) { if (disp) { lv_display_flush_ready(disp); } return true; } static lv_obj_t* create_main_page(void) { lv_obj_t* main_page; ensure_menu_styles(); lv_obj_t* menu = create_menu_container(); main_page = lv_menu_page_create(menu, NULL); lv_obj_set_style_radius(main_page, 0, LV_PART_MAIN | LV_STATE_ANY); lv_obj_set_style_border_width(main_page, 0, LV_PART_MAIN | LV_STATE_ANY); lv_obj_set_style_pad_all(main_page, 0, LV_PART_MAIN); lv_obj_set_style_pad_row(main_page, 0, LV_PART_MAIN); lv_obj_set_style_pad_column(main_page, 0, LV_PART_MAIN); lv_obj_set_scrollbar_mode(main_page, LV_SCROLLBAR_MODE_AUTO); lv_obj_set_size(main_page, lv_pct(100), lv_pct(100)); // Add menu items lv_obj_t* tmpObj; tmpObj = addMenuItem(main_page, "Bluetooth"); lv_obj_add_state(tmpObj, LV_STATE_FOCUSED); // First item focused addMenuItem(main_page, "Calibration"); addMenuItem(main_page, "Volume"); //addMenuItem(main_page, "About"); addMenuItem(main_page, "Exit"); return main_page; } static void show_menu(void) { lv_obj_t* menu = create_menu_container(); lv_obj_t* main_page = create_main_page(); // Clear the menu stack when starting fresh menu navigation menu_stack_clear(); lv_menu_set_page(menu, main_page); _currentPage = main_page; // Initialize menu context to track the focused item currentFocusIndex(&_menuContext); menu_hide_headers(menu); lv_obj_remove_flag(menu, LV_OBJ_FLAG_HIDDEN); lv_obj_add_flag(_bubble, LV_OBJ_FLAG_HIDDEN); } static void cleanup_menu(void) { if (_menu) { lv_obj_del(_menu); _menu = NULL; _currentPage = NULL; ESP_LOGI(TAG, "Menu cleaned up to free memory"); } } static void show_bubble(void) { cleanup_menu(); // Free menu memory when returning to bubble lv_obj_remove_flag(_bubble, LV_OBJ_FLAG_HIDDEN); } static void create_lvgl_demo(void) { lvgl_port_lock(0); // Create a screen with black background lv_obj_t *scr = lv_scr_act(); createBubble(scr); // Menu will be created lazily when needed lvgl_port_unlock(); } static void lcd_init(void) { ESP_LOGI(TAG, "Initialize SPI bus"); spi_bus_config_t buscfg = { .sclk_io_num = PIN_NUM_CLK, .mosi_io_num = PIN_NUM_MOSI, .miso_io_num = -1, .quadwp_io_num = -1, .quadhd_io_num = -1, //.max_transfer_sz = LCD_H_RES * LCD_V_RES * 2 .max_transfer_sz = LCD_H_RES * 25 * (LCD_BITS_PER_PIXEL/8), }; ESP_ERROR_CHECK(spi_bus_initialize(LCD_SPI_HOST, &buscfg, SPI_DMA_CH_AUTO)); ESP_LOGI(TAG, "Install panel IO"); esp_lcd_panel_io_spi_config_t io_config = { .dc_gpio_num = PIN_NUM_DC, .cs_gpio_num = PIN_NUM_CS, .pclk_hz = LCD_PIXEL_CLOCK_HZ, .lcd_cmd_bits = LCD_CMD_BITS, .lcd_param_bits = LCD_PARAM_BITS, .spi_mode = 3, .trans_queue_depth = 10, .user_ctx = NULL, }; ESP_ERROR_CHECK(esp_lcd_new_panel_io_spi((esp_lcd_spi_bus_handle_t)LCD_SPI_HOST, &io_config, &io_handle)); esp_lcd_panel_dev_config_t panel_config = { .reset_gpio_num = PIN_NUM_RST, .rgb_endian = LCD_RGB_ENDIAN_BGR, .bits_per_pixel = LCD_BITS_PER_PIXEL, }; ESP_ERROR_CHECK(esp_lcd_new_panel_st7789(io_handle, &panel_config, &panel_handle)); ESP_ERROR_CHECK(esp_lcd_panel_reset(panel_handle)); ESP_ERROR_CHECK(esp_lcd_panel_init(panel_handle)); ESP_ERROR_CHECK(esp_lcd_panel_invert_color(panel_handle, true)); ESP_ERROR_CHECK(esp_lcd_panel_set_gap(panel_handle, 1, 26)); // ST7735S typically needs these offsets ESP_ERROR_CHECK(esp_lcd_panel_disp_on_off(panel_handle, true)); } static void lvgl_init(void) { lv_init(); #if 1 const lvgl_port_cfg_t lvgl_cfg = { .task_priority = 4, // LVGL task priority .task_stack = 8192, // LVGL task stack size (reduced for memory savings) .task_affinity = 0, // LVGL task can run on any core .task_max_sleep_ms = 500, // Maximum sleep in LVGL task .timer_period_ms = 5 // LVGL timer period }; ESP_ERROR_CHECK(lvgl_port_init(&lvgl_cfg)); #endif const lvgl_port_display_cfg_t disp_cfg = { .io_handle = io_handle, .panel_handle = panel_handle, // .buffer_size = LCD_H_RES * LCD_V_RES * 2, .buffer_size = LCD_H_RES * 25 * (LCD_BITS_PER_PIXEL/8), // was full screen .double_buffer = false, .hres = LCD_H_RES, .vres = LCD_V_RES, .monochrome = false, .rotation = { .swap_xy = true, .mirror_x = true, .mirror_y = false, }, .flags = { //.buff_dma = true, .swap_bytes = true, } }; disp = lvgl_port_add_disp(&disp_cfg); //lv_display_set_color_format(disp, LV_COLOR_FORMAT_RGB565); } static void createBubble(lv_obj_t * scr) { // 2) Create a bubble level of size 200×60, with range [−30°, +30°], initial 0°: lv_obj_t * level = bubble_create(scr, 150, 40, -10.0f, +10.0f, 0.0f); lv_obj_align(level, LV_ALIGN_CENTER, 0, 0); // 3) … Later, when you read your accelerometer or keypad‐derived angle … float new_angle = 10.0f; bubble_setValue(level, new_angle); // 4) You can call bubble_level_set_value(level, …) as often as you like. // Each call invalidates the object and LVGL will call the draw callback // (usually on the next tick, or immediately if LVGL is idle). _bubble = level; } void gui_start(void) { // Initialize LCD lcd_init(); // Initialize LVGL lvgl_init(); // Create UI create_lvgl_demo(); keypad_start(); gpio_set_level(PIN_NUM_BK_LIGHT, 1); xTaskCreate(gui_task, "gui_task", 8192, NULL, 5, NULL); } static uint32_t waitForKeyPress(void) { QueueHandle_t q = keypad_getQueue(); uint32_t ev = 0; if (xQueueReceive(q, &ev, portMAX_DELAY) == pdTRUE) { return ev; } return 0; } static void handleMainMenu(void) { lvgl_port_lock(0); // Create a screen with black background lv_obj_t *scr = lv_scr_act(); //create_menu(scr); lvgl_port_unlock(); } // ───── MENU CONFIG ───── #define ROW_H 20 // height of each row static const char * items[] = { // your menu entries "VMode Crossmade", "Second Choice", "Another Option", "Yet Another", "Yet Another", "Last One" }; #define ITEM_COUNT (sizeof(items)/sizeof(items[0])) // ───── STATE & HELPERS ───── static lv_obj_t * btn_array[ITEM_COUNT]; static int selected_idx = 0; // Called whenever a button is "activated" (Enter/Click) static void btn_click_cb(lv_event_t * e) { lv_obj_t * btn = lv_event_get_target(e); const char * txt = lv_label_get_text(lv_obj_get_child(btn, 0)); ESP_LOGI(TAG, "Activated: %s\n", txt); // Handle specific menu items if (strcmp(txt, "Bluetooth") == 0) { LOCK(); // Push current page onto stack before navigating menu_stack_push(_currentPage); show_bt_device_list(); UNLOCK(); } else if (strcmp(txt, "Volume") == 0) { LOCK(); // Push current page onto stack before navigating menu_stack_push(_currentPage); show_volume_control(); UNLOCK(); } else if (strcmp(txt, "Back") == 0) { LOCK(); menu_go_back(); UNLOCK(); } else if (strcmp(txt, "Exit") == 0) { LOCK(); _mode = GUI_BUBBLE; show_bubble(); UNLOCK(); } // Add more menu handlers here as needed ESP_LOGI(TAG, "End btn_click_cb"); } // Repaint all rows so only btn_array[selected_idx] is highlighted static void refresh_highlight(void) { return; lvgl_port_lock(0); int index = 0; lv_obj_t *page = _currentPage; lv_obj_t * child = NULL; lv_obj_t * next = lv_obj_get_child(page, index); lv_obj_t * selected = NULL; while(next) { child = next; ESP_LOGI(TAG, "Child: %p", child); if (lv_obj_has_flag(child, LV_OBJ_FLAG_USER_1)) { lv_obj_set_style_bg_color(child, lv_color_hex(0xFF8800), 0); lv_obj_set_style_text_color(lv_obj_get_child(child,0), lv_color_white(), 0); selected = child; ESP_LOGI(TAG, "Selected"); } else { lv_obj_set_style_bg_color(child, lv_color_white(), 0); lv_obj_set_style_text_color(lv_obj_get_child(child,0), lv_color_black(), 0); } index++; next = lv_obj_get_child(page, index); } if (selected) { lv_obj_scroll_to_view(selected, LV_ANIM_ON); } lvgl_port_unlock(); } static void menuInc(int inc) { LOCK(); currentFocusIndex(&_menuContext); ESP_LOGI(TAG, "Current Index: %d", _menuContext.selected); lv_obj_t *next = NULL; // check if we are at the first or last in the page lv_obj_t *test = lv_obj_get_child(_currentPage, (inc > 0 ? -1 : 0)); if (_menuContext.obj != test) { next = lv_obj_get_child(_currentPage, _menuContext.selected + inc); lv_obj_add_state(next, LV_STATE_FOCUSED); lv_obj_clear_state(_menuContext.obj, LV_STATE_FOCUSED); lv_obj_scroll_to_view(next, LV_ANIM_ON); _menuContext.obj = next; _menuContext.selected += inc; } UNLOCK(); } static void menuNext(void) { menuInc(1); } static void menuPrevious(void) { menuInc(-1); } // Fire the “clicked” event on the selected row static void activate_selected(void) { LOCK(); lv_obj_send_event(_menuContext.obj, LV_EVENT_CLICKED, NULL); UNLOCK(); } // Event handler to control label scrolling based on focus state static void label_scroll_focus_cb(lv_event_t * e) { lv_event_code_t code = lv_event_get_code(e); lv_obj_t * btn = lv_event_get_target(e); lv_obj_t * lbl = lv_obj_get_child(btn, 0); // Get the label child if (!lbl) return; if (code == LV_EVENT_FOCUSED) { // Start scrolling when focused lv_label_set_long_mode(lbl, LV_LABEL_LONG_SCROLL_CIRCULAR); } else if (code == LV_EVENT_DEFOCUSED) { // Stop scrolling and reset position when defocused lv_label_set_long_mode(lbl, LV_LABEL_LONG_CLIP); } } static lv_obj_t * addMenuItem(lv_obj_t *page, const char *text) { if (!page || !text) { ESP_LOGE(TAG, "Null parameters in addMenuItem"); return NULL; } // Ensure styles are initialized ensure_menu_styles(); lv_obj_t * btn = lv_btn_create(page); if (!btn) { ESP_LOGE(TAG, "Failed to create button in addMenuItem"); return NULL; } lv_obj_set_size(btn, LV_PCT(100), ROW_H); lv_obj_add_style(btn, &_styleUnfocusedBtn, LV_PART_MAIN | LV_STATE_DEFAULT); lv_obj_add_style(btn, &_styleFocusedBtn, LV_PART_MAIN | LV_STATE_FOCUSED); lv_obj_add_state(btn, LV_STATE_DEFAULT); // style it just like your old list // lv_obj_set_style_bg_color(btn, lv_color_white(), 0); lv_obj_set_style_radius(btn, 0, LV_PART_MAIN); lv_obj_set_style_border_width(btn, 0, LV_PART_MAIN); lv_obj_clear_flag(btn, LV_OBJ_FLAG_SCROLLABLE); // label & center lv_obj_t * lbl = lv_label_create(btn); if (!lbl) { ESP_LOGE(TAG, "Failed to create label in addMenuItem"); lv_obj_del(btn); return NULL; } lv_label_set_text(lbl, text); lv_label_set_long_mode(lbl, LV_LABEL_LONG_CLIP); // Start with clipping, scrolling only when focused lv_obj_set_width(lbl, LV_PCT(95)); // Set width to allow scrolling lv_obj_set_style_radius(lbl, 0, LV_PART_MAIN | LV_STATE_ANY); lv_obj_set_style_border_width(lbl, 0, LV_PART_MAIN | LV_STATE_ANY); lv_obj_set_style_anim_time(lbl, 4000, LV_PART_MAIN); // Set scrolling duration (4 seconds per cycle - slower) lv_obj_align(lbl, LV_ALIGN_LEFT_MID, 0, 0); // Center vertically, align left horizontally // Add focus event handler to control scrolling lv_obj_add_event_cb(btn, label_scroll_focus_cb, LV_EVENT_FOCUSED, NULL); lv_obj_add_event_cb(btn, label_scroll_focus_cb, LV_EVENT_DEFOCUSED, NULL); // click callback lv_obj_add_event_cb(btn, btn_click_cb, LV_EVENT_CLICKED, NULL); return btn; } static int getSelectedIndex(lv_obj_t *page) { return -1; } static void currentFocusIndex(menu_context_t *ctx) { ctx->count = lv_obj_get_child_cnt(_currentPage); ctx->obj = NULL; ctx->selected = -1; // return the index of the currently focused object for(int i = 0; i < ctx->count; i++) { lv_obj_t * child = lv_obj_get_child(_currentPage, i); if (lv_obj_has_state(child, LV_STATE_FOCUSED)) { ctx->obj = child; ctx->selected = i; return; } } } // ───── LAZY MENU CREATION ───── static void ensure_menu_styles(void) { static bool styles_initialized = false; if (!styles_initialized) { lv_style_init(&_styleFocusedBtn); lv_style_init(&_styleUnfocusedBtn); lv_style_set_bg_color(&_styleUnfocusedBtn, lv_color_make(0xff,0xff,0xff)); // white bg lv_style_set_text_color(&_styleUnfocusedBtn, lv_color_black()); // black text //lv_style_set_text_font(&_styleUnfocusedBtn, &lv_font_unscii_16); // larger font lv_style_set_bg_color(&_styleFocusedBtn, lv_color_make(0x33,0x99,0xFF)); // blue bg lv_style_set_text_color(&_styleFocusedBtn, lv_color_white()); // white text //lv_style_set_text_font(&_styleFocusedBtn, &lv_font_unscii_16); // larger font styles_initialized = true; } } static lv_obj_t* create_menu_container(void) { if (_menu == NULL) { _menu = lv_menu_create(lv_scr_act()); menu_hide_headers(_menu); lv_obj_set_style_radius(_menu, 0, LV_PART_MAIN | LV_STATE_ANY); lv_obj_set_style_border_width(_menu, 0, LV_PART_MAIN | LV_STATE_ANY); lv_obj_set_size(_menu, lv_pct(100), lv_pct(100)); lv_obj_center(_menu); lv_obj_set_scrollbar_mode(_menu, LV_SCROLLBAR_MODE_AUTO); lv_obj_add_flag(_menu, LV_OBJ_FLAG_HIDDEN); // Hidden by default // Hide the sidebar/header lv_menu_set_sidebar_page(_menu, NULL); lv_obj_set_style_pad_all(_menu, 0, LV_PART_MAIN); // Disable the back button in header lv_menu_set_mode_root_back_button(_menu, LV_MENU_ROOT_BACK_BUTTON_DISABLED); // Find and hide the header container // The menu has 2 children: child 0 is header, child 1 is content area uint32_t menu_child_count = lv_obj_get_child_count(_menu); ESP_LOGI(TAG, "Menu container has %d children", (int)menu_child_count); if (menu_child_count >= 1) { lv_obj_t* header = lv_obj_get_child(_menu, 0); int32_t h = lv_obj_get_height(header); int32_t w = lv_obj_get_width(header); ESP_LOGI(TAG, " Header (child 0): %p, size=%dx%d", header, (int)w, (int)h); // Hide the header completely lv_obj_add_flag(header, LV_OBJ_FLAG_HIDDEN); lv_obj_set_height(header, 0); ESP_LOGI(TAG, " Hidden header"); } } return _menu; } // ───── BLUETOOTH DEVICE LIST PAGE ───── static lv_obj_t* _bt_page = NULL; static int _bt_selected_device = 0; // ───── VOLUME CONTROL PAGE ───── static lv_obj_t* _volume_page = NULL; static lv_obj_t* _volume_bar = NULL; static lv_obj_t* _volume_label = NULL; static int _current_volume = 50; // Default volume (0-100) static void bt_device_click_cb(lv_event_t * e) { if (!e) { ESP_LOGE(TAG, "Null event in bt_device_click_cb"); return; } lv_obj_t * btn = lv_event_get_target(e); if (!btn) { ESP_LOGE(TAG, "Null button in bt_device_click_cb"); return; } lv_obj_t * child = lv_obj_get_child(btn, 0); if (!child) { ESP_LOGE(TAG, "Null child in bt_device_click_cb"); return; } const char * txt = lv_label_get_text(child); if (!txt) { ESP_LOGE(TAG, "Null text in bt_device_click_cb"); return; } // Handle special buttons if (strcmp(txt, "Back") == 0) { LOCK(); bt_stop_discovery(); menu_go_back(); UNLOCK(); return; } else if (strcmp(txt, "Refresh") == 0) { LOCK(); // Recreate the BT page with discovery enabled to show "Scanning..." show_bt_device_list(); UNLOCK(); return; } else if (strcmp(txt, "Clear Paired") == 0) { LOCK(); // Clear all paired devices from NVS and device list esp_err_t ret = system_clearAllPairedDevices(); if (ret == ESP_OK) { ESP_LOGI(TAG, "Successfully cleared all paired devices from NVS"); // Also clear the in-memory device list used by GUI bt_clear_all_devices(); // Refresh the device list to show updated state system_requestBtRefresh(); } else { ESP_LOGE(TAG, "Failed to clear paired devices: %s", esp_err_to_name(ret)); } UNLOCK(); return; } // Find which device was clicked bt_device_list_t* device_list = bt_get_device_list(); if (!device_list) { ESP_LOGE(TAG, "Null device list in bt_device_click_cb"); return; } if (!_bt_page) { ESP_LOGE(TAG, "Null _bt_page in bt_device_click_cb"); return; } for (int i = 0; i < device_list->count; i++) { lv_obj_t * child = lv_obj_get_child(_bt_page, i); if (child == btn) { ESP_LOGI(TAG, "Requesting connection to device %d: %s", i, device_list->devices[i].name); // Use system event instead of direct BT call system_requestBtConnect(i); // Return to bubble mode after selection _mode = GUI_BUBBLE; show_bubble(); return; } } } static lv_obj_t* create_bt_device_page(bool start_discovery) { ESP_LOGI(TAG, "Creating Bluetooth device page (start_discovery=%d)", start_discovery); lv_obj_t* menu = create_menu_container(); if (!menu) { ESP_LOGE(TAG, "Failed to create menu container"); return NULL; } // Delete old BT page if it exists to prevent memory leaks if (_bt_page) { ESP_LOGI(TAG, "Deleting old Bluetooth page"); lv_obj_del(_bt_page); _bt_page = NULL; } _bt_page = lv_menu_page_create(menu, NULL); if (!_bt_page) { ESP_LOGE(TAG, "Failed to create Bluetooth page"); return NULL; } lv_obj_set_style_radius(_bt_page, 0, LV_PART_MAIN | LV_STATE_ANY); lv_obj_set_style_border_width(_bt_page, 0, LV_PART_MAIN | LV_STATE_ANY); lv_obj_set_style_pad_all(_bt_page, 0, LV_PART_MAIN); lv_obj_set_style_pad_row(_bt_page, 0, LV_PART_MAIN); lv_obj_set_style_pad_column(_bt_page, 0, LV_PART_MAIN); lv_obj_set_scrollbar_mode(_bt_page, LV_SCROLLBAR_MODE_AUTO); lv_obj_set_size(_bt_page, lv_pct(100), lv_pct(100)); bool discovery_started = false; if (start_discovery) { ESP_LOGI(TAG, "Starting Bluetooth discovery"); // Try to start discovery (may fail if BT stack is busy) discovery_started = bt_start_discovery(); if (!discovery_started) { ESP_LOGW(TAG, "Discovery not started - will show paired devices only"); } } // Get device list ESP_LOGI(TAG, "Getting device list"); bt_device_list_t* device_list = bt_get_device_list(); if (!device_list) { ESP_LOGE(TAG, "Failed to get device list"); return _bt_page; } if (device_list->count == 0) { // Show appropriate message based on discovery status const char* msg = discovery_started ? "Scanning..." : "No devices found"; lv_obj_t* tmpObj = addMenuItem(_bt_page, msg); lv_obj_add_state(tmpObj, LV_STATE_DISABLED); lv_obj_clear_state(tmpObj, LV_STATE_FOCUSED); // Remove focus from disabled item } else { // Add devices to the page bool first = true; for (int i = 0; i < device_list->count; i++) { char device_text[64]; snprintf(device_text, sizeof(device_text), "%s%s", device_list->devices[i].name, device_list->devices[i].is_paired ? " (paired)" : ""); lv_obj_t* btn = addMenuItem(_bt_page, device_text); lv_obj_add_event_cb(btn, bt_device_click_cb, LV_EVENT_CLICKED, NULL); if (first) { lv_obj_add_state(btn, LV_STATE_FOCUSED); first = false; } } } // Add back/refresh/clear options lv_obj_t* refresh_btn = addMenuItem(_bt_page, "Refresh"); lv_obj_add_event_cb(refresh_btn, bt_device_click_cb, LV_EVENT_CLICKED, NULL); // If no devices were listed, focus on Refresh button if (device_list->count == 0) { lv_obj_add_state(refresh_btn, LV_STATE_FOCUSED); } lv_obj_t* clear_btn = addMenuItem(_bt_page, "Clear Paired"); lv_obj_add_event_cb(clear_btn, bt_device_click_cb, LV_EVENT_CLICKED, NULL); lv_obj_t* back_btn = addMenuItem(_bt_page, "Back"); lv_obj_add_event_cb(back_btn, bt_device_click_cb, LV_EVENT_CLICKED, NULL); return _bt_page; } static void show_bt_device_list(void) { ESP_LOGI(TAG, "Showing Bluetooth device list with discovery"); // Start discovery when explicitly showing the BT device list bool should_start_discovery = true; lv_obj_t* menu = create_menu_container(); if (!menu) { ESP_LOGE(TAG, "Failed to create menu container for Bluetooth list"); return; } lv_obj_t* bt_page = create_bt_device_page(should_start_discovery); if (!bt_page) { ESP_LOGE(TAG, "Failed to create Bluetooth device page"); return; } lv_menu_set_page(menu, bt_page); _currentPage = bt_page; _mode = GUI_MENU; // Keep in menu mode menu_hide_headers(menu); lv_obj_remove_flag(menu, LV_OBJ_FLAG_HIDDEN); lv_obj_add_flag(_bubble, LV_OBJ_FLAG_HIDDEN); ESP_LOGI(TAG, "Bluetooth device list displayed"); } static void refresh_bt_device_list(void) { ESP_LOGI(TAG, "Refreshing Bluetooth device list without new discovery"); // Don't start new discovery, just refresh the display with current devices bool should_start_discovery = false; lv_obj_t* menu = create_menu_container(); if (!menu) { ESP_LOGE(TAG, "Failed to create menu container for Bluetooth list"); return; } lv_obj_t* bt_page = create_bt_device_page(should_start_discovery); if (!bt_page) { ESP_LOGE(TAG, "Failed to create Bluetooth device page"); return; } lv_menu_set_page(menu, bt_page); _currentPage = bt_page; _mode = GUI_MENU; // Keep in menu mode menu_hide_headers(menu); lv_obj_remove_flag(menu, LV_OBJ_FLAG_HIDDEN); lv_obj_add_flag(_bubble, LV_OBJ_FLAG_HIDDEN); ESP_LOGI(TAG, "Bluetooth device list refreshed"); } // ───── VOLUME CONTROL PAGE ───── static lv_obj_t* create_volume_page(void) { ESP_LOGI(TAG, "Creating volume control page"); lv_obj_t* menu = create_menu_container(); if (!menu) { ESP_LOGE(TAG, "Failed to create menu container for volume control"); return NULL; } _volume_page = lv_menu_page_create(menu, NULL); if (!_volume_page) { ESP_LOGE(TAG, "Failed to create volume page"); return NULL; } lv_obj_set_style_radius(_volume_page, 0, LV_PART_MAIN | LV_STATE_ANY); lv_obj_set_style_border_width(_volume_page, 0, LV_PART_MAIN | LV_STATE_ANY); lv_obj_set_scrollbar_mode(_volume_page, LV_SCROLLBAR_MODE_OFF); lv_obj_set_size(_volume_page, lv_pct(100), lv_pct(100)); // Hide header uint32_t child_count = lv_obj_get_child_count(_volume_page); if (child_count >= 1) { lv_obj_t* header = lv_obj_get_child(_volume_page, 0); lv_obj_add_flag(header, LV_OBJ_FLAG_HIDDEN); lv_obj_set_height(header, 0); } // Create title label lv_obj_t* title = lv_label_create(_volume_page); lv_label_set_text(title, "Volume Control"); lv_obj_set_style_text_align(title, LV_TEXT_ALIGN_CENTER, 0); lv_obj_set_style_text_color(title, lv_color_black(), 0); //lv_obj_set_style_text_font(title, &lv_font_montserrat_8, 0); lv_obj_align(title, LV_ALIGN_TOP_MID, 0, 5); // Create volume bar (progress bar) _volume_bar = lv_bar_create(_volume_page); lv_obj_set_size(_volume_bar, 120, 20); lv_obj_align(_volume_bar, LV_ALIGN_CENTER, 0, -10); lv_bar_set_range(_volume_bar, 0, 100); lv_bar_set_value(_volume_bar, _current_volume, LV_ANIM_OFF); // Create volume percentage label _volume_label = lv_label_create(_volume_page); char volume_text[16]; snprintf(volume_text, sizeof(volume_text), "%d%%", _current_volume); lv_label_set_text(_volume_label, volume_text); lv_obj_set_style_text_align(_volume_label, LV_TEXT_ALIGN_CENTER, 0); lv_obj_align(_volume_label, LV_ALIGN_CENTER, 0, 15); #if 0 // Create instruction labels lv_obj_t* instr1 = lv_label_create(_volume_page); lv_label_set_text(instr1, "KEY0: Volume Up"); lv_obj_set_style_text_align(instr1, LV_TEXT_ALIGN_CENTER, 0); lv_obj_align(instr1, LV_ALIGN_BOTTOM_MID, 0, -25); lv_obj_t* instr2 = lv_label_create(_volume_page); lv_label_set_text(instr2, "KEY1: Volume Down"); lv_obj_set_style_text_align(instr2, LV_TEXT_ALIGN_CENTER, 0); lv_obj_align(instr2, LV_ALIGN_BOTTOM_MID, 0, -10); #endif // Add Back button lv_obj_t* back_btn = addMenuItem(_volume_page, "Back"); lv_obj_add_event_cb(back_btn, btn_click_cb, LV_EVENT_CLICKED, NULL); return _volume_page; } static void show_volume_control(void) { ESP_LOGI(TAG, "Showing volume control"); lv_obj_t* menu = create_menu_container(); if (!menu) { ESP_LOGE(TAG, "Failed to create menu container for volume control"); return; } lv_obj_t* volume_page = create_volume_page(); if (!volume_page) { ESP_LOGE(TAG, "Failed to create volume page"); return; } lv_menu_set_page(menu, volume_page); _currentPage = volume_page; _mode = GUI_MENU; // Keep in menu mode menu_hide_headers(menu); lv_obj_remove_flag(menu, LV_OBJ_FLAG_HIDDEN); lv_obj_add_flag(_bubble, LV_OBJ_FLAG_HIDDEN); ESP_LOGI(TAG, "Volume control displayed"); } // Menu stack management functions static void menu_stack_push(lv_obj_t *page) { if (_menuStack.count < MAX_MENU_STACK_SIZE && page != NULL) { _menuStack.pages[_menuStack.count++] = page; ESP_LOGI(TAG, "Menu stack push: page=%p, count=%d", page, _menuStack.count); } else if (_menuStack.count >= MAX_MENU_STACK_SIZE) { ESP_LOGW(TAG, "Menu stack overflow, cannot push more pages"); } } static lv_obj_t* menu_stack_pop(void) { if (_menuStack.count > 0) { lv_obj_t *page = _menuStack.pages[--_menuStack.count]; ESP_LOGI(TAG, "Menu stack pop: page=%p, count=%d", page, _menuStack.count); return page; } ESP_LOGI(TAG, "Menu stack is empty, cannot pop"); return NULL; } static void menu_stack_clear(void) { _menuStack.count = 0; ESP_LOGI(TAG, "Menu stack cleared"); } static bool menu_stack_is_empty(void) { return (_menuStack.count == 0); } static void menu_go_back(void) { ESP_LOGI(TAG, "Menu go back requested"); if (menu_stack_is_empty()) { ESP_LOGI(TAG, "Menu stack empty, returning to bubble mode"); _mode = GUI_BUBBLE; show_bubble(); return; } lv_obj_t *previous_page = menu_stack_pop(); if (previous_page != NULL) { ESP_LOGI(TAG, "Returning to previous menu page"); lv_obj_t* menu = create_menu_container(); if (menu) { lv_menu_set_page(menu, previous_page); _currentPage = previous_page; _mode = GUI_MENU; lv_obj_remove_flag(menu, LV_OBJ_FLAG_HIDDEN); lv_obj_add_flag(_bubble, LV_OBJ_FLAG_HIDDEN); } } else { ESP_LOGI(TAG, "No previous page found, returning to bubble mode"); _mode = GUI_BUBBLE; show_bubble(); } } static void update_volume_display(int volume) { if (_volume_bar && _volume_label) { LOCK(); lv_bar_set_value(_volume_bar, volume, LV_ANIM_ON); char volume_text[16]; snprintf(volume_text, sizeof(volume_text), "%d%%", volume); lv_label_set_text(_volume_label, volume_text); UNLOCK(); _current_volume = volume; ESP_LOGI(TAG, "Volume display updated to %d%%", volume); } } // ───── BUILD THE MENU ───── static void build_scrollable_menu(void) { lv_style_init(&_styleFocusedBtn); lv_style_init(&_styleUnfocusedBtn); lv_style_set_bg_color(&_styleUnfocusedBtn, lv_color_make(0xff,0xff,0xff)); // gray bg lv_style_set_text_color(&_styleUnfocusedBtn, lv_color_hex(0xFF8800)); lv_style_set_bg_color(&_styleFocusedBtn, lv_color_make(0x33,0x99,0xFF)); // blue bg lv_style_set_text_color(&_styleUnfocusedBtn,lv_color_black()); // 2) Inside it, create the lv_menu and hide its sidebar/header lv_obj_t *menu = lv_menu_create(lv_scr_act()); _menu = menu; lv_obj_set_style_radius(menu, 0, LV_PART_MAIN | LV_STATE_ANY); lv_obj_set_style_border_width(menu, 0, LV_PART_MAIN | LV_STATE_ANY); lv_obj_set_size(menu, lv_pct(100), lv_pct(100)); lv_obj_center(menu); lv_obj_set_scrollbar_mode(menu, LV_SCROLLBAR_MODE_AUTO); lv_obj_t * main = lv_menu_page_create(menu, NULL); lv_obj_set_style_radius(main, 0, LV_PART_MAIN | LV_STATE_ANY); lv_obj_set_style_border_width(main, 0, LV_PART_MAIN | LV_STATE_ANY); lv_obj_set_scrollbar_mode(main, LV_SCROLLBAR_MODE_AUTO); lv_obj_set_size(main, lv_pct(100), lv_pct(100)); // Hide header uint32_t child_count = lv_obj_get_child_count(main); if (child_count >= 1) { lv_obj_t* header = lv_obj_get_child(main, 0); lv_obj_add_flag(header, LV_OBJ_FLAG_HIDDEN); lv_obj_set_height(header, 0); } lv_menu_set_page(menu, main); lv_obj_t * tmpObj; lv_obj_t * calMenu = lv_menu_page_create(menu, NULL); lv_obj_set_style_radius(calMenu, 0, LV_PART_MAIN | LV_STATE_ANY); lv_obj_set_style_border_width(calMenu, 0, LV_PART_MAIN | LV_STATE_ANY); lv_obj_set_scrollbar_mode(calMenu, LV_SCROLLBAR_MODE_AUTO); // Hide header child_count = lv_obj_get_child_count(calMenu); if (child_count >= 1) { lv_obj_t* header = lv_obj_get_child(calMenu, 0); lv_obj_add_flag(header, LV_OBJ_FLAG_HIDDEN); lv_obj_set_height(header, 0); } tmpObj = addMenuItem(main, "Bluetooth"); lv_obj_add_state(tmpObj, LV_STATE_FOCUSED); //lv_obj_add_flag(tmpObj, LV_OBJ_FLAG_USER_1); tmpObj = addMenuItem(main, "Calibration"); lv_menu_set_load_page_event(menu, tmpObj, calMenu); tmpObj = addMenuItem(main, "Volume"); addMenuItem(main, "About"); addMenuItem(main, "Exit"); addMenuItem(calMenu, "Calibrate Level"); addMenuItem(calMenu, "Reset Calibration"); addMenuItem(calMenu, "Exit"); _currentPage = main; // 6) Initial highlight selected_idx = 0; refresh_highlight(); } static void gui_task(void *pvParameters) { (void)pvParameters; // Unused parameter system_subscribe(xTaskGetCurrentTaskHandle()); // Grab queue handle QueueHandle_t q = keypad_getQueue(); uint32_t ev = 0; LOCK(); _mode = GUI_BUBBLE; lv_obj_remove_flag(_bubble, LV_OBJ_FLAG_HIDDEN); UNLOCK(); ESP_LOGI(TAG, "Start GUI Task..."); while (1) { if (xQueueReceive(q, &ev, pdMS_TO_TICKS(10)) == pdTRUE) { switch (ev) { #if 0 case (KEY_UP << KEY_LONG_PRESS): { system_setZeroAngle(); break; } case (KEY_DOWN << KEY_LONG_PRESS): { system_clearZeroAngle(); break; } #endif case (KEY_UP << KEY_SHORT_PRESS): if (_mode == GUI_MENU) { // Check if we're on the volume control page if (_currentPage == _volume_page) { // Volume up if (_current_volume < 100) { _current_volume += 5; if (_current_volume > 100) _current_volume = 100; update_volume_display(_current_volume); system_requestVolumeUp(); } } else { menuPrevious(); } } ESP_LOGI(TAG, "MAIN: Button UP SHORT"); break; case (KEY_DOWN << KEY_SHORT_PRESS): { if (_mode == GUI_MENU) { // Check if we're on the volume control page if (_currentPage == _volume_page) { // Volume down if (_current_volume > 0) { _current_volume -= 5; if (_current_volume < 0) _current_volume = 0; update_volume_display(_current_volume); system_requestVolumeDown(); } } else { menuNext(); } } ESP_LOGI(TAG, "MAIN: Button DOWN SHORT"); break; } case (KEY0 << KEY_LONG_PRESS): { ESP_LOGI(TAG, "MAIN: Button 0 LONG - Enter"); LOCK(); if (_mode != GUI_MENU) { _mode = GUI_MENU; show_menu(); // Use lazy loading } else if (_mode == GUI_MENU) { activate_selected(); // After activation, check if we should stay in menu or exit // (activation may have changed _currentPage or _mode) if (_mode == GUI_BUBBLE) { // Already handled by the menu item (e.g., "Exit" was selected) } else if (_currentPage == _bt_page || _currentPage == _volume_page) { // Stay in menu mode on these pages } // Note: For main menu items that navigate to subpages, // the btn_click_cb handles the navigation and keeps _mode = GUI_MENU } UNLOCK(); ESP_LOGI(TAG, "MAIN: Button 0 LONG - Exit"); break; } case (KEY1 << KEY_LONG_PRESS): { LOCK(); if (_mode == GUI_MENU) { menu_go_back(); // Use menu stack navigation } else { // Power off on long press from bubble mode gpio_set_level(PIN_NUM_nON, 0); } UNLOCK(); ESP_LOGI(TAG, "MAIN: Button 1 LONG - Back/Power"); break; } default: break; } } // Check for system events uint32_t notifiedBits = 0; xTaskNotifyWait( 0, // don't clear on entry 0xFFFFFFFF, // clear bits on exit ¬ifiedBits, pdMS_TO_TICKS(10)); if (notifiedBits & EM_EVENT_BT_DISCOVERY_COMPLETE) { // Discovery completed - refresh the BT page if we're on it if (_mode == GUI_MENU && _currentPage == _bt_page) { ESP_LOGI(TAG, "Discovery complete, refreshing Bluetooth page"); LOCK(); // Recreate the BT page with updated device list (without starting new discovery) refresh_bt_device_list(); UNLOCK(); } } if (_mode == GUI_BUBBLE) { if (notifiedBits & EM_EVENT_NEW_DATA) { ImuData_t d = system_getImuData(); bubble_setValue(_bubble, -d.angle); } } } }