Files
Juli bd05fca812 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.
2026-09-17 02:04:57 +02:00

175 lines
7.0 KiB
C

#include "insecam.h"
#include "httpstream.h"
#include "sdkconfig.h"
#include "esp_heap_caps.h"
#include "esp_log.h"
#include "esp_random.h"
#include <stdbool.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
static const char *TAG = "insecam";
#define PAGE_CAP (192 * 1024)
// ASCII folding for U+00C0..U+00FF and U+0100..U+017F so LVGL's Montserrat can show them.
static const char latin1[] = "AAAAAAACEEEEIIIIDNOOOOOxOUUUUYTsaaaaaaaceeeeiiiidnooooo/ouuuuyty";
static const char latin_ext_a[] =
"AaAaAa" "CcCcCcCc" "DdDd" "EeEeEeEeEe" "GgGgGgGg" "HhHh" "IiIiIiIiIiIi" "Jj" "Kkk" "LlLlLlLlLl"
"NnNnNnnNn" "OoOoOoOo" "RrRrRr" "SsSsSsSs" "TtTtTt" "UuUuUuUuUuUu" "Ww" "YyY" "ZzZzZz" "s";
_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.
void insecam_clean_text(char *s, size_t cap)
{
char *out = s;
const unsigned char *in = (const unsigned char *)s;
size_t n = 0;
while (*in && n + 1 < cap) {
unsigned char c = *in;
char ch = 0;
if (c == '&') {
if (!strncmp((const char *)in, "&amp;", 5)) { ch = '&'; in += 5; }
else if (!strncmp((const char *)in, "&quot;", 6)) { ch = '"'; in += 6; }
else if (!strncmp((const char *)in, "&#39;", 5)) { ch = '\''; in += 5; }
else if (!strncmp((const char *)in, "&lt;", 4)) { ch = '<'; in += 4; }
else if (!strncmp((const char *)in, "&gt;", 4)) { ch = '>'; in += 4; }
else if (in[1] == '#') {
char *end;
long cp = strtol((const char *)in + 2, &end, 10);
if (*end == ';') { ch = (cp >= 32 && cp < 127) ? (char)cp : 0; in = (const unsigned char *)end + 1; }
else { ch = '&'; in++; }
} else { ch = '&'; in++; }
} else if (c < 0x80) {
ch = (c >= 32) ? c : ' ';
in++;
} else {
uint32_t cp = 0;
int extra = (c & 0xE0) == 0xC0 ? 1 : (c & 0xF0) == 0xE0 ? 2 : (c & 0xF8) == 0xF0 ? 3 : 0;
cp = extra == 1 ? c & 0x1F : extra == 2 ? c & 0x0F : extra == 3 ? c & 0x07 : 0;
in++;
for (int k = 0; k < extra && (*in & 0xC0) == 0x80; k++) cp = (cp << 6) | (*in++ & 0x3F);
if (cp >= 0xC0 && cp <= 0xFF) ch = latin1[cp - 0xC0];
else if (cp >= 0x100 && cp <= 0x17F) ch = latin_ext_a[cp - 0x100];
}
if (ch) out[n++] = ch;
}
out[n] = 0;
// trim trailing spaces
while (n && out[n - 1] == ' ') out[--n] = 0;
}
static bool copy_attr(const char *from, const char *limit, const char *attr, char *dst, size_t cap)
{
const char *p = strstr(from, attr);
if (!p || p >= limit) return false;
p += strlen(attr);
const char *q = strchr(p, '"');
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;
}
void insecam_effective_url(const cam_info_t *cam, char *out, size_t cap)
{
strlcpy(out, cam->url, cap);
char *c;
while ((c = strstr(out, "COUNTER"))) { // the site's JS swaps this for a cache-buster
char digits[8];
snprintf(digits, sizeof digits, "%07lu", (unsigned long)(esp_random() % 10000000));
memcpy(c, digits, 7);
}
}
static int parse_page(char *html, cam_info_t *out, int max, int *total_pages)
{
const char *nav = strstr(html, "pagenavigator(\"?page=\", ");
if (nav) {
int tp = atoi(nav + strlen("pagenavigator(\"?page=\", "));
if (tp > 0) *total_pages = tp;
}
int count = 0;
// Anchor on the view link so header images can't masquerade as cameras.
static const char anchor[] = "class=\"thumbnail-item__wrap\" href=\"/en/view/";
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 + 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++) 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);
const char *in = ok ? strstr(tmp, " in ") : NULL;
if (in) {
size_t ml = in - tmp;
if (ml >= sizeof c->maker) ml = sizeof c->maker - 1;
memcpy(c->maker, tmp, ml);
c->maker[ml] = 0;
strlcpy(c->place, in + 4, sizeof c->place);
} 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
}
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;
}
return count;
}
esp_err_t insecam_fetch_page(insecam_listing_t listing, int page, cam_info_t *out, int max,
int *count, int *total_pages)
{
char url[96];
snprintf(url, sizeof url, "http://www.insecam.org/en/%s/?page=%d",
listing == INSECAM_BY_NEW ? "bynew" : "byrating", page);
*count = 0;
char *html = heap_caps_malloc(PAGE_CAP, MALLOC_CAP_SPIRAM);
if (!html) return ESP_ERR_NO_MEM;
httpstream_t s;
esp_err_t err = httpstream_open(&s, url, CONFIG_WIDGET_USER_AGENT, 5000, 8000);
if (err != ESP_OK) {
ESP_LOGW(TAG, "open %s failed: %s", url, esp_err_to_name(err));
free(html);
return err;
}
if (s.status != 200) {
ESP_LOGW(TAG, "%s -> HTTP %d", url, s.status);
httpstream_close(&s);
free(html);
return ESP_FAIL;
}
size_t len = 0;
while (len < PAGE_CAP - 1) {
int r = httpstream_read(&s, (uint8_t *)html + len, PAGE_CAP - 1 - len);
if (r == HTTPSTREAM_EOF) break;
if (r < 0) { ESP_LOGW(TAG, "read error %d after %u bytes", r, (unsigned)len); break; }
len += r;
}
httpstream_close(&s);
for (size_t i = 0; i < len; i++) if (!html[i]) html[i] = ' ';
html[len] = 0;
*count = parse_page(html, out, max, total_pages);
free(html);
ESP_LOGI(TAG, "%s page %d: %u bytes, %d cameras, %d pages total",
listing == INSECAM_BY_NEW ? "bynew" : "byrating", page, (unsigned)len, *count, *total_pages);
return *count ? ESP_OK : ESP_ERR_NOT_FOUND;
}