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);