Battery/USB readout and wrap-around menu scrolling

BAT_ADC (GPIO4) sits between two 100k resistors on the switched supply
rail, so it reads the cell on battery and ~4.4 V on USB; above 4.25 V is
reported as USB power. On battery the caption shows a glyph + percentage
from a LiPo OCV table; the menu shows voltage and source. Menu lists now
wrap at both ends.
This commit is contained in:
Juli committed 2026-09-17 01:28:59 +02:00
1 parent 1f37ccfb61
commit f238cfc9bf
11 files changed
+151 -12

No files matched your search

+17 -5
View File
@@ -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 |
+2 -2
View File
@@ -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
)
+68
View File
@@ -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;
}
+13
View File
@@ -0,0 +1,13 @@
#pragma once
#include <stdbool.h>
// 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);
+2
View File
@@ -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
};
+2
View File
@@ -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
+2
View File
@@ -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.
+10 -3
View File
@@ -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;
+20
View File
@@ -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 <stdio.h>
@@ -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);
+13 -2
View File
@@ -5,7 +5,7 @@
#include <string.h>
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();
}
+2
View File
@@ -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