diff --git a/README.md b/README.md index 2d1a59e..07c64f4 100644 --- a/README.md +++ b/README.md @@ -16,7 +16,9 @@ Either LilyGO T-Embed. The firmware tells them apart at boot by probing the I2C bus (ES7210 on the 2022 board, BQ27220/BQ25896/PN532 on the CC1101 board) and picks the matching pin map from `main/board.c`. Nothing beyond the board is needed: ESP32-S3R8, 16 MB flash, 8 MB PSRAM, ST7789 170×320, EC11 encoder, -APA102 (×7) or WS2812 (×8) ring. +APA102 (×7) or WS2812 (×8) ring. Printable shells for the original board are in +LilyGO's repo under +[`Shell_file/`](https://github.com/Xinyuan-LilyGO/T-Embed/tree/master/Shell_file). ## Build and flash @@ -50,8 +52,9 @@ your Wi-Fi. Holding the knob down while the widget starts forces the portal. | double-click | hide / show the caption | | hold | open the menu (hold again, or *Close*, to leave it) | -In the menu: turn to move, click to select or toggle, click a number to edit it -(turn changes it, click confirms), hold to go back. Entries: +In the menu: turn to move (the list wraps around), click to select or toggle, +click a number to edit it (turn changes it, click confirms), hold to go back. +Entries: * **Save as favourite / Remove from favourites** — the camera that was playing; saving asks for one of the 12 slots (overwrites it). @@ -60,11 +63,20 @@ In the menu: turn to move, click to select or toggle, click a number to edit it * **Source** — *Random* draws from insecam, *Favourites* cycles the saved slots. * **Time per camera**, **Listing** (mixed / top rated / newest), **Screen brightness**, **Ring brightness**, **Caption**. -* **Forget a favourite**, **Wi-Fi setup** (reboots into the hotspot), and the - board name with its IP. +* **Forget a favourite**, **Wi-Fi setup** (reboots into the hotspot), the board + name with its IP, and the power source. The menu closes by itself after 45 s without input. +## Battery + +The original board has no fuel gauge, only a 100 k/100 k divider on the supply +rail (GPIO 4). On USB that rail sits at ~4.4 V (5 V minus a Schottky drop), so +anything above 4.25 V is reported as *USB powered*; below that it is the cell +voltage, turned into a rough percentage from a LiPo discharge table. While on +battery the caption bar shows a small battery glyph with the percentage; on USB +it stays out of the way. The CC1101 board's BQ27220 gauge is not supported. + ## LED ring | ring | meaning | diff --git a/main/CMakeLists.txt b/main/CMakeLists.txt index f93128f..b0cc173 100644 --- a/main/CMakeLists.txt +++ b/main/CMakeLists.txt @@ -1,6 +1,6 @@ idf_component_register( SRCS "main.c" "board.c" "display.c" "ui.c" "leds.c" "input.c" "net.c" - "httpstream.c" "jpegframe.c" "jpegdec.c" "insecam.c" "slideshow.c" "settings.c" "menu.c" + "httpstream.c" "jpegframe.c" "jpegdec.c" "insecam.c" "slideshow.c" "settings.c" "menu.c" "battery.c" INCLUDE_DIRS "." - PRIV_REQUIRES esp_lcd esp_wifi nvs_flash esp_netif esp_event esp_http_server driver esp_timer + PRIV_REQUIRES esp_lcd esp_wifi nvs_flash esp_netif esp_event esp_http_server driver esp_timer esp_adc ) diff --git a/main/battery.c b/main/battery.c new file mode 100644 index 0000000..02a4817 --- /dev/null +++ b/main/battery.c @@ -0,0 +1,68 @@ +#include "battery.h" +#include "board.h" +#include "esp_adc/adc_oneshot.h" +#include "esp_adc/adc_cali.h" +#include "esp_adc/adc_cali_scheme.h" +#include "esp_log.h" + +static const char *TAG = "battery"; +static adc_oneshot_unit_handle_t unit; +static adc_cali_handle_t cali; +static adc_channel_t channel; +static bool ready; + +void battery_init(void) +{ + if (board->bat_adc < 0) return; + adc_unit_t unit_id; + if (adc_oneshot_io_to_channel(board->bat_adc, &unit_id, &channel) != ESP_OK || unit_id != ADC_UNIT_1) { + ESP_LOGW(TAG, "GPIO%d is not an ADC1 pin", board->bat_adc); + return; + } + adc_oneshot_unit_init_cfg_t ucfg = {.unit_id = ADC_UNIT_1}; + ESP_ERROR_CHECK(adc_oneshot_new_unit(&ucfg, &unit)); + adc_oneshot_chan_cfg_t ccfg = {.atten = ADC_ATTEN_DB_12, .bitwidth = ADC_BITWIDTH_DEFAULT}; + ESP_ERROR_CHECK(adc_oneshot_config_channel(unit, channel, &ccfg)); + adc_cali_curve_fitting_config_t cal = {.unit_id = ADC_UNIT_1, .chan = channel, .atten = ADC_ATTEN_DB_12, + .bitwidth = ADC_BITWIDTH_DEFAULT}; + if (adc_cali_create_scheme_curve_fitting(&cal, &cali) != ESP_OK) cali = NULL; + ready = true; + int mv, pct; + bool usb; + if (battery_read(&mv, &pct, &usb)) ESP_LOGI(TAG, "%d mV: %s", mv, usb ? "USB powered" : "on battery"); +} + +// Open-circuit voltage -> charge, single LiPo cell. +static int percent_from_mv(int mv) +{ + static const int table[][2] = {{4200, 100}, {4100, 90}, {4000, 78}, {3900, 62}, {3800, 45}, + {3700, 25}, {3600, 10}, {3500, 3}, {3300, 0}}; + const int n = sizeof table / sizeof table[0]; + if (mv >= table[0][0]) return 100; + for (int i = 1; i < n; i++) { + if (mv >= table[i][0]) { + int v1 = table[i - 1][0], p1 = table[i - 1][1], v0 = table[i][0], p0 = table[i][1]; + return p0 + (mv - v0) * (p1 - p0) / (v1 - v0); + } + } + return 0; +} + +#define USB_THRESHOLD_MV 4250 + +bool battery_read(int *millivolts, int *percent, bool *on_usb) +{ + if (!ready) return false; + long sum = 0; + const int samples = 32; + for (int i = 0; i < samples; i++) { + int raw = 0, mv = 0; + if (adc_oneshot_read(unit, channel, &raw) != ESP_OK) return false; + if (cali && adc_cali_raw_to_voltage(cali, raw, &mv) == ESP_OK) sum += mv; + else sum += raw * 3100 / 4095; // rough fallback without calibration + } + *millivolts = (int)(sum / samples) * 2; // 100k/100k divider + *on_usb = *millivolts > USB_THRESHOLD_MV; + *percent = *on_usb ? 100 : percent_from_mv(*millivolts); + return true; +} diff --git a/main/battery.h b/main/battery.h new file mode 100644 index 0000000..100f75d --- /dev/null +++ b/main/battery.h @@ -0,0 +1,13 @@ +#pragma once +#include + +// Battery sense on the original T-Embed: BAT_ADC (GPIO4) sits between two 100k +// resistors on the switched supply rail (schematic net V_BAT), so V = 2 x V_pin. +// That rail is the cell when running on battery, but USB minus a Schottky drop +// (~4.4 V) when a cable is in — which is how we tell the two apart. There is no +// fuel gauge; the percentage is an open-circuit LiPo estimate. The CC1101 board +// is not handled (it has a BQ27220 instead). +void battery_init(void); +// Averages a burst of samples; returns false if the board has no ADC divider. +// *on_usb is set when the rail is above what a LiPo cell can reach. +bool battery_read(int *millivolts, int *percent, bool *on_usb); diff --git a/main/board.c b/main/board.c index ff8afad..5a59b63 100644 --- a/main/board.c +++ b/main/board.c @@ -15,6 +15,7 @@ static const board_t tembed = { .lcd_bgr = false, .enc_a = 2, .enc_b = 1, .enc_btn = 0, .user_key = -1, .led_ws2812 = false, .led_clk = 45, .led_data = 42, .led_count = 7, + .bat_adc = 4, .i2c_sda = 18, .i2c_scl = 8, .spi_other_cs = {-1, -1, -1}, }; @@ -26,6 +27,7 @@ static const board_t tembed_cc1101 = { .lcd_bgr = true, .enc_a = 4, .enc_b = 5, .enc_btn = 0, .user_key = 6, .led_ws2812 = true, .led_clk = -1, .led_data = 14, .led_count = 8, + .bat_adc = -1, .i2c_sda = 8, .i2c_scl = 18, .spi_other_cs = {13, 12, 44}, // SD, CC1101, nRF24 }; diff --git a/main/board.h b/main/board.h index 9f5207e..6145237 100644 --- a/main/board.h +++ b/main/board.h @@ -18,6 +18,8 @@ typedef struct { // LED ring: APA102 (SPI, clk+data) on the original, WS2812 (one wire) on the CC1101 bool led_ws2812; int led_clk, led_data, led_count; + // battery voltage divider (original board only; -1 = none) + int bat_adc; // I2C, used only to tell the two boards apart int i2c_sda, i2c_scl; // chip selects sharing the LCD SPI bus on the CC1101 board; parked high diff --git a/main/main.c b/main/main.c index a02cb5a..da087e8 100644 --- a/main/main.c +++ b/main/main.c @@ -1,3 +1,4 @@ +#include "battery.h" #include "board.h" #include "display.h" #include "input.h" @@ -39,6 +40,7 @@ void app_main(void) display_set_backlight(settings.lcd_brightness); ESP_ERROR_CHECK(jpegdec_init()); + battery_init(); net_init(); // Holding the knob after power-up forces the setup hotspot. diff --git a/main/menu.c b/main/menu.c index 5807ca8..7acb727 100644 --- a/main/menu.c +++ b/main/menu.c @@ -1,4 +1,5 @@ #include "menu.h" +#include "battery.h" #include "board.h" #include "display.h" #include "input.h" @@ -18,7 +19,7 @@ static const char *TAG = "menu"; typedef enum { VIEW_TOP, VIEW_FAV_PLAY, VIEW_FAV_SAVE, VIEW_FAV_FORGET } view_t; enum { ROW_FAV_TOGGLE, ROW_FAVS, ROW_SOURCE, ROW_INTERVAL, ROW_LISTING, ROW_LCD, ROW_RING, ROW_CAPTION, - ROW_FORGET, ROW_WIFI, ROW_IP, ROW_CLOSE, TOP_ROWS }; + ROW_FORGET, ROW_WIFI, ROW_IP, ROW_BATTERY, ROW_CLOSE, TOP_ROWS }; static ui_menu_row_t rows[FAV_SLOTS + 1]; static char values[TOP_ROWS][20]; @@ -49,6 +50,12 @@ static int build_top(const cam_info_t *current, int fav_slot) rows[ROW_FORGET] = (ui_menu_row_t){"Forget a favourite", NULL, fav_count() == 0}; rows[ROW_WIFI] = (ui_menu_row_t){"Wi-Fi setup", NULL, false}; rows[ROW_IP] = (ui_menu_row_t){board->name, net_ip_str(), true}; + int mv, pct; + bool usb; + if (!battery_read(&mv, &pct, &usb)) strlcpy(values[ROW_BATTERY], "no monitor", sizeof values[0]); + else if (usb) snprintf(values[ROW_BATTERY], sizeof values[0], "USB %d.%02d V", mv / 1000, (mv % 1000) / 10); + else snprintf(values[ROW_BATTERY], sizeof values[0], "%d.%02d V %d%%", mv / 1000, (mv % 1000) / 10, pct); + rows[ROW_BATTERY] = (ui_menu_row_t){usb ? "Power" : "Battery", values[ROW_BATTERY], true}; rows[ROW_CLOSE] = (ui_menu_row_t){"Close", NULL, false}; return TOP_ROWS; } @@ -110,8 +117,8 @@ menu_result_t menu_run(const cam_info_t *current, int *fav_slot_out) continue; } - if (ev == INPUT_CW) { sel++; continue; } - if (ev == INPUT_CCW) { sel--; continue; } + if (ev == INPUT_CW) { sel = (sel + 1) % count; continue; } // wraps around + if (ev == INPUT_CCW) { sel = (sel + count - 1) % count; continue; } if (ev == INPUT_LONG || ev == INPUT_KEY) { if (view == VIEW_TOP) break; view = VIEW_TOP; diff --git a/main/slideshow.c b/main/slideshow.c index 2909106..d2153f7 100644 --- a/main/slideshow.c +++ b/main/slideshow.c @@ -1,4 +1,5 @@ #include "slideshow.h" +#include "battery.h" #include "display.h" #include "httpstream.h" #include "input.h" @@ -15,6 +16,7 @@ #include "esp_log.h" #include "esp_random.h" #include "esp_timer.h" +#include "lvgl.h" #include "freertos/FreeRTOS.h" #include "freertos/task.h" #include @@ -56,6 +58,23 @@ static bool paused; static int64_t now_ms(void) { return esp_timer_get_time() / 1000; } +// Shows "78%" with a battery glyph while running on the battery; hidden on USB power. +static void update_battery(void) +{ + static int64_t last; + int64_t now = now_ms(); + if (last && now - last < 15000) return; + last = now; + int mv, pct; + bool usb; + if (!battery_read(&mv, &pct, &usb) || usb) { ui_set_battery(NULL); return; } + const char *glyph = pct > 80 ? LV_SYMBOL_BATTERY_FULL : pct > 55 ? LV_SYMBOL_BATTERY_3 : + pct > 30 ? LV_SYMBOL_BATTERY_2 : pct > 10 ? LV_SYMBOL_BATTERY_1 : LV_SYMBOL_BATTERY_EMPTY; + char text[24]; + snprintf(text, sizeof text, "%s %d%%", glyph, pct); + ui_set_battery(text); +} + static insecam_listing_t pick_listing(void) { if (settings.listing == LISTING_RATING) return INSECAM_BY_RATING; @@ -189,6 +208,7 @@ static leave_t show_camera(const cam_info_t *cam) if (shown_ms >= interval_ms) { leave = LEAVE_NEXT; goto done; } if (now - last_progress >= 200) { last_progress = now; + update_battery(); int permille = 1000 - (int)(shown_ms * 1000 / interval_ms); ui_set_progress(permille); leds_set_countdown(permille); diff --git a/main/ui.c b/main/ui.c index 4d07d44..96f77c0 100644 --- a/main/ui.c +++ b/main/ui.c @@ -5,7 +5,7 @@ #include static lv_obj_t *scr_status, *lbl_title, *lbl_msg; -static lv_obj_t *scr_cam, *canvas, *overlay, *lbl_place, *lbl_meta, *badge, *marker, *bar, *toast; +static lv_obj_t *scr_cam, *canvas, *overlay, *lbl_place, *lbl_meta, *lbl_batt, *badge, *marker, *bar, *toast; static lv_obj_t *scr_menu, *menu_title, *menu_row[UI_MENU_ROWS], *menu_label[UI_MENU_ROWS], *menu_value[UI_MENU_ROWS]; static lv_obj_t *prev_screen; static lv_timer_t *toast_timer; @@ -69,9 +69,12 @@ void ui_init(void) lv_label_set_long_mode(lbl_place, LV_LABEL_LONG_DOT); lv_obj_set_pos(lbl_place, 8, 3); lbl_meta = label(overlay, &lv_font_montserrat_12, 0xc4ccd8); - lv_obj_set_width(lbl_meta, LCD_H_RES - 16); + lv_obj_set_width(lbl_meta, LCD_H_RES - 76); lv_label_set_long_mode(lbl_meta, LV_LABEL_LONG_DOT); lv_obj_set_pos(lbl_meta, 8, 26); + lbl_batt = label(overlay, &lv_font_montserrat_12, 0xc4ccd8); + lv_obj_align(lbl_batt, LV_ALIGN_BOTTOM_RIGHT, -8, -3); + lv_obj_set_hidden(lbl_batt, true); bar = lv_bar_create(scr_cam); lv_obj_remove_style_all(bar); @@ -261,3 +264,11 @@ void ui_menu_hide(void) if (lv_screen_active() == scr_menu) lv_screen_load(prev_screen ? prev_screen : scr_cam); lvgl_port_unlock(); } + +void ui_set_battery(const char *text) +{ + lvgl_port_lock(0); + if (!text) lv_obj_set_hidden(lbl_batt, true); + else { lv_label_set_text(lbl_batt, text); lv_obj_set_hidden(lbl_batt, false); } + lvgl_port_unlock(); +} diff --git a/main/ui.h b/main/ui.h index 0061d49..20da042 100644 --- a/main/ui.h +++ b/main/ui.h @@ -23,6 +23,8 @@ void ui_set_overlay_visible(bool visible); bool ui_overlay_visible(void); // Small marker in the top-left corner of the camera page ("FAV 3"); NULL hides it. void ui_set_marker(const char *text); +// Battery readout at the right end of the caption bar; NULL hides it. +void ui_set_battery(const char *text); // --- menu page --- #define UI_MENU_ROWS 6