#include "slideshow.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 "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 "freertos/FreeRTOS.h" #include "freertos/task.h" #include #include static const char *TAG = "slideshow"; #define HISTORY_LEN 24 #define PAGE_ITEMS 6 #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]; static int page_count, page_next; 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; 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 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) { insecam_listing_t listing = pick_listing(); int page = 1 + esp_random() % total_pages[listing]; int n = 0; esp_err_t err = insecam_fetch_page(listing, page, page_cams, PAGE_ITEMS, &n, &total_pages[listing]); if (err == ESP_OK && n > 0) { // shuffle so the six don't always play in site order for (int i = 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() ? "insecam.org 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 const 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]; } } } if (page_next >= page_count) refill_queue(); history_push(&page_cams[page_next++]); 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(const cam_info_t *cam) { 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); 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; 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 " #%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); 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); } 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); 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); 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]) { ESP_LOGE(TAG, "out of PSRAM"); abort(); } xTaskCreatePinnedToCore(slideshow_task, "slideshow", 12288, NULL, 5, NULL, 0); }