From 30b1381a8b36c06b8a96d9778a047e4defedff10 Mon Sep 17 00:00:00 2001 From: type-two Date: Wed, 29 Jul 2026 15:01:19 +1000 Subject: [PATCH] =?UTF-8?q?feat:=20Wave=206.2=20=E2=80=94=20swap=20paywall?= =?UTF-8?q?ed=20Windy=20for=20free=20511-network=20road=20cams?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Windy moved its Webcams API behind a paid tier (only the in-site Plugins API is free), so the windycams layer is gone before ever going live. In its place: roadcams — official public traffic cameras from keyless 511 APIs (a North-America-standard platform, one adapter covers many regions). Launching with Alberta (367) + Ontario (946); more regions are one entry in REGIONS + serve.py CAMS_REGIONS + an nginx location each (several 511s just need free keys). InfoBox descriptions are CallbackProperties, so the snapshot cache-buster is computed at click time — always-fresh images with zero entity rebuilds. Verified: 1,302 cams, live Toronto snapshot in-app. Co-Authored-By: Claude Fable 5 --- js/layers/roadcams.js | 81 +++++++++++++++++++++++++++++++++++++++ js/layers/windycams.js | 87 ------------------------------------------ js/main.js | 2 +- js/ui.js | 2 +- serve.py | 60 +++++++++++++---------------- 5 files changed, 109 insertions(+), 123 deletions(-) create mode 100644 js/layers/roadcams.js delete mode 100644 js/layers/windycams.js diff --git a/js/layers/roadcams.js b/js/layers/roadcams.js new file mode 100644 index 0000000..12ee899 --- /dev/null +++ b/js/layers/roadcams.js @@ -0,0 +1,81 @@ +// Road cams layer (Wave 6.2) — official public traffic cameras from keyless +// government APIs. The "511" platform is a North-America standard, so one +// adapter covers many regions; today: Alberta + Ontario (~1,100 cams). London +// has its own dedicated TfL layer. JSON comes via proxy/cams/ (no CORS +// upstream); snapshot IMAGES load directly in the InfoBox (no CORS needed). +// +// Add a region = one entry in REGIONS here + serve.py CAMS_REGIONS + a prod +// nginx location. Several more 511s (Georgia, Nevada, New England…) just need +// their free API keys. + +const REGIONS = [ + { id: 'alberta', name: 'Alberta' }, + { id: 'ontario', name: 'Ontario' }, +]; + +export default function create(ctx) { + const { viewer, Cesium, lib, ui } = ctx; + const ds = new Cesium.CustomDataSource('roadcams'); + ds.show = false; + viewer.dataSources.add(ds); + + let enabled = false; + let loaded = false; + + ui.addLayer('roadcams', 'Road cams (511 network)', false, (on) => { + enabled = on; + ds.show = on; + if (on && !loaded) load(); + else if (on) ui.setStatus('roadcams', statusText(), 'ok'); + else ui.setStatus('roadcams', '', 'ok'); + }, 'Context'); + + const okRegions = []; + function statusText() { + return `${ds.entities.values.length} cams · ${okRegions.join(' · ') || '—'}`; + } + + async function load() { + ui.setStatus('roadcams', 'loading cameras…', 'warn'); + const results = await Promise.allSettled(REGIONS.map(async (r) => { + const res = await fetch(`proxy/cams/${r.id}`); + if (!res.ok) throw new Error(`HTTP ${res.status}`); + return { region: r, cams: await res.json() }; + })); + for (const rr of results) { + if (rr.status !== 'fulfilled') continue; + const { region, cams } = rr.value; + let n = 0; + for (const c of Array.isArray(cams) ? cams : []) { + if (!isFinite(c.Latitude) || !isFinite(c.Longitude)) continue; + const views = (c.Views || []).filter((v) => v && v.Url && v.Status !== 'Disabled'); + if (!views.length) continue; + ds.entities.add({ + position: Cesium.Cartesian3.fromDegrees(c.Longitude, c.Latitude), + point: { + pixelSize: 5, color: lib.cz('#ffb84d'), + outlineColor: Cesium.Color.BLACK, outlineWidth: 1, + disableDepthTestDistance: 50000, + }, + // CallbackProperty: HTML is built the moment the InfoBox opens, so the + // snapshot cache-buster is always current — no stale images, no rebuilds. + description: new Cesium.CallbackProperty(() => { + const cb = Math.floor(Date.now() / 120000); // 2-min buckets + const imgs = views.slice(0, 2).map((v) => + `

traffic cam` + + (v.Description ? `
${lib.escapeHtml(v.Description)}` : '') + `

`).join(''); + return `

${lib.escapeHtml(c.Location || c.Roadway || 'Camera')}

` + imgs + + `

${lib.escapeHtml([c.Roadway, region.name].filter(Boolean).join(' · '))} · 511 open data

`; + }, false), + }); + n++; + } + if (n) okRegions.push(region.name); + } + loaded = true; + const anyFail = results.some((r) => r.status !== 'fulfilled'); + if (enabled) ui.setStatus('roadcams', statusText() + (anyFail ? ' · some regions down' : ''), anyFail ? 'warn' : 'ok'); + } + + return { id: 'roadcams' }; +} diff --git a/js/layers/windycams.js b/js/layers/windycams.js deleted file mode 100644 index c7248c8..0000000 --- a/js/layers/windycams.js +++ /dev/null @@ -1,87 +0,0 @@ -// Windy webcams layer (Wave 6.1) — live webcams near wherever you're looking, -// worldwide. Viewport-driven like the radius layer: each time the camera stops -// moving we ask proxy/windy for the ~50 nearest cams (key injected server-side, -// 10 min cache, coarsened cache key so nearby pans reuse entries). Cams render -// as entities so the InfoBox shows the live preview image + a player link. - -export default function create(ctx) { - const { viewer, Cesium, lib, ui } = ctx; - const ds = new Cesium.CustomDataSource('windycams'); - ds.show = false; - viewer.dataSources.add(ds); - - let enabled = false; - let inFlight = false; - let debounce = null; - let moveUnsub = null; - - ui.addLayer('windycams', 'Webcams (Windy)', false, (on) => { - enabled = on; - ds.show = on; - if (on) { - moveUnsub = viewer.camera.moveEnd.addEventListener(scheduleRefresh); - refresh(); - } else { - if (moveUnsub) { moveUnsub(); moveUnsub = null; } - if (debounce) { clearTimeout(debounce); debounce = null; } - ui.setStatus('windycams', '', 'ok'); - } - }, 'Context'); - - function scheduleRefresh() { - if (!enabled) return; - if (debounce) clearTimeout(debounce); - debounce = setTimeout(refresh, 400); - } - - function viewCenter() { - const c = Cesium.Cartographic.fromCartesian(viewer.camera.positionWC); - const km = Math.min(250, Math.max(15, Math.round(c.height / 2000))); - return { lat: Cesium.Math.toDegrees(c.latitude), lon: Cesium.Math.toDegrees(c.longitude), km }; - } - - async function refresh() { - if (!enabled || inFlight) return; - inFlight = true; - try { - const { lat, lon, km } = viewCenter(); - let res; - try { - res = await fetch(`proxy/windy?nearby=${lat.toFixed(2)},${lon.toFixed(2)},${km}`); - } catch { - ui.setStatus('windycams', 'network error', 'warn'); return; - } - if (res.status === 401) { ui.setStatus('windycams', 'key rejected — needs a Webcams API key', 'err'); return; } - if (res.status === 503) { ui.setStatus('windycams', 'no key — add windycredentials.json', 'err'); return; } - if (!res.ok) { ui.setStatus('windycams', `HTTP ${res.status}`, 'warn'); return; } - let j; - try { j = await res.json(); } catch { ui.setStatus('windycams', 'bad response', 'err'); return; } - const cams = j.webcams || []; - ds.entities.removeAll(); - for (const w of cams) { - const loc = w.location || {}; - if (!isFinite(loc.latitude) || !isFinite(loc.longitude)) continue; - const img = lib.safeUrl(((w.images || {}).current || {}).preview); - const detail = lib.safeUrl((w.urls || {}).detail || ((w.player || {}).day)); - ds.entities.add({ - position: Cesium.Cartesian3.fromDegrees(loc.longitude, loc.latitude), - point: { - pixelSize: 6, color: lib.cz('#ffd166'), - outlineColor: Cesium.Color.BLACK, outlineWidth: 1, - disableDepthTestDistance: 50000, - }, - description: - `

${lib.escapeHtml(w.title || 'Webcam')}

` + - (img ? `

webcam preview

` : '') + - `

${lib.escapeHtml([loc.city, loc.country].filter(Boolean).join(' · '))}

` + - (detail ? `

Open live player ↗

` : ''), - }); - } - ui.setStatus('windycams', `${ds.entities.values.length} cams near view${j.total ? ` · ${j.total} in area` : ''}`, 'ok'); - } finally { - inFlight = false; - } - } - - return { id: 'windycams' }; -} diff --git a/js/main.js b/js/main.js index f560aea..627f54a 100644 --- a/js/main.js +++ b/js/main.js @@ -185,7 +185,7 @@ const LAYER_MODULES = [ './layers/celestial.js', './layers/radiogarden.js', './layers/firms.js', - './layers/windycams.js', + './layers/roadcams.js', ]; const activeLayers = []; diff --git a/js/ui.js b/js/ui.js index 8a3f382..82e10f1 100644 --- a/js/ui.js +++ b/js/ui.js @@ -13,7 +13,7 @@ const CATEGORY_BY_ID = { emergency: 'Air', 'rare-types': 'Air', shadow: 'Air', radius: 'Air', ships: 'Sea', quakes: 'Earth & hazards', fires: 'Earth & hazards', gdacs: 'Earth & hazards', nws: 'Earth & hazards', - infra: 'Context', events: 'Context', radio: 'Context', windycams: 'Context', + infra: 'Context', events: 'Context', radio: 'Context', roadcams: 'Context', rfs: 'Regional — Australia', jamcams: 'Regional — London', inciweb: 'Earth & hazards', firms: 'Earth & hazards', }; diff --git a/serve.py b/serve.py index 74be6e9..8de6c22 100644 --- a/serve.py +++ b/serve.py @@ -93,7 +93,7 @@ RG_PATH_RE = re.compile(r'^ara/content/(places|page/[A-Za-z0-9]{4,16}/channels)$ RG_CACHE_SEC = {"ara/content/places": 43200} # default for others: 3600 _rg_cache = {} # path -> (body, ts) -# ---- Keyed upstreams: NASA FIRMS + Windy webcams ----------------------------- +# ---- Keyed upstreams: NASA FIRMS -------------------------------------------- # Keys live in gitignored files at repo root and are injected SERVER-SIDE only # (never reach the browser). Files are re-read on every request (they're tiny), # so dropping in a fixed key needs no server restart. The loader is tolerant: @@ -117,9 +117,18 @@ def read_key_file(path): FIRMS_CACHE_SEC = 3600 # FIRMS refreshes ~3-hourly; 1h cache is generous _firms_cache = [None, 0.0] # (body, ts) -WINDY_NEARBY_RE = re.compile(r'^-?\d{1,2}(\.\d+)?,-?\d{1,3}(\.\d+)?,\d{1,3}$') -WINDY_CACHE_SEC = 600 -_windy_cache = {} # rounded-nearby -> (body, ts) + +# ---- Road cams (511-network et al.) ------------------------------------------ +# Official public traffic-camera APIs with no key. The 511 platform is a +# North-America standard, so one adapter covers many regions; add entries here +# (and a matching prod nginx location) to light up more. JSON needs this proxy +# (no ACAO upstream); the snapshot IMAGES load directly in (no CORS needed). +CAMS_REGIONS = { + "alberta": "https://511.alberta.ca/api/v2/get/cameras", + "ontario": "https://511on.ca/api/v2/get/cameras", +} +CAMS_CACHE_SEC = 1800 # camera LISTS are near-static; images are fetched live +_cams_cache = {} # region -> (body, ts) # ---- ADS-B Exchange military feed (paid RapidAPI, ~10k req/month) ------------ # proxy/adsbx-mil injects the RapidAPI key server-side (never in the browser) and @@ -243,8 +252,8 @@ class Handler(SimpleHTTPRequestHandler): return self._serve_rg(name[len("rg/"):]) if name == "firms": return self._serve_firms() - if name == "windy": - return self._serve_windy(query) + if name.startswith("cams/"): + return self._serve_cams(name[len("cams/"):]) if name == "adsbx-mil": return self._serve_adsbx_mil() base = UPSTREAMS.get(name) @@ -455,41 +464,24 @@ class Handler(SimpleHTTPRequestHandler): code = getattr(e, "code", 502) return self._send_json(code if isinstance(code, int) else 502, {"error": "FIRMS upstream failed"}) - def _serve_windy(self, query): - # Windy Webcams v3: cams near a point. Query is nearby=lat,lon,radius_km - # (validated), key goes in the x-windy-api-key HEADER server-side. Cache - # keyed on a coarsened nearby so panning nearby reuses entries. - params = urllib.parse.parse_qs(query) - nearby = (params.get("nearby") or [""])[0] - if not WINDY_NEARBY_RE.match(nearby): - return self._send_json(400, {"error": "bad nearby=lat,lon,radius_km"}) - lat, lon, rad = nearby.split(",") - ck = f"{round(float(lat) * 4) / 4},{round(float(lon) * 4) / 4},{rad}" - hit = _windy_cache.get(ck) - if hit and time.time() - hit[1] < WINDY_CACHE_SEC: + def _serve_cams(self, region): + # Road cams: allowlisted official public feeds (see CAMS_REGIONS). + base = CAMS_REGIONS.get(region) + if not base: + return self._send_json(400, {"error": f"unknown cams region {region!r}"}) + hit = _cams_cache.get(region) + if hit and time.time() - hit[1] < CAMS_CACHE_SEC: return self._send(200, "application/json", hit[0], {"X-Godsigh-Cache": "hit"}) - key = read_key_file("windycredentials.json") - if not key: - return self._send_json(503, {"error": "no Windy key (windycredentials.json)"}) try: - url = ("https://api.windy.com/webcams/api/v3/webcams?" - f"nearby={nearby}&limit=50&include=location,images,player,urls") - req = urllib.request.Request(url, headers={ - "User-Agent": "godsigh/1.0 (partly.party)", "x-windy-api-key": key}) - with urllib.request.urlopen(req, timeout=15) as r: + req = urllib.request.Request(base, headers={"User-Agent": "godsigh/1.0 (partly.party)"}) + with urllib.request.urlopen(req, timeout=20) as r: data = r.read() - _windy_cache[ck] = (data, time.time()) + _cams_cache[region] = (data, time.time()) return self._send(200, "application/json", data, {"X-Godsigh-Cache": "miss"}) - except urllib.error.HTTPError as e: - if hit is not None: - return self._send(200, "application/json", hit[0], {"X-Godsigh-Cache": "stale"}) - if e.code == 401: - return self._send_json(401, {"error": "Windy key rejected — needs a WEBCAMS API key (api.windy.com/webcams)"}) - return self._send_json(e.code, {"error": f"windy upstream {e.code}"}) except Exception: if hit is not None: return self._send(200, "application/json", hit[0], {"X-Godsigh-Cache": "stale"}) - return self._send_json(502, {"error": "windy upstream failed"}) + return self._send_json(502, {"error": f"cams upstream {region} failed"}) def _serve_adsbx_mil(self): host, key = load_adsbx_creds()