From d92a2e53cc9ef97f39fe2ee3aadd9fb1305bd0bc Mon Sep 17 00:00:00 2001 From: Juli Date: Thu, 17 Sep 2026 00:57:02 +0200 Subject: [PATCH] On-device menu, persistent settings and 12 favourite slots Hold the knob to open a six-row menu: save/remove the playing camera as a favourite (12 NVS slots), play or forget favourites, choose the source (random insecam or favourites), time per camera, listing, screen and ring brightness, caption, and reboot into Wi-Fi setup. Settings live in NVS and override the Kconfig first-boot defaults. Camera URLs are stored with their COUNTER placeholder so favourites get a fresh cache-buster on every visit. --- README.md | 27 +++++- main/CMakeLists.txt | 2 +- main/Kconfig.projbuild | 2 + main/insecam.c | 6 +- main/insecam.h | 6 +- main/leds.c | 11 +-- main/leds.h | 1 + main/main.c | 9 +- main/menu.c | 189 +++++++++++++++++++++++++++++++++++++++++ main/menu.h | 8 ++ main/settings.c | 118 +++++++++++++++++++++++++ main/settings.h | 33 +++++++ main/slideshow.c | 67 ++++++++++----- main/ui.c | 83 +++++++++++++++++- main/ui.h | 13 +++ 15 files changed, 536 insertions(+), 39 deletions(-) create mode 100644 main/menu.c create mode 100644 main/menu.h create mode 100644 main/settings.c create mode 100644 main/settings.h diff --git a/README.md b/README.md index 1d2e054..a51302d 100644 --- a/README.md +++ b/README.md @@ -30,8 +30,9 @@ idf.py -p /dev/ttyACM0 flash monitor The partition table keeps `nvs` where the ESPHome build that shipped on this unit had it (`0xf90000`), so stored Wi-Fi credentials survive re-flashing. -Options (interval, listing, brightness, forced variant) live under -`idf.py menuconfig` → *insecam desk widget*. +First-boot defaults (interval, listing, brightness, forced variant) live under +`idf.py menuconfig` → *insecam desk widget*; the on-device menu overrides them +and stores its choices in NVS. ## First boot / Wi-Fi @@ -46,7 +47,23 @@ your Wi-Fi. Holding the knob down while the widget starts forces the portal. | turn clockwise | next camera | | turn counter-clockwise | previous camera (history of 24) | | click | pause / resume the countdown (the live stream keeps playing) | -| double-click or hold | hide / show the caption | +| 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: + +* **Save as favourite / Remove from favourites** — the camera that was playing; + saving asks for one of the 12 slots (overwrites it). +* **Favourites** — the 12 slots; click one to play it. A saved camera shows a + yellow *FAV n* marker while it plays. +* **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. + +The menu closes by itself after 45 s without input. ## How it works @@ -63,4 +80,6 @@ your Wi-Fi. Holding the knob down while the widget starts forces the portal. * `slideshow.c` runs the state machine: connect, play frames until the interval elapses or the knob moves, skip dead cameras, keep history for "previous". * `ui.c` is LVGL 9 via `esp_lvgl_port`: a canvas for the frame, a translucent - caption bar, a LIVE/PAUSED badge and a hairline progress bar. + caption bar, a LIVE/PAUSED badge, a hairline progress bar and a six-row menu page. +* `settings.c` keeps the settings blob and the 12 favourite slots in NVS + (namespace `widget`); `menu.c` is the knob-driven menu that edits them. diff --git a/main/CMakeLists.txt b/main/CMakeLists.txt index 6d25522..f93128f 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" + "httpstream.c" "jpegframe.c" "jpegdec.c" "insecam.c" "slideshow.c" "settings.c" "menu.c" INCLUDE_DIRS "." PRIV_REQUIRES esp_lcd esp_wifi nvs_flash esp_netif esp_event esp_http_server driver esp_timer ) diff --git a/main/Kconfig.projbuild b/main/Kconfig.projbuild index 1cea11d..ce41cb9 100644 --- a/main/Kconfig.projbuild +++ b/main/Kconfig.projbuild @@ -1,4 +1,6 @@ menu "insecam desk widget" + # Interval, listing and brightness are first-boot defaults only; the + # on-device menu (hold the knob) overrides them and persists to NVS. choice TEMBED_VARIANT prompt "LilyGO T-Embed variant" diff --git a/main/insecam.c b/main/insecam.c index 113cc3f..4b2d443 100644 --- a/main/insecam.c +++ b/main/insecam.c @@ -74,10 +74,11 @@ static bool copy_attr(const char *from, const char *limit, const char *attr, cha return true; } -static void fill_counter(char *url) +void insecam_effective_url(const cam_info_t *cam, char *out, size_t cap) { + strlcpy(out, cam->url, cap); char *c; - while ((c = strstr(url, "COUNTER"))) { + while ((c = strstr(out, "COUNTER"))) { // the site's JS swaps this for a cache-buster char digits[8]; snprintf(digits, sizeof digits, "%07lu", (unsigned long)(esp_random() % 10000000)); memcpy(c, digits, 7); @@ -108,7 +109,6 @@ static int parse_page(char *html, cam_info_t *out, int max, int *total_pages) } for (int pass = 0; pass < 3 && strstr(c->url, "&"); pass++) clean_text(c->url, sizeof c->url); clean_text(c->url, sizeof c->url); - fill_counter(c->url); // ; fall back to the link title const char *img = strstr(item, " #include #include "esp_err.h" typedef struct { uint32_t id; // insecam view id - char url[300]; // direct camera URL (COUNTER placeholder already filled) + char url[300]; // direct camera URL as listed (may contain a COUNTER placeholder) char place[64]; // "Imola, Italy" (ASCII-folded for the built-in font) char maker[24]; // "Mobotix" } cam_info_t; typedef enum { INSECAM_BY_RATING, INSECAM_BY_NEW } insecam_listing_t; +// Copies the camera URL with any COUNTER placeholder replaced by a fresh random number. +void insecam_effective_url(const cam_info_t *cam, char *out, size_t cap); + // Downloads one listing page (six cameras) and parses it. total_pages is // updated from the page's own paginator when found. esp_err_t insecam_fetch_page(insecam_listing_t listing, int page, cam_info_t *out, int max, diff --git a/main/leds.c b/main/leds.c index 9ee4bd3..efc1214 100644 --- a/main/leds.c +++ b/main/leds.c @@ -21,19 +21,19 @@ static uint8_t apa_frame[4 + MAX_LEDS * 4 + 4]; static volatile led_mode_t mode = LED_OFF; static volatile uint32_t colour = 0; static volatile int countdown = 1000; +static volatile uint8_t brightness = CONFIG_WIDGET_LED_BRIGHTNESS; static void push(const uint8_t (*rgb)[3], int n) { if (board->led_ws2812) { for (int i = 0; i < n; i++) - led_strip_set_pixel(strip, i, rgb[i][0] * CONFIG_WIDGET_LED_BRIGHTNESS / 255, - rgb[i][1] * CONFIG_WIDGET_LED_BRIGHTNESS / 255, - rgb[i][2] * CONFIG_WIDGET_LED_BRIGHTNESS / 255); + led_strip_set_pixel(strip, i, rgb[i][0] * brightness / 255, rgb[i][1] * brightness / 255, + rgb[i][2] * brightness / 255); led_strip_refresh(strip); } else { // APA102: 32-bit zero start frame, per-LED 0b111xxxxx global brightness + B G R, ones end frame. - int gb = CONFIG_WIDGET_LED_BRIGHTNESS * 31 / 255; - if (gb < 1 && CONFIG_WIDGET_LED_BRIGHTNESS) gb = 1; + int gb = brightness * 31 / 255; + if (gb < 1 && brightness) gb = 1; uint8_t *p = apa_frame; memset(p, 0, 4); p += 4; for (int i = 0; i < n; i++) { @@ -131,3 +131,4 @@ void leds_init(void) void leds_set_mode(led_mode_t m, uint32_t rgb) { colour = rgb; mode = m; } void leds_set_countdown(int permille) { countdown = permille; } +void leds_set_brightness(uint8_t b) { brightness = b; } diff --git a/main/leds.h b/main/leds.h index aec8536..9efdd7f 100644 --- a/main/leds.h +++ b/main/leds.h @@ -13,3 +13,4 @@ typedef enum { void leds_init(void); void leds_set_mode(led_mode_t mode, uint32_t rgb); void leds_set_countdown(int permille); +void leds_set_brightness(uint8_t b); // 0..255 diff --git a/main/main.c b/main/main.c index 81357e5..a02cb5a 100644 --- a/main/main.c +++ b/main/main.c @@ -4,6 +4,7 @@ #include "jpegdec.h" #include "leds.h" #include "net.h" +#include "settings.h" #include "slideshow.h" #include "ui.h" #include "sdkconfig.h" @@ -27,13 +28,15 @@ static void run_portal(void) void app_main(void) { board_init(); + settings_init(); leds_init(); + leds_set_brightness(settings.led_brightness); leds_set_mode(LED_BREATHE, 0x404040); display_init(); ui_init(); ui_status("insecam widget", "starting"); - display_set_backlight(CONFIG_WIDGET_LCD_BRIGHTNESS); + display_set_backlight(settings.lcd_brightness); ESP_ERROR_CHECK(jpegdec_init()); net_init(); @@ -41,7 +44,7 @@ void app_main(void) // Holding the knob after power-up forces the setup hotspot. gpio_config_t btn = {.pin_bit_mask = 1ULL << board->enc_btn, .mode = GPIO_MODE_INPUT, .pull_up_en = 1}; gpio_config(&btn); - bool force_setup = !gpio_get_level(board->enc_btn); + bool force_setup = !gpio_get_level(board->enc_btn) || settings_take_portal_request(); input_init(); if (force_setup || !net_has_credentials()) { @@ -51,7 +54,7 @@ void app_main(void) if (!net_wait_connected(20000)) run_portal(); } ESP_LOGI(TAG, "online at %s", net_ip_str()); - ui_status("insecam widget", "online " "\xE2\x80\xA2" " finding cameras\n\nknob: next / previous\nclick: pause double-click: hide text"); + ui_status("insecam widget", "online " "\xE2\x80\xA2" " finding cameras\n\nknob: next / previous\nclick: pause hold: menu"); leds_set_mode(LED_SPIN, 0x2060ff); slideshow_start(); } diff --git a/main/menu.c b/main/menu.c new file mode 100644 index 0000000..5807ca8 --- /dev/null +++ b/main/menu.c @@ -0,0 +1,189 @@ +#include "menu.h" +#include "board.h" +#include "display.h" +#include "input.h" +#include "leds.h" +#include "net.h" +#include "settings.h" +#include "ui.h" +#include "esp_log.h" +#include "esp_system.h" +#include "freertos/FreeRTOS.h" +#include "freertos/task.h" +#include +#include + +static const char *TAG = "menu"; +#define IDLE_CLOSE_MS 45000 + +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 }; + +static ui_menu_row_t rows[FAV_SLOTS + 1]; +static char values[TOP_ROWS][20]; +static char slot_labels[FAV_SLOTS][4]; + +static const char *listing_name(uint8_t l) +{ + return l == LISTING_RATING ? "Top rated" : l == LISTING_NEW ? "Newest" : "Mixed"; +} + +static int build_top(const cam_info_t *current, int fav_slot) +{ + if (fav_slot >= 0) snprintf(values[ROW_FAV_TOGGLE], sizeof values[0], "slot %d", fav_slot + 1); + else values[ROW_FAV_TOGGLE][0] = 0; + snprintf(values[ROW_FAVS], sizeof values[0], "%d of %d", fav_count(), FAV_SLOTS); + snprintf(values[ROW_INTERVAL], sizeof values[0], "%u s", settings.interval_s); + snprintf(values[ROW_LCD], sizeof values[0], "%u%%", settings.lcd_brightness); + snprintf(values[ROW_RING], sizeof values[0], "%u%%", (settings.led_brightness * 100 + 127) / 255); + rows[ROW_FAV_TOGGLE] = (ui_menu_row_t){fav_slot >= 0 ? "Remove from favourites" : "Save as favourite", + values[ROW_FAV_TOGGLE], current == NULL}; + rows[ROW_FAVS] = (ui_menu_row_t){"Favourites", values[ROW_FAVS], false}; + rows[ROW_SOURCE] = (ui_menu_row_t){"Source", settings.source == SOURCE_FAVORITES ? "Favourites" : "Random", false}; + rows[ROW_INTERVAL] = (ui_menu_row_t){"Time per camera", values[ROW_INTERVAL], false}; + rows[ROW_LISTING] = (ui_menu_row_t){"Listing", listing_name(settings.listing), false}; + rows[ROW_LCD] = (ui_menu_row_t){"Screen brightness", values[ROW_LCD], false}; + rows[ROW_RING] = (ui_menu_row_t){"Ring brightness", values[ROW_RING], false}; + rows[ROW_CAPTION] = (ui_menu_row_t){"Caption", settings.caption ? "Shown" : "Hidden", false}; + 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}; + rows[ROW_CLOSE] = (ui_menu_row_t){"Close", NULL, false}; + return TOP_ROWS; +} + +// Slot list: "1 .. 12" with the place name, then "Back". +static int build_slots(void) +{ + for (int i = 0; i < FAV_SLOTS; i++) { + const cam_info_t *c = fav_get(i); + snprintf(slot_labels[i], sizeof slot_labels[i], "%d", i + 1); + rows[i] = (ui_menu_row_t){slot_labels[i], c ? c->place : "empty", c == NULL}; + } + rows[FAV_SLOTS] = (ui_menu_row_t){"Back", NULL, false}; + return FAV_SLOTS + 1; +} + +static int clampi(int v, int lo, int hi) { return v < lo ? lo : v > hi ? hi : v; } + +menu_result_t menu_run(const cam_info_t *current, int *fav_slot_out) +{ + view_t view = VIEW_TOP; + int sel = 0, top = 0, count = 0; + bool editing = false; + menu_result_t result = MENU_RESUME; + leds_set_mode(LED_SOLID, 0x202028); + + while (1) { + int fav_slot = current ? fav_find(current) : -1; + const char *title = "insecam widget"; + if (view == VIEW_TOP) count = build_top(current, fav_slot); + else { + count = build_slots(); + title = view == VIEW_FAV_PLAY ? "Favourites" : view == VIEW_FAV_SAVE ? "Save to slot" : "Forget favourite"; + } + sel = clampi(sel, 0, count - 1); + if (sel < top) top = sel; + if (sel >= top + UI_MENU_ROWS) top = sel - UI_MENU_ROWS + 1; + ui_menu_show(title, rows, count, top, sel, editing); + + input_event_t ev = input_wait(IDLE_CLOSE_MS); + if (ev == INPUT_NONE) break; // idle: close + + // --- editing a number: knob changes it, click confirms + if (editing) { + int dir = ev == INPUT_CW ? 1 : ev == INPUT_CCW ? -1 : 0; + if (dir) { + if (sel == ROW_INTERVAL) settings.interval_s = clampi(settings.interval_s + dir * 5, 5, 300); + else if (sel == ROW_LCD) { + settings.lcd_brightness = clampi(settings.lcd_brightness + dir * 5, 5, 100); + display_set_backlight(settings.lcd_brightness); + } else if (sel == ROW_RING) { + settings.led_brightness = clampi(settings.led_brightness + dir * 13, 0, 255); + leds_set_brightness(settings.led_brightness); + } + } else if (ev == INPUT_CLICK || ev == INPUT_DOUBLE || ev == INPUT_LONG) { + editing = false; + settings_save(); + } + continue; + } + + if (ev == INPUT_CW) { sel++; continue; } + if (ev == INPUT_CCW) { sel--; continue; } + if (ev == INPUT_LONG || ev == INPUT_KEY) { + if (view == VIEW_TOP) break; + view = VIEW_TOP; + sel = top = 0; + continue; + } + if (ev != INPUT_CLICK && ev != INPUT_DOUBLE) continue; + + // --- click + if (view != VIEW_TOP) { + if (sel == FAV_SLOTS) { view = VIEW_TOP; sel = top = 0; continue; } + const cam_info_t *c = fav_get(sel); + char msg[48]; + if (view == VIEW_FAV_SAVE) { + if (current) { + fav_set(sel, current); + snprintf(msg, sizeof msg, "Saved to slot %d", sel + 1); + ui_toast(msg, 1500); + } + view = VIEW_TOP; sel = top = 0; + } else if (view == VIEW_FAV_PLAY) { + if (!c) { ui_toast("Empty slot", 1000); continue; } + *fav_slot_out = sel; + result = MENU_PLAY_FAV; + goto out; + } else { // VIEW_FAV_FORGET + if (!c) { ui_toast("Empty slot", 1000); continue; } + fav_clear(sel); + snprintf(msg, sizeof msg, "Forgot slot %d", sel + 1); + ui_toast(msg, 1500); + } + continue; + } + switch (sel) { + case ROW_FAV_TOGGLE: + if (!current) break; + if (fav_slot >= 0) { fav_clear(fav_slot); ui_toast("Removed from favourites", 1500); } + else { view = VIEW_FAV_SAVE; sel = top = 0; } + break; + case ROW_FAVS: view = VIEW_FAV_PLAY; sel = top = 0; break; + case ROW_FORGET: view = VIEW_FAV_FORGET; sel = top = 0; break; + case ROW_SOURCE: + settings.source = settings.source == SOURCE_RANDOM ? SOURCE_FAVORITES : SOURCE_RANDOM; + if (settings.source == SOURCE_FAVORITES && fav_count() == 0) ui_toast("No favourites saved yet", 1500); + settings_save(); + break; + case ROW_LISTING: + settings.listing = (settings.listing + 1) % 3; + settings_save(); + break; + case ROW_CAPTION: + settings.caption = !settings.caption; + settings_save(); + break; + case ROW_INTERVAL: case ROW_LCD: case ROW_RING: + editing = true; + break; + case ROW_WIFI: + settings_request_portal(); + ui_status("Wi-Fi setup", "restarting into the setup hotspot"); + vTaskDelay(pdMS_TO_TICKS(800)); + esp_restart(); + break; + case ROW_CLOSE: + goto out; + default: + break; + } + } +out: + ui_menu_hide(); + ui_set_overlay_visible(settings.caption); + ESP_LOGI(TAG, "closed (%s)", result == MENU_PLAY_FAV ? "play favourite" : "resume"); + return result; +} diff --git a/main/menu.h b/main/menu.h new file mode 100644 index 0000000..a176a6b --- /dev/null +++ b/main/menu.h @@ -0,0 +1,8 @@ +#pragma once +#include "insecam.h" + +typedef enum { MENU_RESUME, MENU_PLAY_FAV } menu_result_t; + +// Runs the on-device settings menu on the calling task until it closes. +// `current` is the camera that was playing (for the favourite toggle). +menu_result_t menu_run(const cam_info_t *current, int *fav_slot); diff --git a/main/settings.c b/main/settings.c new file mode 100644 index 0000000..da7f9f2 --- /dev/null +++ b/main/settings.c @@ -0,0 +1,118 @@ +#include "settings.h" +#include "sdkconfig.h" +#include "esp_log.h" +#include "nvs.h" +#include "nvs_flash.h" +#include +#include + +static const char *TAG = "settings"; +static nvs_handle_t nvs; +settings_t settings; +static cam_info_t favs[FAV_SLOTS]; +static bool fav_used[FAV_SLOTS]; + +static void slot_key(int slot, char *key) { snprintf(key, 8, "fav%d", slot); } + +void settings_init(void) +{ + esp_err_t err = nvs_flash_init(); + if (err == ESP_ERR_NVS_NO_FREE_PAGES || err == ESP_ERR_NVS_NEW_VERSION_FOUND) { + ESP_ERROR_CHECK(nvs_flash_erase()); + err = nvs_flash_init(); + } + ESP_ERROR_CHECK(err); + ESP_ERROR_CHECK(nvs_open("widget", NVS_READWRITE, &nvs)); + + settings = (settings_t){ + .interval_s = CONFIG_WIDGET_INTERVAL_S, .lcd_brightness = CONFIG_WIDGET_LCD_BRIGHTNESS, + .led_brightness = CONFIG_WIDGET_LED_BRIGHTNESS, +#if CONFIG_WIDGET_LISTING_RATING + .listing = LISTING_RATING, +#elif CONFIG_WIDGET_LISTING_NEW + .listing = LISTING_NEW, +#else + .listing = LISTING_MIXED, +#endif + .source = SOURCE_RANDOM, .caption = 1, + }; + settings_t stored; + size_t len = sizeof stored; + if (nvs_get_blob(nvs, "settings", &stored, &len) == ESP_OK && len == sizeof stored) { + settings = stored; + if (settings.interval_s < 5 || settings.interval_s > 300) settings.interval_s = CONFIG_WIDGET_INTERVAL_S; + if (settings.lcd_brightness > 100) settings.lcd_brightness = 100; + if (settings.listing > LISTING_NEW) settings.listing = LISTING_MIXED; + if (settings.source > SOURCE_FAVORITES) settings.source = SOURCE_RANDOM; + } + for (int i = 0; i < FAV_SLOTS; i++) { + char key[8]; + slot_key(i, key); + len = sizeof favs[i]; + fav_used[i] = nvs_get_blob(nvs, key, &favs[i], &len) == ESP_OK && len == sizeof favs[i] && favs[i].url[0]; + } + ESP_LOGI(TAG, "interval %us, lcd %u%%, ring %u, listing %u, source %u, %d favourites", + settings.interval_s, settings.lcd_brightness, settings.led_brightness, settings.listing, + settings.source, fav_count()); +} + +void settings_save(void) +{ + if (nvs_set_blob(nvs, "settings", &settings, sizeof settings) != ESP_OK || nvs_commit(nvs) != ESP_OK) + ESP_LOGW(TAG, "save failed"); +} + +void settings_request_portal(void) +{ + nvs_set_u8(nvs, "portal", 1); + nvs_commit(nvs); +} + +bool settings_take_portal_request(void) +{ + uint8_t v = 0; + if (nvs_get_u8(nvs, "portal", &v) != ESP_OK || !v) return false; + nvs_erase_key(nvs, "portal"); + nvs_commit(nvs); + return true; +} + +const cam_info_t *fav_get(int slot) +{ + return (slot >= 0 && slot < FAV_SLOTS && fav_used[slot]) ? &favs[slot] : NULL; +} + +void fav_set(int slot, const cam_info_t *cam) +{ + if (slot < 0 || slot >= FAV_SLOTS) return; + char key[8]; + slot_key(slot, key); + favs[slot] = *cam; + fav_used[slot] = true; + if (nvs_set_blob(nvs, key, &favs[slot], sizeof favs[slot]) != ESP_OK || nvs_commit(nvs) != ESP_OK) + ESP_LOGW(TAG, "favourite %d not saved", slot); +} + +void fav_clear(int slot) +{ + if (slot < 0 || slot >= FAV_SLOTS) return; + char key[8]; + slot_key(slot, key); + fav_used[slot] = false; + nvs_erase_key(nvs, key); + nvs_commit(nvs); +} + +int fav_find(const cam_info_t *cam) +{ + for (int i = 0; i < FAV_SLOTS; i++) + if (fav_used[i] && favs[i].id == cam->id && !strcmp(favs[i].url, cam->url)) return i; + return -1; +} + +int fav_count(void) +{ + int n = 0; + for (int i = 0; i < FAV_SLOTS; i++) n += fav_used[i]; + return n; +} diff --git a/main/settings.h b/main/settings.h new file mode 100644 index 0000000..b12ebcd --- /dev/null +++ b/main/settings.h @@ -0,0 +1,33 @@ +#pragma once +#include +#include +#include "insecam.h" + +// Persistent settings (NVS namespace "widget"); Kconfig supplies first-boot defaults. +typedef enum { LISTING_MIXED = 0, LISTING_RATING, LISTING_NEW } listing_pref_t; +typedef enum { SOURCE_RANDOM = 0, SOURCE_FAVORITES } source_pref_t; + +typedef struct { + uint16_t interval_s; // 5..300 + uint8_t lcd_brightness; // 0..100 + uint8_t led_brightness; // 0..255 + uint8_t listing; // listing_pref_t + uint8_t source; // source_pref_t + uint8_t caption; // 1 = caption shown +} settings_t; + +extern settings_t settings; + +void settings_init(void); // nvs_flash_init + load settings and favourites +void settings_save(void); + +// One-shot flag: reboot into the Wi-Fi setup hotspot. +void settings_request_portal(void); +bool settings_take_portal_request(void); + +#define FAV_SLOTS 12 +const cam_info_t *fav_get(int slot); // NULL when empty +void fav_set(int slot, const cam_info_t *cam); +void fav_clear(int slot); +int fav_find(const cam_info_t *cam); // slot holding this camera (by id), or -1 +int fav_count(void); diff --git a/main/slideshow.c b/main/slideshow.c index afacb9e..2909106 100644 --- a/main/slideshow.c +++ b/main/slideshow.c @@ -6,7 +6,9 @@ #include "jpegdec.h" #include "jpegframe.h" #include "leds.h" +#include "menu.h" #include "net.h" +#include "settings.h" #include "ui.h" #include "sdkconfig.h" #include "esp_heap_caps.h" @@ -36,7 +38,7 @@ static const char *TAG = "slideshow"; #define COL_PAUSED 0xffa000 #define COL_ERROR 0xff2020 -typedef enum { LEAVE_NEXT, LEAVE_PREV, LEAVE_ERROR } leave_t; +typedef enum { LEAVE_NEXT, LEAVE_PREV, LEAVE_ERROR, LEAVE_MENU } leave_t; static cam_info_t page_cams[PAGE_ITEMS]; static int page_count, page_next; @@ -44,6 +46,7 @@ static int total_pages[2] = {287, 287}; static cam_info_t history[HISTORY_LEN]; static int hist_len, hist_pos = -1; +static int fav_cursor; static uint8_t *jpeg_buf; static size_t jpeg_cap; @@ -55,13 +58,9 @@ static int64_t now_ms(void) { return esp_timer_get_time() / 1000; } static insecam_listing_t pick_listing(void) { -#if CONFIG_WIDGET_LISTING_RATING - return INSECAM_BY_RATING; -#elif CONFIG_WIDGET_LISTING_NEW - return INSECAM_BY_NEW; -#else + if (settings.listing == LISTING_RATING) return INSECAM_BY_RATING; + if (settings.listing == LISTING_NEW) return INSECAM_BY_NEW; return (esp_random() & 1) ? INSECAM_BY_RATING : INSECAM_BY_NEW; -#endif } // Refills the page queue from a random listing page; blocks (with retries) until it has cameras. @@ -112,27 +111,42 @@ static const cam_info_t *next_camera(bool backwards) return &history[hist_pos]; } if (hist_pos >= 0 && hist_pos < hist_len - 1) return &history[++hist_pos]; + if (settings.source == SOURCE_FAVORITES && fav_count() > 0) { + for (int tries = 0; tries < FAV_SLOTS; tries++) { + const cam_info_t *c = fav_get(fav_cursor); + fav_cursor = (fav_cursor + 1) % FAV_SLOTS; + if (c) { history_push(c); return &history[hist_pos]; } + } + } if (page_next >= page_count) refill_queue(); history_push(&page_cams[page_next++]); return &history[hist_pos]; } -static void set_paused(bool p, int64_t remaining_ms) +static void set_paused(bool p) { paused = p; ui_set_badge(p ? "PAUSED" : "LIVE", p ? COL_PAUSED : COL_LIVE); leds_set_mode(p ? LED_SOLID : LED_COUNTDOWN, p ? COL_PAUSED : COL_LIVE); - (void)remaining_ms; +} + +static void show_marker(const cam_info_t *cam) +{ + int slot = fav_find(cam); + char text[20]; + if (slot >= 0) snprintf(text, sizeof text, "FAV %d", slot + 1); + ui_set_marker(slot >= 0 ? text : NULL); } // Streams one camera until the interval elapses, the user intervenes, or it fails. static leave_t show_camera(const cam_info_t *cam) { - const int64_t interval_ms = (int64_t)CONFIG_WIDGET_INTERVAL_S * 1000; - char meta[96]; + const int64_t interval_ms = (int64_t)settings.interval_s * 1000; + char meta[96], url[sizeof cam->url]; snprintf(meta, sizeof meta, "%s " BULLET " connecting", cam->maker[0] ? cam->maker : "camera"); ui_set_caption(cam->place, meta); ui_set_badge("...", 0x505860); + show_marker(cam); ui_set_progress(-1); leds_set_mode(LED_SPIN, COL_CONNECT); ESP_LOGI(TAG, "#%lu %s (%s) %s", (unsigned long)cam->id, cam->place, cam->maker, cam->url); @@ -159,10 +173,14 @@ static leave_t show_camera(const cam_info_t *cam) switch (ev) { case INPUT_CW: case INPUT_KEY: leave = LEAVE_NEXT; goto done; case INPUT_CCW: leave = LEAVE_PREV; goto done; - case INPUT_CLICK: if (frames) set_paused(!paused, interval_ms - shown_ms); break; - case INPUT_DOUBLE: case INPUT_LONG: - ui_set_overlay_visible(!ui_overlay_visible()); - if (frames) set_paused(paused, 0); + case INPUT_CLICK: if (frames) set_paused(!paused); break; + case INPUT_LONG: leave = LEAVE_MENU; goto done; + case INPUT_DOUBLE: + settings.caption = !ui_overlay_visible(); + settings_save(); + ui_set_overlay_visible(settings.caption); + if (frames) set_paused(paused); else ui_set_badge("...", 0x505860); + show_marker(cam); break; default: break; } @@ -180,7 +198,8 @@ static leave_t show_camera(const cam_info_t *cam) // (re)open if (!open) { if (ended_at && now - ended_at < REFRESH_STILL_MS) { vTaskDelay(pdMS_TO_TICKS(50)); continue; } - esp_err_t err = httpstream_open(&s, cam->url, "Mozilla/5.0 (compatible; insecam-widget)", CONNECT_TIMEOUT_MS, RECV_SLICE_MS); + insecam_effective_url(cam, url, sizeof url); + esp_err_t err = httpstream_open(&s, url, "Mozilla/5.0 (compatible; insecam-widget)", CONNECT_TIMEOUT_MS, RECV_SLICE_MS); if (err != ESP_OK || s.status != 200) { if (err == ESP_OK) { ESP_LOGW(TAG, "HTTP %d", s.status); httpstream_close(&s); } else ESP_LOGW(TAG, "connect failed: %s", esp_err_to_name(err)); @@ -244,7 +263,7 @@ static leave_t show_camera(const cam_info_t *cam) snprintf(meta, sizeof meta, "%s " BULLET " %dx%d " BULLET " #%lu", cam->maker[0] ? cam->maker : (is_stream ? "mjpeg" : "still"), w, h, (unsigned long)cam->id); ui_set_caption(cam->place, meta); - set_paused(paused, interval_ms); + set_paused(paused); last_tick = now_ms(); } } @@ -256,8 +275,9 @@ done: static void slideshow_task(void *arg) { - bool backwards = false; + bool backwards = false, replay = false; int consecutive_errors = 0; + ui_set_overlay_visible(settings.caption); ui_show_camera_page(); while (1) { if (!net_is_connected()) { @@ -265,9 +285,16 @@ static void slideshow_task(void *arg) leds_set_mode(LED_BREATHE, COL_ERROR); net_wait_connected(-1); } - const cam_info_t *cam = next_camera(backwards); - backwards = false; + const cam_info_t *cam = replay ? &history[hist_pos] : next_camera(backwards); + backwards = replay = false; leave_t why = show_camera(cam); + if (why == LEAVE_MENU) { + int slot = -1; + if (menu_run(cam, &slot) == MENU_PLAY_FAV && fav_get(slot)) history_push(fav_get(slot)); + replay = true; // play the chosen favourite, or pick the same camera back up + paused = false; + continue; + } if (why == LEAVE_ERROR) { consecutive_errors++; ui_toast("Camera offline, skipping", 900); diff --git a/main/ui.c b/main/ui.c index 329ccbe..4d07d44 100644 --- a/main/ui.c +++ b/main/ui.c @@ -5,7 +5,9 @@ #include static lv_obj_t *scr_status, *lbl_title, *lbl_msg; -static lv_obj_t *scr_cam, *canvas, *overlay, *lbl_place, *lbl_meta, *badge, *bar, *toast; +static lv_obj_t *scr_cam, *canvas, *overlay, *lbl_place, *lbl_meta, *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; static bool overlay_on = true; @@ -87,7 +89,17 @@ void ui_init(void) lv_obj_align(badge, LV_ALIGN_TOP_RIGHT, -6, 6); lv_obj_set_hidden(badge, true); - toast = label(scr_cam, &lv_font_montserrat_14, 0xffffff); + marker = label(scr_cam, &lv_font_montserrat_12, 0x101010); + lv_obj_set_style_bg_color(marker, lv_color_hex(0xffd040), 0); + lv_obj_set_style_bg_opa(marker, LV_OPA_COVER, 0); + lv_obj_set_style_radius(marker, 3, 0); + lv_obj_set_style_pad_hor(marker, 5, 0); + lv_obj_set_style_pad_ver(marker, 2, 0); + lv_obj_align(marker, LV_ALIGN_TOP_LEFT, 6, 6); + lv_obj_set_hidden(marker, true); + + // toast lives on the top layer so it shows over every page + toast = label(lv_layer_top(), &lv_font_montserrat_14, 0xffffff); lv_obj_set_style_bg_color(toast, lv_color_hex(0x202830), 0); lv_obj_set_style_bg_opa(toast, LV_OPA_90, 0); lv_obj_set_style_radius(toast, 6, 0); @@ -95,6 +107,29 @@ void ui_init(void) lv_obj_align(toast, LV_ALIGN_CENTER, 0, -10); lv_obj_set_hidden(toast, true); + // --- menu page --- + scr_menu = plain_screen(); + lv_obj_t *titlebar = lv_obj_create(scr_menu); + lv_obj_remove_style_all(titlebar); + lv_obj_set_size(titlebar, LCD_H_RES, 22); + lv_obj_set_style_bg_color(titlebar, lv_color_hex(0x1c2430), 0); + lv_obj_set_style_bg_opa(titlebar, LV_OPA_COVER, 0); + menu_title = label(titlebar, &lv_font_montserrat_14, 0x9fb3c8); + lv_obj_align(menu_title, LV_ALIGN_LEFT_MID, 8, 0); + for (int i = 0; i < UI_MENU_ROWS; i++) { + menu_row[i] = lv_obj_create(scr_menu); + lv_obj_remove_style_all(menu_row[i]); + lv_obj_set_size(menu_row[i], LCD_H_RES, 24); + lv_obj_set_pos(menu_row[i], 0, 24 + i * 24); + lv_obj_set_style_bg_opa(menu_row[i], LV_OPA_COVER, 0); + menu_label[i] = label(menu_row[i], &lv_font_montserrat_14, 0xffffff); + lv_obj_set_width(menu_label[i], 190); + lv_label_set_long_mode(menu_label[i], LV_LABEL_LONG_DOT); + lv_obj_align(menu_label[i], LV_ALIGN_LEFT_MID, 10, 0); + menu_value[i] = label(menu_row[i], &lv_font_montserrat_14, 0xc4ccd8); + lv_obj_align(menu_value[i], LV_ALIGN_RIGHT_MID, -10, 0); + } + lv_screen_load(scr_status); lvgl_port_unlock(); } @@ -176,9 +211,53 @@ void ui_set_overlay_visible(bool visible) } else { lv_obj_set_hidden(overlay, true); lv_obj_set_hidden(badge, true); + lv_obj_set_hidden(marker, true); lv_obj_set_hidden(bar, true); } lvgl_port_unlock(); } bool ui_overlay_visible(void) { return overlay_on; } + +void ui_set_marker(const char *text) +{ + lvgl_port_lock(0); + if (!text || !overlay_on) { + lv_obj_set_hidden(marker, true); + } else { + lv_label_set_text(marker, text); + lv_obj_set_hidden(marker, false); + } + lvgl_port_unlock(); +} + +void ui_menu_show(const char *title, const ui_menu_row_t *rows, int count, int top, int selected, bool editing) +{ + lvgl_port_lock(0); + lv_label_set_text(menu_title, title); + for (int i = 0; i < UI_MENU_ROWS; i++) { + int idx = top + i; + bool present = idx < count; + lv_obj_set_hidden(menu_row[i], !present); + if (!present) continue; + bool sel = idx == selected; + lv_obj_set_style_bg_color(menu_row[i], lv_color_hex(sel ? 0x2f81f7 : 0x000000), 0); + lv_label_set_text(menu_label[i], rows[idx].label); + lv_obj_set_style_text_color(menu_label[i], lv_color_hex(rows[idx].dim ? 0x6c7684 : 0xffffff), 0); + lv_label_set_text(menu_value[i], rows[idx].value ? rows[idx].value : ""); + lv_obj_set_style_text_color(menu_value[i], + lv_color_hex(sel && editing ? 0xffd040 : sel ? 0xffffff : 0xc4ccd8), 0); + } + if (lv_screen_active() != scr_menu) { + prev_screen = lv_screen_active(); + lv_screen_load(scr_menu); + } + lvgl_port_unlock(); +} + +void ui_menu_hide(void) +{ + lvgl_port_lock(0); + if (lv_screen_active() == scr_menu) lv_screen_load(prev_screen ? prev_screen : scr_cam); + lvgl_port_unlock(); +} diff --git a/main/ui.h b/main/ui.h index 8616803..0061d49 100644 --- a/main/ui.h +++ b/main/ui.h @@ -21,3 +21,16 @@ void ui_set_progress(int permille); void ui_toast(const char *msg, uint32_t ms); 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); + +// --- menu page --- +#define UI_MENU_ROWS 6 +typedef struct { + const char *label; + const char *value; // right-aligned; may be NULL + bool dim; // informational row, drawn greyed +} ui_menu_row_t; +// Draws rows[top .. top+UI_MENU_ROWS) with `selected` highlighted; `editing` recolours the value. +void ui_menu_show(const char *title, const ui_menu_row_t *rows, int count, int top, int selected, bool editing); +void ui_menu_hide(void);