feat: tik op het regenblok voor de Buienradar-animatie
Het regenblok linksonder opent een overlay met de geanimeerde regenradar van Buienradar (550x512, tijd in het beeld), naast de regencijfers die we al hebben: vandaag, de 3 vorige dagen en het maandtotaal. Sluit na 20 seconden of bij een tik, net als het uuroverzicht. Het beeld loopt via onze eigen /api/radar.php in plaats van een <img> rechtstreeks naar Buienradar: de spec wil alles lokaal geserveerd, de iPad praat zo nooit met een derde partij, we cachen 4 minuten (de radar ververst elke 5) en de servicewerker kan het laatste beeld offline bewaren. Bij een storing komt het laatst opgehaalde beeld terug; pas als er niets is, een nette melding in plaats van een leeg vlak. Bronvermelding staat in de overlay, zoals Buienradar vraagt. Verder: de overlay-machinerie (20s-timer, balk, sluiten) staat nu een keer in plaats van per overlay, Http kan optioneel redirects volgen (de beeld-API leidt door naar een CDN), en alle /api/-paden zijn in de servicewerker network-first met een cachesleutel zonder querystring. Servicewerker naar v7. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -32,6 +32,7 @@ return [
|
||||
'measure' => 900,
|
||||
'pressure' => 900,
|
||||
'forecast' => 3600,
|
||||
'radar' => 240,
|
||||
],
|
||||
|
||||
// Map voor tokens en cache. Moet schrijfbaar zijn voor de webserver en buiten de docroot liggen.
|
||||
|
||||
@@ -260,6 +260,12 @@ De iconen `sun`, `partly`, `rain` en `showers` staan in de mockup. Maak `cloud`,
|
||||
- "Vandaag" groot, in mm met 1 decimaal.
|
||||
- Daarnaast 4 staafjes: de 3 vorige dagen (labels "Di", "Wo", "Do") en vandaag, uitgelicht. De hoogte is `mm / max(4 waarden) × 70px`, met een minimum van 2px. Zijn alle waarden 0, dan zijn alle staafjes 2px.
|
||||
- Daaronder "September 38,5 mm".
|
||||
_Toegevoegd 2026-09-13: het regenblok is tikbaar en opent de regenradar van Buienradar (geanimeerde GIF,
|
||||
550x512, met de tijd in het beeld), naast de regencijfers van vandaag, de 3 vorige dagen en het maandtotaal.
|
||||
Sluit na 20 seconden of bij een tik. Het beeld loopt via `/api/radar.php` op onze eigen server (cache 4 min,
|
||||
bij storing het laatst opgehaalde beeld), zodat de iPad niet zelf met een derde partij praat en het beeld
|
||||
ook offline blijft staan. Bronvermelding "bron: Buienradar.nl" staat in de overlay; dat vraagt Buienradar
|
||||
bij vrij gebruik. Zie `src/BuienradarClient.php` en `src/RadarService.php`._
|
||||
- **Verwachting (rechtsonder):** 6 kolommen met dagafkorting ("Za"), icoon (50px), "max° min°" als hele getallen (min in `--muted`) en "x,x mm" plus neerslagkans in %. Geen neerslag toont "0 mm". Eerste kolom is vandaag.
|
||||
_Toegevoegd 2026-09-13: elke dagkolom is tikbaar. Een tik opent een uuroverzicht voor die dag (temperatuur,
|
||||
regen en wind, elk om de 2 uur — 12 punten), dat na 20 seconden vanzelf sluit of eerder bij een tik erop.
|
||||
@@ -355,6 +361,7 @@ Hangt af van de iPadOS-versie in §0:
|
||||
- [ ] Alle waarden komen overeen met de Netatmo-app: buiten, 3× binnen, min/max met tijdstippen, regen vandaag en de afgelopen 3 dagen, en het maandtotaal.
|
||||
- [ ] De verwachting toont 6 dagen (vandaag t/m 5 dagen verder) met het juiste icoon per WMO-code.
|
||||
- [ ] Tikken op een dag opent het uuroverzicht (temperatuur, regen, wind) en sluit na 20 seconden vanzelf of bij een tik erop.
|
||||
- [ ] Tikken op het regenblok opent de Buienradar-animatie en sluit op dezelfde manier.
|
||||
- [ ] De data ververst zichzelf. Na middernacht staat de nieuwe datum er binnen 5 minuten.
|
||||
- [ ] Wifi uit: de laatste data blijft staan, met een melding "geen verbinding sinds". Wifi aan: herstelt vanzelf.
|
||||
- [ ] De Netatmo-token ververst meerdere dagen achter elkaar zonder handmatig ingrijpen.
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use Weerscherm\BuienradarClient;
|
||||
use Weerscherm\Config;
|
||||
use Weerscherm\Http;
|
||||
use Weerscherm\RadarService;
|
||||
|
||||
$root = dirname(__DIR__, 2);
|
||||
|
||||
require $root . '/src/autoload.php';
|
||||
|
||||
header('X-Robots-Tag: noindex');
|
||||
|
||||
$configFile = $root . '/config.php';
|
||||
if (!is_file($configFile)) {
|
||||
http_response_code(500);
|
||||
header('Content-Type: text/plain; charset=utf-8');
|
||||
echo "config.php ontbreekt\n";
|
||||
exit;
|
||||
}
|
||||
|
||||
try {
|
||||
$config = Config::load($configFile);
|
||||
$ttl = $config->get('cache_ttl')['radar'] ?? 240;
|
||||
|
||||
$service = new RadarService(
|
||||
// Buienradar leidt door naar een CDN, dus deze client volgt redirects.
|
||||
new BuienradarClient(new Http(followRedirects: true)),
|
||||
rtrim((string) $config->get('storage_path'), '/') . '/cache/radar.gif',
|
||||
(int) $ttl,
|
||||
);
|
||||
$radar = $service->image();
|
||||
|
||||
header('Content-Type: image/gif');
|
||||
header('Content-Length: ' . strlen($radar['bytes']));
|
||||
// De browser mag het beeld hergebruiken tot de radar zelf ververst is.
|
||||
header('Cache-Control: public, max-age=' . max(30, (int) $ttl - $radar['age']));
|
||||
header('X-Radar-Stale: ' . ($radar['stale'] ? '1' : '0'));
|
||||
echo $radar['bytes'];
|
||||
} catch (Throwable $e) {
|
||||
error_log('weerscherm: radar: ' . $e->getMessage());
|
||||
http_response_code(503);
|
||||
header('Content-Type: text/plain; charset=utf-8');
|
||||
header('Cache-Control: no-store');
|
||||
echo "Radarbeeld niet beschikbaar\n";
|
||||
}
|
||||
+72
-12
@@ -14,7 +14,8 @@ const FETCH_TIMEOUT_MS = 10000;
|
||||
const STALE_MINUTES = 30;
|
||||
const RELOAD_HOUR = 4;
|
||||
const FALLBACK_TZ = 'Europe/Berlin';
|
||||
const HOURLY_OVERLAY_MS = 20000;
|
||||
const OVERLAY_MS = 20000; // hoe lang een overlay blijft staan zonder tik
|
||||
const RADAR_BUCKET_MS = 240000; // hergebruikvenster voor het radarbeeld, gelijk aan de servercache
|
||||
const HOURLY_WIDTH = 1045; // = stage-breedte 1133 min 2x 44px padding, zelfde marge als de rest van het scherm
|
||||
|
||||
const $ = (id) => document.getElementById(id);
|
||||
@@ -120,7 +121,8 @@ function renderForecast(days) {
|
||||
|
||||
/* ---------- uuroverzicht per dag ---------- */
|
||||
|
||||
let hourlyOverlayTimer = null;
|
||||
let overlayTimer = null;
|
||||
let openOverlayId = null;
|
||||
|
||||
function renderHourlyCharts(hourly) {
|
||||
const temps = hourly.map((h) => h.temp);
|
||||
@@ -147,25 +149,81 @@ function openHourOverlay(date) {
|
||||
const day = (state.data?.forecast || []).find((d) => d.date === date);
|
||||
if (!day) return;
|
||||
|
||||
clearTimeout(hourlyOverlayTimer);
|
||||
setText('ho-date', formatFullDate(day.date, state.tz));
|
||||
renderHourlyCharts(day.hourly || []);
|
||||
showOverlay('hour-overlay', 'ho-bar-fill');
|
||||
}
|
||||
|
||||
const fill = $('ho-bar-fill');
|
||||
/* ---------- regenradar ---------- */
|
||||
|
||||
function renderRainDetails(rain) {
|
||||
setText('ro-today', f1(rain.today));
|
||||
$('ro-days').innerHTML = rain.previous_days
|
||||
.map((p) => `<p><span>${dayAbbr(p.date, state.tz)}</span><b class="num">${fmm(p.mm)}</b></p>`)
|
||||
.join('');
|
||||
setText('ro-month', `${monthName(new Date(), state.tz)} ${f1(rain.month_total)} mm`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Zet het radarbeeld klaar. De bucket in de URL verandert elke RADAR_BUCKET_MS, zodat de browser
|
||||
* binnen dat venster het al geladen beeld hergebruikt (tik = meteen zichtbaar) en daarna vanzelf
|
||||
* een verser beeld ophaalt. Er wordt niets op de achtergrond geladen zolang niemand kijkt.
|
||||
*/
|
||||
function loadRadarImage() {
|
||||
const img = $('ro-img');
|
||||
const status = $('ro-status');
|
||||
const src = `api/radar.php?t=${Math.floor(Date.now() / RADAR_BUCKET_MS)}`;
|
||||
if (img.getAttribute('src') === src && img.complete && img.naturalWidth > 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
status.textContent = 'Radar laden…';
|
||||
status.hidden = false;
|
||||
img.hidden = true;
|
||||
img.onload = () => {
|
||||
status.hidden = true;
|
||||
img.hidden = false;
|
||||
};
|
||||
img.onerror = () => {
|
||||
status.textContent = 'Radar niet beschikbaar';
|
||||
status.hidden = false;
|
||||
img.hidden = true;
|
||||
};
|
||||
img.setAttribute('src', src);
|
||||
}
|
||||
|
||||
function openRadarOverlay() {
|
||||
if (!state.data) return;
|
||||
renderRainDetails(state.data.rain);
|
||||
loadRadarImage();
|
||||
showOverlay('radar-overlay', 'ro-bar-fill');
|
||||
}
|
||||
|
||||
/* ---------- gedeelde overlay-machinerie ---------- */
|
||||
|
||||
/** Toont een overlay, start de 20s-balk opnieuw en zet de sluittimer. */
|
||||
function showOverlay(id, barFillId) {
|
||||
closeOverlay();
|
||||
|
||||
const fill = $(barFillId);
|
||||
fill.style.transition = 'none';
|
||||
fill.style.transform = 'scaleX(1)';
|
||||
fill.getBoundingClientRect(); // forceer reflow zodat de volgende transitie opnieuw start
|
||||
fill.style.transition = `transform ${HOURLY_OVERLAY_MS}ms linear`;
|
||||
fill.style.transition = `transform ${OVERLAY_MS}ms linear`;
|
||||
fill.style.transform = 'scaleX(0)';
|
||||
|
||||
$('hour-overlay').hidden = false;
|
||||
hourlyOverlayTimer = setTimeout(closeHourOverlay, HOURLY_OVERLAY_MS);
|
||||
$(id).hidden = false;
|
||||
openOverlayId = id;
|
||||
overlayTimer = setTimeout(closeOverlay, OVERLAY_MS);
|
||||
}
|
||||
|
||||
function closeHourOverlay() {
|
||||
clearTimeout(hourlyOverlayTimer);
|
||||
hourlyOverlayTimer = null;
|
||||
$('hour-overlay').hidden = true;
|
||||
function closeOverlay() {
|
||||
clearTimeout(overlayTimer);
|
||||
overlayTimer = null;
|
||||
if (openOverlayId !== null) {
|
||||
$(openOverlayId).hidden = true;
|
||||
openOverlayId = null;
|
||||
}
|
||||
}
|
||||
|
||||
function renderBanner() {
|
||||
@@ -319,7 +377,9 @@ $('forecast').addEventListener('click', (e) => {
|
||||
const day = e.target.closest('.a-day');
|
||||
if (day) openHourOverlay(day.dataset.date);
|
||||
});
|
||||
$('hour-overlay').addEventListener('click', closeHourOverlay);
|
||||
$('hour-overlay').addEventListener('click', closeOverlay);
|
||||
$('radar-overlay').addEventListener('click', closeOverlay);
|
||||
$('rain-block').addEventListener('click', openRadarOverlay);
|
||||
|
||||
$('rooms').addEventListener('click', (e) => {
|
||||
const room = e.target.closest('.a-room');
|
||||
|
||||
+23
-2
@@ -47,7 +47,7 @@
|
||||
<section class="a-inside" id="rooms"></section>
|
||||
|
||||
<section class="a-bottom">
|
||||
<div>
|
||||
<div id="rain-block">
|
||||
<p class="a-sub">Regen</p>
|
||||
<div class="a-rainrow">
|
||||
<div>
|
||||
@@ -63,7 +63,7 @@
|
||||
|
||||
<div class="banner" id="banner">Gegevens laden…</div>
|
||||
|
||||
<div class="hour-overlay" id="hour-overlay" hidden>
|
||||
<div class="overlay" id="hour-overlay" hidden>
|
||||
<div class="ho-bar"><i id="ho-bar-fill"></i></div>
|
||||
<header class="ho-head">
|
||||
<span class="ho-date" id="ho-date"></span>
|
||||
@@ -79,6 +79,27 @@
|
||||
<div class="ho-grid ho-wind" id="ho-wind"></div>
|
||||
<div class="ho-grid ho-hours" id="ho-hours"></div>
|
||||
</div>
|
||||
|
||||
<div class="overlay" id="radar-overlay" hidden>
|
||||
<div class="ho-bar"><i id="ro-bar-fill"></i></div>
|
||||
<header class="ho-head">
|
||||
<span class="ho-date">Regen</span>
|
||||
<span class="ho-hint">tik om te sluiten</span>
|
||||
</header>
|
||||
<div class="ro-body">
|
||||
<div class="ro-map">
|
||||
<p class="ro-status" id="ro-status">Radar laden…</p>
|
||||
<img class="ro-img" id="ro-img" alt="Regenradar" hidden>
|
||||
</div>
|
||||
<div class="ro-side">
|
||||
<p class="ro-big"><span class="num" id="ro-today">–</span> mm</p>
|
||||
<p class="ro-sub">vandaag</p>
|
||||
<div class="ro-days" id="ro-days"></div>
|
||||
<p class="ro-month num" id="ro-month">–</p>
|
||||
<p class="ro-source">bron: Buienradar.nl</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
<div id="rotate">Draai de iPad een kwartslag</div>
|
||||
|
||||
+19
-3
@@ -163,8 +163,9 @@ html, body {
|
||||
}
|
||||
.banner small { display: block; margin-top: 16px; font-size: 20px; color: var(--muted); }
|
||||
|
||||
/* Uuroverzicht per dag: tik op een dagkolom om te openen, tik erop of wacht 20s om te sluiten. */
|
||||
.hour-overlay {
|
||||
/* Overlays (uuroverzicht, regenradar): tik om te openen, tik erop of wacht 20s om te sluiten.
|
||||
De .ho-*-chrome (balk, kop) wordt door beide overlays gedeeld. */
|
||||
.overlay {
|
||||
position: absolute;
|
||||
left: 44px;
|
||||
right: 44px;
|
||||
@@ -180,7 +181,7 @@ html, body {
|
||||
box-shadow: 0 12px 32px rgba(27, 42, 53, .12);
|
||||
z-index: 5;
|
||||
}
|
||||
.hour-overlay[hidden] { display: none; }
|
||||
.overlay[hidden] { display: none; }
|
||||
|
||||
.ho-bar { margin: 0 30px; height: 4px; border-radius: 2px; background: var(--line); overflow: hidden; }
|
||||
.ho-bar i { display: block; width: 100%; height: 100%; background: var(--rain); transform-origin: left; }
|
||||
@@ -207,6 +208,21 @@ html, body {
|
||||
|
||||
.ho-hours span { font-size: 16px; color: var(--muted); }
|
||||
|
||||
/* Regenradar */
|
||||
.ro-body { display: flex; gap: 56px; margin: 0 30px; min-height: 0; }
|
||||
.ro-map { display: flex; align-items: center; justify-content: center; width: 560px; flex: none; }
|
||||
.ro-img { height: 512px; width: auto; display: block; border-radius: 10px; }
|
||||
.ro-status { font-size: 22px; color: var(--muted); }
|
||||
|
||||
.ro-side { display: flex; flex-direction: column; justify-content: center; }
|
||||
.ro-big { font-size: 26px; color: var(--rain); }
|
||||
.ro-big span { font-size: 76px; font-weight: 300; line-height: 1; }
|
||||
.ro-sub { font-size: 19px; color: var(--muted); margin-bottom: 26px; }
|
||||
.ro-days p { display: flex; justify-content: space-between; width: 260px; font-size: 21px; color: var(--muted); padding: 7px 0; border-top: 1px solid var(--line); }
|
||||
.ro-days b { color: var(--ink); font-weight: 600; }
|
||||
.ro-month { width: 260px; font-size: 19px; color: var(--muted); margin-top: 18px; }
|
||||
.ro-source { font-size: 15px; color: var(--muted); margin-top: auto; padding-top: 22px; }
|
||||
|
||||
#rotate {
|
||||
display: none;
|
||||
position: fixed;
|
||||
|
||||
+13
-5
@@ -1,6 +1,6 @@
|
||||
/* Service worker: app-shell cache-first, API network-first met timeout en fallback op de laatste response. */
|
||||
|
||||
const VERSION = 'weerscherm-v6';
|
||||
const VERSION = 'weerscherm-v7';
|
||||
const SHELL = [
|
||||
'./',
|
||||
'./index.html',
|
||||
@@ -14,7 +14,8 @@ const SHELL = [
|
||||
'./icons/icon-192.png',
|
||||
'./icons/icon-512.png',
|
||||
];
|
||||
const API_PATH = '/api/data.php';
|
||||
const API_PREFIX = '/api/';
|
||||
const DATA_PATH = '/api/data.php';
|
||||
const API_TIMEOUT_MS = 8000;
|
||||
|
||||
self.addEventListener('install', (event) => {
|
||||
@@ -37,7 +38,7 @@ self.addEventListener('fetch', (event) => {
|
||||
const url = new URL(req.url);
|
||||
if (url.origin !== self.location.origin) return;
|
||||
|
||||
if (url.pathname.endsWith(API_PATH)) {
|
||||
if (url.pathname.includes(API_PREFIX)) {
|
||||
event.respondWith(networkFirst(req));
|
||||
} else {
|
||||
event.respondWith(cacheFirst(req));
|
||||
@@ -46,25 +47,32 @@ self.addEventListener('fetch', (event) => {
|
||||
|
||||
async function networkFirst(req) {
|
||||
const cache = await caches.open(VERSION);
|
||||
const url = new URL(req.url);
|
||||
// Sleutel zonder querystring: het radarbeeld krijgt elke paar minuten een andere ?t=, en zonder
|
||||
// normaliseren zou de cache vollopen met varianten en zou offline nooit een treffer geven.
|
||||
const key = url.origin + url.pathname;
|
||||
const controller = new AbortController();
|
||||
const timer = setTimeout(() => controller.abort(), API_TIMEOUT_MS);
|
||||
try {
|
||||
const res = await fetch(req, { signal: controller.signal, cache: 'no-store' });
|
||||
if (res.ok) {
|
||||
cache.put(req, res.clone());
|
||||
cache.put(key, res.clone());
|
||||
}
|
||||
return res;
|
||||
} catch (err) {
|
||||
const cached = await cache.match(req);
|
||||
const cached = await cache.match(key);
|
||||
if (cached) {
|
||||
const headers = new Headers(cached.headers);
|
||||
headers.set('X-From-Cache', '1');
|
||||
return new Response(await cached.arrayBuffer(), { status: 200, statusText: 'OK', headers });
|
||||
}
|
||||
if (url.pathname.endsWith(DATA_PATH)) {
|
||||
return new Response(JSON.stringify({ errors: ['offline'] }), {
|
||||
status: 503,
|
||||
headers: { 'Content-Type': 'application/json; charset=utf-8', 'Cache-Control': 'no-store' },
|
||||
});
|
||||
}
|
||||
return new Response('offline', { status: 503, headers: { 'Cache-Control': 'no-store' } });
|
||||
} finally {
|
||||
clearTimeout(timer);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Weerscherm;
|
||||
|
||||
/**
|
||||
* Haalt de geanimeerde regenradar op bij Buienradar. Vrij te gebruiken met bronvermelding;
|
||||
* de frontend toont daarom "bron: Buienradar.nl" bij het beeld.
|
||||
*
|
||||
* De endpoint leidt door naar een CDN, dus de meegegeven HttpClient moet redirects volgen.
|
||||
* Het beeld is 550 x 512 met de tijd erin gebrand; opgegeven afmetingen worden genegeerd.
|
||||
*/
|
||||
final class BuienradarClient
|
||||
{
|
||||
private const URL = 'https://image.buienradar.nl/2.0/image/animation/RadarmapRainNL';
|
||||
|
||||
public function __construct(private readonly HttpClient $http)
|
||||
{
|
||||
}
|
||||
|
||||
/** @return string de ruwe bytes van een geanimeerde GIF */
|
||||
public function rainRadar(): string
|
||||
{
|
||||
$res = $this->http->get(self::URL, ['w' => 550, 'h' => 512], ['Accept: image/gif,image/*']);
|
||||
|
||||
if ($res->status !== 200) {
|
||||
throw new UpstreamException("Buienradar: HTTP {$res->status}");
|
||||
}
|
||||
if (!str_starts_with($res->body, 'GIF8')) {
|
||||
throw new UpstreamException('Buienradar: antwoord is geen GIF');
|
||||
}
|
||||
return $res->body;
|
||||
}
|
||||
}
|
||||
@@ -20,6 +20,7 @@ final class Config
|
||||
'measure' => 900,
|
||||
'pressure' => 900,
|
||||
'forecast' => 3600,
|
||||
'radar' => 240,
|
||||
],
|
||||
'storage_path' => null,
|
||||
];
|
||||
|
||||
+12
-3
@@ -7,8 +7,16 @@ namespace Weerscherm;
|
||||
/** Dunne curl-wrapper met korte timeouts. Netwerkfouten worden UpstreamException. */
|
||||
final class Http implements HttpClient
|
||||
{
|
||||
public function __construct(private readonly int $timeout = 8, private readonly int $connectTimeout = 5)
|
||||
{
|
||||
/**
|
||||
* @param bool $followRedirects standaard uit: de API's die we aanroepen horen direct te antwoorden, en een
|
||||
* stille omleiding naar een andere host wil je niet. De beeld-API van Buienradar
|
||||
* leidt bewust door naar een CDN, dus die client zet dit expliciet aan.
|
||||
*/
|
||||
public function __construct(
|
||||
private readonly int $timeout = 8,
|
||||
private readonly int $connectTimeout = 5,
|
||||
private readonly bool $followRedirects = false,
|
||||
) {
|
||||
}
|
||||
|
||||
public function get(string $url, array $query = [], array $headers = []): HttpResponse
|
||||
@@ -33,7 +41,8 @@ final class Http implements HttpClient
|
||||
}
|
||||
curl_setopt_array($ch, [
|
||||
CURLOPT_RETURNTRANSFER => true,
|
||||
CURLOPT_FOLLOWLOCATION => false,
|
||||
CURLOPT_FOLLOWLOCATION => $this->followRedirects,
|
||||
CURLOPT_MAXREDIRS => 3,
|
||||
CURLOPT_CONNECTTIMEOUT => $this->connectTimeout,
|
||||
CURLOPT_TIMEOUT => $this->timeout,
|
||||
CURLOPT_HTTPHEADER => array_merge(['Accept: application/json', 'User-Agent: weerscherm/1.0'], $headers),
|
||||
|
||||
@@ -0,0 +1,75 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Weerscherm;
|
||||
|
||||
/**
|
||||
* Serveert het radarbeeld met een eigen bestandscache. De radar ververst ongeveer elke 5 minuten,
|
||||
* dus een TTL van 4 minuten houdt het beeld actueel zonder het CDN van Buienradar onnodig te belasten.
|
||||
*
|
||||
* Bij een storing komt het laatst opgehaalde beeld terug, gemarkeerd als verouderd. Alleen als er
|
||||
* helemaal niets in de cache staat, faalt de aanroep. Zo blijft het scherm ook zonder verbinding iets tonen.
|
||||
*/
|
||||
final class RadarService
|
||||
{
|
||||
/** @var \Closure(): int */
|
||||
private readonly \Closure $now;
|
||||
|
||||
public function __construct(
|
||||
private readonly BuienradarClient $client,
|
||||
private readonly string $file,
|
||||
private readonly int $ttl = 240,
|
||||
?\Closure $now = null,
|
||||
) {
|
||||
$this->now = $now ?? static fn(): int => time();
|
||||
}
|
||||
|
||||
/** @return array{bytes: string, stale: bool, age: int} */
|
||||
public function image(): array
|
||||
{
|
||||
$cached = $this->readCache();
|
||||
if ($cached !== null && $cached['age'] < $this->ttl) {
|
||||
return ['bytes' => $cached['bytes'], 'stale' => false, 'age' => $cached['age']];
|
||||
}
|
||||
|
||||
try {
|
||||
$bytes = $this->client->rainRadar();
|
||||
$this->writeCache($bytes);
|
||||
return ['bytes' => $bytes, 'stale' => false, 'age' => 0];
|
||||
} catch (\Throwable $e) {
|
||||
error_log('weerscherm: radar: ' . $e->getMessage());
|
||||
if ($cached !== null) {
|
||||
return ['bytes' => $cached['bytes'], 'stale' => true, 'age' => $cached['age']];
|
||||
}
|
||||
throw new UpstreamException('Radarbeeld niet beschikbaar en niets in de cache', 0, $e);
|
||||
}
|
||||
}
|
||||
|
||||
/** @return array{bytes: string, age: int}|null */
|
||||
private function readCache(): ?array
|
||||
{
|
||||
if (!is_file($this->file)) {
|
||||
return null;
|
||||
}
|
||||
$bytes = file_get_contents($this->file);
|
||||
$mtime = filemtime($this->file);
|
||||
if ($bytes === false || $bytes === '' || $mtime === false) {
|
||||
return null;
|
||||
}
|
||||
return ['bytes' => $bytes, 'age' => max(0, ($this->now)() - $mtime)];
|
||||
}
|
||||
|
||||
private function writeCache(string $bytes): void
|
||||
{
|
||||
$dir = dirname($this->file);
|
||||
if (!is_dir($dir) && !mkdir($dir, 0750, true) && !is_dir($dir)) {
|
||||
throw new \RuntimeException("Cachemap kan niet worden aangemaakt: $dir");
|
||||
}
|
||||
$tmp = $this->file . '.tmp.' . uniqid('', true);
|
||||
if (file_put_contents($tmp, $bytes, LOCK_EX) === false || !rename($tmp, $this->file)) {
|
||||
@unlink($tmp);
|
||||
throw new \RuntimeException("Radarbeeld kan niet worden opgeslagen: {$this->file}");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Weerscherm\Tests;
|
||||
|
||||
use PHPUnit\Framework\TestCase;
|
||||
use Weerscherm\BuienradarClient;
|
||||
use Weerscherm\UpstreamException;
|
||||
|
||||
final class BuienradarClientTest extends TestCase
|
||||
{
|
||||
private const GIF = "GIF89a\x01\x00\x01\x00fake-animatie";
|
||||
|
||||
public function testFetchesTheAnimatedRadarImage(): void
|
||||
{
|
||||
$http = (new FakeHttp())->queue(200, self::GIF);
|
||||
|
||||
$bytes = (new BuienradarClient($http))->rainRadar();
|
||||
|
||||
self::assertSame(self::GIF, $bytes);
|
||||
self::assertSame('https://image.buienradar.nl/2.0/image/animation/RadarmapRainNL', $http->requests[0]['url']);
|
||||
self::assertSame(['w' => 550, 'h' => 512], $http->requests[0]['params']);
|
||||
}
|
||||
|
||||
public function testErrorStatusIsUpstreamException(): void
|
||||
{
|
||||
$http = (new FakeHttp())->queue(503, '');
|
||||
|
||||
$this->expectException(UpstreamException::class);
|
||||
$this->expectExceptionMessage('HTTP 503');
|
||||
(new BuienradarClient($http))->rainRadar();
|
||||
}
|
||||
|
||||
public function testNonGifResponseIsUpstreamException(): void
|
||||
{
|
||||
// Bijvoorbeeld een foutpagina of een redirect die als HTML binnenkomt.
|
||||
$http = (new FakeHttp())->queue(200, '<!doctype html><title>Oeps</title>');
|
||||
|
||||
$this->expectException(UpstreamException::class);
|
||||
$this->expectExceptionMessage('geen GIF');
|
||||
(new BuienradarClient($http))->rainRadar();
|
||||
}
|
||||
}
|
||||
@@ -28,7 +28,7 @@ final class ConfigTest extends TestCase
|
||||
|
||||
self::assertSame('Europe/Berlin', $config->get('timezone'));
|
||||
self::assertSame('fixture', $config->get('data_source'));
|
||||
self::assertSame(['stations' => 300, 'measure' => 900, 'pressure' => 900, 'forecast' => 3600], $config->get('cache_ttl'));
|
||||
self::assertSame(['stations' => 300, 'measure' => 900, 'pressure' => 900, 'forecast' => 3600, 'radar' => 240], $config->get('cache_ttl'));
|
||||
self::assertSame([], $config->get('indoor_modules'));
|
||||
self::assertNull($config->get('netatmo_device_id'));
|
||||
}
|
||||
@@ -39,7 +39,7 @@ final class ConfigTest extends TestCase
|
||||
$config = Config::load($this->file);
|
||||
|
||||
self::assertSame('Europe/Amsterdam', $config->get('timezone'));
|
||||
self::assertSame(['stations' => 60, 'measure' => 900, 'pressure' => 900, 'forecast' => 3600], $config->get('cache_ttl'));
|
||||
self::assertSame(['stations' => 60, 'measure' => 900, 'pressure' => 900, 'forecast' => 3600, 'radar' => 240], $config->get('cache_ttl'));
|
||||
}
|
||||
|
||||
public function testUnknownKeyReturnsGivenDefault(): void
|
||||
|
||||
@@ -0,0 +1,118 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Weerscherm\Tests;
|
||||
|
||||
use PHPUnit\Framework\TestCase;
|
||||
use Weerscherm\BuienradarClient;
|
||||
use Weerscherm\RadarService;
|
||||
use Weerscherm\UpstreamException;
|
||||
|
||||
final class RadarServiceTest extends TestCase
|
||||
{
|
||||
private const NOW = 1_800_000_000;
|
||||
private const GIF = "GIF89a\x01\x00\x01\x00vers";
|
||||
private const OLD_GIF = "GIF89a\x01\x00\x01\x00oud";
|
||||
|
||||
private string $dir;
|
||||
private string $file;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
$this->dir = sys_get_temp_dir() . '/weerscherm-radar-' . uniqid();
|
||||
$this->file = $this->dir . '/radar.gif';
|
||||
}
|
||||
|
||||
protected function tearDown(): void
|
||||
{
|
||||
array_map('unlink', glob($this->dir . '/*') ?: []);
|
||||
@rmdir($this->dir);
|
||||
}
|
||||
|
||||
private function service(FakeHttp $http, int $ttl = 240): RadarService
|
||||
{
|
||||
return new RadarService(
|
||||
new BuienradarClient($http),
|
||||
$this->file,
|
||||
$ttl,
|
||||
static fn(): int => self::NOW,
|
||||
);
|
||||
}
|
||||
|
||||
/** Legt een cachebestand neer met een gegeven leeftijd in seconden. */
|
||||
private function seedCache(string $bytes, int $ageSeconds): void
|
||||
{
|
||||
mkdir($this->dir, 0700, true);
|
||||
file_put_contents($this->file, $bytes);
|
||||
touch($this->file, self::NOW - $ageSeconds);
|
||||
}
|
||||
|
||||
public function testFetchesAndCachesWhenNothingIsStored(): void
|
||||
{
|
||||
$http = (new FakeHttp())->queue(200, self::GIF);
|
||||
|
||||
$result = $this->service($http)->image();
|
||||
|
||||
self::assertSame(['bytes' => self::GIF, 'stale' => false, 'age' => 0], $result);
|
||||
self::assertStringEqualsFile($this->file, self::GIF);
|
||||
self::assertSame([], glob($this->dir . '/*.tmp.*') ?: [], 'geen tijdelijke bestanden achtergelaten');
|
||||
}
|
||||
|
||||
public function testFreshCacheIsServedWithoutFetching(): void
|
||||
{
|
||||
$this->seedCache(self::OLD_GIF, 100);
|
||||
$http = new FakeHttp(); // geen response gepland: een fetch zou de test laten falen
|
||||
|
||||
$result = $this->service($http)->image();
|
||||
|
||||
self::assertSame(self::OLD_GIF, $result['bytes']);
|
||||
self::assertFalse($result['stale']);
|
||||
self::assertSame(100, $result['age']);
|
||||
self::assertSame([], $http->requests);
|
||||
}
|
||||
|
||||
public function testExpiredCacheIsRefetched(): void
|
||||
{
|
||||
$this->seedCache(self::OLD_GIF, 300);
|
||||
$http = (new FakeHttp())->queue(200, self::GIF);
|
||||
|
||||
$result = $this->service($http)->image();
|
||||
|
||||
self::assertSame(self::GIF, $result['bytes']);
|
||||
self::assertFalse($result['stale']);
|
||||
self::assertCount(1, $http->requests);
|
||||
self::assertStringEqualsFile($this->file, self::GIF);
|
||||
}
|
||||
|
||||
public function testUpstreamFailureFallsBackToTheCachedImageMarkedStale(): void
|
||||
{
|
||||
$this->seedCache(self::OLD_GIF, 3600);
|
||||
$http = (new FakeHttp())->queue(500, '');
|
||||
|
||||
$result = $this->service($http)->image();
|
||||
|
||||
self::assertSame(self::OLD_GIF, $result['bytes'], 'ongeacht de leeftijd: liever een oud beeld dan niets');
|
||||
self::assertTrue($result['stale']);
|
||||
self::assertSame(3600, $result['age']);
|
||||
}
|
||||
|
||||
public function testUpstreamFailureWithoutCacheThrows(): void
|
||||
{
|
||||
$http = (new FakeHttp())->queue(500, '');
|
||||
|
||||
$this->expectException(UpstreamException::class);
|
||||
$this->service($http)->image();
|
||||
}
|
||||
|
||||
public function testEmptyCacheFileIsIgnored(): void
|
||||
{
|
||||
$this->seedCache('', 10);
|
||||
$http = (new FakeHttp())->queue(200, self::GIF);
|
||||
|
||||
$result = $this->service($http)->image();
|
||||
|
||||
self::assertSame(self::GIF, $result['bytes']);
|
||||
self::assertCount(1, $http->requests, 'een leeg cachebestand telt niet als geldige cache');
|
||||
}
|
||||
}
|
||||
+41
-1
@@ -9,7 +9,8 @@ function loadServiceWorker({ fetchImpl }) {
|
||||
const store = new Map();
|
||||
const cache = {
|
||||
async addAll(urls) { urls.forEach((u) => store.set(new URL(u, 'http://x/').href, new Response('shell'))); },
|
||||
async put(req, res) { store.set(req.url, res); },
|
||||
// De echte Cache API accepteert zowel een Request als een URL-string als sleutel.
|
||||
async put(req, res) { store.set(typeof req === 'string' ? req : req.url, res); },
|
||||
async match(req) { return store.get(typeof req === 'string' ? req : req.url) || undefined; },
|
||||
async keys() { return [...store.keys()].map((u) => new Request(u)); },
|
||||
};
|
||||
@@ -96,3 +97,42 @@ test('cross-origin and non-GET requests are left alone', () => {
|
||||
listeners.fetch({ request: new Request('http://x/api/data.php', { method: 'POST' }), respondWith: () => { handled = true; } });
|
||||
assert.equal(handled, false);
|
||||
});
|
||||
|
||||
test('the radar image is network-first and cached under a key without querystring', async () => {
|
||||
const { listeners, store } = loadServiceWorker({
|
||||
fetchImpl: async () => new Response('GIF89a', { status: 200, headers: { 'Content-Type': 'image/gif' } }),
|
||||
});
|
||||
|
||||
const res = await dispatchFetch(listeners, 'http://x/api/radar.php?t=123');
|
||||
|
||||
assert.equal(res.status, 200);
|
||||
assert.ok(store.has('http://x/api/radar.php'), 'sleutel zonder ?t=, anders loopt de cache vol met varianten');
|
||||
assert.equal(store.has('http://x/api/radar.php?t=123'), false);
|
||||
});
|
||||
|
||||
test('an offline radar request falls back to the cached image, even with a different querystring', async () => {
|
||||
let online = true;
|
||||
const { listeners } = loadServiceWorker({
|
||||
fetchImpl: async () => {
|
||||
if (!online) throw new TypeError('Failed to fetch');
|
||||
return new Response('GIF89a', { status: 200, headers: { 'Content-Type': 'image/gif' } });
|
||||
},
|
||||
});
|
||||
|
||||
await dispatchFetch(listeners, 'http://x/api/radar.php?t=1');
|
||||
online = false;
|
||||
const res = await dispatchFetch(listeners, 'http://x/api/radar.php?t=2');
|
||||
|
||||
assert.equal(res.status, 200);
|
||||
assert.equal(res.headers.get('X-From-Cache'), '1');
|
||||
assert.equal(await res.text(), 'GIF89a');
|
||||
});
|
||||
|
||||
test('an offline radar request without any cache gives a plain 503, not JSON', async () => {
|
||||
const { listeners } = loadServiceWorker({ fetchImpl: async () => { throw new TypeError('offline'); } });
|
||||
|
||||
const res = await dispatchFetch(listeners, 'http://x/api/radar.php?t=1');
|
||||
|
||||
assert.equal(res.status, 503);
|
||||
assert.equal(await res.text(), 'offline');
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user