Author SHA1 Message Date
theoandClaude Opus 5 ca2fc76552 fix: modulekolom past zich aan het aantal modules aan
Er is een vierde binnenmodule bijgekomen. De kaart verscheen al vanzelf
(StationMapper leest alle NAModule4-modules uit), maar de kolom was op
drie kaarten ontworpen: vier kaarten van 120px duwden de rij op 481px en
daarmee de onderste strook 39px voorbij de onderrand van het canvas.

De kolom heeft nu een vaste hoogte van 412px met overflow verborgen, en
fitRooms() verkleint de inhoud met een schaal zodra die niet past. Bij
drie modules verandert er niets (schaal 1), bij vier wordt het 0,86 en
met een vastgezette module 0,78. Zo blijft het scherm kloppen, ongeacht
hoeveel modules erbij komen.

De dev-fixture heeft er een vierde module bij, zodat dit lokaal te zien
is zonder Netatmo-koppeling. Servicewerker naar v12.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-14 18:44:38 +02:00
theoandClaude Opus 5 75111a5571 Merge feature/module-grafiekjes: verloop van vandaag bij elke binnenmodule
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-13 12:10:40 +02:00
theoandClaude Opus 5 bfc96acb5a feat: temperatuurverloop van vandaag ook bij de binnenmodules
Naast het getal in elke modulekaart staat nu hetzelfde soort lijntje als
bij buiten (200x40). Zonder labels: daar is in zo'n kaart geen ruimte
voor, de schaal staat al op de regel eronder en het venster is hetzelfde
"vandaag" als bij het grafiekje van buiten ernaast.

Per module een eigen getmeasure, gecachet onder temperature-<module> met
een gedeelde TTL; fetch() heeft daarvoor een losse ttlKey gekregen, want
anders zou de TTL-lookup per module mislukken en zou er helemaal niet
gecachet worden. Het basisstation meet zelf temperatuur en wordt zonder
module_id opgevraagd, dus die parameter is optioneel geworden. In de
payload als indoor[].history; mislukt er een, dan blijft die lijst leeg
en verdwijnt alleen dat lijntje, met de foutcode in errors. Een
stale-vlag per kamer zou het contract nodeloos zwaar maken.

De hoogte is overal 40px, ook bij een vastgezette module: gelijke hoogte
houdt de kamers onderling vergelijkbaar, en een hoger lijntje zou de
toch al krappe speling onder de kolom opeten. De grafiekjes voegen geen
hoogte toe aan de kaarten.

FakeSource houdt nu alle aanroepen bij in plaats van alleen de laatste,
zodat een test de volgorde van de vier temperatuur-calls kan nalopen.
Servicewerker naar v11.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-13 12:10:40 +02:00
theoandClaude Opus 5 4c4881fd11 Merge feature/temperatuurverloop: verloop van vandaag naast de buitentemperatuur
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-13 11:50:40 +02:00
theoandClaude Opus 5 107978b265 feat: klein grafiekje van het temperatuurverloop van vandaag
In de lege ruimte rechts van de grote buitentemperatuur staat nu het
verloop van vandaag: onderschrift "Vandaag", de lijn, en tijdlabels op
hele uren met "nu" rechts. Geen y-as, want de laagste en hoogste waarde
van vandaag staan al onder de balk en dat is precies de schaal.

Het getal en het grafiekje staan in een flexrij, zodat het grafiekje de
resterende ruimte pakt en meebeweegt als het getal smaller of breder
wordt. Vlak na middernacht zijn er nog geen twee meetpunten; dan blijft
het blokje verborgen in plaats van een leeg kader te tonen.

Data uit een extra getmeasure (scale=30min, type=Temperature, op de
buitenmodule) vanaf middernacht lokale tijd, cache 15 minuten, in de
payload als outdoor_history: {points, stale} zodat een storing daar de
rest niet raakt. PressureHistory is generiek gemaakt tot MeasureSeries
met de naam van het waardeveld als parameter, in plaats van er een bijna
identieke tweede klasse naast te zetten. StationMapper geeft nu ook het
id van de buitenmodule door.

Onderweg gevonden: FixtureSource deed een modulo op een float, wat een
PHP-deprecation vóór de JSON in de response zette. Dat verklaart de
kapotte JSON die eerder in de browserconsole langskwam. Opgelost met
intdiv. Servicewerker naar v10.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-13 11:50:40 +02:00
theoandClaude Opus 5 5058bb850d Merge feature/hele-uren: tijdlabels op hele uren bij de luchtdrukgrafiek
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-13 11:33:14 +02:00
theoandClaude Opus 5 5d61b6ec90 feat: tijdlabels op hele uren in plaats van de ruwe meettijden
De labels toonden de werkelijke meettijdstippen van Netatmo (11:41,
17:41, 23:41), wat onrustig leest. Nu markeringen op hele uren die op de
klok zijn uitgelijnd: elke 6 uur vanaf middernacht, dus 00:00, 06:00,
12:00, 18:00, elk op zijn echte tijdspositie in het venster. "nu" blijft
op de rechterrand; een markering die daar te dicht bij komt valt weg.

axisMarks() is vervangen door hourTicks(), die uur voor uur door het
venster loopt en telkens de lokale klok raadpleegt. Daardoor blijven de
labels ook op de zomertijdwissel hele lokale uren, waar optellen in vaste
stappen er een uur naast zou zitten; daar is een test voor. Een label
vlak op de linkerrand wordt niet gecentreerd, zodat het binnen de
grafiek blijft. Servicewerker naar v9.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-13 11:33:14 +02:00
theoandClaude Opus 5 e2db301aef Merge feature/luchtdruk-assen: x- en y-as bij de luchtdrukgrafiek
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-13 11:30:15 +02:00
theoandClaude Opus 5 c0715b7cc9 feat: assen bij de luchtdrukgrafiek
De lijn had geen schaal, dus je kon niet zien hoeveel hPa de uitslag was
of wanneer een dip viel. Nu links de hoogste en laagste waarde van het
venster in hele hPa, uitgelijnd op boven- en onderkant van de lijn, en
eronder vijf tijdlabels (0/25/50/75/100% van het venster) met "nu"
rechts, ondersteund door drie lichte verticale hulplijnen. Posities
komen uit de pure functie axisMarks(), met tests.

De losse tekst "1009 – 1015 hPa in 24 u" in de kopregel vervalt: de
y-as zegt hetzelfde en de x-as maakt het venster duidelijk. De extra
labelrij past door de marge onder de grote temperatuur 8px te krappen;
er blijft 5px speling boven en onder de hero. Servicewerker naar v8.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-13 11:30:11 +02:00
theoandClaude Opus 5 c95d198341 Merge feature/buienradar: regenradar-overlay bij een tik op het regenblok
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-13 11:13:32 +02:00
theoandClaude Opus 5 c542601d4e 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>
2026-09-13 11:13:28 +02:00
theoandClaude Sonnet 5 2200a849f9 Merge feature/module-pinnen: tik op een module om bovenaan te zetten met groter font
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-13 10:27:14 +02:00
theoandClaude Sonnet 5 350dafe591 feat: tik op een module om die bovenaan te zetten met een groter temperatuurgetal
Tik op een van de drie binnenmodules zet die bovenaan de lijst (de
overige twee behouden hun onderlinge volgorde) en vergroot alleen het
temperatuurgetal van 50 naar 92px. Nogmaals tikken op de bovenste
(vastgezette) module zet 'm weer los. Puur client-side, geen nieuwe
API-velden: state.pinnedRoomId + de pure functie pinRoom() (met tests)
in format.js. De keuze overleeft de 5-minuten-verversing, reset bij een
volledige herlaad. Servicewerker naar v6.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-13 10:27:09 +02:00
theoandClaude Sonnet 5 48d68b86fa fix: treat a stale/legacy-shaped forecast cache as unavailable, not silently empty
Na de deploy van het uuroverzicht (commit 8e90eb1) bleef de nog geldige
cache op de server een tijdlang in het oude platte 'daily'-blok staan
i.p.v. het nieuwe {daily, hourly}. DashboardService::forecast() las
daaruit stil een lege verwachting zonder errors-melding. Nu wordt een
cache- of bronresultaat zonder geldig 'daily'-blok behandeld als een
storing (forecast_unavailable), zichtbaar in plaats van stil.

Handmatig verholpen op productie door de verouderde cachefile te
wissen; deze fix voorkomt herhaling bij een volgende formaatwijziging.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-13 10:20:00 +02:00
theoandClaude Sonnet 5 b6f4836b12 Merge feature/uuroverzicht: uuroverzicht per dag met temperatuur, regen en wind
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-13 10:18:17 +02:00
theoandClaude Sonnet 5 8e90eb1129 feat: uuroverzicht per dag (temperatuur, regen, wind)
Tik op een dag in de verwachtingsstrook voor een uuroverzicht van
temperatuur, regen en wind (richting + snelheid), om de 2 uur, 12
punten per dag. Sluit na 20 seconden vanzelf of bij een tik op de
overlay zelf.

Backend: Open-Meteo levert hourly-data in dezelfde call als daily
(geen extra verzoek, dezelfde 60min-cache). Nieuwe HourlyForecastMapper
groepeert per kalenderdatum en bemonstert om het andere uur, robuust
voor de zomertijddag (23/25 uur). OpenMeteoClient::dailyForecast()
hernoemd naar forecast(), geeft nu {daily, hourly} terug. Elke dag in
de forecast-array krijgt een hourly-lijst.

Frontend: temperatuurlijn hergebruikt sparklinePath (nu ook als
sparklinePoints voor exacte uitlijning met de 12-koloms grid), regen
als kleine staafjes, windpijl (nieuw icoon) wijst waarheen de wind
waait. 20s-balk via CSS transition met reflow-restart. Servicewerker
naar v5.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-13 10:18:11 +02:00
theoandClaude Sonnet 5 90e979f562 Merge feature/verwachting-vandaag: verwachting toont nu ook vandaag (6 dagen)
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-13 10:01:20 +02:00
theoandClaude Sonnet 5 8bbfd62d16 feat: toon vandaag ook in de 5-daagse verwachting (nu 6 dagen)
ForecastMapper mapt voortaan index 0 t/m 5 van Open-Meteo (was 1 t/m 5),
zodat vandaag als eerste kolom in de verwachtingsstrook verschijnt naast
de bestaande 5 dagen erna. Verwachtingskolommen gaan van 5 naar 6 breed
(103px i.p.v. 124px), weericoon iets verkleind (58 -> 50px) om te passen.
Spec bijgewerkt om de wijziging vast te leggen. Service-worker-versie naar
v4 zodat het scherm dit zelf binnen enkele minuten oppikt.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-13 10:01:14 +02:00
theoandClaude Sonnet 5 281eb1406f fix: dedupe SW registration, proactively check for updates every 5min and on foreground
Op een kiosk-scherm dat nooit navigeert gebeurt de browser-eigen update-check
voor de service worker onvoorspelbaar laat (soms pas bij de dagelijkse
04:00-reload). Roep nu expliciet registration.update() aan bij het opstarten,
elke 5 minuten en bij elke visibilitychange naar zichtbaar. Ook een eerdere
niet-bedoelde dubbele registerServiceWorker()-aanroep bij visibilitychange
verwijderd (ontstaan door een brede Python str.replace zonder count-limiet).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-13 09:56:21 +02:00
theoandClaude Fable 5.1 ef37c69076 Merge feature/luchtkwaliteit-luchtdruk: luchtvochtigheid, CO2-pil en luchtdrukgrafiek
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-13 09:35:53 +02:00
38 changed files with 2612 additions and 175 deletions
+2
View File
@@ -31,7 +31,9 @@ return [
'stations' => 300,
'measure' => 900,
'pressure' => 900,
'temperature' => 900,
'forecast' => 3600,
'radar' => 240,
],
// Map voor tokens en cache. Moet schrijfbaar zijn voor de webserver en buiten de docroot liggen.
+51 -5
View File
@@ -1,6 +1,6 @@
# Weerscherm: bouwspecificatie v1
Opdracht voor Claude Code: bouw een PWA die fullscreen draait op een iPad mini (liggend) in de woonkamer. Het scherm toont actuele gegevens van een Netatmo-weerstation en een weersverwachting voor 5 dagen.
Opdracht voor Claude Code: bouw een PWA die fullscreen draait op een iPad mini (liggend) in de woonkamer. Het scherm toont actuele gegevens van een Netatmo-weerstation en een weersverwachting voor 6 dagen.
**Ontwerp A** in `docs/design/weerscherm-layouts.html` is leidend voor de layout. Dat bestand is een statische mockup met voorbeelddata. De CSS van ontwerp A (klasse `.da` en alle `.a-*`-klassen) is getest op 1133 × 744 punten en dient als uitgangspunt. De tabbladen B en C zijn niet in scope.
@@ -26,7 +26,8 @@ Werkwijze: lees eerst dit document en de mockup. Stel daarna een plan voor per f
- Buitentemperatuur, met de laagste en hoogste temperatuur van vandaag (inclusief tijdstippen) en de trend.
- De 3 binnensensoren, elk met huidige, laagste en hoogste temperatuur van vandaag.
- Regen: vandaag, de afgelopen 3 dagen per dag, en het totaal van de huidige maand.
- Verwachting voor de komende 5 dagen (vanaf morgen): max/min-temperatuur, neerslag in mm, neerslagkans en een weericoon.
- Verwachting voor 6 dagen (vandaag t/m 5 dagen verder): max/min-temperatuur, neerslag in mm, neerslagkans en een weericoon.
_Wijziging 2026-09-13: was oorspronkelijk 5 dagen vanaf morgen; op verzoek van Theo toegevoegd: vandaag als eerste kolom._
- Installeerbaar als PWA op het beginscherm, blijft werken met de laatst bekende data als de verbinding wegvalt.
**Niet in v1:** nachtmodus (ontwerp C), andere meetwaarden (CO₂, luchtvochtigheid, wind), meerdere stations, een instellingenscherm, pushmeldingen.
@@ -250,12 +251,55 @@ De iconen `sun`, `partly`, `rain` en `showers` staan in de mockup. Maak `cloud`,
- De temperatuur staat groot, met 1 decimaal ("16,4°"). Het label heeft een trend-aanduiding: `up` → "↗ stijgend", `down` → "↘ dalend", `stable` → "→ stabiel".
- Daaronder de gradiëntbalk van `--cold` naar `--warm`. De marker staat op `(temp − min) / (max − min)`, begrensd op 0–100%. Bij `max == min` staat hij op 50%.
- Onder de balk: "9,2° laagst om 06:10" en "19,8° hoogst om 13:05".
- **Binnen (rechts):** 3 rijen met naam, temperatuur (1 decimaal) en "↓ min° ↑ max°". Een onbereikbare module toont "–" in `--muted`.
- Rechts van het grote getal een klein grafiekje van het temperatuurverloop van vandaag.
_Toegevoegd 2026-09-13: onderschrift "Vandaag", de lijn, en tijdlabels op hele uren met "nu" rechts
(zelfde `hourTicks` als de luchtdruk). Geen y-as: de laagste en hoogste waarde van vandaag staan al
onder de balk en dat is precies de schaal van de lijn. Data uit een extra `getmeasure`
(`scale=30min`, `type=Temperature`, buitenmodule) vanaf middernacht lokale tijd, in de payload als
`outdoor_history: {points, stale}`. Vlak na middernacht zijn er nog geen twee punten; dan blijft het
blokje verborgen in plaats van een leeg kader te tonen._
- Daaronder de luchtdruk: "Luchtdruk 1014 hPa ↗ stijgend" met een grafiek van de afgelopen 24 uur.
_Assen toegevoegd 2026-09-13: links de hoogste en laagste waarde van het venster in hele hPa, onder de
lijn tijdlabels op hele uren die op de klok zijn uitgelijnd (elke 6 uur vanaf middernacht, dus 00:00,
06:00, 12:00, 18:00) op hun echte tijdspositie, met "nu" op de rechterrand, plus drie lichte verticale
hulplijnen. Een markering te dicht bij het einde valt weg omdat "nu" daar staat. De losse tekst
"1009 – 1015 hPa in 24 u" in de kopregel is daarmee vervallen. Zie `hourTicks()` in `public/format.js`;
die loopt uur voor uur door het venster en raadpleegt telkens de lokale klok, zodat de zomertijdwissel
vanzelf goed gaat._
- **Binnen (rechts):** een rij per module met naam, temperatuur (1 decimaal) en "↓ min° ↑ max°". Een onbereikbare module toont "–" in `--muted`.
_Gewijzigd 2026-09-14: het aantal modules ligt niet vast op 3. De kolom heeft een vaste hoogte van 412px
(744 − 2×30 padding − 40 kopregel − 232 onderstrook) en `fitRooms()` verkleint de inhoud met een schaal
zodra die niet past. Zonder dat duwde een vierde module de onderste strook 39px van het scherm af. Bij
drie modules verandert er niets (schaal 1); vier geeft ongeveer 0,86, en met een vastgezette module 0,78._
_Toegevoegd 2026-09-13: rechts van elk temperatuurgetal staat het verloop van vandaag als klein lijntje
(200x40, zonder labels: de schaal staat op de regel eronder en het venster is hetzelfde "vandaag" als bij
buiten). Data per module uit een eigen `getmeasure`, apart gecachet onder `temperature-<module>` maar met
één gedeelde TTL; het basisstation meet zelf en wordt zonder `module_id` opgevraagd. In de payload als
`indoor[].history`. Mislukt er één, dan blijft die lijst leeg, verdwijnt alleen dat lijntje en staat de
foutcode in `errors`; een stale-vlag per kamer zou het contract nodeloos zwaar maken. De hoogte is overal
gelijk, ook bij een vastgezette module, zodat de kamers vergelijkbaar blijven._
_Toegevoegd 2026-09-13: een module is tikbaar. Een tik zet die module bovenaan de lijst (de overige twee
behouden hun onderlinge volgorde) en vergroot alleen het temperatuurgetal van 50 naar 92px. Nogmaals tikken
op de bovenste (vastgezette) module maakt hem weer los. De keuze blijft staan bij de 5-minuten-verversing en
reset bij een volledige herlaad. Zie `pinRoom()` in `public/format.js`._
- **Regen (linksonder):**
- "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".
- **Verwachting (rechtsonder):** 5 kolommen met dagafkorting ("Za"), icoon (58px), "max° min°" als hele getallen (min in `--muted`) en "x,x mm" plus neerslagkans in %. Geen neerslag toont "0 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.
De uurdata komt in dezelfde Open-Meteo-call mee (`hourly=temperature_2m,precipitation,wind_speed_10m,
wind_direction_10m`), dus geen extra netwerkverzoek bij het openen. Windpijl wijst waarheen de wind waait
(richting + 180°). Zie `src/HourlyForecastMapper.php` en `renderHourlyCharts()`/`openHourOverlay()` in
`public/app.js`._
### 6.4 Verversen
@@ -342,7 +386,9 @@ Hangt af van de iPadOS-versie in §0:
- [ ] Op de iPad mini, gestart vanaf het beginscherm, draait de app fullscreen zonder Safari-balken en klopt de layout visueel met ontwerp A.
- [ ] 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 5 dagen vanaf morgen met het juiste icoon per WMO-code.
- [ ] 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.
+48
View File
@@ -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";
}
+211 -11
View File
@@ -2,8 +2,9 @@ import {
f1, fInt, fmm, markerPos, barHeights, wmoIcon, trendLabel,
formatDate, formatTime, dayAbbr, monthName, isOlderThan, msUntil,
co2Level, fPct, fHpa, seriesRange, sparklinePath,
windArrowDeg, fKmh, formatHour, formatFullDate, pinRoom, hourTicks, scaleToFit,
} from './format.js';
import { icon } from './icons.js';
import { icon, windArrow } from './icons.js';
const CANVAS_W = 1133;
const CANVAS_H = 744;
@@ -13,16 +14,21 @@ const FETCH_TIMEOUT_MS = 10000;
const STALE_MINUTES = 30;
const RELOAD_HOUR = 4;
const FALLBACK_TZ = 'Europe/Berlin';
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 INSIDE_H = 412; // hoogte van de middelste rij: 744 - 2x30 padding - 40 kopregel - 232 onderstrook
const $ = (id) => document.getElementById(id);
const stage = $('stage');
const viewport = $('viewport');
const state = {
data: null, // laatste payload, ook als die uit de SW-cache komt
data: null, // laatste payload, ook als die uit de SW-cache komt
tz: FALLBACK_TZ,
lastOk: null, // Date van de laatste geslaagde live-fetch
offlineSince: null // Date vanaf wanneer er geen verbinding is
lastOk: null, // Date van de laatste geslaagde live-fetch
offlineSince: null, // Date vanaf wanneer er geen verbinding is
pinnedRoomId: null, // id van de module die vastgezet is bovenaan; blijft staan tot opnieuw getikt of een volledige herlaad
};
/* ---------- schaal ---------- */
@@ -67,18 +73,59 @@ function renderOutdoor(o) {
setText('max-at', `hoogst om ${formatTime(o.max_at, state.tz)}`);
}
/** Verloop van vandaag naast het grote getal. Met minder dan twee punten valt er niets te tekenen. */
function renderTodayChart(history) {
const block = $('today-chart');
const points = history?.points || [];
if (points.length < 2) {
block.hidden = true;
return;
}
block.hidden = false;
$('today-path').setAttribute('d', sparklinePath(points.map((p) => p.temp), 200, 96, 4));
const ticks = hourTicks(points[0].at, points[points.length - 1].at, state.tz);
$('today-hours').innerHTML = ticks
.map((t) => {
const style = t.percent < 6 ? 'left:0' : `left:${t.percent.toFixed(1)}%;transform:translateX(-50%)`;
return `<span style="${style}">${formatTime(t.at, state.tz)}</span>`;
})
.concat(['<span style="right:0">nu</span>'])
.join('');
}
function renderRooms(rooms) {
const html = rooms.map((r) => {
const ordered = pinRoom(rooms, state.pinnedRoomId);
const html = ordered.map((r) => {
const off = !r.reachable || r.temp === null;
const co2 = co2Level(off ? null : r.co2);
const pill = `<span class="pill ${co2.level} num" title="${co2.label}">${co2.level === 'unknown' ? '–' : r.co2 + ' ppm'}</span>`;
return `<div class="a-room${off ? ' off' : ''}">
const pinned = r.id === state.pinnedRoomId;
const points = r.history || [];
// Zonder twee punten valt er niets te tekenen; dan blijft de plek naast het getal gewoon leeg.
const spark = points.length < 2 ? '' : `<svg class="a-rspark" viewBox="0 0 200 40" preserveAspectRatio="none" aria-hidden="true">
<path class="a-spark-line" d="${sparklinePath(points.map((p) => p.temp), 200, 40, 3)}"/>
</svg>`;
return `<div class="a-room${off ? ' off' : ''}${pinned ? ' pinned' : ''}" data-id="${esc(r.id)}">
<p class="a-rname"><span>${esc(r.name)}</span>${pill}</p>
<p class="a-rtemp num">${off ? '–' : f1(r.temp) + '°'}</p>
<div class="a-rrow"><p class="a-rtemp num">${off ? '–' : f1(r.temp) + '°'}</p>${spark}</div>
<p class="a-rmm num"><span class="cold">↓ ${off ? '–' : f1(r.min) + '°'}</span><span class="warm">↑ ${off ? '–' : f1(r.max) + '°'}</span><span class="rv">RV ${off ? '–' : fPct(r.humidity)}</span></p>
</div>`;
});
$('rooms').innerHTML = html.join('');
fitRooms();
}
/**
* Houdt de modulekolom binnen zijn vaste hoogte. Bij drie modules past alles op ware grootte; komt er een
* module bij (of groeit er een doordat je hem vastzet), dan wordt het geheel net genoeg verkleind. Zonder
* dit duwt een vierde kaart de onderste strook van het scherm af.
*/
function fitRooms() {
const el = $('rooms');
el.style.transform = 'none';
const scale = scaleToFit(el.scrollHeight, INSIDE_H);
el.style.transform = scale < 1 ? `scale(${scale.toFixed(4)})` : '';
}
function renderRain(rain, now) {
@@ -97,14 +144,29 @@ function renderRain(rain, now) {
function renderPressure(p) {
setText('press', fHpa(p.current));
setText('press-trend', trendLabel(p.trend));
const values = p.history.map((h) => h.hpa);
const range = seriesRange(values);
setText('press-range', range ? `${Math.round(range.min)} – ${Math.round(range.max)} hPa in 24 u` : '');
setText('press-max', range ? String(Math.round(range.max)) : '–');
setText('press-min', range ? String(Math.round(range.min)) : '–');
$('press-path').setAttribute('d', sparklinePath(values, 560, 56));
// Hele uren op hun echte tijdspositie, plus "nu" op de rechterrand.
const first = p.history[0];
const last = p.history[p.history.length - 1];
const ticks = first && last ? hourTicks(first.at, last.at, state.tz) : [];
$('press-hours').innerHTML = ticks
.map((t) => {
// Een markering vlak op de linkerrand niet centreren, anders steekt het label buiten de grafiek.
const style = t.percent < 3 ? 'left:0' : `left:${t.percent.toFixed(1)}%;transform:translateX(-50%)`;
return `<span style="${style}">${formatTime(t.at, state.tz)}</span>`;
})
.concat(last ? ['<span style="right:0">nu</span>'] : [])
.join('');
}
function renderForecast(days) {
$('forecast').innerHTML = days.map((d) => `<div class="a-day">
$('forecast').innerHTML = days.map((d) => `<div class="a-day" data-date="${d.date}">
<p class="a-dname">${dayAbbr(d.date, state.tz)}</p>
<div class="a-icon">${icon(wmoIcon(d.weather_code))}</div>
<p class="a-hl num">${fInt(d.temp_max)}°<span>${fInt(d.temp_min)}°</span></p>
@@ -112,6 +174,113 @@ function renderForecast(days) {
</div>`).join('');
}
/* ---------- uuroverzicht per dag ---------- */
let overlayTimer = null;
let openOverlayId = null;
function renderHourlyCharts(hourly) {
const temps = hourly.map((h) => h.temp);
$('ho-temp-path').setAttribute('d', sparklinePath(temps, HOURLY_WIDTH, 64, HOURLY_WIDTH / 24));
$('ho-temp-nums').innerHTML = hourly.map((h) => `<span class="num">${fInt(h.temp)}°</span>`).join('');
const heights = barHeights(hourly.map((h) => h.precip_mm), 40, 2);
$('ho-rain').innerHTML = hourly.map((h, i) => `<div class="ho-rbar">
<i style="height:${heights[i].toFixed(1)}px"></i><span class="num">${fmm(h.precip_mm)}</span>
</div>`).join('');
$('ho-wind').innerHTML = hourly.map((h) => {
const deg = windArrowDeg(h.wind_dir);
const arrow = deg === null
? '<span class="ho-warrow off">–</span>'
: `<span class="ho-warrow" style="transform:rotate(${deg}deg)">${windArrow()}</span>`;
return `<div class="ho-wcell">${arrow}<span class="num">${fKmh(h.wind_kmh)}</span></div>`;
}).join('');
$('ho-hours').innerHTML = hourly.map((h) => `<span class="num">${formatHour(h.at, state.tz)}</span>`).join('');
}
function openHourOverlay(date) {
const day = (state.data?.forecast || []).find((d) => d.date === date);
if (!day) return;
setText('ho-date', formatFullDate(day.date, state.tz));
renderHourlyCharts(day.hourly || []);
showOverlay('hour-overlay', '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 ${OVERLAY_MS}ms linear`;
fill.style.transform = 'scaleX(0)';
$(id).hidden = false;
openOverlayId = id;
overlayTimer = setTimeout(closeOverlay, OVERLAY_MS);
}
function closeOverlay() {
clearTimeout(overlayTimer);
overlayTimer = null;
if (openOverlayId !== null) {
$(openOverlayId).hidden = true;
openOverlayId = null;
}
}
function renderBanner() {
const banner = $('banner');
const errors = state.data ? state.data.errors : [];
@@ -142,6 +311,7 @@ function render() {
if (d) {
renderMeasurement(now);
renderOutdoor(d.outdoor);
renderTodayChart(d.outdoor_history);
renderRooms(d.indoor);
renderRain(d.rain, now);
renderPressure(d.pressure || { current: null, trend: null, history: [] });
@@ -232,9 +402,13 @@ async function requestWakeLock() {
/* ---------- service worker ---------- */
let swRegistration = null;
function registerServiceWorker() {
if (!('serviceWorker' in navigator)) return;
navigator.serviceWorker.register('./sw.js').catch((err) => console.warn('weerscherm: service worker', err));
navigator.serviceWorker.register('./sw.js')
.then((reg) => { swRegistration = reg; })
.catch((err) => console.warn('weerscherm: service worker', err));
let reloading = false;
navigator.serviceWorker.addEventListener('controllerchange', () => {
if (reloading) return;
@@ -243,8 +417,33 @@ function registerServiceWorker() {
});
}
/**
* Vraagt de browser expliciet een nieuwe sw.js te controleren. Op een kiosk-scherm dat nooit navigeert
* (geen tabwissel, geen echte reload) gebeurt dat anders alleen impliciet en onvoorspelbaar laat — soms
* pas bij de volgende dagelijkse 04:00-reload. Met skipWaiting + clients.claim in sw.js activeert een
* gevonden update zich meteen, en de controllerchange-listener hierboven herlaadt de pagina dan vanzelf.
*/
function checkForUpdate() {
swRegistration?.update().catch(() => {});
}
/* ---------- start ---------- */
$('forecast').addEventListener('click', (e) => {
const day = e.target.closest('.a-day');
if (day) openHourOverlay(day.dataset.date);
});
$('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');
if (!room || !state.data) return;
state.pinnedRoomId = state.pinnedRoomId === room.dataset.id ? null : room.dataset.id;
renderRooms(state.data.indoor);
});
window.addEventListener('resize', fit);
window.addEventListener('orientationchange', () => setTimeout(fit, 50));
document.addEventListener('visibilitychange', () => {
@@ -252,7 +451,7 @@ document.addEventListener('visibilitychange', () => {
fit();
refresh();
requestWakeLock();
registerServiceWorker();
checkForUpdate();
}
});
@@ -260,6 +459,7 @@ fit();
render();
refresh();
setInterval(refresh, REFRESH_MS);
setInterval(checkForUpdate, REFRESH_MS);
scheduleClock();
scheduleMidnight();
scheduleReload();
+94 -10
View File
@@ -148,23 +148,107 @@ export function seriesRange(values) {
return { min: Math.min(...nums), max: Math.max(...nums) };
}
/**
* Coördinaten van een reeks binnen een box van w×h met `pad` px marge, geschaald op min..max.
* Een vlakke reeks komt op het midden. `y` is null voor een onbekende waarde; `x` blijft altijd gezet
* zodat labels/staafjes/pijlen op dezelfde plek als de lijn kunnen worden gepositioneerd.
*/
export function sparklinePoints(values, w, h, pad = 3) {
const range = seriesRange(values);
const innerW = w - 2 * pad;
const innerH = h - 2 * pad;
const step = values.length > 1 ? innerW / (values.length - 1) : 0;
const span = range ? range.max - range.min : 0;
return values.map((v, i) => {
const x = pad + i * step;
if (!isNum(v) || !range) return { x, y: null };
const y = span === 0 ? h / 2 : pad + innerH - ((v - range.min) / span) * innerH;
return { x, y };
});
}
/**
* SVG-pad voor een sparkline: punten gelijkmatig over de breedte, waarden geschaald op min..max
* met `pad` px marge. Een vlakke reeks wordt een lijn door het midden. Minder dan 2 punten -> "".
*/
export function sparklinePath(values, w, h, pad = 3) {
const range = seriesRange(values);
if (!range || values.length < 2) return '';
const span = range.max - range.min;
const innerW = w - 2 * pad;
const innerH = h - 2 * pad;
const step = innerW / (values.length - 1);
if (values.length < 2) return '';
const parts = [];
values.forEach((v, i) => {
if (!isNum(v)) return;
const x = pad + i * step;
const y = span === 0 ? h / 2 : pad + innerH - ((v - range.min) / span) * innerH;
sparklinePoints(values, w, h, pad).forEach(({ x, y }) => {
if (y === null) return;
parts.push(`${parts.length === 0 ? 'M' : 'L'}${x.toFixed(1)},${y.toFixed(1)}`);
});
return parts.join(' ');
}
/** Windrichting (meteorologisch: vanwaar de wind komt) -> rotatie zodat de pijl waarheen wijst. */
export function windArrowDeg(dir) {
return isNum(dir) ? (dir + 180) % 360 : null;
}
/** 8.4 -> "8 km/u"; null -> "–" */
export function fKmh(v) {
return isNum(v) ? `${Math.round(v)} km/u` : DASH;
}
/** ISO-tijdstip -> "14" (tweecijferig uur) in de tijdzone; null -> "–" */
export function formatHour(iso, tz) {
const d = iso instanceof Date ? iso : new Date(iso);
if (!iso || Number.isNaN(d.getTime())) return DASH;
const parts = new Intl.DateTimeFormat('en-GB', { hour: '2-digit', hourCycle: 'h23', timeZone: tz }).formatToParts(d);
return parts.find((p) => p.type === 'hour').value;
}
/** "2026-09-14" -> "Maandag 14 september" in de tijdzone (net als formatDate, maar vanuit een datumstring). */
export function formatFullDate(dateStr, tz) {
const d = new Date(dateStr + 'T12:00:00Z');
if (Number.isNaN(d.getTime())) return DASH;
return formatDate(d, tz);
}
/**
* Herschikt kamers zodat de kamer met `pinnedId` vooraan staat; de rest behoudt de onderlinge volgorde.
* Zonder pinnedId, of als die niet voorkomt of al vooraan staat, blijft de oorspronkelijke array (referentie) terug.
*/
export function pinRoom(rooms, pinnedId) {
if (!pinnedId) return rooms;
const index = rooms.findIndex((r) => r.id === pinnedId);
if (index <= 0) return rooms;
const copy = rooms.slice();
const [pinned] = copy.splice(index, 1);
copy.unshift(pinned);
return copy;
}
/**
* Markeringen op hele uren binnen een tijdvenster, uitgelijnd op de klok: elke `stepHours` uur
* gerekend vanaf middernacht in de gegeven tijdzone, dus 00:00, 06:00, 12:00, 18:00 bij een stap van 6.
* Per markering het tijdstip en de positie in procenten van het venster.
*
* Er wordt uur voor uur door het venster gelopen en telkens de lokale klok geraadpleegd, zodat de
* zomertijdwissel vanzelf goed gaat: een dag van 23 of 25 uur levert gewoon de juiste hele uren op.
* Markeringen binnen `minGapPercent` van het einde vallen weg; daar staat het label "nu".
*/
export function hourTicks(startIso, endIso, tz, stepHours = 6, minGapPercent = 9) {
const t0 = new Date(startIso).getTime();
const t1 = new Date(endIso).getTime();
if (Number.isNaN(t0) || Number.isNaN(t1) || t1 <= t0) return [];
const hourMs = 3600000;
const span = t1 - t0;
const ticks = [];
for (let t = Math.ceil(t0 / hourMs) * hourMs; t <= t1; t += hourMs) {
const at = new Date(t).toISOString();
if (Number(formatHour(at, tz)) % stepHours !== 0) continue;
const percent = ((t - t0) / span) * 100;
if (percent > 100 - minGapPercent) break;
ticks.push({ at, percent });
}
return ticks;
}
/** Schaal om `natural` px binnen `available` px te passen; nooit vergroten, nooit negatief. */
export function scaleToFit(natural, available) {
if (!(natural > 0) || !(available > 0)) return 1;
return Math.min(1, available / natural);
}
+5
View File
@@ -30,3 +30,8 @@ export function icon(name) {
const inner = ICONS[name] || ICONS.cloud;
return `<svg class="ic" viewBox="0 0 48 48" aria-hidden="true">${inner}</svg>`;
}
/** Compacte windpijl, wijst standaard naar boven. Draai 'm met een inline transform naar de juiste hoek. */
export function windArrow() {
return '<svg class="wind-ic" viewBox="0 0 24 24" aria-hidden="true"><path d="M12 19V5M6.5 10.5 12 5l5.5 5.5"/></svg>';
}
+64 -8
View File
@@ -27,7 +27,17 @@
<section class="a-hero">
<p class="a-label">Buiten <span class="a-trend" id="trend"></span><span class="a-hum" id="out-hum"></span></p>
<p class="a-temp num" id="temp">–</p>
<div class="a-temprow">
<p class="a-temp num" id="temp">–</p>
<div class="a-today" id="today-chart" hidden>
<p class="a-tcap">Vandaag</p>
<svg class="a-tspark" viewBox="0 0 200 96" preserveAspectRatio="none" aria-hidden="true">
<line class="a-spark-base" x1="0" y1="95.5" x2="200" y2="95.5"/>
<path class="a-spark-line" id="today-path" d=""/>
</svg>
<div class="a-px num" id="today-hours"></div>
</div>
</div>
<div class="a-range">
<div class="a-track"><span class="a-mark" id="mark" style="left:50%"></span></div>
<div class="a-ends num">
@@ -36,18 +46,26 @@
</div>
</div>
<div class="a-pressure">
<p class="a-plabel">Luchtdruk <b class="num" id="press">–</b><span class="a-trend" id="press-trend"></span><span class="a-prange num" id="press-range"></span></p>
<svg class="a-spark" viewBox="0 0 560 56" preserveAspectRatio="none" aria-hidden="true">
<line class="a-spark-base" x1="0" y1="55.5" x2="560" y2="55.5"/>
<path class="a-spark-line" id="press-path" d=""/>
</svg>
<p class="a-plabel">Luchtdruk <b class="num" id="press">–</b><span class="a-trend" id="press-trend"></span></p>
<div class="a-pgrid">
<div class="a-py num"><span id="press-max">–</span><span id="press-min">–</span></div>
<svg class="a-spark" viewBox="0 0 560 56" preserveAspectRatio="none" aria-hidden="true">
<line class="a-spark-grid" x1="140" y1="0" x2="140" y2="56"/>
<line class="a-spark-grid" x1="280" y1="0" x2="280" y2="56"/>
<line class="a-spark-grid" x1="420" y1="0" x2="420" y2="56"/>
<line class="a-spark-base" x1="0" y1="55.5" x2="560" y2="55.5"/>
<path class="a-spark-line" id="press-path" d=""/>
</svg>
<div></div>
<div class="a-px num" id="press-hours"></div>
</div>
</div>
</section>
<section class="a-inside" id="rooms"></section>
<section class="a-inside"><div id="rooms"></div></section>
<section class="a-bottom">
<div>
<div id="rain-block">
<p class="a-sub">Regen</p>
<div class="a-rainrow">
<div>
@@ -62,6 +80,44 @@
</section>
<div class="banner" id="banner">Gegevens laden…</div>
<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>
<span class="ho-hint">tik om te sluiten</span>
</header>
<div class="ho-temp">
<svg class="ho-temp-svg" viewBox="0 0 1045 64" preserveAspectRatio="none" aria-hidden="true">
<path class="ho-temp-line" id="ho-temp-path" d=""/>
</svg>
<div class="ho-grid" id="ho-temp-nums"></div>
</div>
<div class="ho-grid ho-rain" id="ho-rain"></div>
<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>
+89 -11
View File
@@ -62,7 +62,7 @@ html, body {
.warm { color: var(--warm); }
.muted { color: var(--muted); }
.ic { fill: none; stroke: currentColor; stroke-width: 2.4; stroke-linecap: round; stroke-linejoin: round; width: 100%; height: 100%; display: block; }
.ic, .wind-ic { fill: none; stroke: currentColor; stroke-width: 2.4; stroke-linecap: round; stroke-linejoin: round; width: 100%; height: 100%; display: block; }
.i-sun { stroke: var(--sun); }
.i-drop { stroke: var(--rain); }
.i-cloud { fill: var(--iconfill); }
@@ -80,7 +80,14 @@ html, body {
.a-hero { align-self: center; }
.a-label { font-size: 22px; color: var(--muted); font-weight: 500; display: flex; gap: 14px; align-items: baseline; }
.a-trend { font-size: 17px; font-weight: 400; }
.a-temp { font-size: 200px; font-weight: 200; letter-spacing: -.045em; line-height: .95; margin: 4px 0 18px -8px; }
.a-temprow { display: flex; align-items: center; gap: 28px; margin: 4px 0 10px 0; }
.a-temp { font-size: 200px; font-weight: 200; letter-spacing: -.045em; line-height: .95; margin-left: -8px; }
/* Temperatuurverloop van vandaag, in de ruimte rechts van het grote getal */
.a-today { flex: 1; min-width: 0; }
.a-today[hidden] { display: none; }
.a-tcap { font-size: 15px; color: var(--muted); margin-bottom: 4px; }
.a-tspark { display: block; width: 100%; height: 96px; overflow: visible; }
.a-hum { font-size: 17px; font-weight: 400; }
.a-hum:not(:empty)::before { content: "·"; margin-right: 14px; }
.a-range { width: 560px; }
@@ -89,17 +96,23 @@ html, body {
.a-ends { display: flex; justify-content: space-between; margin-top: 14px; font-size: 17px; color: var(--muted); }
.a-ends b { font-size: 24px; font-weight: 600; margin-right: 6px; }
/* Luchtdruk, 24 uur */
.a-pressure { width: 560px; margin-top: 18px; }
/* Luchtdruk, 24 uur. Grafiek met een y-as (hoogste/laagste waarde) en tijdlabels op de x-as. */
.a-pressure { width: 560px; margin-top: 14px; }
.a-plabel { font-size: 17px; color: var(--muted); display: flex; gap: 12px; align-items: baseline; margin-bottom: 6px; }
.a-plabel b { font-size: 20px; font-weight: 600; color: var(--ink); }
.a-prange { margin-left: auto; font-size: 14px; }
.a-spark { display: block; width: 560px; height: 56px; overflow: visible; }
.a-pgrid { display: grid; grid-template-columns: 44px 1fr; column-gap: 8px; row-gap: 3px; }
.a-py { display: flex; flex-direction: column; justify-content: space-between; text-align: right; font-size: 13px; color: var(--muted); line-height: 1; padding: 1px 0; }
.a-px { position: relative; height: 17px; font-size: 13px; color: var(--muted); }
.a-px span { position: absolute; top: 0; white-space: nowrap; }
.a-spark { display: block; width: 100%; height: 56px; overflow: visible; }
.a-spark-base { stroke: var(--line); stroke-width: 1; }
.a-spark-grid { stroke: var(--line); stroke-width: 1; vector-effect: non-scaling-stroke; opacity: .6; }
.a-spark-line { fill: none; stroke: var(--rain); stroke-width: 2.4; stroke-linecap: round; stroke-linejoin: round; vector-effect: non-scaling-stroke; }
/* Binnen */
.a-inside { align-self: center; }
/* Binnen. Vaste hoogte met overflow verborgen: hoeveel modules er ook zijn, de kolom duwt nooit de
onderste strook van het scherm af. Past de inhoud niet, dan verkleint fitRooms() hem met een schaal. */
.a-inside { align-self: center; height: 412px; display: flex; align-items: center; overflow: hidden; }
#rooms { width: 100%; transform-origin: center center; }
.a-room { padding: 12px 0; border-top: 1px solid var(--line); }
.a-room:first-child { border-top: 0; }
.a-rname { font-size: 19px; color: var(--muted); display: flex; justify-content: space-between; align-items: center; }
@@ -108,8 +121,13 @@ html, body {
.pill.medium { background: var(--warm); }
.pill.bad { background: var(--bad); }
.pill.unknown { background: var(--line); color: var(--muted); }
.a-rtemp { font-size: 50px; font-weight: 300; line-height: 1.05; }
.a-rrow { display: flex; align-items: center; gap: 16px; }
.a-rtemp { font-size: 50px; font-weight: 300; line-height: 1.05; transition: font-size .25s ease; flex: none; }
/* Overal even hoog: zo zijn de kamers onderling vergelijkbaar, en groeit een vastgezette kaart niet
zo ver door dat de speling onder de kolom opraakt. */
.a-rspark { flex: 1; min-width: 0; height: 40px; overflow: visible; }
.a-room.off .a-rtemp { color: var(--muted); }
.a-room.pinned .a-rtemp { font-size: 92px; }
.a-rmm { font-size: 17px; display: flex; gap: 18px; }
.a-rmm .rv { color: var(--muted); }
@@ -129,11 +147,11 @@ html, body {
.a-bar.now span { color: var(--ink); font-weight: 600; }
/* Verwachting */
.a-fc { display: grid; grid-template-columns: repeat(5, 1fr); text-align: center; }
.a-fc { display: grid; grid-template-columns: repeat(6, 1fr); text-align: center; }
.a-day { display: flex; flex-direction: column; align-items: center; gap: 6px; border-left: 1px solid var(--line); }
.a-day:first-child { border-left: 0; }
.a-dname { font-size: 19px; font-weight: 600; }
.a-icon { width: 58px; height: 58px; }
.a-icon { width: 50px; height: 50px; }
.a-hl { font-size: 28px; font-weight: 500; }
.a-hl span { font-size: 22px; color: var(--muted); font-weight: 400; margin-left: 6px; }
.a-fr { font-size: 16px; color: var(--rain); }
@@ -162,6 +180,66 @@ html, body {
}
.banner small { display: block; margin-top: 16px; font-size: 20px; color: var(--muted); }
/* 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;
top: 40px;
bottom: 40px;
display: flex;
flex-direction: column;
gap: 22px;
padding: 26px 0 22px;
background: var(--bg);
border: 1px solid var(--line);
border-radius: 18px;
box-shadow: 0 12px 32px rgba(27, 42, 53, .12);
z-index: 5;
}
.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; }
.ho-head { margin: 0 30px; display: flex; justify-content: space-between; align-items: baseline; }
.ho-date { font-size: 22px; font-weight: 500; }
.ho-hint { font-size: 16px; color: var(--muted); }
.ho-grid { display: grid; grid-template-columns: repeat(12, 1fr); text-align: center; }
.ho-temp { display: flex; flex-direction: column; gap: 6px; }
#ho-temp-nums .num { font-size: 22px; font-weight: 500; }
.ho-temp-svg { display: block; width: 100%; height: 64px; }
.ho-temp-line { fill: none; stroke: var(--ink); stroke-width: 2.4; stroke-linecap: round; stroke-linejoin: round; vector-effect: non-scaling-stroke; }
.ho-rain .ho-rbar { display: flex; flex-direction: column; align-items: center; justify-content: flex-end; gap: 6px; height: 66px; }
.ho-rbar i { display: block; width: 14px; border-radius: 3px 3px 0 0; background: var(--rain); opacity: .55; }
.ho-rbar .num { font-size: 14px; color: var(--muted); }
.ho-wind .ho-wcell { display: flex; flex-direction: column; align-items: center; gap: 6px; }
.ho-warrow { display: flex; align-items: center; justify-content: center; width: 26px; height: 26px; color: var(--muted); }
.ho-warrow.off { font-size: 18px; }
.ho-wcell .num { font-size: 15px; color: var(--muted); }
.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;
+17 -9
View File
@@ -1,6 +1,6 @@
/* Service worker: app-shell cache-first, API network-first met timeout en fallback op de laatste response. */
const VERSION = 'weerscherm-v2';
const VERSION = 'weerscherm-v12';
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 });
}
return new Response(JSON.stringify({ errors: ['offline'] }), {
status: 503,
headers: { 'Content-Type': 'application/json; charset=utf-8', 'Cache-Control': 'no-store' },
});
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);
}
+35
View File
@@ -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;
}
}
+2
View File
@@ -19,7 +19,9 @@ final class Config
'stations' => 300,
'measure' => 900,
'pressure' => 900,
'temperature' => 900,
'forecast' => 3600,
'radar' => 240,
],
'storage_path' => null,
];
+85 -10
View File
@@ -10,6 +10,8 @@ namespace Weerscherm;
*/
final class DashboardService
{
private const PRESSURE_HOURS = 24;
private const ERR_NETATMO_AUTH = 'netatmo_auth';
private const ERR_NETATMO = 'netatmo_unavailable';
private const ERR_FORECAST = 'forecast_unavailable';
@@ -48,6 +50,7 @@ final class DashboardService
$rain = $this->rain($station, $now);
$pressure = $this->pressure($station, $now);
$outdoorHistory = $this->outdoorHistory($station, $now);
$forecast = $this->forecast($station, $tz);
return [
@@ -66,7 +69,8 @@ final class DashboardService
'max_at' => $this->iso($station['outdoor']['max_at'], $tz),
'humidity' => $station['outdoor']['humidity'],
],
'indoor' => $station['indoor'],
'outdoor_history' => $outdoorHistory,
'indoor' => $this->indoorWithHistory($station['indoor'], $station['device_id'], $now),
'rain' => $rain,
'pressure' => $pressure,
'forecast' => $forecast['days'],
@@ -101,18 +105,73 @@ final class DashboardService
if ($station['device_id'] === null) {
return $base + ['history' => [], 'stale' => false];
}
$begin = $now->getTimestamp() - PressureHistory::HOURS * 3600;
$begin = $now->getTimestamp() - self::PRESSURE_HOURS * 3600;
$measure = $this->fetch(
'pressure',
fn() => $this->source->pressureHistory($station['device_id'], $begin, $now->getTimestamp()),
self::ERR_NETATMO,
);
return $base + [
'history' => $measure['data'] === null ? [] : PressureHistory::map($measure['data'], $now->getTimezone()),
'history' => $measure['data'] === null ? [] : MeasureSeries::map($measure['data'], $now->getTimezone(), 'hpa'),
'stale' => $measure['stale'],
];
}
/**
* Temperatuurverloop van vandaag: vanaf middernacht lokale tijd tot nu.
*
* @return array{points: list<array{at: string, temp: float}>, stale: bool}
*/
private function outdoorHistory(array $station, \DateTimeImmutable $now): array
{
$moduleId = $station['outdoor_module_id'];
if ($station['device_id'] === null || $moduleId === null) {
return ['points' => [], 'stale' => false];
}
return $this->temperatureHistory($station['device_id'], $moduleId, $now);
}
/**
* Temperatuurverloop van vandaag voor één module, apart gecachet per module.
*
* @return array{points: list<array{at: string, temp: float}>, stale: bool}
*/
private function temperatureHistory(string $deviceId, ?string $moduleId, \DateTimeImmutable $now): array
{
$begin = $now->setTime(0, 0, 0)->getTimestamp();
$measure = $this->fetch(
'temperature-' . ($moduleId ?? $deviceId),
fn() => $this->source->temperatureHistory($deviceId, $moduleId, $begin, $now->getTimestamp()),
self::ERR_NETATMO,
'temperature',
);
return [
'points' => $measure['data'] === null ? [] : MeasureSeries::map($measure['data'], $now->getTimezone(), 'temp'),
'stale' => $measure['stale'],
];
}
/**
* Zet het temperatuurverloop van vandaag bij elke binnenmodule. Mislukt er één, dan blijft die lijst
* leeg (het grafiekje verdwijnt) en staat de foutcode al in errors; een eigen stale-vlag per kamer
* zou het contract nodeloos zwaar maken.
*
* @param list<array<string, mixed>> $rooms
* @return list<array<string, mixed>>
*/
private function indoorWithHistory(array $rooms, ?string $deviceId, \DateTimeImmutable $now): array
{
if ($deviceId === null) {
return $rooms;
}
foreach ($rooms as &$room) {
$moduleId = $room['id'] === $deviceId ? null : $room['id'];
$room['history'] = $this->temperatureHistory($deviceId, $moduleId, $now)['points'];
}
unset($room);
return $rooms;
}
/** @return array{days: list<array>, stale: bool} */
private function forecast(array $station, \DateTimeZone $tz): array
{
@@ -122,23 +181,39 @@ final class DashboardService
$this->addError(self::ERR_FORECAST);
return ['days' => [], 'stale' => true];
}
$daily = $this->fetch('forecast', fn() => $this->source->forecast($lat, $lon, $tz->getName()), self::ERR_FORECAST);
return [
'days' => $daily['data'] === null ? [] : ForecastMapper::map($daily['data']),
'stale' => $daily['stale'],
];
$result = $this->fetch('forecast', fn() => $this->source->forecast($lat, $lon, $tz->getName()), self::ERR_FORECAST);
$daily = is_array($result['data']['daily'] ?? null) ? $result['data']['daily'] : null;
if ($daily === null) {
// Data ontbreekt (echte storing, al gemeld door fetch()), of de cache staat nog in een ouder
// formaat na een deploy dat de opgeslagen vorm wijzigde. In beide gevallen liever zichtbaar
// niets tonen dan stil een lege verwachting zonder foutmelding.
if ($result['data'] !== null) {
$this->addError(self::ERR_FORECAST);
}
return ['days' => [], 'stale' => true];
}
$days = ForecastMapper::map($daily);
$hourlyByDate = HourlyForecastMapper::mapByDate($result['data']['hourly'] ?? [], $tz);
foreach ($days as &$day) {
$day['hourly'] = $hourlyByDate[$day['date']] ?? [];
}
unset($day);
return ['days' => $days, 'stale' => $result['stale']];
}
/**
* Cache-first per bron. Bij een fout: stale cache (ongeacht leeftijd) en een foutcode, anders null.
*
* @param callable(): array $fetcher
* @param ?string $ttlKey losse TTL-sleutel wanneer de cachesleutel per module verschilt
* @return array{data: ?array, stale: bool}
*/
private function fetch(string $key, callable $fetcher, string $errorCode): array
private function fetch(string $key, callable $fetcher, string $errorCode, ?string $ttlKey = null): array
{
$now = ($this->now ?? new \DateTimeImmutable('now'))->getTimestamp();
$ttl = $this->ttl[$key] ?? 0;
$ttl = $this->ttl[$ttlKey ?? $key] ?? 0;
if ($ttl > 0 && $this->cache->isFresh($key, $ttl, $now)) {
return ['data' => $this->cache->read($key)['data'], 'stale' => false];
}
+67 -9
View File
@@ -38,6 +38,8 @@ final class FixtureSource implements WeatherSource
'dashboard_data' => ['time_utc' => $at('-4 minutes'), 'Temperature' => 18.9, 'CO2' => 1180, 'Humidity' => 52, 'min_temp' => 18.1, 'max_temp' => 19.6, 'temp_trend' => 'up']],
['_id' => '03:00:00:00:00:03', 'type' => 'NAModule4', 'module_name' => 'Kantoor', 'reachable' => true,
'dashboard_data' => ['time_utc' => $at('-4 minutes'), 'Temperature' => 20.2, 'CO2' => 1640, 'Humidity' => 44, 'min_temp' => 19.0, 'max_temp' => 21.0, 'temp_trend' => 'stable']],
['_id' => '03:00:00:00:00:05', 'type' => 'NAModule4', 'module_name' => 'Zolder', 'reachable' => true,
'dashboard_data' => ['time_utc' => $at('-5 minutes'), 'Temperature' => 17.4, 'CO2' => 780, 'Humidity' => 55, 'min_temp' => 16.2, 'max_temp' => 18.0, 'temp_trend' => 'up']],
['_id' => '05:00:00:00:00:04', 'type' => 'NAModule3', 'module_name' => 'Regenmeter', 'reachable' => true,
'dashboard_data' => ['time_utc' => $at('-3 minutes'), 'Rain' => 0, 'sum_rain_1' => 0, 'sum_rain_24' => 2.4]],
],
@@ -61,25 +63,81 @@ final class FixtureSource implements WeatherSource
{
$body = [];
for ($ts = $begin - ($begin % 1800); $ts <= $end; $ts += 1800) {
$i = ($end - $ts) / 1800;
// Hele halfuren sinds het einde; intdiv houdt $i een int, zodat de modulo hieronder geen
// deprecation geeft (en de waarschuwing niet vóór de JSON in de response belandt).
$i = intdiv($end - $ts, 1800);
$body[(string) $ts] = [round(1012 + 3 * sin($i / 8) + ($i % 5) * 0.1, 1)];
}
return $body;
}
public function temperatureHistory(string $deviceId, ?string $moduleId, int $begin, int $end): array
{
// Dagcurve tussen de min en max die stationsData() voor deze module meldt: koudst rond 06:00,
// warmst rond 15:00. Binnen beweegt minder en later dan buiten, zodat de grafiekjes verschillen.
[$min, $max, $shift] = match ($moduleId) {
'02:00:00:00:00:01' => [9.2, 19.8, 10], // buiten
'03:00:00:00:00:02' => [18.1, 19.6, 13], // slaapkamer
'03:00:00:00:00:03' => [19.0, 21.0, 14], // kantoor
'03:00:00:00:00:05' => [16.2, 18.0, 15], // zolder
default => [20.4, 22.1, 12], // basisstation
};
$body = [];
for ($ts = $begin - ($begin % 1800); $ts <= $end; $ts += 1800) {
if ($ts < $begin) {
continue;
}
$at = (new \DateTimeImmutable('@' . $ts))->setTimezone($this->now->getTimezone());
$hour = (int) $at->format('G') + (int) $at->format('i') / 60;
$t = $min + ($max - $min) * (0.5 + 0.5 * sin((($hour - $shift) / 24) * 2 * M_PI));
$body[(string) $ts] = [round($t, 1)];
}
return $body;
}
public function forecast(float $lat, float $lon, string $timezone): array
{
$tmin = [11.0, 10.1, 11.3, 12.0, 9.4, 8.1];
$tmax = [17.9, 18.3, 20.1, 17.4, 15.2, 16.0];
$precipDay = [0.0, 0.4, 0.0, 8.2, 3.1, 0.2];
$dates = [];
for ($i = 0; $i < 6; $i++) {
$dates[] = $this->now->modify("+$i days")->format('Y-m-d');
$hourlyTime = [];
$hourlyTemp = [];
$hourlyPrecip = [];
$hourlyWind = [];
$hourlyDir = [];
for ($d = 0; $d < 6; $d++) {
$day = $this->now->modify("+$d days");
$dates[] = $day->format('Y-m-d');
for ($h = 0; $h < 24; $h++) {
$hourlyTime[] = $day->setTime($h, 0)->format('Y-m-d\TH:i');
$t = $tmin[$d] + ($tmax[$d] - $tmin[$d]) * (0.5 + 0.5 * sin((($h - 9) / 24) * 2 * M_PI));
$hourlyTemp[] = round($t, 1);
$p = ($precipDay[$d] > 0 && $h >= 14 && $h <= 16) ? round($precipDay[$d] / 3, 1) : 0.0;
$hourlyPrecip[] = $p;
$hourlyWind[] = round(3 + 6 * (0.5 + 0.5 * sin((($h + $d * 3) / 24) * 2 * M_PI)), 1);
$hourlyDir[] = ($d * 40 + $h * 7) % 360;
}
}
return [
'time' => $dates,
'weather_code' => [3, 2, 0, 61, 80, 95],
'temperature_2m_max' => [17.9, 18.3, 20.1, 17.4, 15.2, 16.0],
'temperature_2m_min' => [11.0, 10.1, 11.3, 12.0, 9.4, 8.1],
'precipitation_sum' => [0.0, 0.4, 0.0, 8.2, 3.1, 0.2],
'precipitation_probability_max' => [10, 20, 5, 80, 60, 15],
'daily' => [
'time' => $dates,
'weather_code' => [3, 2, 0, 61, 80, 95],
'temperature_2m_max' => $tmax,
'temperature_2m_min' => $tmin,
'precipitation_sum' => $precipDay,
'precipitation_probability_max' => [10, 20, 5, 80, 60, 15],
],
'hourly' => [
'time' => $hourlyTime,
'temperature_2m' => $hourlyTemp,
'precipitation' => $hourlyPrecip,
'wind_speed_10m' => $hourlyWind,
'wind_direction_10m' => $hourlyDir,
],
];
}
}
+3 -3
View File
@@ -4,10 +4,10 @@ declare(strict_types=1);
namespace Weerscherm;
/** Zet het "daily"-blok van Open-Meteo om naar 5 dagen vanaf morgen (index 0 is vandaag). */
/** Zet het "daily"-blok van Open-Meteo om naar 6 dagen, inclusief vandaag (index 0). */
final class ForecastMapper
{
private const DAYS = 5;
private const DAYS = 6;
/**
* @param array<string, mixed> $daily
@@ -17,7 +17,7 @@ final class ForecastMapper
{
$dates = is_array($daily['time'] ?? null) ? $daily['time'] : [];
$out = [];
for ($i = 1; $i <= self::DAYS && isset($dates[$i]); $i++) {
for ($i = 0; $i < self::DAYS && isset($dates[$i]); $i++) {
if (!is_string($dates[$i])) {
continue;
}
+74
View File
@@ -0,0 +1,74 @@
<?php
declare(strict_types=1);
namespace Weerscherm;
/**
* Zet het "hourly"-blok van Open-Meteo om naar uurpunten per kalenderdatum, om het andere uur.
*
* Groepeert eerst chronologisch per datum in de gegeven tijdzone, en neemt daarna om het andere
* punt (index 0, 2, 4, …) binnen die dag. Dat is robuust voor de zomertijddag: een dag met 23 of 25
* uren levert vanzelf iets minder of meer dan 12 punten op, zonder speciale wissel-logica.
*/
final class HourlyForecastMapper
{
private const STEP = 2;
/**
* @param array<string, mixed> $hourly
* @return array<string, list<array{at: string, temp: ?float, precip_mm: ?float, wind_kmh: ?float, wind_dir: ?int}>>
* sleutel is de kalenderdatum (Y-m-d) in $tz
*/
public static function mapByDate(array $hourly, \DateTimeZone $tz): array
{
$times = is_array($hourly['time'] ?? null) ? $hourly['time'] : [];
$perDate = [];
foreach ($times as $i => $iso) {
if (!is_string($iso)) {
continue;
}
$at = self::parse($iso, $tz);
if ($at === null) {
continue;
}
$perDate[$at->format('Y-m-d')][] = [
'at' => $at->format(\DATE_ATOM),
'temp' => self::floatOrNull($hourly['temperature_2m'][$i] ?? null),
'precip_mm' => self::floatOrNull($hourly['precipitation'][$i] ?? null),
'wind_kmh' => self::floatOrNull($hourly['wind_speed_10m'][$i] ?? null),
'wind_dir' => self::intOrNull($hourly['wind_direction_10m'][$i] ?? null),
];
}
$out = [];
foreach ($perDate as $date => $points) {
$sampled = [];
foreach (array_values($points) as $index => $point) {
if ($index % self::STEP === 0) {
$sampled[] = $point;
}
}
$out[$date] = $sampled;
}
return $out;
}
/** Open-Meteo levert lokale tijd zonder offset, bv. "2026-09-14T00:00". */
private static function parse(string $iso, \DateTimeZone $tz): ?\DateTimeImmutable
{
$dt = \DateTimeImmutable::createFromFormat('Y-m-d\TH:i', $iso, $tz);
return $dt === false ? null : $dt;
}
private static function floatOrNull(mixed $v): ?float
{
return is_int($v) || is_float($v) ? (float) $v : null;
}
private static function intOrNull(mixed $v): ?int
{
return is_int($v) || is_float($v) ? (int) round($v) : null;
}
}
+12 -3
View File
@@ -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),
+6 -1
View File
@@ -39,8 +39,13 @@ final class LiveSource implements WeatherSource
return $this->netatmo->getPressureHistory($deviceId, $begin, $end);
}
public function temperatureHistory(string $deviceId, ?string $moduleId, int $begin, int $end): array
{
return $this->netatmo->getTemperatureHistory($deviceId, $moduleId, $begin, $end);
}
public function forecast(float $lat, float $lon, string $timezone): array
{
return $this->openMeteo->dailyForecast($lat, $lon, $timezone);
return $this->openMeteo->forecast($lat, $lon, $timezone);
}
}
+38
View File
@@ -0,0 +1,38 @@
<?php
declare(strict_types=1);
namespace Weerscherm;
/**
* Zet een getmeasure-body (één waarde per tijdstip, bv. scale=30min met type=Pressure of Temperature)
* om naar een chronologisch gesorteerde reeks punten. De naam van het waardeveld verschilt per meting,
* zodat de payload "hpa" of "temp" kan heten.
*/
final class MeasureSeries
{
/**
* @param array<string|int, mixed> $body
* @return list<array{at: string, ...}> elk punt heeft "at" en het veld $valueKey
*/
public static function map(array $body, \DateTimeZone $tz, string $valueKey): array
{
$points = [];
foreach ($body as $timestamp => $values) {
if (!is_numeric($timestamp) || !is_array($values) || !isset($values[0]) || !is_numeric($values[0])) {
continue;
}
$points[(int) $timestamp] = (float) $values[0];
}
ksort($points);
$out = [];
foreach ($points as $ts => $value) {
$out[] = [
'at' => (new \DateTimeImmutable('@' . $ts))->setTimezone($tz)->format(\DATE_ATOM),
$valueKey => $value,
];
}
return $out;
}
}
+27 -4
View File
@@ -60,17 +60,40 @@ final class NetatmoClient
/** @return array<string|int, mixed> timestamp => [hPa], per 30 minuten, van het basisstation */
public function getPressureHistory(string $deviceId, int $begin, int $end): array
{
$json = $this->apiGet('getmeasure', [
'device_id' => $deviceId,
return $this->measureSeries('Pressure', ['device_id' => $deviceId], $begin, $end);
}
/**
* @param ?string $moduleId null meet het basisstation zelf; Netatmo verwacht dan geen module_id
* @return array<string|int, mixed> timestamp => [°C], per 30 minuten
*/
public function getTemperatureHistory(string $deviceId, ?string $moduleId, int $begin, int $end): array
{
$target = ['device_id' => $deviceId];
if ($moduleId !== null) {
$target['module_id'] = $moduleId;
}
return $this->measureSeries('Temperature', $target, $begin, $end);
}
/**
* Eén meetreeks per 30 minuten ophalen. $target bevat device_id en eventueel module_id.
*
* @param array<string, string> $target
* @return array<string|int, mixed>
*/
private function measureSeries(string $type, array $target, int $begin, int $end): array
{
$json = $this->apiGet('getmeasure', $target + [
'scale' => '30min',
'type' => 'Pressure',
'type' => $type,
'date_begin' => $begin,
'date_end' => $end,
'optimize' => 'false',
'real_time' => 'false',
]);
if (!is_array($json['body'] ?? null)) {
throw new UpstreamException('getmeasure (Pressure): geen body in antwoord');
throw new UpstreamException("getmeasure ($type): geen body in antwoord");
}
return $json['body'];
}
+7 -3
View File
@@ -13,13 +13,14 @@ final class OpenMeteoClient
{
}
/** @return array<string, mixed> het "daily"-blok */
public function dailyForecast(float $lat, float $lon, string $timezone): array
/** @return array{daily: array<string, mixed>, hourly: array<string, mixed>} */
public function forecast(float $lat, float $lon, string $timezone): array
{
$res = $this->http->get(self::URL, [
'latitude' => $lat,
'longitude' => $lon,
'daily' => 'weather_code,temperature_2m_max,temperature_2m_min,precipitation_sum,precipitation_probability_max',
'hourly' => 'temperature_2m,precipitation,wind_speed_10m,wind_direction_10m',
'timezone' => $timezone,
'forecast_days' => 6,
]);
@@ -30,6 +31,9 @@ final class OpenMeteoClient
if (!is_array($json['daily'] ?? null)) {
throw new UpstreamException('Open-Meteo: geen daily-blok in antwoord');
}
return $json['daily'];
return [
'daily' => $json['daily'],
'hourly' => is_array($json['hourly'] ?? null) ? $json['hourly'] : [],
];
}
}
-33
View File
@@ -1,33 +0,0 @@
<?php
declare(strict_types=1);
namespace Weerscherm;
/** Zet een getmeasure-body (scale=30min, type=Pressure) om naar een gesorteerde reeks punten. */
final class PressureHistory
{
public const HOURS = 24;
/**
* @param array<string|int, mixed> $body
* @return list<array{at: string, hpa: float}>
*/
public static function map(array $body, \DateTimeZone $tz): array
{
$points = [];
foreach ($body as $timestamp => $values) {
if (!is_numeric($timestamp) || !is_array($values) || !isset($values[0]) || !is_numeric($values[0])) {
continue;
}
$points[(int) $timestamp] = (float) $values[0];
}
ksort($points);
$out = [];
foreach ($points as $ts => $hpa) {
$out[] = ['at' => (new \DateTimeImmutable('@' . $ts))->setTimezone($tz)->format(\DATE_ATOM), 'hpa' => $hpa];
}
return $out;
}
}
+75
View File
@@ -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}");
}
}
}
+3 -2
View File
@@ -20,7 +20,7 @@ final class StationMapper
* outdoor: array{temp: ?float, trend: ?string, min: ?float, min_at: ?int, max: ?float, max_at: ?int, humidity: ?float},
* indoor: list<array{id: string, name: string, temp: ?float, min: ?float, max: ?float, humidity: ?float, co2: ?int, reachable: bool}>,
* pressure: array{current: ?float, trend: ?string},
* rain_module_id: ?string, sum_rain_24: ?float}
* outdoor_module_id: ?string, rain_module_id: ?string, sum_rain_24: ?float}
*/
public static function map(array $body, ?string $deviceId, array $indoorModules): array
{
@@ -29,7 +29,7 @@ final class StationMapper
return [
'timezone' => null, 'lat' => null, 'lon' => null, 'device_id' => null, 'measured_at' => null,
'outdoor' => self::EMPTY_OUTDOOR, 'indoor' => [], 'pressure' => self::EMPTY_PRESSURE,
'rain_module_id' => null, 'sum_rain_24' => null,
'outdoor_module_id' => null, 'rain_module_id' => null, 'sum_rain_24' => null,
];
}
@@ -50,6 +50,7 @@ final class StationMapper
'current' => self::floatOrNull(self::dashboard($device)['Pressure'] ?? null),
'trend' => self::stringOrNull(self::dashboard($device)['pressure_trend'] ?? null),
],
'outdoor_module_id' => $outdoor === null ? null : self::stringOrNull($outdoor['_id'] ?? null),
'rain_module_id' => $rain === null ? null : self::stringOrNull($rain['_id'] ?? null),
'sum_rain_24' => self::floatOrNull(self::dashboard($rain)['sum_rain_24'] ?? null),
];
+7 -1
View File
@@ -16,6 +16,12 @@ interface WeatherSource
/** @return array<string|int, mixed> de "body" van Netatmo getmeasure (timestamp => [hPa]) voor het basisstation */
public function pressureHistory(string $deviceId, int $begin, int $end): array;
/** @return array<string, mixed> het "daily"-blok van Open-Meteo */
/**
* @param ?string $moduleId null meet het basisstation zelf
* @return array<string|int, mixed> de "body" van Netatmo getmeasure (timestamp => [°C])
*/
public function temperatureHistory(string $deviceId, ?string $moduleId, int $begin, int $end): array;
/** @return array{daily: array<string, mixed>, hourly: array<string, mixed>} de "daily"- en "hourly"-blokken van Open-Meteo */
public function forecast(float $lat, float $lon, string $timezone): array;
}
+44
View 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();
}
}
+2 -2
View File
@@ -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, 'temperature' => 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, 'temperature' => 900, 'forecast' => 3600, 'radar' => 240], $config->get('cache_ttl'));
}
public function testUnknownKeyReturnsGivenDefault(): void
+84 -5
View File
@@ -32,7 +32,7 @@ final class DashboardServiceTest extends TestCase
return new DashboardService(
$this->source,
new Cache($this->dir),
['stations' => 300, 'measure' => 900, 'pressure' => 900, 'forecast' => 3600],
['stations' => 300, 'measure' => 900, 'pressure' => 900, 'temperature' => 900, 'forecast' => 3600],
'Europe/Amsterdam',
$lat,
$lon,
@@ -46,7 +46,7 @@ final class DashboardServiceTest extends TestCase
{
$p = $this->service()->build();
self::assertSame(['generated_at', 'timezone', 'station', 'outdoor', 'indoor', 'rain', 'pressure', 'forecast', 'forecast_stale', 'errors'], array_keys($p));
self::assertSame(['generated_at', 'timezone', 'station', 'outdoor', 'outdoor_history', 'indoor', 'rain', 'pressure', 'forecast', 'forecast_stale', 'errors'], array_keys($p));
self::assertSame('2026-09-11T14:32:05+02:00', $p['generated_at']);
self::assertSame('Europe/Berlin', $p['timezone']);
self::assertSame(['measured_at' => '2026-09-11T14:30:12+02:00', 'stale' => false], $p['station']);
@@ -63,8 +63,11 @@ final class DashboardServiceTest extends TestCase
self::assertSame(['2026-09-08', '2026-09-09', '2026-09-10'], array_column($p['rain']['previous_days'], 'date'));
self::assertEqualsWithDelta(38.5, $p['rain']['month_total'], 0.0001);
self::assertFalse($p['rain']['stale']);
self::assertCount(5, $p['forecast']);
self::assertSame('2026-09-12', $p['forecast'][0]['date']);
self::assertCount(6, $p['forecast']);
self::assertSame('2026-09-11', $p['forecast'][0]['date']);
self::assertCount(12, $p['forecast'][0]['hourly']);
self::assertSame(['at', 'temp', 'precip_mm', 'wind_kmh', 'wind_dir'], array_keys($p['forecast'][0]['hourly'][0]));
self::assertSame('2026-09-11T00:00:00+02:00', $p['forecast'][0]['hourly'][0]['at']);
self::assertFalse($p['forecast_stale']);
self::assertSame([], $p['errors']);
}
@@ -80,6 +83,63 @@ final class DashboardServiceTest extends TestCase
self::assertSame((new \DateTimeImmutable('2026-09-11 12:32:05 UTC'))->getTimestamp(), $end);
}
public function testOutdoorHistoryCoversTodayFromLocalMidnight(): void
{
$p = $this->service()->build();
[$deviceId, $moduleId, $begin, $end] = $this->source->allArgs['temperature'][0];
self::assertSame('70:ee:50:00:00:01', $deviceId);
self::assertSame('02:00:00:00:00:01', $moduleId, 'de buitenmodule, niet het basisstation');
self::assertSame((new \DateTimeImmutable('2026-09-11 00:00', new \DateTimeZone('Europe/Berlin')))->getTimestamp(), $begin);
self::assertSame((new \DateTimeImmutable('2026-09-11 12:32:05 UTC'))->getTimestamp(), $end);
self::assertNotEmpty($p['outdoor_history']['points']);
self::assertSame(['at', 'temp'], array_keys($p['outdoor_history']['points'][0]));
self::assertFalse($p['outdoor_history']['stale']);
}
public function testEveryIndoorModuleGetsItsOwnHistory(): void
{
$p = $this->service()->build();
foreach ($p['indoor'] as $room) {
self::assertNotEmpty($room['history'], "kamer {$room['name']} heeft een verloop");
self::assertSame(['at', 'temp'], array_keys($room['history'][0]));
}
// Het basisstation meet zelf; dat vraag je op zonder module_id.
$modules = array_map(fn(array $args) => $args[1], $this->source->allArgs['temperature']);
self::assertSame(
['02:00:00:00:00:01', null, '03:00:00:00:00:02', '03:00:00:00:00:03'],
$modules,
'buiten, dan het basisstation zonder module_id, dan de twee losse modules',
);
}
public function testIndoorHistoryFailureOnlyEmptiesThatList(): void
{
$this->source->responses['temperature'] = new UpstreamException('500');
$p = $this->service()->build();
foreach ($p['indoor'] as $room) {
self::assertSame([], $room['history']);
}
self::assertSame(21.3, $p['indoor'][0]['temp'], 'de actuele waarden komen uit stationsdata en blijven staan');
self::assertSame(['netatmo_unavailable'], $p['errors']);
}
public function testOutdoorHistoryFailureLeavesTheRestIntact(): void
{
$this->source->responses['temperature'] = new UpstreamException('500');
$p = $this->service()->build();
self::assertSame(['points' => [], 'stale' => true], $p['outdoor_history']);
self::assertSame(16.4, $p['outdoor']['temp'], 'de actuele waarden komen uit stationsdata en blijven staan');
self::assertSame(['netatmo_unavailable'], $p['errors']);
}
public function testPressureHistoryIsRequestedForLast24Hours(): void
{
$this->service()->build();
@@ -127,6 +187,8 @@ final class DashboardServiceTest extends TestCase
self::assertNull($p['rain']['today']);
self::assertSame(0, $this->source->calls['measure'], 'zonder regenmeter-id geen getmeasure-call');
self::assertSame(0, $this->source->calls['pressure'], 'zonder device-id geen luchtdruk-call');
self::assertSame(0, $this->source->calls['temperature'], 'zonder buitenmodule geen temperatuur-call');
self::assertSame(['points' => [], 'stale' => false], $p['outdoor_history']);
self::assertSame(['current' => null, 'trend' => null, 'history' => [], 'stale' => false], $p['pressure']);
self::assertSame('Europe/Amsterdam', $p['timezone'], 'fallback-tijdzone uit config');
self::assertSame(['netatmo_unavailable', 'forecast_unavailable'], $p['errors']);
@@ -179,15 +241,32 @@ final class DashboardServiceTest extends TestCase
self::assertSame(16.4, $p['outdoor']['temp']);
}
public function testLegacyCachedForecastShapeIsTreatedAsUnavailableNotSilentlyEmpty(): void
{
// Vóór 2026-09-13 cachete DashboardService het platte "daily"-blok direct onder de sleutel
// "forecast". Na de uitbreiding met uurdata verwacht de code {daily, hourly}. Een nog niet
// verlopen cache in het oude formaat mag niet stil een lege verwachting zonder foutmelding geven.
$legacyDailyOnly = json_decode((string) file_get_contents(__DIR__ . '/fixtures/forecast.json'), true)['daily'];
(new Cache($this->dir))->write('forecast', $legacyDailyOnly, (new \DateTimeImmutable('2026-09-11 12:30:00 UTC'))->getTimestamp());
$p = $this->service()->build();
self::assertSame([], $p['forecast']);
self::assertTrue($p['forecast_stale']);
self::assertSame(['forecast_unavailable'], $p['errors']);
self::assertSame(0, $this->source->calls['forecast'], 'de cache was nog vers, dus geen live call verwacht');
}
public function testSecondBuildWithinTtlUsesCache(): void
{
$this->service()->build();
$this->service('2026-09-11 12:36:05 UTC')->build();
self::assertSame(['stations' => 1, 'measure' => 1, 'pressure' => 1, 'forecast' => 1], $this->source->calls);
self::assertSame(['stations' => 1, 'measure' => 1, 'pressure' => 1, 'temperature' => 4, 'forecast' => 1], $this->source->calls);
$this->service('2026-09-11 12:38:05 UTC')->build();
self::assertSame(2, $this->source->calls['stations'], 'stations na 5 min opnieuw');
self::assertSame(4, $this->source->calls['temperature'], 'verloop per module nog binnen 15 min');
self::assertSame(1, $this->source->calls['measure'], 'measure nog binnen 15 min');
}
}
+15 -2
View File
@@ -13,11 +13,14 @@ final class FakeSource implements WeatherSource
public array $responses = [];
/** @var array<string, int> */
public array $calls = ['stations' => 0, 'measure' => 0, 'pressure' => 0, 'forecast' => 0];
public array $calls = ['stations' => 0, 'measure' => 0, 'pressure' => 0, 'temperature' => 0, 'forecast' => 0];
/** @var array<string, array> */
public array $lastArgs = [];
/** @var array<string, list<array>> alle aanroepen per soort, in volgorde */
public array $allArgs = [];
public static function fromFixtures(): self
{
$s = new self();
@@ -25,7 +28,11 @@ final class FakeSource implements WeatherSource
'stations' => json_decode((string) file_get_contents(__DIR__ . '/fixtures/getstationsdata.json'), true)['body'],
'measure' => json_decode((string) file_get_contents(__DIR__ . '/fixtures/getmeasure.json'), true)['body'],
'pressure' => json_decode((string) file_get_contents(__DIR__ . '/fixtures/getmeasure-pressure.json'), true)['body'],
'forecast' => json_decode((string) file_get_contents(__DIR__ . '/fixtures/forecast.json'), true)['daily'],
'temperature' => json_decode((string) file_get_contents(__DIR__ . '/fixtures/getmeasure-temperature.json'), true)['body'],
'forecast' => array_intersect_key(
json_decode((string) file_get_contents(__DIR__ . '/fixtures/forecast.json'), true),
['daily' => null, 'hourly' => null],
),
];
return $s;
}
@@ -45,6 +52,11 @@ final class FakeSource implements WeatherSource
return $this->answer('pressure', func_get_args());
}
public function temperatureHistory(string $deviceId, ?string $moduleId, int $begin, int $end): array
{
return $this->answer('temperature', func_get_args());
}
public function forecast(float $lat, float $lon, string $timezone): array
{
return $this->answer('forecast', func_get_args());
@@ -54,6 +66,7 @@ final class FakeSource implements WeatherSource
{
$this->calls[$key]++;
$this->lastArgs[$key] = $args;
$this->allArgs[$key][] = $args;
$r = $this->responses[$key] ?? new \RuntimeException("geen response voor $key");
if ($r instanceof \Throwable) {
throw $r;
+9 -9
View File
@@ -15,16 +15,16 @@ final class ForecastMapperTest extends TestCase
return json_decode((string) file_get_contents(__DIR__ . '/fixtures/forecast.json'), true)['daily'];
}
public function testSkipsTodayAndReturnsFiveDays(): void
public function testIncludesTodayAndReturnsSixDays(): void
{
$f = ForecastMapper::map($this->daily());
self::assertCount(5, $f);
self::assertCount(6, $f);
self::assertSame([
'date' => '2026-09-12', 'weather_code' => 2, 'temp_max' => 18.3, 'temp_min' => 10.1,
'precip_mm' => 0.4, 'precip_prob' => 20,
'date' => '2026-09-11', 'weather_code' => 3, 'temp_max' => 17.9, 'temp_min' => 11.0,
'precip_mm' => 0.0, 'precip_prob' => 10,
], $f[0]);
self::assertSame('2026-09-16', $f[4]['date']);
self::assertSame('2026-09-16', $f[5]['date']);
}
public function testNullValuesStayNull(): void
@@ -35,9 +35,9 @@ final class ForecastMapperTest extends TestCase
$f = ForecastMapper::map($daily);
self::assertNull($f[2]['weather_code']);
self::assertNull($f[2]['precip_prob']);
self::assertSame(17.4, $f[2]['temp_max']);
self::assertNull($f[3]['weather_code']);
self::assertNull($f[3]['precip_prob']);
self::assertSame(17.4, $f[3]['temp_max']);
}
public function testShorterResponseGivesFewerDays(): void
@@ -47,7 +47,7 @@ final class ForecastMapperTest extends TestCase
$daily[$key] = array_slice($values, 0, 3);
}
self::assertCount(2, ForecastMapper::map($daily));
self::assertCount(3, ForecastMapper::map($daily));
}
public function testMissingTimeArrayGivesEmptyList(): void
+103
View File
@@ -0,0 +1,103 @@
<?php
declare(strict_types=1);
namespace Weerscherm\Tests;
use PHPUnit\Framework\TestCase;
use Weerscherm\HourlyForecastMapper;
final class HourlyForecastMapperTest extends TestCase
{
private \DateTimeZone $tz;
protected function setUp(): void
{
$this->tz = new \DateTimeZone('Europe/Berlin');
}
/** @return array<string, mixed> */
private function hourly(): array
{
return json_decode((string) file_get_contents(__DIR__ . '/fixtures/forecast.json'), true)['hourly'];
}
public function testGroupsByDateAndSamplesEveryOtherHour(): void
{
$byDate = HourlyForecastMapper::mapByDate($this->hourly(), $this->tz);
self::assertSame(['2026-09-11', '2026-09-12', '2026-09-13', '2026-09-14', '2026-09-15', '2026-09-16'], array_keys($byDate));
self::assertCount(12, $byDate['2026-09-11']);
$hours = array_map(
fn(array $p) => (int) (new \DateTimeImmutable($p['at']))->format('H'),
$byDate['2026-09-11'],
);
self::assertSame([0, 2, 4, 6, 8, 10, 12, 14, 16, 18, 20, 22], $hours);
}
public function testPointShapeAndValues(): void
{
$byDate = HourlyForecastMapper::mapByDate($this->hourly(), $this->tz);
$noon = $byDate['2026-09-11'][6]; // index 6 = 12:00 (0,2,4,6,8,10,12,...)
self::assertSame(['at', 'temp', 'precip_mm', 'wind_kmh', 'wind_dir'], array_keys($noon));
self::assertSame('2026-09-11T12:00:00+02:00', $noon['at']);
self::assertIsFloat($noon['temp']);
self::assertIsFloat($noon['precip_mm']);
self::assertIsFloat($noon['wind_kmh']);
self::assertIsInt($noon['wind_dir']);
}
public function testRainOnlyOnTheHoursThatHaveIt(): void
{
$byDate = HourlyForecastMapper::mapByDate($this->hourly(), $this->tz);
// 2026-09-14 heeft 8,2 mm in de dagfixture, verdeeld over 14-16u; de bemonsterde uren zijn 0,2,4,..22.
$rainy = array_filter($byDate['2026-09-14'], fn(array $p) => $p['precip_mm'] > 0);
self::assertNotEmpty($rainy);
foreach ($rainy as $p) {
self::assertGreaterThanOrEqual(14, (int) (new \DateTimeImmutable($p['at']))->format('H'));
}
}
public function testDaylightSavingDayWithTwentyFiveHoursSamplesWithoutError(): void
{
$hourly = json_decode((string) file_get_contents(__DIR__ . '/fixtures/hourly-dst.json'), true);
$byDate = HourlyForecastMapper::mapByDate($hourly, $this->tz);
self::assertCount(13, $byDate['2026-10-25'], '25 punten, om de 2: index 0,2,...,24 = 13 stuks');
}
public function testNullValuesStayNull(): void
{
$hourly = $this->hourly();
$hourly['temperature_2m'][0] = null;
$hourly['wind_direction_10m'][0] = 'kapot';
$byDate = HourlyForecastMapper::mapByDate($hourly, $this->tz);
self::assertNull($byDate['2026-09-11'][0]['temp']);
self::assertNull($byDate['2026-09-11'][0]['wind_dir']);
}
public function testMalformedTimestampsAreSkipped(): void
{
$byDate = HourlyForecastMapper::mapByDate([
'time' => ['niet-een-datum', '2026-09-11T05:00'],
'temperature_2m' => [1.0, 2.0],
'precipitation' => [0.0, 0.0],
'wind_speed_10m' => [1.0, 1.0],
'wind_direction_10m' => [1, 1],
], $this->tz);
self::assertSame(['2026-09-11'], array_keys($byDate));
self::assertCount(1, $byDate['2026-09-11']);
}
public function testMissingTimeArrayGivesEmptyResult(): void
{
self::assertSame([], HourlyForecastMapper::mapByDate([], $this->tz));
}
}
@@ -5,16 +5,16 @@ declare(strict_types=1);
namespace Weerscherm\Tests;
use PHPUnit\Framework\TestCase;
use Weerscherm\PressureHistory;
use Weerscherm\MeasureSeries;
final class PressureHistoryTest extends TestCase
final class MeasureSeriesTest extends TestCase
{
public function testMapsAndSortsPoints(): void
{
$body = json_decode((string) file_get_contents(__DIR__ . '/fixtures/getmeasure-pressure.json'), true)['body'];
$shuffled = array_reverse($body, true);
$points = PressureHistory::map($shuffled, new \DateTimeZone('Europe/Berlin'));
$points = MeasureSeries::map($shuffled, new \DateTimeZone('Europe/Berlin'), 'hpa');
self::assertCount(49, $points);
self::assertSame('2026-09-10T14:30:00+02:00', $points[0]['at']);
@@ -25,8 +25,19 @@ final class PressureHistoryTest extends TestCase
public function testSkipsGarbage(): void
{
$points = PressureHistory::map(['abc' => [1000.0], '1789129800' => ['x'], '1789133400' => [1013.5]], new \DateTimeZone('UTC'));
$points = MeasureSeries::map(['abc' => [1000.0], '1789129800' => ['x'], '1789133400' => [1013.5]], new \DateTimeZone('UTC'), 'hpa');
self::assertSame([['at' => '2026-09-11T13:30:00+00:00', 'hpa' => 1013.5]], $points);
}
public function testTheValueFieldIsNamedPerMeasurement(): void
{
$body = ['1789129800' => [16.4], '1789131600' => [16.9]];
$temps = MeasureSeries::map($body, new \DateTimeZone('Europe/Berlin'), 'temp');
self::assertSame(['at', 'temp'], array_keys($temps[0]));
self::assertSame(16.4, $temps[0]['temp']);
self::assertSame(16.9, $temps[1]['temp']);
}
}
+16 -5
View File
@@ -10,19 +10,21 @@ use Weerscherm\UpstreamException;
final class OpenMeteoClientTest extends TestCase
{
public function testRequestsSixDaysWithAllDailyVariables(): void
public function testRequestsSixDaysWithDailyAndHourlyVariables(): void
{
$http = new FakeHttp();
$http->queue(200, json_decode((string) file_get_contents(__DIR__ . '/fixtures/forecast.json'), true));
$daily = (new OpenMeteoClient($http))->dailyForecast(51.82, 6.55, 'Europe/Berlin');
$result = (new OpenMeteoClient($http))->forecast(51.82, 6.55, 'Europe/Berlin');
self::assertCount(6, $daily['time']);
self::assertCount(6, $result['daily']['time']);
self::assertCount(144, $result['hourly']['time']);
self::assertSame('https://api.open-meteo.com/v1/forecast', $http->requests[0]['url']);
self::assertSame([
'latitude' => 51.82,
'longitude' => 6.55,
'daily' => 'weather_code,temperature_2m_max,temperature_2m_min,precipitation_sum,precipitation_probability_max',
'hourly' => 'temperature_2m,precipitation,wind_speed_10m,wind_direction_10m',
'timezone' => 'Europe/Berlin',
'forecast_days' => 6,
], $http->requests[0]['params']);
@@ -32,13 +34,22 @@ final class OpenMeteoClientTest extends TestCase
{
$http = (new FakeHttp())->queue(429, ['reason' => 'rate limit']);
$this->expectException(UpstreamException::class);
(new OpenMeteoClient($http))->dailyForecast(1, 2, 'UTC');
(new OpenMeteoClient($http))->forecast(1, 2, 'UTC');
}
public function testMissingDailyIsUpstreamException(): void
{
$http = (new FakeHttp())->queue(200, ['hourly' => []]);
$this->expectException(UpstreamException::class);
(new OpenMeteoClient($http))->dailyForecast(1, 2, 'UTC');
(new OpenMeteoClient($http))->forecast(1, 2, 'UTC');
}
public function testMissingHourlyGivesEmptyHourlyBlockInsteadOfError(): void
{
$http = (new FakeHttp())->queue(200, ['daily' => ['time' => ['2026-09-11']]]);
$result = (new OpenMeteoClient($http))->forecast(1, 2, 'UTC');
self::assertSame([], $result['hourly']);
}
}
+118
View File
@@ -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');
}
}
+804 -23
View File
@@ -1,25 +1,806 @@
{
"latitude": 51.82,
"longitude": 6.56,
"generationtime_ms": 0.2,
"utc_offset_seconds": 7200,
"timezone": "Europe/Berlin",
"timezone_abbreviation": "CEST",
"elevation": 20.0,
"daily_units": {
"time": "iso8601",
"weather_code": "wmo code",
"temperature_2m_max": "°C",
"temperature_2m_min": "°C",
"precipitation_sum": "mm",
"precipitation_probability_max": "%"
},
"daily": {
"time": ["2026-09-11", "2026-09-12", "2026-09-13", "2026-09-14", "2026-09-15", "2026-09-16"],
"weather_code": [3, 2, 0, 61, 80, 2],
"temperature_2m_max": [17.9, 18.3, 20.1, 17.4, 15.2, 16.0],
"temperature_2m_min": [11.0, 10.1, 11.3, 12.0, 9.4, 8.1],
"precipitation_sum": [0.0, 0.4, 0.0, 8.2, 3.1, 0.2],
"precipitation_probability_max": [10, 20, 5, 80, 60, 15]
}
"latitude": 51.82,
"longitude": 6.56,
"generationtime_ms": 0.2,
"utc_offset_seconds": 7200,
"timezone": "Europe/Berlin",
"timezone_abbreviation": "CEST",
"elevation": 20,
"daily_units": {
"time": "iso8601",
"weather_code": "wmo code",
"temperature_2m_max": "\u00b0C",
"temperature_2m_min": "\u00b0C",
"precipitation_sum": "mm",
"precipitation_probability_max": "%"
},
"daily": {
"time": [
"2026-09-11",
"2026-09-12",
"2026-09-13",
"2026-09-14",
"2026-09-15",
"2026-09-16"
],
"weather_code": [
3,
2,
0,
61,
80,
2
],
"temperature_2m_max": [
17.9,
18.3,
20.1,
17.4,
15.2,
16
],
"temperature_2m_min": [
11,
10.1,
11.3,
12,
9.4,
8.1
],
"precipitation_sum": [
0,
0.4,
0,
8.2,
3.1,
0.2
],
"precipitation_probability_max": [
10,
20,
5,
80,
60,
15
]
},
"hourly_units": {
"time": "iso8601",
"temperature_2m": "\u00b0C",
"precipitation": "mm",
"wind_speed_10m": "km/h",
"wind_direction_10m": "\u00b0"
},
"hourly": {
"time": [
"2026-09-11T00:00",
"2026-09-11T01:00",
"2026-09-11T02:00",
"2026-09-11T03:00",
"2026-09-11T04:00",
"2026-09-11T05:00",
"2026-09-11T06:00",
"2026-09-11T07:00",
"2026-09-11T08:00",
"2026-09-11T09:00",
"2026-09-11T10:00",
"2026-09-11T11:00",
"2026-09-11T12:00",
"2026-09-11T13:00",
"2026-09-11T14:00",
"2026-09-11T15:00",
"2026-09-11T16:00",
"2026-09-11T17:00",
"2026-09-11T18:00",
"2026-09-11T19:00",
"2026-09-11T20:00",
"2026-09-11T21:00",
"2026-09-11T22:00",
"2026-09-11T23:00",
"2026-09-12T00:00",
"2026-09-12T01:00",
"2026-09-12T02:00",
"2026-09-12T03:00",
"2026-09-12T04:00",
"2026-09-12T05:00",
"2026-09-12T06:00",
"2026-09-12T07:00",
"2026-09-12T08:00",
"2026-09-12T09:00",
"2026-09-12T10:00",
"2026-09-12T11:00",
"2026-09-12T12:00",
"2026-09-12T13:00",
"2026-09-12T14:00",
"2026-09-12T15:00",
"2026-09-12T16:00",
"2026-09-12T17:00",
"2026-09-12T18:00",
"2026-09-12T19:00",
"2026-09-12T20:00",
"2026-09-12T21:00",
"2026-09-12T22:00",
"2026-09-12T23:00",
"2026-09-13T00:00",
"2026-09-13T01:00",
"2026-09-13T02:00",
"2026-09-13T03:00",
"2026-09-13T04:00",
"2026-09-13T05:00",
"2026-09-13T06:00",
"2026-09-13T07:00",
"2026-09-13T08:00",
"2026-09-13T09:00",
"2026-09-13T10:00",
"2026-09-13T11:00",
"2026-09-13T12:00",
"2026-09-13T13:00",
"2026-09-13T14:00",
"2026-09-13T15:00",
"2026-09-13T16:00",
"2026-09-13T17:00",
"2026-09-13T18:00",
"2026-09-13T19:00",
"2026-09-13T20:00",
"2026-09-13T21:00",
"2026-09-13T22:00",
"2026-09-13T23:00",
"2026-09-14T00:00",
"2026-09-14T01:00",
"2026-09-14T02:00",
"2026-09-14T03:00",
"2026-09-14T04:00",
"2026-09-14T05:00",
"2026-09-14T06:00",
"2026-09-14T07:00",
"2026-09-14T08:00",
"2026-09-14T09:00",
"2026-09-14T10:00",
"2026-09-14T11:00",
"2026-09-14T12:00",
"2026-09-14T13:00",
"2026-09-14T14:00",
"2026-09-14T15:00",
"2026-09-14T16:00",
"2026-09-14T17:00",
"2026-09-14T18:00",
"2026-09-14T19:00",
"2026-09-14T20:00",
"2026-09-14T21:00",
"2026-09-14T22:00",
"2026-09-14T23:00",
"2026-09-15T00:00",
"2026-09-15T01:00",
"2026-09-15T02:00",
"2026-09-15T03:00",
"2026-09-15T04:00",
"2026-09-15T05:00",
"2026-09-15T06:00",
"2026-09-15T07:00",
"2026-09-15T08:00",
"2026-09-15T09:00",
"2026-09-15T10:00",
"2026-09-15T11:00",
"2026-09-15T12:00",
"2026-09-15T13:00",
"2026-09-15T14:00",
"2026-09-15T15:00",
"2026-09-15T16:00",
"2026-09-15T17:00",
"2026-09-15T18:00",
"2026-09-15T19:00",
"2026-09-15T20:00",
"2026-09-15T21:00",
"2026-09-15T22:00",
"2026-09-15T23:00",
"2026-09-16T00:00",
"2026-09-16T01:00",
"2026-09-16T02:00",
"2026-09-16T03:00",
"2026-09-16T04:00",
"2026-09-16T05:00",
"2026-09-16T06:00",
"2026-09-16T07:00",
"2026-09-16T08:00",
"2026-09-16T09:00",
"2026-09-16T10:00",
"2026-09-16T11:00",
"2026-09-16T12:00",
"2026-09-16T13:00",
"2026-09-16T14:00",
"2026-09-16T15:00",
"2026-09-16T16:00",
"2026-09-16T17:00",
"2026-09-16T18:00",
"2026-09-16T19:00",
"2026-09-16T20:00",
"2026-09-16T21:00",
"2026-09-16T22:00",
"2026-09-16T23:00"
],
"temperature_2m": [
10.3,
9.7,
9.3,
9.2,
9.3,
9.7,
10.3,
11,
11.9,
12.8,
13.7,
14.6,
15.3,
15.9,
16.3,
16.4,
16.3,
15.9,
15.3,
14.6,
13.7,
12.8,
11.9,
11,
11.3,
10.6,
10.2,
10.1,
10.2,
10.6,
11.3,
12.2,
13.1,
14.2,
15.3,
16.3,
17.1,
17.8,
18.2,
18.3,
18.2,
17.8,
17.1,
16.3,
15.3,
14.2,
13.1,
12.2,
12.6,
11.9,
11.4,
11.3,
11.4,
11.9,
12.6,
13.5,
14.6,
15.7,
16.8,
17.9,
18.8,
19.5,
20,
20.1,
20,
19.5,
18.8,
17.9,
16.8,
15.7,
14.6,
13.5,
12.8,
12.4,
12.1,
12,
12.1,
12.4,
12.8,
13.4,
14,
14.7,
15.4,
16.1,
16.6,
17,
17.3,
17.4,
17.3,
17,
16.6,
16.1,
15.4,
14.7,
14,
13.4,
10.2,
9.8,
9.5,
9.4,
9.5,
9.8,
10.2,
10.9,
11.5,
12.3,
13.1,
13.8,
14.4,
14.8,
15.1,
15.2,
15.1,
14.8,
14.4,
13.8,
13.1,
12.3,
11.5,
10.9,
9.3,
8.6,
8.2,
8.1,
8.2,
8.6,
9.3,
10.1,
11,
12.1,
13.1,
14,
14.8,
15.5,
15.9,
16,
15.9,
15.5,
14.8,
14,
13.1,
12.1,
11,
10.1
],
"precipitation": [
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0.1,
0.1,
0.1,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
2.7,
2.7,
2.7,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
1,
1,
1,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0.1,
0.1,
0.1,
0,
0,
0,
0,
0,
0,
0
],
"wind_speed_10m": [
6,
6.8,
7.5,
8.1,
8.6,
8.9,
9,
8.9,
8.6,
8.1,
7.5,
6.8,
6,
5.2,
4.5,
3.9,
3.4,
3.1,
3,
3.1,
3.4,
3.9,
4.5,
5.2,
8.1,
8.6,
8.9,
9,
8.9,
8.6,
8.1,
7.5,
6.8,
6,
5.2,
4.5,
3.9,
3.4,
3.1,
3,
3.1,
3.4,
3.9,
4.5,
5.2,
6,
6.8,
7.5,
9,
8.9,
8.6,
8.1,
7.5,
6.8,
6,
5.2,
4.5,
3.9,
3.4,
3.1,
3,
3.1,
3.4,
3.9,
4.5,
5.2,
6,
6.8,
7.5,
8.1,
8.6,
8.9,
8.1,
7.5,
6.8,
6,
5.2,
4.5,
3.9,
3.4,
3.1,
3,
3.1,
3.4,
3.9,
4.5,
5.2,
6,
6.8,
7.5,
8.1,
8.6,
8.9,
9,
8.9,
8.6,
6,
5.2,
4.5,
3.9,
3.4,
3.1,
3,
3.1,
3.4,
3.9,
4.5,
5.2,
6,
6.8,
7.5,
8.1,
8.6,
8.9,
9,
8.9,
8.6,
8.1,
7.5,
6.8,
3.9,
3.4,
3.1,
3,
3.1,
3.4,
3.9,
4.5,
5.2,
6,
6.8,
7.5,
8.1,
8.6,
8.9,
9,
8.9,
8.6,
8.1,
7.5,
6.8,
6,
5.2,
4.5
],
"wind_direction_10m": [
0,
7,
14,
21,
28,
35,
42,
49,
56,
63,
70,
77,
84,
91,
98,
105,
112,
119,
126,
133,
140,
147,
154,
161,
40,
47,
54,
61,
68,
75,
82,
89,
96,
103,
110,
117,
124,
131,
138,
145,
152,
159,
166,
173,
180,
187,
194,
201,
80,
87,
94,
101,
108,
115,
122,
129,
136,
143,
150,
157,
164,
171,
178,
185,
192,
199,
206,
213,
220,
227,
234,
241,
120,
127,
134,
141,
148,
155,
162,
169,
176,
183,
190,
197,
204,
211,
218,
225,
232,
239,
246,
253,
260,
267,
274,
281,
160,
167,
174,
181,
188,
195,
202,
209,
216,
223,
230,
237,
244,
251,
258,
265,
272,
279,
286,
293,
300,
307,
314,
321,
200,
207,
214,
221,
228,
235,
242,
249,
256,
263,
270,
277,
284,
291,
298,
305,
312,
319,
326,
333,
340,
347,
354,
1
]
}
}
+95
View File
@@ -0,0 +1,95 @@
{
"body": {
"1789077600": [
11.9
],
"1789079400": [
11.9
],
"1789081200": [
10.8
],
"1789083000": [
10.8
],
"1789084800": [
9.9
],
"1789086600": [
9.9
],
"1789088400": [
9.4
],
"1789090200": [
9.4
],
"1789092000": [
9.2
],
"1789093800": [
9.2
],
"1789095600": [
9.4
],
"1789097400": [
9.4
],
"1789099200": [
9.9
],
"1789101000": [
9.9
],
"1789102800": [
10.8
],
"1789104600": [
10.8
],
"1789106400": [
11.9
],
"1789108200": [
11.9
],
"1789110000": [
13.1
],
"1789111800": [
13.1
],
"1789113600": [
14.5
],
"1789115400": [
14.5
],
"1789117200": [
15.9
],
"1789119000": [
15.9
],
"1789120800": [
17.2
],
"1789122600": [
17.2
],
"1789124400": [
18.2
],
"1789126200": [
18.2
],
"1789128000": [
19.1
],
"1789129800": [
19.1
]
},
"status": "ok"
}
+137
View File
@@ -0,0 +1,137 @@
{
"time": [
"2026-10-25T00:00",
"2026-10-25T01:00",
"2026-10-25T02:00",
"2026-10-25T03:00",
"2026-10-25T04:00",
"2026-10-25T05:00",
"2026-10-25T06:00",
"2026-10-25T07:00",
"2026-10-25T08:00",
"2026-10-25T09:00",
"2026-10-25T10:00",
"2026-10-25T11:00",
"2026-10-25T12:00",
"2026-10-25T13:00",
"2026-10-25T14:00",
"2026-10-25T15:00",
"2026-10-25T16:00",
"2026-10-25T17:00",
"2026-10-25T18:00",
"2026-10-25T19:00",
"2026-10-25T20:00",
"2026-10-25T21:00",
"2026-10-25T22:00",
"2026-10-25T23:00",
"2026-10-25T02:00"
],
"temperature_2m": [
10,
10.1,
10.2,
10.3,
10.4,
10.5,
10.6,
10.7,
10.8,
10.9,
11,
11.1,
11.2,
11.3,
11.4,
11.5,
11.6,
11.7,
11.8,
11.9,
12,
12.1,
12.2,
12.3,
12.4
],
"precipitation": [
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0
],
"wind_speed_10m": [
5,
5,
5,
5,
5,
5,
5,
5,
5,
5,
5,
5,
5,
5,
5,
5,
5,
5,
5,
5,
5,
5,
5,
5,
5
],
"wind_direction_10m": [
180,
180,
180,
180,
180,
180,
180,
180,
180,
180,
180,
180,
180,
180,
180,
180,
180,
180,
180,
180,
180,
180,
180,
180,
180
]
}
+107 -1
View File
@@ -119,7 +119,10 @@ test('msUntil computes the wait until the next local time', () => {
assert.equal(msUntil(new Date('2026-09-11T22:00:00.000Z'), TZ, 0), 86400000, 'precies op het moment zelf: pas morgen weer');
});
import { co2Level, fPct, fHpa, seriesRange, sparklinePath } from '../../public/format.js';
import {
co2Level, fPct, fHpa, seriesRange, sparklinePath, sparklinePoints,
windArrowDeg, fKmh, formatHour, formatFullDate, pinRoom, hourTicks, scaleToFit,
} from '../../public/format.js';
test('co2Level uses the Netatmo thresholds', () => {
assert.deepEqual(co2Level(612), { level: 'good', label: 'goed' });
@@ -151,3 +154,106 @@ test('sparklinePath scales points into the box', () => {
assert.equal(sparklinePath([1000], 100, 50), '');
assert.equal(sparklinePath([], 100, 50), '');
});
test('sparklinePoints gives x for every index and null y for unknown values', () => {
const points = sparklinePoints([1000, null, 1010], 100, 50, 0);
assert.deepEqual(points.map((p) => p.x), [0, 50, 100]);
assert.equal(points[0].y, 50);
assert.equal(points[1].y, null);
assert.equal(points[2].y, 0);
});
test('windArrowDeg rotates 180 degrees so the arrow points where the wind blows to', () => {
assert.equal(windArrowDeg(0), 180);
assert.equal(windArrowDeg(270), 90);
assert.equal(windArrowDeg(200), 20);
assert.equal(windArrowDeg(null), null);
});
test('fKmh rounds to whole km/u', () => {
assert.equal(fKmh(8.4), '8 km/u');
assert.equal(fKmh(8.6), '9 km/u');
assert.equal(fKmh(null), '–');
});
test('formatHour gives a two-digit hour in the station timezone', () => {
assert.equal(formatHour('2026-09-14T00:00:00+02:00', TZ), '00');
assert.equal(formatHour('2026-09-14T14:00:00+02:00', TZ), '14');
assert.equal(formatHour('2026-09-13T23:00:00Z', TZ), '01', '23:00 UTC is 01:00 de volgende dag in Berlijn');
assert.equal(formatHour(null, TZ), '–');
});
test('formatFullDate matches formatDate for the same calendar date', () => {
assert.equal(formatFullDate('2026-09-14', TZ), 'Maandag 14 september');
assert.equal(formatFullDate('kapot', TZ), '–');
});
test('pinRoom moves the matching room to the front, keeping the others in order', () => {
const rooms = [{ id: 'a' }, { id: 'b' }, { id: 'c' }];
assert.deepEqual(pinRoom(rooms, 'b').map((r) => r.id), ['b', 'a', 'c']);
assert.deepEqual(pinRoom(rooms, 'c').map((r) => r.id), ['c', 'a', 'b']);
});
test('pinRoom returns the original array unchanged without a match, id, or when already first', () => {
const rooms = [{ id: 'a' }, { id: 'b' }];
assert.equal(pinRoom(rooms, null), rooms);
assert.equal(pinRoom(rooms, undefined), rooms);
assert.equal(pinRoom(rooms, 'x'), rooms, 'onbekend id: geen match, ongewijzigd terug');
assert.equal(pinRoom(rooms, 'a'), rooms, 'al vooraan: geen kopie nodig');
});
test('pinRoom does not mutate the input array', () => {
const rooms = [{ id: 'a' }, { id: 'b' }, { id: 'c' }];
const original = rooms.slice();
pinRoom(rooms, 'c');
assert.deepEqual(rooms, original);
});
test('hourTicks puts marks on clock-aligned whole hours inside the window', () => {
const ticks = hourTicks('2026-09-12T11:41:00+02:00', '2026-09-13T11:41:00+02:00', TZ);
assert.deepEqual(ticks.map((t) => formatTime(t.at, TZ)), ['12:00', '18:00', '00:00', '06:00']);
assert.deepEqual(ticks.map((t) => Number(t.percent.toFixed(2))), [1.32, 26.32, 51.32, 76.32]);
});
test('hourTicks drops a mark that would collide with the "nu" label', () => {
// Venster eindigt om 12:05, dus de markering van 12:00 zit op 99,7% en valt weg.
const ticks = hourTicks('2026-09-12T12:05:00+02:00', '2026-09-13T12:05:00+02:00', TZ);
assert.deepEqual(ticks.map((t) => formatTime(t.at, TZ)), ['18:00', '00:00', '06:00']);
});
test('hourTicks stays on local whole hours across the daylight saving change', () => {
// 25 oktober 2026: de klok gaat om 03:00 terug naar 02:00, dus dat venster duurt 25 uur.
// Zonder de lokale klok te raadplegen zou 00:00 hier op 01:00 uitkomen.
const ticks = hourTicks('2026-10-24T12:00:00+02:00', '2026-10-25T12:00:00+01:00', TZ);
assert.deepEqual(ticks.map((t) => formatTime(t.at, TZ)), ['12:00', '18:00', '00:00', '06:00']);
assert.equal(ticks[0].percent, 0, 'een venster dat precies op een heel uur begint krijgt daar een markering');
});
test('hourTicks honours a different step', () => {
const ticks = hourTicks('2026-09-13T09:30:00+02:00', '2026-09-13T21:30:00+02:00', TZ, 3);
assert.deepEqual(ticks.map((t) => formatTime(t.at, TZ)), ['12:00', '15:00', '18:00']);
});
test('hourTicks gives nothing for an empty or invalid window', () => {
assert.deepEqual(hourTicks('2026-09-13T10:00:00+02:00', '2026-09-13T10:00:00+02:00', TZ), []);
assert.deepEqual(hourTicks('kapot', '2026-09-13T10:00:00+02:00', TZ), []);
assert.deepEqual(hourTicks(undefined, undefined, TZ), []);
});
test('scaleToFit shrinks to fit but never enlarges', () => {
assert.equal(scaleToFit(481, 412), 412 / 481);
assert.equal(scaleToFit(361, 412), 1, 'past al: niet vergroten');
assert.equal(scaleToFit(412, 412), 1);
});
test('scaleToFit is safe with nonsense input', () => {
assert.equal(scaleToFit(0, 412), 1);
assert.equal(scaleToFit(-10, 412), 1);
assert.equal(scaleToFit(481, 0), 1);
assert.equal(scaleToFit(undefined, 412), 1);
});
+41 -1
View File
@@ -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');
});