Themes (Phosphor, Pink, Amber, Ice, Terminal) restyle the menu, boot page and toasts live from the menu; all but Terminal use readable Montserrat text. Source gains Popular: the top-rated listings walked in rank order from page 1, alternating providers when mixed.
383 lines
15 KiB
C
383 lines
15 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 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][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 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; }
|
|
|
|
// 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]; }
|
|
}
|
|
}
|
|
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(cam_info_t *cam)
|
|
{
|
|
const int64_t interval_ms = (int64_t)settings.interval_s * 1000;
|
|
char meta[96], url[sizeof cam->url];
|
|
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, retried_resolve = 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;
|
|
bool stored_url = cam->provider == PROVIDER_OPENTOPIA && cam->url[0]; // from a favourite/history, may be stale
|
|
leave_t why = show_camera(cam);
|
|
if (why == LEAVE_ERROR && stored_url && !retried_resolve) {
|
|
cam->url[0] = 0; // stored stream URL may be stale; look it up again once
|
|
retried_resolve = true;
|
|
replay = true;
|
|
continue;
|
|
}
|
|
retried_resolve = false;
|
|
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);
|
|
}
|