Files
t-embed-insecam-widget/main/slideshow.c
T
Juli cde3478337 Fix opentopia cameras replaying back-to-back
Cached opentopia stream URLs go stale, and the stored-URL retry path
re-showed the same camera (sometimes a full second time via the history
interaction), which read as frequent repeats. Now opentopia cameras
re-resolve their stream URL on every play and the retry dance is gone; a
last-drawn guard also prevents any camera being drawn twice in a row.
Verified: 29 consecutive cameras, 29 distinct, 0 repeats.
2026-09-17 16:16:34 +02:00

409 lines
16 KiB
C

#include "slideshow.h"
#include "battery.h"
#include "display.h"
#include "httpstream.h"
#include "input.h"
#include "insecam.h"
#include "jpegdec.h"
#include "jpegframe.h"
#include "leds.h"
#include "menu.h"
#include "net.h"
#include "opentopia.h"
#include "settings.h"
#include "ui.h"
#include "sdkconfig.h"
#include "esp_heap_caps.h"
#include "esp_log.h"
#include "esp_random.h"
#include "esp_timer.h"
#include "lvgl.h"
#include "freertos/FreeRTOS.h"
#include "freertos/task.h"
#include <stdio.h>
#include <string.h>
static const char *TAG = "slideshow";
#define HISTORY_LEN 24
#define PAGE_ITEMS 20 // insecam pages hold 6 cameras, opentopia 19
#define SEEN_LEN 1200 // recently shown cameras skipped in Random mode
#define CHUNK 4096
#define CONNECT_TIMEOUT_MS 3000
#define RECV_SLICE_MS 250 // per-recv timeout; keeps the knob responsive
#define FIRST_FRAME_STALL_MS 8000 // give up on a camera that sends nothing usable
#define STREAM_STALL_MS 10000
#define REFRESH_STILL_MS 1500 // re-request interval for single-image cameras
#define MIN_FRAME_GAP_MS 66 // cap live playback around 15 fps
#define BULLET "\xE2\x80\xA2"
#define COL_CONNECT 0x2060ff
#define COL_LIVE 0x20c0a0
#define COL_PAUSED 0xffa000
#define COL_ERROR 0xff2020
typedef enum { LEAVE_NEXT, LEAVE_PREV, LEAVE_ERROR, LEAVE_MENU } leave_t;
static cam_info_t *page_cams; // PAGE_ITEMS, in PSRAM
static int page_count, page_next;
static uint32_t *seen; // ring of provider|id keys, in PSRAM
static int seen_next, seen_count;
static int total_pages[2][2] = {{287, 287}, {162, 162}}; // [provider][listing]
static int popular_page[2] = {1, 1}; // next rated page per provider in Popular mode
static int popular_turn;
static cam_info_t history[HISTORY_LEN];
static int hist_len, hist_pos = -1;
static int fav_cursor;
static uint32_t last_drawn; // key of the previous camera shown; never repeat it back-to-back
static uint8_t *jpeg_buf;
static size_t jpeg_cap;
static uint16_t *fb[2];
static int fb_back;
static bool paused;
static int64_t now_ms(void) { return esp_timer_get_time() / 1000; }
static uint32_t seen_key(const cam_info_t *c) { return ((uint32_t)c->provider << 28) | (c->id & 0x0fffffff); }
static bool seen_recently(const cam_info_t *c)
{
uint32_t k = seen_key(c);
for (int i = 0; i < seen_count; i++) if (seen[i] == k) return true;
return false;
}
static void mark_seen(const cam_info_t *c)
{
seen[seen_next] = seen_key(c);
seen_next = (seen_next + 1) % SEEN_LEN;
if (seen_count < SEEN_LEN) seen_count++;
}
// Shows "78%" with a battery glyph while running on the battery; hidden on USB power.
static void update_battery(void)
{
static int64_t last;
int64_t now = now_ms();
if (last && now - last < 15000) return;
last = now;
int mv, pct;
bool usb;
if (!battery_read(&mv, &pct, &usb) || usb) { ui_set_battery(NULL); return; }
const char *glyph = pct > 80 ? LV_SYMBOL_BATTERY_FULL : pct > 55 ? LV_SYMBOL_BATTERY_3 :
pct > 30 ? LV_SYMBOL_BATTERY_2 : pct > 10 ? LV_SYMBOL_BATTERY_1 : LV_SYMBOL_BATTERY_EMPTY;
char text[24];
snprintf(text, sizeof text, "%s %d%%", glyph, pct);
ui_set_battery(text);
}
static insecam_listing_t pick_listing(void)
{
if (settings.listing == LISTING_RATING) return INSECAM_BY_RATING;
if (settings.listing == LISTING_NEW) return INSECAM_BY_NEW;
return (esp_random() & 1) ? INSECAM_BY_RATING : INSECAM_BY_NEW;
}
// Refills the page queue from a random listing page; blocks (with retries) until it has cameras.
static void refill_queue(void)
{
int failures = 0;
while (1) {
bool popular = settings.source == SOURCE_POPULAR;
insecam_listing_t listing = popular ? INSECAM_BY_RATING : pick_listing();
provider_t provider = settings.provider == PROVIDER_PREF_INSECAM ? PROVIDER_INSECAM :
settings.provider == PROVIDER_PREF_OPENTOPIA ? PROVIDER_OPENTOPIA :
popular ? ((popular_turn++ & 1) ? PROVIDER_OPENTOPIA : PROVIDER_INSECAM) :
(esp_random() & 1) ? PROVIDER_OPENTOPIA : PROVIDER_INSECAM;
int page;
if (popular) { // walk the top-rated listing from the top, in rank order
if (popular_page[provider] > total_pages[provider][listing]) popular_page[provider] = 1;
page = popular_page[provider]++;
} else {
page = 1 + esp_random() % total_pages[provider][listing];
}
int n = 0;
esp_err_t err = provider == PROVIDER_OPENTOPIA
? opentopia_fetch_page(listing, page, page_cams, PAGE_ITEMS, &n, &total_pages[provider][listing])
: insecam_fetch_page(listing, page, page_cams, PAGE_ITEMS, &n, &total_pages[provider][listing]);
if (err == ESP_OK && n > 0) {
// shuffle so the six don't always play in site order (Popular keeps rank order)
for (int i = popular ? 0 : n - 1; i > 0; i--) {
int j = esp_random() % (i + 1);
cam_info_t t = page_cams[i];
page_cams[i] = page_cams[j];
page_cams[j] = t;
}
page_count = n;
page_next = 0;
return;
}
failures++;
ESP_LOGW(TAG, "listing fetch failed (%d)", failures);
ui_toast(net_is_connected() ? "Camera site not answering, retrying" : "Wi-Fi lost, waiting", 2500);
leds_set_mode(LED_BLINK, COL_ERROR);
if (!net_is_connected()) net_wait_connected(30000);
vTaskDelay(pdMS_TO_TICKS(failures < 5 ? 3000 : 10000));
}
}
static void history_push(const cam_info_t *c)
{
if (hist_len == HISTORY_LEN) {
memmove(&history[0], &history[1], sizeof(cam_info_t) * (HISTORY_LEN - 1));
hist_len--;
}
history[hist_len++] = *c;
hist_pos = hist_len - 1;
}
static cam_info_t *next_camera(bool backwards)
{
if (backwards) {
if (hist_pos > 0) hist_pos--;
else ui_toast("Start of history", 1200);
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]; }
}
}
// Random mode: skip cameras shown recently; after a few fully-seen pages give up and repeat.
for (int pages_tried = 0; ; ) {
if (page_next >= page_count) {
refill_queue();
pages_tried++;
}
cam_info_t *c = &page_cams[page_next++];
if (pages_tried < 6 && seen_key(c) == last_drawn) continue; // no back-to-back repeat
if (settings.source == SOURCE_RANDOM && pages_tried < 4 && seen_recently(c)) continue;
mark_seen(c);
history_push(c);
return &history[hist_pos];
}
}
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);
}
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(cam_info_t *cam)
{
const int64_t interval_ms = (int64_t)settings.interval_s * 1000;
char meta[96], url[sizeof cam->url];
if (cam->provider == PROVIDER_OPENTOPIA) cam->url[0] = 0; // re-resolve every play; opentopia URLs go stale
const char *what = cam->provider == PROVIDER_OPENTOPIA ? (cam->title[0] ? cam->title : "opentopia")
: (cam->maker[0] ? cam->maker : "camera");
snprintf(meta, sizeof meta, "%s " BULLET " connecting", what);
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, "%s #%lu %s (%s) %s", cam->provider == PROVIDER_OPENTOPIA ? "opentopia" : "insecam",
(unsigned long)cam->id, cam->place, what, cam->url);
if (cam->provider == PROVIDER_OPENTOPIA && !cam->url[0]) {
snprintf(meta, sizeof meta, "%s " BULLET " finding the feed", what);
ui_set_caption(cam->place, meta);
if (opentopia_resolve(cam) != ESP_OK) return LEAVE_ERROR;
what = cam->title[0] ? cam->title : "opentopia";
snprintf(meta, sizeof meta, "%s " BULLET " connecting", what);
ui_set_caption(cam->place, meta);
}
httpstream_t s;
jpegframe_t fx;
jpegframe_init(&fx, jpeg_buf, jpeg_cap);
jpegframe_reset(&fx);
static uint8_t chunk[CHUNK];
int frames = 0, decode_failures = 0, reopens = 0;
int64_t shown_ms = 0, last_tick = now_ms(), last_data = now_ms(), last_present = 0, ended_at = 0, last_progress = 0;
bool open = false, is_stream = false;
leave_t leave = LEAVE_NEXT;
while (1) {
int64_t now = now_ms();
if (!paused && frames > 0) shown_ms += now - last_tick;
last_tick = now;
// knob / button
input_event_t ev;
while ((ev = input_poll()) != INPUT_NONE) {
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); 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;
}
}
if (frames > 0 && !paused) {
if (shown_ms >= interval_ms) { leave = LEAVE_NEXT; goto done; }
if (now - last_progress >= 200) {
last_progress = now;
update_battery();
int permille = 1000 - (int)(shown_ms * 1000 / interval_ms);
ui_set_progress(permille);
leds_set_countdown(permille);
}
}
// (re)open
if (!open) {
if (ended_at && now - ended_at < REFRESH_STILL_MS) { vTaskDelay(pdMS_TO_TICKS(50)); continue; }
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));
if (frames == 0) { leave = LEAVE_ERROR; goto done; }
ended_at = now; // a still camera that stopped answering: keep the last frame
continue;
}
open = true;
is_stream = !strncasecmp(s.content_type, "multipart/", 10);
jpegframe_reset(&fx);
last_data = now;
}
int r = httpstream_read(&s, chunk, sizeof chunk);
if (r == HTTPSTREAM_TIMEOUT) {
int64_t stall = frames ? STREAM_STALL_MS : FIRST_FRAME_STALL_MS;
if (now - last_data > stall) {
ESP_LOGW(TAG, "stalled after %d frames", frames);
if (frames == 0) { leave = LEAVE_ERROR; goto done; }
httpstream_close(&s); open = false; ended_at = now;
}
continue;
}
if (r <= 0) { // EOF or error
httpstream_close(&s);
open = false;
if (frames == 0 && (is_stream || ++reopens >= 2)) { leave = LEAVE_ERROR; goto done; }
ended_at = now;
continue;
}
last_data = now;
// feed every byte to the extractor, decoding each completed frame
size_t used = 0;
while (used < (size_t)r) {
bool complete, overflow;
used += jpegframe_feed(&fx, chunk + used, r - used, &complete, &overflow);
if (overflow) {
ESP_LOGW(TAG, "frame larger than %u KB, skipping camera", (unsigned)(jpeg_cap / 1024));
if (frames == 0) { leave = LEAVE_ERROR; goto done; }
continue;
}
if (frames == 0 && fx.skipped > 65536) { leave = LEAVE_ERROR; goto done; } // HTML, not a camera
if (!complete) continue;
if (frames && now_ms() - last_present < MIN_FRAME_GAP_MS) continue; // drop, too soon
int w, h;
int64_t t0 = now_ms();
esp_err_t err = jpegdec_to_screen(fx.out, fx.out_len, fb[fb_back], &w, &h);
if (err != ESP_OK) {
if (frames == 0 && ++decode_failures >= 2) { leave = LEAVE_ERROR; goto done; }
continue;
}
ui_present_frame(fb[fb_back]);
fb_back ^= 1;
last_present = now_ms();
frames++;
if (frames == 1) {
ESP_LOGI(TAG, "first frame %dx%d, %u bytes, decode %lld ms, %s",
w, h, (unsigned)fx.out_len, (long long)(last_present - t0), is_stream ? "mjpeg" : "still");
snprintf(meta, sizeof meta, "%s " BULLET " %dx%d " BULLET " %s#%lu", what, w, h,
cam->provider == PROVIDER_OPENTOPIA ? "opentopia " : "", (unsigned long)cam->id);
ui_set_caption(cam->place, meta);
set_paused(paused);
last_tick = now_ms();
}
}
}
done:
if (open) httpstream_close(&s);
return leave;
}
static void slideshow_task(void *arg)
{
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()) {
ui_toast("Wi-Fi lost, reconnecting", 3000);
leds_set_mode(LED_BREATHE, COL_ERROR);
net_wait_connected(-1);
}
cam_info_t *cam = replay ? &history[hist_pos] : next_camera(backwards);
backwards = replay = false;
leave_t why = show_camera(cam);
last_drawn = seen_key(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);
leds_set_mode(LED_BLINK, COL_ERROR);
if (consecutive_errors >= 8) vTaskDelay(pdMS_TO_TICKS(3000)); // don't hammer when the network is sick
} else {
consecutive_errors = 0;
}
if (why == LEAVE_PREV) backwards = true;
if (why == LEAVE_ERROR && hist_pos == hist_len - 1) {
// drop dead cameras from history so "previous" doesn't land on them
if (hist_len) { hist_len--; hist_pos = hist_len - 1; }
}
paused = false;
}
}
void slideshow_start(void)
{
jpeg_cap = (size_t)CONFIG_WIDGET_MAX_JPEG_KB * 1024;
jpeg_buf = heap_caps_malloc(jpeg_cap, MALLOC_CAP_SPIRAM);
page_cams = heap_caps_calloc(PAGE_ITEMS, sizeof *page_cams, MALLOC_CAP_SPIRAM);
seen = heap_caps_calloc(SEEN_LEN, sizeof *seen, MALLOC_CAP_SPIRAM);
for (int i = 0; i < 2; i++)
fb[i] = heap_caps_aligned_calloc(64, LCD_H_RES * LCD_V_RES, sizeof(uint16_t), MALLOC_CAP_SPIRAM);
if (!jpeg_buf || !fb[0] || !fb[1] || !page_cams || !seen) {
ESP_LOGE(TAG, "out of PSRAM");
abort();
}
xTaskCreatePinnedToCore(slideshow_task, "slideshow", 12288, NULL, 5, NULL, 0);
}