From bd05fca812339337efe30318d1519c6f58cb448a Mon Sep 17 00:00:00 2001 From: Juli Date: Thu, 17 Sep 2026 02:04:57 +0200 Subject: [PATCH] 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. --- README.md | 21 ++++-- main/CMakeLists.txt | 4 +- main/insecam.c | 11 ++-- main/insecam.h | 11 +++- main/menu.c | 13 +++- main/opentopia.c | 152 ++++++++++++++++++++++++++++++++++++++++++++ main/opentopia.h | 9 +++ main/settings.c | 22 ++++--- main/settings.h | 4 ++ main/slideshow.c | 50 +++++++++++---- main/webpage.c | 53 +++++++++++++++ main/webpage.h | 8 +++ 12 files changed, 318 insertions(+), 40 deletions(-) create mode 100644 main/opentopia.c create mode 100644 main/opentopia.h create mode 100644 main/webpage.c create mode 100644 main/webpage.h diff --git a/README.md b/README.md index c74d507..d7fbb31 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/main/CMakeLists.txt b/main/CMakeLists.txt index b0cc173..edc0b8f 100644 --- a/main/CMakeLists.txt +++ b/main/CMakeLists.txt @@ -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 ) diff --git a/main/insecam.c b/main/insecam.c index 4b2d443..422c26b 100644 --- a/main/insecam.c +++ b/main/insecam.c @@ -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); // ; fall back to the link title const char *img = strstr(item, "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; diff --git a/main/insecam.h b/main/insecam.h index 5de35d9..c7c2abc 100644 --- a/main/insecam.h +++ b/main/insecam.h @@ -3,11 +3,16 @@ #include #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; diff --git a/main/menu.c b/main/menu.c index 7acb727..8dbf64a 100644 --- a/main/menu.c +++ b/main/menu.c @@ -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(); diff --git a/main/opentopia.c b/main/opentopia.c new file mode 100644 index 0000000..7e4c09f --- /dev/null +++ b/main/opentopia.c @@ -0,0 +1,152 @@ +#include "opentopia.h" +#include "webpage.h" +#include "sdkconfig.h" +#include "esp_log.h" +#include +#include +#include +#include + +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, "", "", country, sizeof country); + const char *r = strstr(loc, ""); + if (r && r < limit) between(r, limit, "", "", region, sizeof region); + const char *r2 = r ? strstr(r + 7, "") : NULL; + if (r2 && r2 < limit) between(r2, limit, "", "", 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
; "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, "
"); + if (!big) return false; + const char *img = strstr(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, "