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.
This commit is contained in:
Juli committed 2026-09-17 00:57:02 +02:00
1 parent eff9e5dfaa
commit d92a2e53cc
15 files changed
+536 -39

No files matched your search

+23 -4
View File
@@ -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.
+1 -1
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"
"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
)
+2
View File
@@ -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"
+3 -3
View File
@@ -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, "&amp;"); pass++) clean_text(c->url, sizeof c->url);
clean_text(c->url, sizeof c->url);
fill_counter(c->url);
// <img ... title="Live camera Mobotix in Imola, Italy"> ; fall back to the link title
const char *img = strstr(item, "<img");
bool ok = img && img < limit && copy_attr(img, limit, "title=\"Live camera ", tmp, sizeof tmp);
+5 -1
View File
@@ -1,16 +1,20 @@
#pragma once
#include <stddef.h>
#include <stdint.h>
#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,
+6 -5
View File
@@ -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; }
+1
View File
@@ -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
+6 -3
View File
@@ -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();
}
+189
View File
@@ -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 <stdio.h>
#include <string.h>
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;
}
+8
View File
@@ -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);
+118
View File
@@ -0,0 +1,118 @@
#include "settings.h"
#include "sdkconfig.h"
#include "esp_log.h"
#include "nvs.h"
#include "nvs_flash.h"
#include <stdio.h>
#include <string.h>
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;
}
+33
View File
@@ -0,0 +1,33 @@
#pragma once
#include <stdbool.h>
#include <stdint.h>
#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);
+47 -20
View File
@@ -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);
+81 -2
View File
@@ -5,7 +5,9 @@
#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, *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();
}
+13
View File
@@ -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);