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:
Juli committed 2026-09-17 02:04:57 +02:00
1 parent 50e98c26aa
commit bd05fca812
12 files changed
+318 -40

No files matched your search

+14 -7
View File
@@ -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.
![The widget on a desk, paused on a favourite Tokyo camera](docs/widget.jpg)
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
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" "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
View File
@@ -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, "&amp;"); pass++) clean_text(c->url, sizeof c->url);
clean_text(c->url, sizeof c->url);
for (int pass = 0; pass < 3 && strstr(c->url, "&amp;"); 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
View File
@@ -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
View File
@@ -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();
+152
View File
@@ -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;
}
+9
View File
@@ -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
View File
@@ -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;
}
+4
View File
@@ -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
View File
@@ -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));
+53
View File
@@ -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;
}
+8
View File
@@ -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);