Add opentopia.com as a second camera provider
Listing pages (rated/newest) and per-camera pages are fetched over HTTPS with the certificate bundle; the camera page's Live Feed mode yields the camera's own MJPEG URL (Current Still as fallback), resolved just before playing and cached in favourites. New Provider menu row: insecam / opentopia / mixed. cam_info_t gains provider + title fields and the settings blob a version byte; older favourites and settings still load.
This commit is contained in:
1 parent
50e98c26aa
commit
bd05fca812
12 files changed
+318
-40
No files matched your search
@@ -1,16 +1,17 @@
|
||||
# insecam desk widget (LilyGO T-Embed)
|
||||
|
||||
A little window onto the world's public webcams. The T-Embed's 320×170 IPS
|
||||
screen shows a random camera from [insecam.org](http://www.insecam.org) — as
|
||||
live MJPEG video where the camera streams it — and moves on to the next one
|
||||
every 20 seconds. The knob steps through cameras, the LED ring drains as the
|
||||
screen shows a random camera from [insecam.org](http://www.insecam.org) or
|
||||
[opentopia.com](https://www.opentopia.com) — as live MJPEG video where the
|
||||
camera streams it — and moves on to the next one every 20 seconds. The knob steps through cameras, the LED ring drains as the
|
||||
countdown runs. Everything happens on the ESP32-S3; no bridge or PC needed.
|
||||
|
||||

|
||||
|
||||
insecam lists cameras that are reachable on the public internet without any
|
||||
password. The widget only ever does what a browser on that site does: fetch a
|
||||
listing page, then fetch the camera's own snapshot/stream URL.
|
||||
Both sites list cameras that are reachable on the public internet without any
|
||||
password (opentopia leans towards harbours, ski slopes and campuses; insecam
|
||||
towards forgotten IP cams). The widget only ever does what a browser on those
|
||||
sites does: fetch a listing page, then fetch the camera's own snapshot/stream URL.
|
||||
|
||||
## Hardware
|
||||
|
||||
@@ -62,7 +63,8 @@ Entries:
|
||||
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.
|
||||
* **Source** — *Random* draws from the sites, *Favourites* cycles the saved slots.
|
||||
* **Provider** — *insecam*, *opentopia* or *Mixed* (a random site per batch).
|
||||
* **Time per camera**, **Listing** (mixed / top rated / newest), **Screen
|
||||
brightness**, **Ring brightness**, **Caption**.
|
||||
* **Forget a favourite**, **Wi-Fi setup** (reboots into the hotspot), the board
|
||||
@@ -95,6 +97,11 @@ it stays out of the way. The CC1101 board's BQ27220 gauge is not supported.
|
||||
|
||||
* `insecam.c` fetches one listing page (`/en/byrating/` or `/en/bynew/`, random
|
||||
page) — six cameras per request, so insecam sees one hit every two minutes.
|
||||
* `opentopia.c` does the same for opentopia (`hiddencam.php`, rated or newest),
|
||||
then, just before a camera plays, reads its page in *Live Feed* mode for the
|
||||
camera's own MJPEG URL (or *Current Still* when there is no video). The site is
|
||||
HTTPS-only, so those pages go through `webpage.c` — `esp_http_client` with the
|
||||
certificate bundle — costing a TLS handshake (~0.7 s) per fetch.
|
||||
* `httpstream.c` is a small raw-socket HTTP client: `esp_http_client` gives up on
|
||||
bodies without `Content-Length`, which is exactly what MJPEG cameras send.
|
||||
* `jpegframe.c` pulls complete JPEG files out of the byte stream by walking the
|
||||
|
||||
+2
-2
@@ -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" "settings.c" "menu.c" "battery.c"
|
||||
"httpstream.c" "jpegframe.c" "jpegdec.c" "insecam.c" "slideshow.c" "settings.c" "menu.c" "battery.c" "webpage.c" "opentopia.c"
|
||||
INCLUDE_DIRS "."
|
||||
PRIV_REQUIRES esp_lcd esp_wifi nvs_flash esp_netif esp_event esp_http_server driver esp_timer esp_adc
|
||||
PRIV_REQUIRES esp_lcd esp_wifi nvs_flash esp_netif esp_event esp_http_server driver esp_timer esp_adc esp_http_client mbedtls
|
||||
)
|
||||
+6
-5
@@ -21,7 +21,7 @@ _Static_assert(sizeof latin1 == 65, "latin1 table");
|
||||
_Static_assert(sizeof latin_ext_a == 129, "latin ext-a table");
|
||||
|
||||
// Decodes the common HTML entities and folds UTF-8 to ASCII, in place, bounded by cap.
|
||||
static void clean_text(char *s, size_t cap)
|
||||
void insecam_clean_text(char *s, size_t cap)
|
||||
{
|
||||
char *out = s;
|
||||
const unsigned char *in = (const unsigned char *)s;
|
||||
@@ -101,14 +101,15 @@ static int parse_page(char *html, cam_info_t *out, int max, int *total_pages)
|
||||
const char *limit = next ? next : html + strlen(html);
|
||||
cam_info_t *c = &out[count];
|
||||
memset(c, 0, sizeof *c);
|
||||
c->provider = PROVIDER_INSECAM;
|
||||
char tmp[128];
|
||||
if (copy_attr(item, limit, "href=\"/en/view/", tmp, sizeof tmp)) c->id = strtoul(tmp, NULL, 10);
|
||||
if (!copy_attr(item, limit, " src=\"", c->url, sizeof c->url) || strncmp(c->url, "http://", 7)) {
|
||||
item = next;
|
||||
continue;
|
||||
}
|
||||
for (int pass = 0; pass < 3 && strstr(c->url, "&"); pass++) clean_text(c->url, sizeof c->url);
|
||||
clean_text(c->url, sizeof c->url);
|
||||
for (int pass = 0; pass < 3 && strstr(c->url, "&"); pass++) insecam_clean_text(c->url, sizeof c->url);
|
||||
insecam_clean_text(c->url, sizeof 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);
|
||||
@@ -122,8 +123,8 @@ static int parse_page(char *html, cam_info_t *out, int max, int *total_pages)
|
||||
} else if (copy_attr(item, limit, "title=\"Live camera in ", tmp, sizeof tmp)) {
|
||||
strlcpy(c->place, tmp, sizeof c->place); // "Country, City" order on the link
|
||||
}
|
||||
clean_text(c->place, sizeof c->place);
|
||||
clean_text(c->maker, sizeof c->maker);
|
||||
insecam_clean_text(c->place, sizeof c->place);
|
||||
insecam_clean_text(c->maker, sizeof c->maker);
|
||||
if (!c->place[0]) strlcpy(c->place, "Unknown location", sizeof c->place);
|
||||
count++;
|
||||
item = next;
|
||||
|
||||
+8
-3
@@ -3,11 +3,16 @@
|
||||
#include <stdint.h>
|
||||
#include "esp_err.h"
|
||||
|
||||
typedef enum { PROVIDER_INSECAM = 0, PROVIDER_OPENTOPIA = 1 } provider_t;
|
||||
|
||||
typedef struct {
|
||||
uint32_t id; // insecam view id
|
||||
char url[300]; // direct camera URL as listed (may contain a COUNTER placeholder)
|
||||
uint32_t id; // view id on the provider's site
|
||||
char url[300]; // direct camera URL (may contain a COUNTER placeholder); empty until resolved
|
||||
char place[64]; // "Imola, Italy" (ASCII-folded for the built-in font)
|
||||
char maker[24]; // "Mobotix"
|
||||
char maker[24]; // "Mobotix" (insecam)
|
||||
// appended later; older favourite blobs without these fields still load
|
||||
uint8_t provider; // provider_t
|
||||
char title[40]; // camera name (opentopia)
|
||||
} cam_info_t;
|
||||
|
||||
typedef enum { INSECAM_BY_RATING, INSECAM_BY_NEW } insecam_listing_t;
|
||||
|
||||
+10
-3
@@ -18,10 +18,10 @@ 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_BATTERY, ROW_CLOSE, TOP_ROWS };
|
||||
enum { ROW_FAV_TOGGLE, ROW_FAVS, ROW_SOURCE, ROW_PROVIDER, ROW_INTERVAL, ROW_LISTING, ROW_LCD, ROW_RING,
|
||||
ROW_CAPTION, ROW_FORGET, ROW_WIFI, ROW_IP, ROW_BATTERY, ROW_CLOSE, TOP_ROWS };
|
||||
|
||||
static ui_menu_row_t rows[FAV_SLOTS + 1];
|
||||
static ui_menu_row_t rows[TOP_ROWS > FAV_SLOTS + 1 ? TOP_ROWS : FAV_SLOTS + 1];
|
||||
static char values[TOP_ROWS][20];
|
||||
static char slot_labels[FAV_SLOTS][4];
|
||||
|
||||
@@ -42,6 +42,9 @@ static int build_top(const cam_info_t *current, int fav_slot)
|
||||
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_PROVIDER] = (ui_menu_row_t){"Provider",
|
||||
settings.provider == PROVIDER_PREF_INSECAM ? "insecam" : settings.provider == PROVIDER_PREF_OPENTOPIA ? "opentopia" : "Mixed",
|
||||
settings.source == SOURCE_FAVORITES};
|
||||
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};
|
||||
@@ -169,6 +172,10 @@ menu_result_t menu_run(const cam_info_t *current, int *fav_slot_out)
|
||||
settings.listing = (settings.listing + 1) % 3;
|
||||
settings_save();
|
||||
break;
|
||||
case ROW_PROVIDER:
|
||||
settings.provider = (settings.provider + 1) % 3;
|
||||
settings_save();
|
||||
break;
|
||||
case ROW_CAPTION:
|
||||
settings.caption = !settings.caption;
|
||||
settings_save();
|
||||
|
||||
@@ -0,0 +1,152 @@
|
||||
#include "opentopia.h"
|
||||
#include "webpage.h"
|
||||
#include "sdkconfig.h"
|
||||
#include "esp_log.h"
|
||||
#include <stdbool.h>
|
||||
#include <stdio.h>
|
||||
#include <stdlib.h>
|
||||
#include <string.h>
|
||||
|
||||
static const char *TAG = "opentopia";
|
||||
#define PAGE_CAP (160 * 1024)
|
||||
|
||||
void insecam_clean_text(char *s, size_t cap); // entity decode + ASCII fold, shared with insecam.c
|
||||
|
||||
static bool between(const char *from, const char *limit, const char *open, const char *close, char *dst, size_t cap)
|
||||
{
|
||||
const char *p = strstr(from, open);
|
||||
if (!p || p >= limit) return false;
|
||||
p += strlen(open);
|
||||
const char *q = strstr(p, close);
|
||||
if (!q || q > limit) return false;
|
||||
size_t n = q - p;
|
||||
if (n >= cap) n = cap - 1;
|
||||
memcpy(dst, p, n);
|
||||
dst[n] = 0;
|
||||
return true;
|
||||
}
|
||||
|
||||
static void trim(char *s)
|
||||
{
|
||||
size_t n = strlen(s);
|
||||
while (n && (s[n - 1] == ' ' || s[n - 1] == '\n' || s[n - 1] == '\r' || s[n - 1] == '\t')) s[--n] = 0;
|
||||
size_t i = 0;
|
||||
while (s[i] == ' ' || s[i] == '\n' || s[i] == '\r' || s[i] == '\t') i++;
|
||||
if (i) memmove(s, s + i, n - i + 1);
|
||||
}
|
||||
|
||||
esp_err_t opentopia_fetch_page(insecam_listing_t listing, int page, cam_info_t *out, int max,
|
||||
int *count, int *total_pages)
|
||||
{
|
||||
char url[160];
|
||||
snprintf(url, sizeof url, "https://www.opentopia.com/hiddencam.php?showmode=standard&country=%%2A&seewhat=%s&p=%d",
|
||||
listing == INSECAM_BY_NEW ? "newest" : "highlyrated", page);
|
||||
*count = 0;
|
||||
char *html;
|
||||
size_t len;
|
||||
esp_err_t err = webpage_get(url, CONFIG_WIDGET_USER_AGENT, PAGE_CAP, &html, &len);
|
||||
if (err != ESP_OK) return err;
|
||||
|
||||
// paginator: take the largest &p=N we can find
|
||||
int maxp = 0;
|
||||
for (const char *p = html; (p = strstr(p, "&p=")); p += 3) {
|
||||
int v = atoi(p + 3);
|
||||
if (v > maxp) maxp = v;
|
||||
}
|
||||
if (maxp > 0) *total_pages = maxp;
|
||||
|
||||
static const char anchor[] = "href=\"/webcam/";
|
||||
const char *item = strstr(html, anchor);
|
||||
while (item && *count < max) {
|
||||
const char *next = strstr(item + sizeof anchor - 1, anchor);
|
||||
const char *limit = next ? next : html + len;
|
||||
cam_info_t *c = &out[*count];
|
||||
memset(c, 0, sizeof *c);
|
||||
c->provider = PROVIDER_OPENTOPIA;
|
||||
c->id = strtoul(item + sizeof anchor - 1, NULL, 10);
|
||||
char name[80] = "", country[48] = "", region[48] = "", city[48] = "";
|
||||
bool has_name = between(item, limit, "class=\"viewcamsname\">", "<", name, sizeof name);
|
||||
const char *loc = strstr(item, "class=\"location\">");
|
||||
if (c->id && has_name && loc && loc < limit) {
|
||||
between(loc, limit, "<span>", "</span>", country, sizeof country);
|
||||
const char *r = strstr(loc, "</span>");
|
||||
if (r && r < limit) between(r, limit, "<span>", "</span>", region, sizeof region);
|
||||
const char *r2 = r ? strstr(r + 7, "</span>") : NULL;
|
||||
if (r2 && r2 < limit) between(r2, limit, "<span>", "</span>", city, sizeof city);
|
||||
trim(name); trim(country); trim(region); trim(city);
|
||||
const char *town = city[0] ? city : region;
|
||||
if (town[0] && country[0]) snprintf(c->place, sizeof c->place, "%s, %s", town, country);
|
||||
else strlcpy(c->place, country[0] ? country : "Somewhere", sizeof c->place);
|
||||
strlcpy(c->title, name, sizeof c->title);
|
||||
insecam_clean_text(c->place, sizeof c->place);
|
||||
insecam_clean_text(c->title, sizeof c->title);
|
||||
(*count)++;
|
||||
}
|
||||
item = next;
|
||||
}
|
||||
free(html);
|
||||
ESP_LOGI(TAG, "%s page %d: %u bytes, %d cameras, %d pages total",
|
||||
listing == INSECAM_BY_NEW ? "newest" : "highlyrated", page, (unsigned)len, *count, *total_pages);
|
||||
return *count ? ESP_OK : ESP_ERR_NOT_FOUND;
|
||||
}
|
||||
|
||||
// The camera page embeds the camera's own URL in <div class="big">; "livevideo" gives the
|
||||
// MJPEG stream, "livestill" a plain JPEG. Both are http:// straight to the camera.
|
||||
static bool extract_big_src(const char *html, char *dst, size_t cap)
|
||||
{
|
||||
const char *big = strstr(html, "<div class=\"big\">");
|
||||
if (!big) return false;
|
||||
const char *img = strstr(big, "<img");
|
||||
if (!img || img - big > 600) return false;
|
||||
const char *src = strstr(img, "src=\"");
|
||||
if (!src || src - img > 300) return false;
|
||||
src += 5;
|
||||
const char *end = strchr(src, '"');
|
||||
if (!end) return false;
|
||||
size_t n = end - src;
|
||||
if (n >= cap || strncmp(src, "http://", 7)) return false; // https/cached snapshots aren't streams
|
||||
memcpy(dst, src, n);
|
||||
dst[n] = 0;
|
||||
return true;
|
||||
}
|
||||
|
||||
esp_err_t opentopia_resolve(cam_info_t *cam)
|
||||
{
|
||||
static const char *modes[] = {"livevideo", "livestill"};
|
||||
for (int m = 0; m < 2; m++) {
|
||||
char url[96];
|
||||
snprintf(url, sizeof url, "https://www.opentopia.com/webcam/%lu?viewmode=%s", (unsigned long)cam->id, modes[m]);
|
||||
char *html;
|
||||
size_t len;
|
||||
esp_err_t err = webpage_get(url, CONFIG_WIDGET_USER_AGENT, PAGE_CAP, &html, &len);
|
||||
if (err != ESP_OK) return err;
|
||||
// only trust the live-video page when the camera actually offers that mode
|
||||
bool ok = (m == 1 || strstr(html, "value=\"livevideo\"")) && extract_big_src(html, cam->url, sizeof cam->url);
|
||||
if (ok) {
|
||||
// the listing clips names to 10 characters; the page has them in full
|
||||
char city[48] = "", country[48] = "", facility[64] = "";
|
||||
const char *end = html + len;
|
||||
between(html, end, "class=\"right locality\">", "<", city, sizeof city);
|
||||
between(html, end, "class=\"right country-name\">", "<", country, sizeof country);
|
||||
between(html, end, "<label class=\"left\">Facility:</label> <label class=\"right\">", "<", facility, sizeof facility);
|
||||
trim(city); trim(country); trim(facility);
|
||||
if (country[0]) {
|
||||
if (city[0]) snprintf(cam->place, sizeof cam->place, "%s, %s", city, country);
|
||||
else strlcpy(cam->place, country, sizeof cam->place);
|
||||
insecam_clean_text(cam->place, sizeof cam->place);
|
||||
}
|
||||
if (facility[0]) {
|
||||
strlcpy(cam->title, facility, sizeof cam->title);
|
||||
insecam_clean_text(cam->title, sizeof cam->title);
|
||||
}
|
||||
}
|
||||
free(html);
|
||||
if (ok) {
|
||||
insecam_clean_text(cam->url, sizeof cam->url);
|
||||
ESP_LOGI(TAG, "#%lu %s -> %s", (unsigned long)cam->id, modes[m], cam->url);
|
||||
return ESP_OK;
|
||||
}
|
||||
}
|
||||
cam->url[0] = 0;
|
||||
return ESP_ERR_NOT_FOUND;
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
#pragma once
|
||||
#include "insecam.h"
|
||||
|
||||
// opentopia.com: curated public webcams. The listing gives name and place; the
|
||||
// direct live URL sits on the camera page and is resolved just before playing.
|
||||
esp_err_t opentopia_fetch_page(insecam_listing_t listing, int page, cam_info_t *out, int max,
|
||||
int *count, int *total_pages);
|
||||
// Fills cam->url with the camera's own MJPEG (preferred) or still URL.
|
||||
esp_err_t opentopia_resolve(cam_info_t *cam);
|
||||
+14
-8
@@ -34,26 +34,32 @@ void settings_init(void)
|
||||
#else
|
||||
.listing = LISTING_MIXED,
|
||||
#endif
|
||||
.source = SOURCE_RANDOM, .caption = 1,
|
||||
.source = SOURCE_RANDOM, .caption = 1, .provider = PROVIDER_PREF_MIXED, .version = SETTINGS_VERSION,
|
||||
};
|
||||
settings_t stored;
|
||||
// Blobs written by older builds may be shorter: copy what is there over the defaults.
|
||||
settings_t stored = settings;
|
||||
size_t len = sizeof stored;
|
||||
if (nvs_get_blob(nvs, "settings", &stored, &len) == ESP_OK && len == sizeof stored) {
|
||||
settings = stored;
|
||||
if (nvs_get_blob(nvs, "settings", &stored, &len) == ESP_OK && len >= 7 && len <= sizeof stored) {
|
||||
memcpy(&settings, &stored, len);
|
||||
if (len < sizeof settings) settings.provider = PROVIDER_PREF_MIXED; // pre-provider blob: padding, not a choice
|
||||
settings.version = SETTINGS_VERSION;
|
||||
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;
|
||||
if (settings.provider > PROVIDER_PREF_MIXED) settings.provider = PROVIDER_PREF_MIXED;
|
||||
}
|
||||
for (int i = 0; i < FAV_SLOTS; i++) {
|
||||
char key[8];
|
||||
slot_key(i, key);
|
||||
memset(&favs[i], 0, sizeof favs[i]);
|
||||
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];
|
||||
fav_used[i] = nvs_get_blob(nvs, key, &favs[i], &len) == ESP_OK && len <= sizeof favs[i] && favs[i].url[0];
|
||||
if (fav_used[i] && favs[i].provider > PROVIDER_OPENTOPIA) favs[i].provider = PROVIDER_INSECAM;
|
||||
}
|
||||
ESP_LOGI(TAG, "interval %us, lcd %u%%, ring %u, listing %u, source %u, %d favourites",
|
||||
ESP_LOGI(TAG, "interval %us, lcd %u%%, ring %u, listing %u, source %u, provider %u, %d favourites",
|
||||
settings.interval_s, settings.lcd_brightness, settings.led_brightness, settings.listing,
|
||||
settings.source, fav_count());
|
||||
settings.source, settings.provider, fav_count());
|
||||
}
|
||||
|
||||
void settings_save(void)
|
||||
@@ -106,7 +112,7 @@ void fav_clear(int slot)
|
||||
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;
|
||||
if (fav_used[i] && favs[i].id == cam->id && favs[i].provider == cam->provider) return i;
|
||||
return -1;
|
||||
}
|
||||
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
// 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 enum { PROVIDER_PREF_INSECAM = 0, PROVIDER_PREF_OPENTOPIA, PROVIDER_PREF_MIXED } provider_pref_t;
|
||||
|
||||
typedef struct {
|
||||
uint16_t interval_s; // 5..300
|
||||
@@ -14,7 +15,10 @@ typedef struct {
|
||||
uint8_t listing; // listing_pref_t
|
||||
uint8_t source; // source_pref_t
|
||||
uint8_t caption; // 1 = caption shown
|
||||
uint8_t provider; // provider_pref_t (appended; older blobs load with the default)
|
||||
uint8_t version; // SETTINGS_VERSION; lets us tell a pre-provider blob apart
|
||||
} settings_t;
|
||||
#define SETTINGS_VERSION 2
|
||||
|
||||
extern settings_t settings;
|
||||
|
||||
|
||||
+38
-12
@@ -9,6 +9,7 @@
|
||||
#include "leds.h"
|
||||
#include "menu.h"
|
||||
#include "net.h"
|
||||
#include "opentopia.h"
|
||||
#include "settings.h"
|
||||
#include "ui.h"
|
||||
#include "sdkconfig.h"
|
||||
@@ -44,7 +45,7 @@ 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 int total_pages[2][2] = {{287, 287}, {162, 162}}; // [provider][listing]
|
||||
|
||||
static cam_info_t history[HISTORY_LEN];
|
||||
static int hist_len, hist_pos = -1;
|
||||
@@ -88,9 +89,14 @@ static void refill_queue(void)
|
||||
int failures = 0;
|
||||
while (1) {
|
||||
insecam_listing_t listing = pick_listing();
|
||||
int page = 1 + esp_random() % total_pages[listing];
|
||||
provider_t provider = settings.provider == PROVIDER_PREF_INSECAM ? PROVIDER_INSECAM :
|
||||
settings.provider == PROVIDER_PREF_OPENTOPIA ? PROVIDER_OPENTOPIA :
|
||||
(esp_random() & 1) ? PROVIDER_OPENTOPIA : PROVIDER_INSECAM;
|
||||
int page = 1 + esp_random() % total_pages[provider][listing];
|
||||
int n = 0;
|
||||
esp_err_t err = insecam_fetch_page(listing, page, page_cams, PAGE_ITEMS, &n, &total_pages[listing]);
|
||||
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
|
||||
for (int i = n - 1; i > 0; i--) {
|
||||
@@ -105,7 +111,7 @@ static void refill_queue(void)
|
||||
}
|
||||
failures++;
|
||||
ESP_LOGW(TAG, "listing fetch failed (%d)", failures);
|
||||
ui_toast(net_is_connected() ? "insecam.org not answering, retrying" : "Wi-Fi lost, waiting", 2500);
|
||||
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));
|
||||
@@ -122,7 +128,7 @@ static void history_push(const cam_info_t *c)
|
||||
hist_pos = hist_len - 1;
|
||||
}
|
||||
|
||||
static const cam_info_t *next_camera(bool backwards)
|
||||
static cam_info_t *next_camera(bool backwards)
|
||||
{
|
||||
if (backwards) {
|
||||
if (hist_pos > 0) hist_pos--;
|
||||
@@ -158,17 +164,29 @@ static void show_marker(const cam_info_t *cam)
|
||||
}
|
||||
|
||||
// Streams one camera until the interval elapses, the user intervenes, or it fails.
|
||||
static leave_t show_camera(const cam_info_t *cam)
|
||||
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];
|
||||
snprintf(meta, sizeof meta, "%s " BULLET " connecting", cam->maker[0] ? cam->maker : "camera");
|
||||
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, "#%lu %s (%s) %s", (unsigned long)cam->id, cam->place, cam->maker, cam->url);
|
||||
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;
|
||||
@@ -280,8 +298,8 @@ static leave_t show_camera(const cam_info_t *cam)
|
||||
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);
|
||||
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();
|
||||
@@ -295,7 +313,7 @@ done:
|
||||
|
||||
static void slideshow_task(void *arg)
|
||||
{
|
||||
bool backwards = false, replay = false;
|
||||
bool backwards = false, replay = false, retried_resolve = false;
|
||||
int consecutive_errors = 0;
|
||||
ui_set_overlay_visible(settings.caption);
|
||||
ui_show_camera_page();
|
||||
@@ -305,9 +323,17 @@ static void slideshow_task(void *arg)
|
||||
leds_set_mode(LED_BREATHE, COL_ERROR);
|
||||
net_wait_connected(-1);
|
||||
}
|
||||
const cam_info_t *cam = replay ? &history[hist_pos] : next_camera(backwards);
|
||||
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));
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
#include "webpage.h"
|
||||
#include "esp_crt_bundle.h"
|
||||
#include "esp_heap_caps.h"
|
||||
#include "esp_http_client.h"
|
||||
#include "esp_log.h"
|
||||
#include <stdlib.h>
|
||||
#include <string.h>
|
||||
|
||||
static const char *TAG = "webpage";
|
||||
|
||||
typedef struct { char *buf; size_t len, cap; bool overflow; } sink_t;
|
||||
|
||||
static esp_err_t on_event(esp_http_client_event_t *e)
|
||||
{
|
||||
sink_t *s = e->user_data;
|
||||
if (e->event_id == HTTP_EVENT_ON_DATA && s) {
|
||||
size_t room = s->cap - 1 - s->len;
|
||||
size_t n = e->data_len < room ? e->data_len : room;
|
||||
memcpy(s->buf + s->len, e->data, n);
|
||||
s->len += n;
|
||||
if (n < (size_t)e->data_len) s->overflow = true;
|
||||
}
|
||||
return ESP_OK;
|
||||
}
|
||||
|
||||
esp_err_t webpage_get(const char *url, const char *user_agent, size_t cap, char **out, size_t *len)
|
||||
{
|
||||
sink_t sink = {.buf = heap_caps_malloc(cap, MALLOC_CAP_SPIRAM), .cap = cap};
|
||||
if (!sink.buf) return ESP_ERR_NO_MEM;
|
||||
esp_http_client_config_t cfg = {
|
||||
.url = url, .user_agent = user_agent, .timeout_ms = 10000,
|
||||
.event_handler = on_event, .user_data = &sink,
|
||||
.crt_bundle_attach = esp_crt_bundle_attach,
|
||||
.buffer_size = 4096, .buffer_size_tx = 1024,
|
||||
.max_redirection_count = 3,
|
||||
};
|
||||
esp_http_client_handle_t client = esp_http_client_init(&cfg);
|
||||
if (!client) { free(sink.buf); return ESP_FAIL; }
|
||||
esp_err_t err = esp_http_client_perform(client);
|
||||
int status = esp_http_client_get_status_code(client);
|
||||
esp_http_client_cleanup(client);
|
||||
if (err != ESP_OK || status != 200) {
|
||||
ESP_LOGW(TAG, "%s -> %s / HTTP %d", url, esp_err_to_name(err), status);
|
||||
free(sink.buf);
|
||||
return err != ESP_OK ? err : ESP_FAIL;
|
||||
}
|
||||
if (sink.overflow) ESP_LOGW(TAG, "%s truncated at %u bytes", url, (unsigned)sink.len);
|
||||
for (size_t i = 0; i < sink.len; i++) if (!sink.buf[i]) sink.buf[i] = ' ';
|
||||
sink.buf[sink.len] = 0;
|
||||
*out = sink.buf;
|
||||
*len = sink.len;
|
||||
return ESP_OK;
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
#pragma once
|
||||
#include <stddef.h>
|
||||
#include "esp_err.h"
|
||||
|
||||
// Fetches a whole http(s) page into a NUL-terminated PSRAM buffer (caller frees).
|
||||
// Uses esp_http_client with the certificate bundle; fine for ordinary pages,
|
||||
// not for endless camera streams (see httpstream.c for those).
|
||||
esp_err_t webpage_get(const char *url, const char *user_agent, size_t cap, char **out, size_t *len);
|
||||
Reference in new issue
Block a user