Author SHA1 Message Date
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
theoandClaude Fable 5.1 768cc7151f feat(ui): humidity, CO2 pill per room and 24h pressure sparkline
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-13 09:35:53 +02:00
theoandClaude Fable 5.1 dba1875683 feat(api): humidity, CO2, current pressure and 24h pressure history
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-13 09:34:20 +02:00
theoandClaude Fable 5.1 66bce8e8d0 chore: ignore phpunit result cache
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-11 15:13:54 +02:00
theoandClaude Fable 5.1 7fd49cc391 docs: deploy via Ansible on pensare-server
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-11 15:13:01 +02:00
theoandClaude Fable 5.1 246756438b Merge feature/weerscherm: Weerscherm v1 (backend, frontend, Netatmo, Open-Meteo, PWA, docs)
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-11 14:50:52 +02:00
theoandClaude Fable 5.1 9dbb10a7ac fix: stack banner title and hint vertically
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-11 14:50:38 +02:00
theoandClaude Fable 5.1 dc8cbee8da docs: README, OrbStack notes, autoload fallback for deploys without composer
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-11 14:49:39 +02:00
theoandClaude Fable 5.1 e971163f71 feat: PWA manifest, app icons, service worker with tests
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-11 14:48:11 +02:00
theoandClaude Fable 5.1 efe6928953 fix: round rain month total to avoid float noise
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-11 14:46:08 +02:00
theoandClaude Fable 5.1 4250fa5abf feat: NetatmoClient with token rotation, OpenMeteoClient, LiveSource, netatmo-init
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-11 14:45:54 +02:00
theoandClaude Fable 5.1 dababbd1f2 feat: Http curl wrapper and TokenStore with atomic write and flock
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-11 14:45:18 +02:00
theoandClaude Fable 5.1 c65bc1d957 feat: frontend design A with scaling, formatting, refresh and error states
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-11 14:43:37 +02:00
theoandClaude Fable 5.1 6ef2d3f060 feat: format.js pure helpers with node tests, weather icons
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-11 14:42:06 +02:00
theoandClaude Fable 5.1 dfc28ee6c3 feat: /api/data.php with fixture source, robots.txt
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-11 14:40:15 +02:00
61 changed files with 4923 additions and 79 deletions
+1
View File
@@ -3,3 +3,4 @@ config.php
/vendor/ /vendor/
/node_modules/ /node_modules/
.DS_Store .DS_Store
.phpunit.result.cache
-1
View File
@@ -1 +0,0 @@
{"version":2,"defects":{"Weerscherm\\Tests\\DashboardServiceTest::testForecastUsesStationLocationUnlessConfigured":7,"Weerscherm\\Tests\\DashboardServiceTest::testMeasureFailureFallsBackToSumRain24":7},"times":{"Weerscherm\\Tests\\ConfigTest::testDefaultsApplyWhenKeysAreMissing":0,"Weerscherm\\Tests\\ConfigTest::testFileValuesOverrideDefaults":0,"Weerscherm\\Tests\\ConfigTest::testUnknownKeyReturnsGivenDefault":0,"Weerscherm\\Tests\\ConfigTest::testFileMustReturnArray":0,"Weerscherm\\Tests\\StationMapperTest::testStationMetadata":0,"Weerscherm\\Tests\\StationMapperTest::testOutdoor":0,"Weerscherm\\Tests\\StationMapperTest::testIndoorDefaultOrderAndUnreachableModule":0,"Weerscherm\\Tests\\StationMapperTest::testIndoorConfigDeterminesOrderAndLabels":0,"Weerscherm\\Tests\\StationMapperTest::testRainModule":0,"Weerscherm\\Tests\\StationMapperTest::testWithoutRainModule":0,"Weerscherm\\Tests\\StationMapperTest::testUnreachableOutdoorModuleGivesNulls":0,"Weerscherm\\Tests\\StationMapperTest::testUnknownDeviceIdFallsBackToFirstStation":0,"Weerscherm\\Tests\\StationMapperTest::testEmptyDevicesGivesNullsWithoutCrashing":0,"Weerscherm\\Tests\\RainCalculatorTest::testNormalWeek":0,"Weerscherm\\Tests\\RainCalculatorTest::testMissingPreviousDayIsNullAndExcludedFromTotal":0,"Weerscherm\\Tests\\RainCalculatorTest::testMissingTodayFallsBackToSumRain24":0,"Weerscherm\\Tests\\RainCalculatorTest::testMonthBoundaryKeepsPreviousDaysButNotInMonthTotal":0,"Weerscherm\\Tests\\RainCalculatorTest::testDaylightSavingTransitionMapsTimestampsToLocalDates":0,"Weerscherm\\Tests\\RainCalculatorTest::testEmptyBodyGivesNulls":0,"Weerscherm\\Tests\\RainCalculatorTest::testDateBeginIsMinOfFirstOfMonthAndThreeDaysAgo":0,"Weerscherm\\Tests\\CacheTest::testWriteThenReadRoundTrip":0.002,"Weerscherm\\Tests\\CacheTest::testMissingKeyReadsNull":0.002,"Weerscherm\\Tests\\CacheTest::testCorruptFileReadsNull":0,"Weerscherm\\Tests\\CacheTest::testFreshness":0,"Weerscherm\\Tests\\CacheTest::testKeyIsSanitised":0,"Weerscherm\\Tests\\CacheTest::testWriteCreatesDirectory":0,"Weerscherm\\Tests\\ForecastMapperTest::testSkipsTodayAndReturnsFiveDays":0,"Weerscherm\\Tests\\ForecastMapperTest::testNullValuesStayNull":0,"Weerscherm\\Tests\\ForecastMapperTest::testShorterResponseGivesFewerDays":0,"Weerscherm\\Tests\\ForecastMapperTest::testMissingTimeArrayGivesEmptyList":0,"Weerscherm\\Tests\\DashboardServiceTest::testHappyPathMatchesContract":0.001,"Weerscherm\\Tests\\DashboardServiceTest::testMeasureIsRequestedForRainModuleFromMidnightOfDateBegin":0.001,"Weerscherm\\Tests\\DashboardServiceTest::testForecastUsesStationLocationUnlessConfigured":0.001,"Weerscherm\\Tests\\DashboardServiceTest::testStationsFailureWithoutCacheGivesNullsAndError":0,"Weerscherm\\Tests\\DashboardServiceTest::testStationsFailureWithCacheServesStaleData":0.001,"Weerscherm\\Tests\\DashboardServiceTest::testAuthFailureGivesNetatmoAuthError":0,"Weerscherm\\Tests\\DashboardServiceTest::testMeasureFailureFallsBackToSumRain24":0,"Weerscherm\\Tests\\DashboardServiceTest::testForecastFailureLeavesRestIntact":0.001,"Weerscherm\\Tests\\DashboardServiceTest::testSecondBuildWithinTtlUsesCache":0.001}}
+170
View File
@@ -0,0 +1,170 @@
# Weerscherm
Een PWA voor een iPad mini (liggend) in de woonkamer. Toont de actuele gegevens van een
Netatmo-weerstation (buiten, drie binnenruimtes, regen) en een verwachting voor vijf dagen van Open-Meteo.
- Spec: [docs/weerscherm-spec.md](docs/weerscherm-spec.md)
- Mockup (ontwerp A is leidend): [docs/design/weerscherm-layouts.html](docs/design/weerscherm-layouts.html)
- Implementatieplan: [docs/superpowers/plans/2026-09-11-weerscherm.md](docs/superpowers/plans/2026-09-11-weerscherm.md)
```
iPad (PWA) ──GET /api/data.php──▶ PHP-backend ──▶ Netatmo API
│ └──▶ Open-Meteo API
└── storage/ (tokens + cache)
```
## Vereisten
- PHP 8.3 met `ext-curl` en `ext-json`. Geen framework, geen runtime-dependencies.
- Composer alleen voor PHPUnit (dev). Node 20+ alleen voor de JS-tests (dev).
- Een webserver met `public/` als document root. `storage/` ligt daarbuiten en moet schrijfbaar zijn voor de webserver.
## Installatie
```bash
git clone <repo> weerscherm
cd weerscherm
composer install # alleen nodig voor tests; de app draait ook zonder vendor/
cp config.example.php config.php
mkdir -p storage && chmod 750 storage
```
Vul `config.php` in. De sleutels:
| Sleutel | Betekenis |
|---|---|
| `data_source` | `fixture` (voorbeelddata, geen koppeling nodig) of `live` |
| `netatmo_client_id`, `netatmo_client_secret` | uit de app op dev.netatmo.com |
| `netatmo_device_id` | optioneel, `_id` van het basisstation; leeg = eerste station |
| `timezone` | fallback als het station geen tijdzone meldt (standaard `Europe/Berlin`) |
| `forecast_lat`, `forecast_lon` | optioneel; leeg = locatie van het station |
| `indoor_modules` | optioneel, `_id => label` in de gewenste volgorde; leeg = Netatmo-namen |
| `cache_ttl` | seconden per bron: `stations` 300, `measure` 900, `forecast` 3600 |
| `storage_path` | map voor tokens en cache, buiten de docroot |
`config.php` en `storage/` staan in `.gitignore` en komen nooit in git.
## Netatmo-koppeling
1. Maak een app aan op <https://dev.netatmo.com>. Zet `client_id` en `client_secret` in `config.php`.
2. Genereer met de token generator op de app-pagina een token met scope `read_station`.
3. Sla de refresh token op:
```bash
php bin/netatmo-init.php '<refresh_token>'
```
Dat schrijft `storage/netatmo-token.json` (mode 600). Bij het eerste verzoek wordt het access token ververst.
De refresh token roteert bij elke verversing; de nieuwste wordt automatisch bewaard. Gebruik een oude nooit opnieuw.
Toont het scherm "Netatmo-koppeling verlopen", genereer dan een nieuwe token en voer stap 3 opnieuw uit.
## Tests
```bash
composer test # PHPUnit: mapping, regenberekening, tokenrotatie, cache, stale-fallback
npm test # node --test: formattering, WMO-mapping, service worker
```
## Lokaal draaien
Snel, zonder Docker:
```bash
php -S 127.0.0.1:8080 -t public
```
Open <http://127.0.0.1:8080/>. Met `data_source = fixture` is er geen koppeling nodig.
Visuele check: een browservenster van exact 1133 × 744 naast de mockup (tab A).
Via de OrbStack shared-Apache-setup (`weerscherm.test`):
```bash
# eenmalig: hostnaam
sudo sh -c 'echo "127.0.0.1 weerscherm.test" >> /etc/hosts'
# shared apache herstarten (vhost en mount staan al in orbstack/shared)
cd /Volumes/development/sites/orbstack/shared && docker-compose up -d --build
# php-container van dit project
cd /Volumes/development/sites/orbstack/divers/weerscherm && docker-compose up -d --build
```
De bestanden staan in `orbstack/divers/weerscherm/` (Dockerfile, compose) en `orbstack/shared/apache/vhosts/weerscherm.conf`.
## Deploy (productie)
Productie-URL: `https://homedisplay.bahne-drei.de/` op `pensare-server`, beheerd met Ansible
(`infra-ansible-pensare`, app `weerscherm` in `inventories/production/host_vars/pensare-server.yml`).
- Code komt uit de publieke Gitea-repo `https://git.pensare.nl/theo/weerscherm.git` (branch `main`);
de role cloont of pullt als Linux-user `weerscherm`.
- `config.php` wordt gerenderd uit `roles/webapp/templates/weerscherm.config.php.j2` met de Netatmo-gegevens
uit de vault (`vault_weerscherm_netatmo_client_id`, `vault_weerscherm_netatmo_client_secret`).
- `storage_path` is `/var/www/weerscherm-storage` (www-data, 0770), buiten de release-map, zodat de token
en de cache deploys overleven.
- Document root is `public/`; `src/`, `bin/` en `config.php` zijn niet via HTTP bereikbaar.
- `composer install` is op de server niet nodig: `src/autoload.php` laadt de klassen zelf.
- SSL via certbot in dezelfde playbook-run.
Nieuwe versie uitrollen:
```bash
git push origin main
cd /Volumes/development/sites/pensare/infra-ansible-pensare
ansible-playbook playbooks/deploy-app.yml -e app=weerscherm
```
Verhoog bij frontend-wijzigingen `VERSION` in `public/sw.js`; de app herlaadt zichzelf bij `controllerchange`
en in elk geval dagelijks rond 04:00.
Token opnieuw koppelen (melding "Netatmo-koppeling verlopen"):
```bash
ssh weerscherm@65.108.40.86 'cd /var/www/weerscherm && sudo -u www-data php8.3 bin/netatmo-init.php "<refresh_token>"'
```
Lukt `sudo -u www-data` niet als `weerscherm`, doe het dan als `deploy` met `sudo`. Controleer daarna
`https://homedisplay.bahne-drei.de/api/data.php` op `"errors": []`.
- De app staat bewust zonder login. `robots.txt` en `X-Robots-Tag: noindex` houden zoekmachines buiten de deur.
## iPad-instellingen
1. Open de URL in Safari, tik op Delen → **Zet op beginscherm**. Start de app daarna altijd vanaf het beginscherm
(fullscreen, zonder Safari-balken).
2. Instellingen → Scherm en helderheid → **Automatisch vergrendelen → Nooit**.
3. Optioneel: Instellingen → Toegankelijkheid → **Begeleide toegang** inschakelen en starten met drie keer op de
zijknop, zodat het scherm niet per ongeluk verlaten wordt.
4. Houd de iPad liggend; in portretstand verschijnt "Draai de iPad een kwartslag".
## Gedrag bij storingen
| Situatie | Wat het scherm doet |
|---|---|
| Meting ouder dan 30 minuten of Netatmo tijdelijk onbereikbaar | "meting HH:MM · verouderd" in oranje, laatste data blijft staan |
| Server of wifi onbereikbaar | laatste data blijft staan, kopregel toont "geen verbinding sinds HH:MM"; herstelt vanzelf |
| Refresh token ongeldig (`netatmo_auth`) | melding over de bovenzone met de herstelinstructie |
| Open-Meteo onbereikbaar | laatst gecachte verwachting, `forecast_stale: true` |
| Allereerste start zonder data | "Gegevens laden…", daarna een concrete foutmelding |
## Acceptatiecriteria (spec §11)
- [ ] Op de iPad mini, gestart vanaf het beginscherm, draait de app fullscreen en klopt de layout met ontwerp A.
- [ ] Alle waarden komen overeen met de Netatmo-app: buiten, 3× binnen, min/max met tijdstippen, regen vandaag,
de afgelopen 3 dagen en het maandtotaal.
- [ ] De verwachting toont 5 dagen vanaf morgen met het juiste icoon per WMO-code.
- [ ] De data ververst zichzelf. Na middernacht staat de nieuwe datum er binnen 5 minuten.
- [ ] Wifi uit: de laatste data blijft staan met "geen verbinding sinds". Wifi aan: herstelt vanzelf.
- [ ] De Netatmo-token ververst meerdere dagen achter elkaar zonder handmatig ingrijpen.
- [ ] Geen secrets in git, in de browser of in responses. `storage/` is niet via HTTP bereikbaar.
- [ ] `composer test` en `npm test` slagen.
## Structuur
```
public/ document root: index.html, styles.css, app.js, format.js, icons.js, sw.js, manifest, icons/, api/data.php
src/ Config, Http, TokenStore, NetatmoClient, OpenMeteoClient, Cache, mappers, DashboardService
bin/ netatmo-init.php
storage/ tokens en cache (niet in git, niet in docroot)
tests/ PHPUnit + JSON-fixtures; tests/js voor node --test
docs/ spec, mockup, plan
```
+42
View File
@@ -0,0 +1,42 @@
#!/usr/bin/env php
<?php
declare(strict_types=1);
/**
* Eenmalig: sla de Netatmo refresh token op.
*
* php bin/netatmo-init.php <refresh_token> [access_token]
*
* De token komt van de token generator op dev.netatmo.com (scope read_station).
* Het access token is optioneel; zonder wordt er bij het eerste verzoek ververst.
* Let op: de refresh token roteert bij elke verversing. Gebruik daarna nooit meer de oude.
*/
use Weerscherm\Config;
use Weerscherm\TokenStore;
require dirname(__DIR__) . '/src/autoload.php';
$refresh = $argv[1] ?? '';
$access = $argv[2] ?? '';
if ($refresh === '' || $refresh === '-h' || $refresh === '--help') {
fwrite(STDERR, "Gebruik: php bin/netatmo-init.php <refresh_token> [access_token]\n");
exit(1);
}
$configFile = dirname(__DIR__) . '/config.php';
if (!is_file($configFile)) {
fwrite(STDERR, "config.php ontbreekt. Kopieer config.example.php naar config.php en vul de Netatmo-gegevens in.\n");
exit(1);
}
$config = Config::load($configFile);
$file = rtrim((string) $config->get('storage_path'), '/') . '/netatmo-token.json';
$store = new TokenStore($file);
$store->withLock(static function () use ($store, $refresh, $access): void {
$store->write(['access_token' => $access, 'refresh_token' => $refresh, 'expires_at' => 0]);
});
printf("Token opgeslagen in %s (mode %s).\n", $file, substr(sprintf('%o', fileperms($file)), -4));
echo "Bij het eerste verzoek wordt het access token ververst; de nieuwe refresh token wordt dan automatisch bewaard.\n";
+3
View File
@@ -30,7 +30,10 @@ return [
'cache_ttl' => [ 'cache_ttl' => [
'stations' => 300, 'stations' => 300,
'measure' => 900, 'measure' => 900,
'pressure' => 900,
'temperature' => 900,
'forecast' => 3600, 'forecast' => 3600,
'radar' => 240,
], ],
// Map voor tokens en cache. Moet schrijfbaar zijn voor de webserver en buiten de docroot liggen. // Map voor tokens en cache. Moet schrijfbaar zijn voor de webserver en buiten de docroot liggen.
+38 -4
View File
@@ -1,6 +1,6 @@
# Weerscherm: bouwspecificatie v1 # 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. **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. - 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. - 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. - 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. - 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. **Niet in v1:** nachtmodus (ontwerp C), andere meetwaarden (CO₂, luchtvochtigheid, wind), meerdere stations, een instellingenscherm, pushmeldingen.
@@ -250,12 +251,43 @@ 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". - 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%. - 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". - Onder de balk: "9,2° laagst om 06:10" en "19,8° hoogst om 13:05".
- 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):** 3 rijen met naam, temperatuur (1 decimaal) en "↓ min° ↑ max°". Een onbereikbare module toont "–" in `--muted`. - **Binnen (rechts):** 3 rijen met naam, temperatuur (1 decimaal) en "↓ min° ↑ max°". Een onbereikbare module toont "–" in `--muted`.
_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):** - **Regen (linksonder):**
- "Vandaag" groot, in mm met 1 decimaal. - "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. - 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". - 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 ### 6.4 Verversen
@@ -342,7 +374,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. - [ ] 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. - [ ] 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. - [ ] 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. - [ ] 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. - [ ] De Netatmo-token ververst meerdere dagen achter elkaar zonder handmatig ingrijpen.
+8
View File
@@ -0,0 +1,8 @@
{
"name": "weerscherm",
"private": true,
"type": "module",
"scripts": {
"test": "node --test \"tests/js/**/*.test.js\""
}
}
+11
View File
@@ -0,0 +1,11 @@
# Alleen relevant als de productieserver AllowOverride toestaat. De vhost-config heeft voorrang.
<IfModule mod_headers.c>
Header set X-Robots-Tag "noindex, nofollow"
<FilesMatch "\.(html|js|css|webmanifest)$">
Header set Cache-Control "no-cache"
</FilesMatch>
</IfModule>
<IfModule mod_mime.c>
AddType application/manifest+json .webmanifest
</IfModule>
Options -Indexes
+53
View File
@@ -0,0 +1,53 @@
<?php
declare(strict_types=1);
use Weerscherm\Cache;
use Weerscherm\Config;
use Weerscherm\DashboardService;
use Weerscherm\FixtureSource;
use Weerscherm\LiveSource;
$root = dirname(__DIR__, 2);
header('Content-Type: application/json; charset=utf-8');
header('Cache-Control: no-store');
header('X-Robots-Tag: noindex');
require $root . '/src/autoload.php';
$configFile = $root . '/config.php';
if (!is_file($configFile)) {
http_response_code(500);
echo json_encode(['errors' => ['config_missing'], 'message' => 'config.php ontbreekt: kopieer config.example.php naar config.php']);
exit;
}
try {
$config = Config::load($configFile);
$storage = rtrim((string) $config->get('storage_path'), '/');
$timezone = (string) $config->get('timezone');
$lat = $config->get('forecast_lat');
$lon = $config->get('forecast_lon');
$source = $config->get('data_source') === 'live'
? LiveSource::fromConfig($config)
: new FixtureSource(null, $timezone);
$service = new DashboardService(
$source,
new Cache($storage . '/cache'),
$config->get('cache_ttl'),
$timezone,
is_numeric($lat) ? (float) $lat : null,
is_numeric($lon) ? (float) $lon : null,
is_array($config->get('indoor_modules')) ? $config->get('indoor_modules') : [],
is_string($config->get('netatmo_device_id')) && $config->get('netatmo_device_id') !== '' ? $config->get('netatmo_device_id') : null,
);
echo json_encode($service->build(), JSON_THROW_ON_ERROR | JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
} catch (Throwable $e) {
error_log('weerscherm: ' . $e->getMessage());
http_response_code(500);
echo json_encode(['errors' => ['server_error']]);
}
+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";
}
+448
View File
@@ -0,0 +1,448 @@
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,
} from './format.js';
import { icon, windArrow } from './icons.js';
const CANVAS_W = 1133;
const CANVAS_H = 744;
const API_URL = 'api/data.php';
const REFRESH_MS = 5 * 60 * 1000;
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 $ = (id) => document.getElementById(id);
const stage = $('stage');
const viewport = $('viewport');
const state = {
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
pinnedRoomId: null, // id van de module die vastgezet is bovenaan; blijft staan tot opnieuw getikt of een volledige herlaad
};
/* ---------- schaal ---------- */
function fit() {
const w = viewport.clientWidth;
const h = viewport.clientHeight;
const s = Math.min(w / CANVAS_W, h / CANVAS_H);
const x = Math.round((w - CANVAS_W * s) / 2);
const y = Math.round((h - CANVAS_H * s) / 2);
stage.style.transform = `translate(${x}px, ${y}px) scale(${s})`;
}
/* ---------- render ---------- */
function setText(id, text) {
$(id).textContent = text;
}
function renderHeader(now) {
setText('date', formatDate(now, state.tz));
setText('clock', formatTime(now, state.tz));
}
function renderMeasurement(now) {
const d = state.data;
const upd = $('upd');
if (!d) return;
const stale = d.station.stale || isOlderThan(d.station.measured_at, now, STALE_MINUTES);
upd.textContent = `meting ${formatTime(d.station.measured_at, state.tz)}${stale ? ' · verouderd' : ''}`;
upd.classList.toggle('stale', stale);
}
function renderOutdoor(o) {
setText('trend', trendLabel(o.trend));
setText('out-hum', o.humidity === null || o.humidity === undefined ? '' : `RV ${fPct(o.humidity)}`);
setText('temp', `${f1(o.temp)}°`);
$('mark').style.left = `${markerPos(o.temp, o.min, o.max).toFixed(1)}%`;
setText('min', `${f1(o.min)}°`);
setText('min-at', `laagst om ${formatTime(o.min_at, state.tz)}`);
setText('max', `${f1(o.max)}°`);
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 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>`;
const pinned = r.id === state.pinnedRoomId;
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>
<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('');
}
function renderRain(rain, now) {
setText('rain-today', f1(rain.today));
setText('month-name', monthName(now, state.tz));
setText('month-total', `${f1(rain.month_total)} mm`);
const bars = rain.previous_days.map((p) => ({ label: dayAbbr(p.date, state.tz), mm: p.mm }));
bars.push({ label: 'Vandaag', mm: rain.today, now: true });
const heights = barHeights(bars.map((b) => b.mm));
$('bars').innerHTML = bars.map((b, i) => `<div class="a-bar${b.now ? ' now' : ''}">
<span class="num">${f1(b.mm)}</span><i style="height:${heights[i].toFixed(1)}px"></i><span>${b.label}</span>
</div>`).join('');
}
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-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" 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>
<p class="a-fr num">${fmm(d.precip_mm)}<span>${d.precip_prob === null ? '–' : d.precip_prob + '%'}</span></p>
</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 : [];
if (errors.includes('netatmo_auth')) {
banner.innerHTML = 'Netatmo-koppeling verlopen<small>Voer <code>bin/netatmo-init.php</code> opnieuw uit met een nieuwe refresh token.</small>';
banner.hidden = false;
} else if (!state.data) {
banner.hidden = false;
} else {
banner.hidden = true;
}
}
function renderStatus() {
const el = $('status');
if (state.offlineSince) {
el.textContent = `geen verbinding sinds ${formatTime(state.offlineSince, state.tz)}`;
el.hidden = false;
} else {
el.hidden = true;
}
}
function render() {
const now = new Date();
const d = state.data;
renderHeader(now);
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: [] });
renderForecast(d.forecast);
}
renderBanner();
renderStatus();
}
function esc(s) {
return String(s).replace(/[&<>"]/g, (c) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;' }[c]));
}
/* ---------- data ---------- */
async function fetchData() {
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), FETCH_TIMEOUT_MS);
try {
const res = await fetch(API_URL, { cache: 'no-store', signal: controller.signal });
if (!res.ok) throw new Error(`HTTP ${res.status}`);
const json = await res.json();
if (!json || !json.station) throw new Error('onvolledig antwoord');
return { json, fromCache: res.headers.get('X-From-Cache') === '1' };
} finally {
clearTimeout(timer);
}
}
let refreshing = false;
async function refresh() {
if (refreshing) return;
refreshing = true;
try {
const { json, fromCache } = await fetchData();
state.data = json;
state.tz = json.timezone || FALLBACK_TZ;
if (fromCache) {
state.offlineSince = state.offlineSince || state.lastOk || new Date();
} else {
state.lastOk = new Date();
state.offlineSince = null;
}
} catch (err) {
console.warn('weerscherm: ophalen mislukt', err);
if (!state.offlineSince) state.offlineSince = state.lastOk || new Date();
if (!state.data) {
$('banner').innerHTML = 'Geen verbinding met de server<small>Er wordt elke 5 minuten opnieuw geprobeerd.</small>';
}
} finally {
refreshing = false;
render();
}
}
/* ---------- timers ---------- */
function scheduleClock() {
const now = new Date();
const wait = 60000 - (now.getSeconds() * 1000 + now.getMilliseconds());
setTimeout(() => {
renderHeader(new Date());
renderMeasurement(new Date());
scheduleClock();
}, wait);
}
function scheduleMidnight() {
setTimeout(() => {
refresh();
scheduleMidnight();
}, msUntil(new Date(), state.tz, 0));
}
function scheduleReload() {
setTimeout(() => location.reload(), msUntil(new Date(), state.tz, RELOAD_HOUR));
}
async function requestWakeLock() {
if (!('wakeLock' in navigator)) return;
try {
await navigator.wakeLock.request('screen');
} catch (err) {
// Niet beschikbaar of geweigerd; Automatisch vergrendelen op "Nooit" is de primaire oplossing.
}
}
/* ---------- service worker ---------- */
let swRegistration = null;
function registerServiceWorker() {
if (!('serviceWorker' in navigator)) return;
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;
reloading = true;
location.reload();
});
}
/**
* 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', () => {
if (document.visibilityState === 'visible') {
fit();
refresh();
requestWakeLock();
checkForUpdate();
}
});
fit();
render();
refresh();
setInterval(refresh, REFRESH_MS);
setInterval(checkForUpdate, REFRESH_MS);
scheduleClock();
scheduleMidnight();
scheduleReload();
requestWakeLock();
registerServiceWorker();
+248
View File
@@ -0,0 +1,248 @@
/**
* Pure formatteer- en rekenfuncties voor het weerscherm. Geen DOM, geen fetch: los testbaar met node --test.
* Notatie nl-NL met decimale komma; tijden in de tijdzone van het station.
*/
const DASH = '–';
const WMO_ICONS = [
[[0], 'sun'],
[[1, 2], 'partly'],
[[3], 'cloud'],
[[45, 48], 'fog'],
[[51, 52, 53, 54, 55, 56, 57], 'drizzle'],
[[61, 62, 63, 64, 65, 66, 67], 'rain'],
[[80, 81, 82], 'showers'],
[[71, 72, 73, 74, 75, 76, 77, 85, 86], 'snow'],
[[95, 96, 97, 98, 99], 'thunder'],
];
const TRENDS = { up: '↗ stijgend', down: '↘ dalend', stable: '→ stabiel' };
function isNum(v) {
return typeof v === 'number' && Number.isFinite(v);
}
function cap(s) {
return s.charAt(0).toUpperCase() + s.slice(1);
}
/** 16.4 -> "16,4"; null -> "–" */
export function f1(v) {
return isNum(v) ? v.toFixed(1).replace('.', ',') : DASH;
}
/** 18.3 -> "18"; null -> "–" */
export function fInt(v) {
return isNum(v) ? String(Math.round(v)) : DASH;
}
/** 0 -> "0 mm"; 0.4 -> "0,4 mm"; null -> "–" */
export function fmm(v) {
if (!isNum(v)) return DASH;
return v === 0 ? '0 mm' : f1(v) + ' mm';
}
/** Positie van de marker op de min/max-balk, 0–100. Bij max == min of onbekend: 50. */
export function markerPos(temp, min, max) {
if (!isNum(temp) || !isNum(min) || !isNum(max) || max === min) return 50;
const pos = ((temp - min) / (max - min)) * 100;
return Math.min(100, Math.max(0, pos));
}
/** Hoogte per staaf in px: mm / max(waarden) * maxPx, minimaal minPx. Alles 0 of null -> allemaal minPx. */
export function barHeights(values, maxPx = 70, minPx = 2) {
const mm = values.map((v) => (isNum(v) ? v : 0));
const top = Math.max(...mm, 0);
return mm.map((v) => (top > 0 ? Math.max(minPx, (v / top) * maxPx) : minPx));
}
/** WMO-weercode -> icoonnaam. Onbekend -> "cloud". */
export function wmoIcon(code) {
for (const [codes, name] of WMO_ICONS) {
if (codes.includes(code)) return name;
}
return 'cloud';
}
/** "up" -> "↗ stijgend" enz.; onbekend -> "" */
export function trendLabel(trend) {
return TRENDS[trend] || '';
}
/** "Vrijdag 11 september" */
export function formatDate(date, tz) {
const s = new Intl.DateTimeFormat('nl-NL', { weekday: 'long', day: 'numeric', month: 'long', timeZone: tz }).format(date);
return cap(s);
}
/** ISO-tijdstip -> "06:10" in de tijdzone; null -> "–" */
export function formatTime(iso, tz) {
const d = iso instanceof Date ? iso : new Date(iso);
if (!iso || Number.isNaN(d.getTime())) return DASH;
return new Intl.DateTimeFormat('nl-NL', { hour: '2-digit', minute: '2-digit', hourCycle: 'h23', timeZone: tz }).format(d);
}
/** "2026-09-12" -> "Za" */
export function dayAbbr(dateStr, tz) {
const d = new Date(dateStr + 'T12:00:00Z');
if (Number.isNaN(d.getTime())) return DASH;
const s = new Intl.DateTimeFormat('nl-NL', { weekday: 'short', timeZone: 'UTC' }).format(d);
return cap(s.replace('.', '')).slice(0, 2);
}
/** "September" */
export function monthName(date, tz) {
return cap(new Intl.DateTimeFormat('nl-NL', { month: 'long', timeZone: tz }).format(date));
}
/** Is het ISO-tijdstip ouder dan `minutes` minuten ten opzichte van `now`? Onbekend telt als verouderd. */
export function isOlderThan(iso, now, minutes) {
if (!iso) return true;
const t = new Date(iso).getTime();
if (Number.isNaN(t)) return true;
return now.getTime() - t > minutes * 60000;
}
/** Lokale tijd (uur, minuut, seconde) van `now` in de tijdzone. */
function localParts(now, tz) {
const parts = new Intl.DateTimeFormat('en-GB', {
hour: '2-digit', minute: '2-digit', second: '2-digit', hourCycle: 'h23', timeZone: tz,
}).formatToParts(now);
const get = (type) => Number(parts.find((p) => p.type === type).value);
return { h: get('hour'), m: get('minute'), s: get('second') };
}
/** Milliseconden tot de volgende keer dat het `hour`:`minute` is in de tijdzone (minimaal 1 s). */
export function msUntil(now, tz, hour, minute = 0) {
const { h, m, s } = localParts(now, tz);
const target = hour * 3600 + minute * 60;
const current = h * 3600 + m * 60 + s;
let diff = target - current;
if (diff <= 0) diff += 86400;
return Math.max(1000, diff * 1000 - now.getMilliseconds());
}
/** CO₂-niveau volgens de Netatmo-drempels: tot 1000 goed, tot 1500 matig, daarboven slecht. */
export function co2Level(ppm) {
if (!isNum(ppm)) return { level: 'unknown', label: DASH };
if (ppm <= 1000) return { level: 'good', label: 'goed' };
if (ppm <= 1500) return { level: 'medium', label: 'matig' };
return { level: 'bad', label: 'slecht' };
}
/** 48 -> "48%"; null -> "–" */
export function fPct(v) {
return isNum(v) ? `${Math.round(v)}%` : DASH;
}
/** 1014.2 -> "1014 hPa"; null -> "–" */
export function fHpa(v) {
return isNum(v) ? `${Math.round(v)} hPa` : DASH;
}
/** Laagste en hoogste waarde van een reeks (null-waarden genegeerd), of null zonder geldige waarden. */
export function seriesRange(values) {
const nums = values.filter(isNum);
if (nums.length === 0) return null;
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) {
if (values.length < 2) return '';
const parts = [];
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;
}
+37
View File
@@ -0,0 +1,37 @@
/**
* Weericonen als inline SVG-strings. viewBox 0 0 48 48, stroke 2.4, ronde uiteinden.
* Zon-delen krijgen de klasse i-sun, wolk-vullingen i-cloud, druppels/vlokken/mist i-drop.
*/
const SUN = '<g class="i-sun"><circle cx="24" cy="24" r="8"/><path d="M24 6v5M24 37v5M6 24h5M37 24h5M11.3 11.3l3.5 3.5M33.2 33.2l3.5 3.5M11.3 36.7l3.5-3.5M33.2 14.8l3.5-3.5"/></g>';
const SMALL_SUN = '<g class="i-sun"><circle cx="18" cy="17" r="6"/><path d="M18 5v3M6 17h3M9.5 8.5l2 2M26.5 8.5l-2 2"/></g>';
const CLOUD_LOW = '<path class="i-cloud" d="M17 40h18a7 7 0 0 0 .8-13.96A10 10 0 0 0 17.3 28 6 6 0 0 0 17 40z"/>';
const CLOUD_HIGH = '<path class="i-cloud" d="M15 30h19a7 7 0 0 0 .8-13.96A10 10 0 0 0 15.3 18 6 6 0 0 0 15 30z"/>';
const CLOUD_MID = '<path class="i-cloud" d="M15 34h19a7 7 0 0 0 .8-13.96A10 10 0 0 0 15.3 22 6 6 0 0 0 15 34z"/>';
const ICONS = {
sun: SUN,
partly: SMALL_SUN + CLOUD_LOW,
cloud: CLOUD_MID,
fog: CLOUD_HIGH + '<path class="i-drop" d="M14 36h20M18 42h14"/>',
drizzle: CLOUD_HIGH + '<path class="i-drop" d="M18 35l-1 2.5M26 35l-1 2.5M34 35l-1 2.5"/>',
rain: CLOUD_HIGH + '<path class="i-drop" d="M18 35l-2 5M26 35l-2 5M34 35l-2 5"/>',
showers: '<g class="i-sun"><circle cx="16" cy="14" r="5"/><path d="M16 4v2.5M6 14h2.5M9 7l1.8 1.8M23 7l-1.8 1.8"/></g>'
+ '<path class="i-cloud" d="M17 32h17a6.5 6.5 0 0 0 .7-12.96A9.5 9.5 0 0 0 17.3 21 5.5 5.5 0 0 0 17 32z"/>'
+ '<path class="i-drop" d="M22 37l-2 5M30 37l-2 5"/>',
snow: CLOUD_HIGH + '<path class="i-drop" d="M17 35v6M14.4 36.5l5.2 3M14.4 39.5l5.2-3M25 35v6M22.4 36.5l5.2 3M22.4 39.5l5.2-3M33 35v6M30.4 36.5l5.2 3M30.4 39.5l5.2-3"/>',
thunder: CLOUD_HIGH + '<path class="i-sun" d="M26 31l-5 8h6l-3 6"/>',
};
export const ICON_NAMES = Object.keys(ICONS);
/** SVG-markup voor een icoonnaam; onbekend -> cloud. */
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>';
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 24 KiB

+7
View File
@@ -0,0 +1,7 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 48 48" width="512" height="512">
<rect width="48" height="48" fill="#E8EDF1"/>
<g fill="none" stroke-width="2.4" stroke-linecap="round" stroke-linejoin="round">
<g stroke="#D39A2C"><circle cx="19" cy="18" r="6"/><path d="M19 6v3M7 18h3M10.5 9.5l2 2M27.5 9.5l-2 2"/></g>
<path fill="#E8EDF1" stroke="#1B2A35" d="M17 40h18a7 7 0 0 0 .8-13.96A10 10 0 0 0 17.3 28 6 6 0 0 0 17 40z"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 459 B

+126
View File
@@ -0,0 +1,126 @@
<!doctype html>
<html lang="nl">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover, user-scalable=no">
<title>Weerscherm</title>
<meta name="apple-mobile-web-app-capable" content="yes">
<meta name="apple-mobile-web-app-status-bar-style" content="default">
<meta name="apple-mobile-web-app-title" content="Weer">
<meta name="theme-color" content="#E8EDF1">
<link rel="manifest" href="manifest.webmanifest">
<link rel="apple-touch-icon" href="icons/icon-180.png">
<link rel="icon" href="icons/icon.svg" type="image/svg+xml">
<link rel="stylesheet" href="styles.css">
</head>
<body>
<div id="viewport">
<main id="stage" class="da" aria-live="polite">
<header class="a-top">
<span class="a-date" id="date">&nbsp;</span>
<span class="a-right">
<span class="a-status" id="status" hidden></span>
<span class="a-upd" id="upd">meting –</span>
<span class="a-clock num" id="clock">–:–</span>
</span>
</header>
<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>
<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">
<span><b class="cold" id="min">–</b><span id="min-at">laagst om –</span></span>
<span><b class="warm" id="max">–</b><span id="max-at">hoogst om –</span></span>
</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></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-bottom">
<div id="rain-block">
<p class="a-sub">Regen</p>
<div class="a-rainrow">
<div>
<p class="a-rbig"><span class="num" id="rain-today">–</span> mm</p>
<p class="a-rsm">vandaag</p>
<p class="a-month"><span id="month-name">–</span><b class="num" id="month-total">–</b></p>
</div>
<div class="a-bars" id="bars"></div>
</div>
</div>
<div class="a-fc" id="forecast"></div>
</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>
<script type="module" src="app.js"></script>
</body>
</html>
+16
View File
@@ -0,0 +1,16 @@
{
"name": "Weerscherm",
"short_name": "Weer",
"description": "Netatmo-weerstation en verwachting voor in de woonkamer",
"start_url": "./",
"scope": "./",
"display": "standalone",
"orientation": "landscape",
"background_color": "#E8EDF1",
"theme_color": "#E8EDF1",
"lang": "nl",
"icons": [
{ "src": "icons/icon-192.png", "sizes": "192x192", "type": "image/png" },
{ "src": "icons/icon-512.png", "sizes": "512x512", "type": "image/png" }
]
}
+2
View File
@@ -0,0 +1,2 @@
User-agent: *
Disallow: /
+250
View File
@@ -0,0 +1,250 @@
/* Weerscherm, ontwerp A. Vast canvas van 1133 × 744 dat via JS geschaald en gecentreerd wordt. */
:root {
--bg: #E8EDF1;
--ink: #1B2A35;
--muted: #5F707C;
--line: #C6D0D8;
--warm: #B8761C;
--cold: #3A75A3;
--rain: #2E6A99;
--sun: #D39A2C;
--good: #4E8F5A;
--bad: #B5433B;
--iconfill: #E8EDF1;
--w: 1133px;
--h: 744px;
}
* { box-sizing: border-box; margin: 0; padding: 0; }
html, body {
height: 100%;
overflow: hidden;
background: var(--bg);
color: var(--ink);
font-family: -apple-system, "SF Pro Display", system-ui, "Segoe UI", Roboto, sans-serif;
font-variant-numeric: tabular-nums;
-webkit-text-size-adjust: none;
-webkit-user-select: none;
user-select: none;
-webkit-touch-callout: none;
}
[hidden] { display: none !important; }
#viewport {
position: fixed;
top: env(safe-area-inset-top, 0px);
right: env(safe-area-inset-right, 0px);
bottom: env(safe-area-inset-bottom, 0px);
left: env(safe-area-inset-left, 0px);
overflow: hidden;
}
#stage {
position: absolute;
top: 0;
left: 0;
width: var(--w);
height: var(--h);
transform-origin: top left;
display: grid;
padding: 30px 44px;
grid-template-columns: 1fr 330px;
grid-template-rows: 40px 1fr 232px;
column-gap: 56px;
background: var(--bg);
}
.num { font-variant-numeric: tabular-nums; }
.cold { color: var(--cold); }
.warm { color: var(--warm); }
.muted { color: var(--muted); }
.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); }
/* Kopregel */
.a-top { grid-column: 1 / -1; display: flex; justify-content: space-between; align-items: baseline; }
.a-date { font-size: 22px; font-weight: 500; }
.a-right { display: flex; gap: 18px; align-items: baseline; }
.a-status { font-size: 15px; color: var(--warm); }
.a-upd { font-size: 15px; color: var(--muted); }
.a-upd.stale { color: var(--warm); }
.a-clock { font-size: 26px; font-weight: 300; }
/* Buiten */
.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-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; }
.a-track { position: relative; height: 12px; border-radius: 6px; background: linear-gradient(90deg, var(--cold), var(--warm)); }
.a-mark { position: absolute; top: 50%; width: 28px; height: 28px; border-radius: 50%; background: var(--bg); border: 5px solid var(--ink); transform: translate(-50%, -50%); transition: left .6s ease; }
.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. 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-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; }
.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; }
.pill { display: inline-block; font-size: 13px; font-weight: 600; line-height: 1; padding: 5px 10px; border-radius: 999px; color: #fff; background: var(--muted); letter-spacing: .01em; white-space: nowrap; }
.pill.good { background: var(--good); }
.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; transition: font-size .25s ease; }
.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); }
/* Onderste strook */
.a-bottom { grid-column: 1 / -1; border-top: 1px solid var(--line); padding-top: 22px; display: grid; grid-template-columns: 390px 1fr; column-gap: 36px; }
.a-sub { font-size: 19px; color: var(--muted); font-weight: 500; margin-bottom: 10px; }
.a-rainrow { display: flex; justify-content: space-between; align-items: flex-end; }
.a-rbig { font-size: 22px; color: var(--rain); }
.a-rbig span { font-size: 60px; font-weight: 300; line-height: 1; }
.a-rsm { font-size: 17px; color: var(--muted); }
.a-month { font-size: 17px; margin-top: 18px; color: var(--muted); white-space: nowrap; }
.a-month b { color: var(--ink); font-weight: 600; font-size: 20px; margin-left: 4px; }
.a-bars { display: flex; gap: 6px; align-items: flex-end; }
.a-bar { display: flex; flex-direction: column; align-items: center; gap: 5px; font-size: 14px; color: var(--muted); min-width: 38px; }
.a-bar i { display: block; width: 24px; border-radius: 4px 4px 0 0; background: var(--rain); opacity: .4; transition: height .6s ease; }
.a-bar.now i { opacity: 1; }
.a-bar.now span { color: var(--ink); font-weight: 600; }
/* Verwachting */
.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: 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); }
.a-fr span { color: var(--muted); margin-left: 6px; }
/* Meldingen */
.banner {
position: absolute;
left: 44px;
right: 44px;
top: 86px;
height: 396px;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
text-align: center;
padding: 40px 60px;
font-size: 30px;
line-height: 1.4;
color: var(--ink);
background: var(--bg);
border: 1px solid var(--line);
border-radius: 18px;
z-index: 2;
}
.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;
top: 0; right: 0; bottom: 0; left: 0;
align-items: center;
justify-content: center;
font-size: 30px;
color: var(--muted);
background: var(--bg);
z-index: 10;
}
@media (orientation: portrait) {
#rotate { display: flex; }
}
+90
View File
@@ -0,0 +1,90 @@
/* Service worker: app-shell cache-first, API network-first met timeout en fallback op de laatste response. */
const VERSION = 'weerscherm-v10';
const SHELL = [
'./',
'./index.html',
'./styles.css',
'./app.js',
'./format.js',
'./icons.js',
'./manifest.webmanifest',
'./icons/icon.svg',
'./icons/icon-180.png',
'./icons/icon-192.png',
'./icons/icon-512.png',
];
const API_PREFIX = '/api/';
const DATA_PATH = '/api/data.php';
const API_TIMEOUT_MS = 8000;
self.addEventListener('install', (event) => {
event.waitUntil(
caches.open(VERSION).then((cache) => cache.addAll(SHELL)).then(() => self.skipWaiting()),
);
});
self.addEventListener('activate', (event) => {
event.waitUntil(
caches.keys()
.then((keys) => Promise.all(keys.filter((k) => k !== VERSION).map((k) => caches.delete(k))))
.then(() => self.clients.claim()),
);
});
self.addEventListener('fetch', (event) => {
const req = event.request;
if (req.method !== 'GET') return;
const url = new URL(req.url);
if (url.origin !== self.location.origin) return;
if (url.pathname.includes(API_PREFIX)) {
event.respondWith(networkFirst(req));
} else {
event.respondWith(cacheFirst(req));
}
});
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(key, res.clone());
}
return res;
} catch (err) {
const cached = await cache.match(key);
if (cached) {
const headers = new Headers(cached.headers);
headers.set('X-From-Cache', '1');
return new Response(await cached.arrayBuffer(), { status: 200, statusText: 'OK', headers });
}
if (url.pathname.endsWith(DATA_PATH)) {
return new Response(JSON.stringify({ errors: ['offline'] }), {
status: 503,
headers: { 'Content-Type': 'application/json; charset=utf-8', 'Cache-Control': 'no-store' },
});
}
return new Response('offline', { status: 503, headers: { 'Cache-Control': 'no-store' } });
} finally {
clearTimeout(timer);
}
}
async function cacheFirst(req) {
const cache = await caches.open(VERSION);
const cached = await cache.match(req, { ignoreSearch: true });
if (cached) return cached;
const res = await fetch(req);
if (res.ok) {
cache.put(req, res.clone());
}
return res;
}
+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;
}
}
+3
View File
@@ -18,7 +18,10 @@ final class Config
'cache_ttl' => [ 'cache_ttl' => [
'stations' => 300, 'stations' => 300,
'measure' => 900, 'measure' => 900,
'pressure' => 900,
'temperature' => 900,
'forecast' => 3600, 'forecast' => 3600,
'radar' => 240,
], ],
'storage_path' => null, 'storage_path' => null,
]; ];
+70 -6
View File
@@ -10,6 +10,8 @@ namespace Weerscherm;
*/ */
final class DashboardService final class DashboardService
{ {
private const PRESSURE_HOURS = 24;
private const ERR_NETATMO_AUTH = 'netatmo_auth'; private const ERR_NETATMO_AUTH = 'netatmo_auth';
private const ERR_NETATMO = 'netatmo_unavailable'; private const ERR_NETATMO = 'netatmo_unavailable';
private const ERR_FORECAST = 'forecast_unavailable'; private const ERR_FORECAST = 'forecast_unavailable';
@@ -18,7 +20,7 @@ final class DashboardService
private array $errors = []; private array $errors = [];
/** /**
* @param array{stations: int, measure: int, forecast: int} $ttl * @param array{stations: int, measure: int, pressure: int, forecast: int} $ttl
* @param array<string, string> $indoorModules * @param array<string, string> $indoorModules
*/ */
public function __construct( public function __construct(
@@ -47,6 +49,8 @@ final class DashboardService
$now = $nowUtc->setTimezone($tz); $now = $nowUtc->setTimezone($tz);
$rain = $this->rain($station, $now); $rain = $this->rain($station, $now);
$pressure = $this->pressure($station, $now);
$outdoorHistory = $this->outdoorHistory($station, $now);
$forecast = $this->forecast($station, $tz); $forecast = $this->forecast($station, $tz);
return [ return [
@@ -63,9 +67,12 @@ final class DashboardService
'min_at' => $this->iso($station['outdoor']['min_at'], $tz), 'min_at' => $this->iso($station['outdoor']['min_at'], $tz),
'max' => $station['outdoor']['max'], 'max' => $station['outdoor']['max'],
'max_at' => $this->iso($station['outdoor']['max_at'], $tz), 'max_at' => $this->iso($station['outdoor']['max_at'], $tz),
'humidity' => $station['outdoor']['humidity'],
], ],
'outdoor_history' => $outdoorHistory,
'indoor' => $station['indoor'], 'indoor' => $station['indoor'],
'rain' => $rain, 'rain' => $rain,
'pressure' => $pressure,
'forecast' => $forecast['days'], 'forecast' => $forecast['days'],
'forecast_stale' => $forecast['stale'], 'forecast_stale' => $forecast['stale'],
'errors' => $this->errors, 'errors' => $this->errors,
@@ -91,6 +98,48 @@ final class DashboardService
return RainCalculator::compute($measure['data'], $now, $station['sum_rain_24']) + ['stale' => $measure['stale']]; return RainCalculator::compute($measure['data'], $now, $station['sum_rain_24']) + ['stale' => $measure['stale']];
} }
/** @return array{current: ?float, trend: ?string, history: list<array{at: string, hpa: float}>, stale: bool} */
private function pressure(array $station, \DateTimeImmutable $now): array
{
$base = ['current' => $station['pressure']['current'], 'trend' => $station['pressure']['trend']];
if ($station['device_id'] === null) {
return $base + ['history' => [], 'stale' => false];
}
$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 ? [] : 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];
}
$begin = $now->setTime(0, 0, 0)->getTimestamp();
$measure = $this->fetch(
'temperature',
fn() => $this->source->temperatureHistory($station['device_id'], $moduleId, $begin, $now->getTimestamp()),
self::ERR_NETATMO,
);
return [
'points' => $measure['data'] === null ? [] : MeasureSeries::map($measure['data'], $now->getTimezone(), 'temp'),
'stale' => $measure['stale'],
];
}
/** @return array{days: list<array>, stale: bool} */ /** @return array{days: list<array>, stale: bool} */
private function forecast(array $station, \DateTimeZone $tz): array private function forecast(array $station, \DateTimeZone $tz): array
{ {
@@ -100,11 +149,26 @@ final class DashboardService
$this->addError(self::ERR_FORECAST); $this->addError(self::ERR_FORECAST);
return ['days' => [], 'stale' => true]; return ['days' => [], 'stale' => true];
} }
$daily = $this->fetch('forecast', fn() => $this->source->forecast($lat, $lon, $tz->getName()), self::ERR_FORECAST); $result = $this->fetch('forecast', fn() => $this->source->forecast($lat, $lon, $tz->getName()), self::ERR_FORECAST);
return [ $daily = is_array($result['data']['daily'] ?? null) ? $result['data']['daily'] : null;
'days' => $daily['data'] === null ? [] : ForecastMapper::map($daily['data']), if ($daily === null) {
'stale' => $daily['stale'], // 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']];
} }
/** /**
+71 -13
View File
@@ -27,16 +27,17 @@ final class FixtureSource implements WeatherSource
return ['devices' => [[ return ['devices' => [[
'_id' => '70:ee:50:00:00:01', 'type' => 'NAMain', 'station_name' => 'Thuis', 'module_name' => 'Woonkamer', 'reachable' => true, '_id' => '70:ee:50:00:00:01', 'type' => 'NAMain', 'station_name' => 'Thuis', 'module_name' => 'Woonkamer', 'reachable' => true,
'place' => ['timezone' => $this->timezone, 'location' => [6.55, 51.82]], 'place' => ['timezone' => $this->timezone, 'location' => [6.55, 51.82]],
'dashboard_data' => ['time_utc' => $at('-2 minutes'), 'Temperature' => 21.3, 'min_temp' => 20.4, 'max_temp' => 22.1, 'dashboard_data' => ['time_utc' => $at('-2 minutes'), 'Temperature' => 21.3, 'CO2' => 612, 'Humidity' => 48,
'date_min_temp' => $today('05:50'), 'date_max_temp' => $today('13:40'), 'temp_trend' => 'stable'], 'Pressure' => 1014.2, 'AbsolutePressure' => 1011.8, 'pressure_trend' => 'up', 'Noise' => 38,
'min_temp' => 20.4, 'max_temp' => 22.1, 'date_min_temp' => $today('05:50'), 'date_max_temp' => $today('13:40'), 'temp_trend' => 'stable'],
'modules' => [ 'modules' => [
['_id' => '02:00:00:00:00:01', 'type' => 'NAModule1', 'module_name' => 'Buiten', 'reachable' => true, ['_id' => '02:00:00:00:00:01', 'type' => 'NAModule1', 'module_name' => 'Buiten', 'reachable' => true,
'dashboard_data' => ['time_utc' => $at('-3 minutes'), 'Temperature' => 16.4, 'min_temp' => 9.2, 'max_temp' => 19.8, 'dashboard_data' => ['time_utc' => $at('-3 minutes'), 'Temperature' => 16.4, 'Humidity' => 71, 'min_temp' => 9.2, 'max_temp' => 19.8,
'date_min_temp' => $today('06:10'), 'date_max_temp' => $today('13:05'), 'temp_trend' => 'down']], 'date_min_temp' => $today('06:10'), 'date_max_temp' => $today('13:05'), 'temp_trend' => 'down']],
['_id' => '03:00:00:00:00:02', 'type' => 'NAModule4', 'module_name' => 'Slaapkamer', 'reachable' => true, ['_id' => '03:00:00:00:00:02', 'type' => 'NAModule4', 'module_name' => 'Slaapkamer', 'reachable' => true,
'dashboard_data' => ['time_utc' => $at('-4 minutes'), 'Temperature' => 18.9, 'min_temp' => 18.1, 'max_temp' => 19.6, 'temp_trend' => 'up']], '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, ['_id' => '03:00:00:00:00:03', 'type' => 'NAModule4', 'module_name' => 'Kantoor', 'reachable' => true,
'dashboard_data' => ['time_utc' => $at('-4 minutes'), 'Temperature' => 20.2, 'min_temp' => 19.0, 'max_temp' => 21.0, 'temp_trend' => 'stable']], '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' => '05:00:00:00:00:04', 'type' => 'NAModule3', 'module_name' => 'Regenmeter', 'reachable' => true, ['_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]], 'dashboard_data' => ['time_utc' => $at('-3 minutes'), 'Rain' => 0, 'sum_rain_1' => 0, 'sum_rain_24' => 2.4]],
], ],
@@ -56,19 +57,76 @@ final class FixtureSource implements WeatherSource
return $body; return $body;
} }
public function pressureHistory(string $deviceId, int $begin, int $end): array
{
$body = [];
for ($ts = $begin - ($begin % 1800); $ts <= $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 uit stationsData(): koudst rond 06:00, warmst rond 15:00.
$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 = 9.2 + (19.8 - 9.2) * (0.5 + 0.5 * sin((($hour - 10) / 24) * 2 * M_PI));
$body[(string) $ts] = [round($t, 1)];
}
return $body;
}
public function forecast(float $lat, float $lon, string $timezone): array 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 = []; $dates = [];
for ($i = 0; $i < 6; $i++) { $hourlyTime = [];
$dates[] = $this->now->modify("+$i days")->format('Y-m-d'); $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 [ return [
'time' => $dates, 'daily' => [
'weather_code' => [3, 2, 0, 61, 80, 95], 'time' => $dates,
'temperature_2m_max' => [17.9, 18.3, 20.1, 17.4, 15.2, 16.0], 'weather_code' => [3, 2, 0, 61, 80, 95],
'temperature_2m_min' => [11.0, 10.1, 11.3, 12.0, 9.4, 8.1], 'temperature_2m_max' => $tmax,
'precipitation_sum' => [0.0, 0.4, 0.0, 8.2, 3.1, 0.2], 'temperature_2m_min' => $tmin,
'precipitation_probability_max' => [10, 20, 5, 80, 60, 15], '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; 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 final class ForecastMapper
{ {
private const DAYS = 5; private const DAYS = 6;
/** /**
* @param array<string, mixed> $daily * @param array<string, mixed> $daily
@@ -17,7 +17,7 @@ final class ForecastMapper
{ {
$dates = is_array($daily['time'] ?? null) ? $daily['time'] : []; $dates = is_array($daily['time'] ?? null) ? $daily['time'] : [];
$out = []; $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])) { if (!is_string($dates[$i])) {
continue; 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;
}
}
+65
View File
@@ -0,0 +1,65 @@
<?php
declare(strict_types=1);
namespace Weerscherm;
/** Dunne curl-wrapper met korte timeouts. Netwerkfouten worden UpstreamException. */
final class Http implements HttpClient
{
/**
* @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
{
if ($query !== []) {
$url .= (str_contains($url, '?') ? '&' : '?') . http_build_query($query);
}
return $this->request($url, $headers, null);
}
public function postForm(string $url, array $fields): HttpResponse
{
return $this->request($url, ['Content-Type: application/x-www-form-urlencoded'], http_build_query($fields));
}
/** @param list<string> $headers */
private function request(string $url, array $headers, ?string $postBody): HttpResponse
{
$ch = curl_init($url);
if ($ch === false) {
throw new UpstreamException('curl_init mislukt');
}
curl_setopt_array($ch, [
CURLOPT_RETURNTRANSFER => true,
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),
CURLOPT_SSL_VERIFYPEER => true,
]);
if ($postBody !== null) {
curl_setopt($ch, CURLOPT_POST, true);
curl_setopt($ch, CURLOPT_POSTFIELDS, $postBody);
}
$body = curl_exec($ch);
$status = (int) curl_getinfo($ch, CURLINFO_RESPONSE_CODE);
$error = curl_error($ch);
curl_close($ch);
if (!is_string($body)) {
throw new UpstreamException("Verzoek mislukt: $error");
}
return new HttpResponse($status, $body);
}
}
+17
View File
@@ -0,0 +1,17 @@
<?php
declare(strict_types=1);
namespace Weerscherm;
interface HttpClient
{
/**
* @param array<string, string|int|float> $query
* @param list<string> $headers
*/
public function get(string $url, array $query = [], array $headers = []): HttpResponse;
/** @param array<string, string|int|float> $fields */
public function postForm(string $url, array $fields): HttpResponse;
}
+26
View File
@@ -0,0 +1,26 @@
<?php
declare(strict_types=1);
namespace Weerscherm;
final class HttpResponse
{
public function __construct(public readonly int $status, public readonly string $body)
{
}
/** @return array<string|int, mixed> */
public function json(): array
{
try {
$decoded = json_decode($this->body, true, 512, JSON_THROW_ON_ERROR);
} catch (\JsonException $e) {
throw new UpstreamException('Ongeldige JSON in antwoord: ' . $e->getMessage(), 0, $e);
}
if (!is_array($decoded)) {
throw new UpstreamException('Antwoord is geen JSON-object');
}
return $decoded;
}
}
+51
View File
@@ -0,0 +1,51 @@
<?php
declare(strict_types=1);
namespace Weerscherm;
/** Echte bronnen: Netatmo (stationsdata, regenmetingen) en Open-Meteo (verwachting). */
final class LiveSource implements WeatherSource
{
public function __construct(private readonly NetatmoClient $netatmo, private readonly OpenMeteoClient $openMeteo)
{
}
public static function fromConfig(Config $config): self
{
$clientId = (string) $config->get('netatmo_client_id');
$clientSecret = (string) $config->get('netatmo_client_secret');
if ($clientId === '' || $clientSecret === '') {
throw new \RuntimeException('netatmo_client_id en netatmo_client_secret ontbreken in config.php');
}
$http = new Http();
$store = new TokenStore(rtrim((string) $config->get('storage_path'), '/') . '/netatmo-token.json');
return new self(new NetatmoClient($http, $store, $clientId, $clientSecret), new OpenMeteoClient($http));
}
public function stationsData(): array
{
return $this->netatmo->getStationsData();
}
public function measure(string $deviceId, string $moduleId, int $begin, int $end): array
{
return $this->netatmo->getMeasure($deviceId, $moduleId, $begin, $end);
}
public function pressureHistory(string $deviceId, int $begin, int $end): array
{
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->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;
}
}
+169
View File
@@ -0,0 +1,169 @@
<?php
declare(strict_types=1);
namespace Weerscherm;
/**
* Netatmo Weather API met OAuth2 refresh-token-flow.
* De refresh token roteert: elk refresh-antwoord bevat een nieuwe, die direct wordt opgeslagen.
* Verversen gebeurt onder een lock (TokenStore::withLock) zodat parallelle requests niet dubbel verversen.
*/
final class NetatmoClient
{
private const TOKEN_URL = 'https://api.netatmo.com/oauth2/token';
private const API_URL = 'https://api.netatmo.com/api/';
private const REFRESH_MARGIN = 300; // seconden vóór verloop waarop proactief ververst wordt
/** @var \Closure(): int */
private readonly \Closure $now;
public function __construct(
private readonly HttpClient $http,
private readonly TokenStore $store,
private readonly string $clientId,
private readonly string $clientSecret,
?\Closure $now = null,
) {
$this->now = $now ?? static fn(): int => time();
}
/** @return array<string, mixed> de "body" van getstationsdata */
public function getStationsData(): array
{
$json = $this->apiGet('getstationsdata', []);
if (!is_array($json['body'] ?? null)) {
throw new UpstreamException('getstationsdata: geen body in antwoord');
}
return $json['body'];
}
/** @return array<string|int, mixed> timestamp => [mm] */
public function getMeasure(string $deviceId, string $moduleId, int $begin, int $end): array
{
$json = $this->apiGet('getmeasure', [
'device_id' => $deviceId,
'module_id' => $moduleId,
'scale' => '1day',
'type' => 'sum_rain',
'date_begin' => $begin,
'date_end' => $end,
'optimize' => 'false',
'real_time' => 'false',
]);
if (!is_array($json['body'] ?? null)) {
throw new UpstreamException('getmeasure: geen body in antwoord');
}
return $json['body'];
}
/** @return array<string|int, mixed> timestamp => [hPa], per 30 minuten, van het basisstation */
public function getPressureHistory(string $deviceId, int $begin, int $end): array
{
return $this->measureSeries('Pressure', ['device_id' => $deviceId], $begin, $end);
}
/** @return array<string|int, mixed> timestamp => [°C], per 30 minuten, van de opgegeven module */
public function getTemperatureHistory(string $deviceId, string $moduleId, int $begin, int $end): array
{
return $this->measureSeries('Temperature', ['device_id' => $deviceId, 'module_id' => $moduleId], $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' => $type,
'date_begin' => $begin,
'date_end' => $end,
'optimize' => 'false',
'real_time' => 'false',
]);
if (!is_array($json['body'] ?? null)) {
throw new UpstreamException("getmeasure ($type): geen body in antwoord");
}
return $json['body'];
}
/** @param array<string, string|int|float> $query */
private function apiGet(string $path, array $query): array
{
$token = $this->accessToken(null);
$res = $this->http->get(self::API_URL . $path, $query, ["Authorization: Bearer $token"]);
if ($res->status === 401 || $res->status === 403) {
$token = $this->accessToken($token);
$res = $this->http->get(self::API_URL . $path, $query, ["Authorization: Bearer $token"]);
if ($res->status === 401 || $res->status === 403) {
throw new NetatmoAuthException("Netatmo weigert het access token ($path: HTTP {$res->status})");
}
}
if ($res->status !== 200) {
throw new UpstreamException("Netatmo $path: HTTP {$res->status}");
}
return $res->json();
}
/**
* Geeft een geldig access token. Met $rejected wordt het token doorgegeven dat zojuist geweigerd is:
* is er intussen (door een ander proces) al een ander token opgeslagen, dan wordt dát gebruikt,
* anders wordt er ververst.
*/
private function accessToken(?string $rejected): string
{
return $this->store->withLock(function () use ($rejected): string {
$token = $this->store->read();
if ($token === null) {
throw new NetatmoAuthException('Geen Netatmo-token opgeslagen; voer bin/netatmo-init.php uit');
}
$usable = $token['access_token'] !== '' && $token['expires_at'] - ($this->now)() > self::REFRESH_MARGIN;
if ($usable && $token['access_token'] !== $rejected) {
return $token['access_token'];
}
return $this->refresh($token['refresh_token']);
});
}
private function refresh(string $refreshToken): string
{
$res = $this->http->postForm(self::TOKEN_URL, [
'grant_type' => 'refresh_token',
'refresh_token' => $refreshToken,
'client_id' => $this->clientId,
'client_secret' => $this->clientSecret,
]);
if ($res->status !== 200) {
$error = null;
try {
$error = $res->json()['error'] ?? null;
} catch (UpstreamException) {
}
if (in_array($error, ['invalid_grant', 'invalid_client', 'invalid_request'], true)) {
throw new NetatmoAuthException("Netatmo token refresh geweigerd: $error");
}
throw new UpstreamException("Netatmo token refresh: HTTP {$res->status}");
}
$json = $res->json();
$access = $json['access_token'] ?? null;
$refresh = $json['refresh_token'] ?? $refreshToken;
$expiresIn = $json['expires_in'] ?? 10800;
if (!is_string($access) || $access === '' || !is_string($refresh)) {
throw new UpstreamException('Netatmo token refresh: onvolledig antwoord');
}
$this->store->write([
'access_token' => $access,
'refresh_token' => $refresh,
'expires_at' => ($this->now)() + (int) $expiresIn,
]);
return $access;
}
}
+39
View File
@@ -0,0 +1,39 @@
<?php
declare(strict_types=1);
namespace Weerscherm;
/** Open-Meteo dagverwachting. Gratis voor niet-commercieel gebruik, geen API-key. */
final class OpenMeteoClient
{
private const URL = 'https://api.open-meteo.com/v1/forecast';
public function __construct(private readonly HttpClient $http)
{
}
/** @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,
]);
if ($res->status !== 200) {
throw new UpstreamException("Open-Meteo: HTTP {$res->status}");
}
$json = $res->json();
if (!is_array($json['daily'] ?? null)) {
throw new UpstreamException('Open-Meteo: geen daily-blok in antwoord');
}
return [
'daily' => $json['daily'],
'hourly' => is_array($json['hourly'] ?? null) ? $json['hourly'] : [],
];
}
}
+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}");
}
}
}
+1 -1
View File
@@ -59,7 +59,7 @@ final class RainCalculator
return [ return [
'today' => $perDate[$today] ?? $sumRain24, 'today' => $perDate[$today] ?? $sumRain24,
'previous_days' => $previous, 'previous_days' => $previous,
'month_total' => $monthTotal, 'month_total' => $monthTotal === null ? null : round($monthTotal, 2),
]; ];
} }
} }
+16 -5
View File
@@ -10,15 +10,17 @@ namespace Weerscherm;
*/ */
final class StationMapper final class StationMapper
{ {
private const EMPTY_OUTDOOR = ['temp' => null, 'trend' => null, 'min' => null, 'min_at' => null, 'max' => null, 'max_at' => null]; private const EMPTY_OUTDOOR = ['temp' => null, 'trend' => null, 'min' => null, 'min_at' => null, 'max' => null, 'max_at' => null, 'humidity' => null];
private const EMPTY_PRESSURE = ['current' => null, 'trend' => null];
/** /**
* @param array<string, mixed> $body * @param array<string, mixed> $body
* @param array<string, string> $indoorModules _id => label, in de gewenste volgorde * @param array<string, string> $indoorModules _id => label, in de gewenste volgorde
* @return array{timezone: ?string, lat: ?float, lon: ?float, device_id: ?string, measured_at: ?int, * @return array{timezone: ?string, lat: ?float, lon: ?float, device_id: ?string, measured_at: ?int,
* outdoor: array{temp: ?float, trend: ?string, min: ?float, min_at: ?int, max: ?float, max_at: ?int}, * 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, reachable: bool}>, * indoor: list<array{id: string, name: string, temp: ?float, min: ?float, max: ?float, humidity: ?float, co2: ?int, reachable: bool}>,
* rain_module_id: ?string, sum_rain_24: ?float} * pressure: array{current: ?float, trend: ?string},
* outdoor_module_id: ?string, rain_module_id: ?string, sum_rain_24: ?float}
*/ */
public static function map(array $body, ?string $deviceId, array $indoorModules): array public static function map(array $body, ?string $deviceId, array $indoorModules): array
{ {
@@ -26,7 +28,8 @@ final class StationMapper
if ($device === null) { if ($device === null) {
return [ return [
'timezone' => null, 'lat' => null, 'lon' => null, 'device_id' => null, 'measured_at' => null, 'timezone' => null, 'lat' => null, 'lon' => null, 'device_id' => null, 'measured_at' => null,
'outdoor' => self::EMPTY_OUTDOOR, 'indoor' => [], 'rain_module_id' => null, 'sum_rain_24' => null, 'outdoor' => self::EMPTY_OUTDOOR, 'indoor' => [], 'pressure' => self::EMPTY_PRESSURE,
'outdoor_module_id' => null, 'rain_module_id' => null, 'sum_rain_24' => null,
]; ];
} }
@@ -43,6 +46,11 @@ final class StationMapper
'measured_at' => self::intOrNull(self::dashboard($device)['time_utc'] ?? self::dashboard($outdoor)['time_utc'] ?? null), 'measured_at' => self::intOrNull(self::dashboard($device)['time_utc'] ?? self::dashboard($outdoor)['time_utc'] ?? null),
'outdoor' => self::mapOutdoor($outdoor), 'outdoor' => self::mapOutdoor($outdoor),
'indoor' => self::mapIndoor($device, $modules, $indoorModules), 'indoor' => self::mapIndoor($device, $modules, $indoorModules),
'pressure' => [
'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), 'rain_module_id' => $rain === null ? null : self::stringOrNull($rain['_id'] ?? null),
'sum_rain_24' => self::floatOrNull(self::dashboard($rain)['sum_rain_24'] ?? null), 'sum_rain_24' => self::floatOrNull(self::dashboard($rain)['sum_rain_24'] ?? null),
]; ];
@@ -92,6 +100,7 @@ final class StationMapper
'min_at' => self::intOrNull($d['date_min_temp'] ?? null), 'min_at' => self::intOrNull($d['date_min_temp'] ?? null),
'max' => self::floatOrNull($d['max_temp'] ?? null), 'max' => self::floatOrNull($d['max_temp'] ?? null),
'max_at' => self::intOrNull($d['date_max_temp'] ?? null), 'max_at' => self::intOrNull($d['date_max_temp'] ?? null),
'humidity' => self::floatOrNull($d['Humidity'] ?? null),
]; ];
} }
@@ -118,6 +127,8 @@ final class StationMapper
'temp' => self::floatOrNull($d['Temperature'] ?? null), 'temp' => self::floatOrNull($d['Temperature'] ?? null),
'min' => self::floatOrNull($d['min_temp'] ?? null), 'min' => self::floatOrNull($d['min_temp'] ?? null),
'max' => self::floatOrNull($d['max_temp'] ?? null), 'max' => self::floatOrNull($d['max_temp'] ?? null),
'humidity' => self::floatOrNull($d['Humidity'] ?? null),
'co2' => self::intOrNull($d['CO2'] ?? null),
'reachable' => $d !== [], 'reachable' => $d !== [],
]; ];
} }
+92
View File
@@ -0,0 +1,92 @@
<?php
declare(strict_types=1);
namespace Weerscherm;
/**
* Bewaart de Netatmo-tokens in één JSON-bestand. Schrijft atomisch (tijdelijk bestand + rename, mode 0600)
* en biedt een flock zodat gelijktijdige requests niet dubbel verversen.
*/
final class TokenStore
{
public function __construct(private readonly string $file)
{
}
public function path(): string
{
return $this->file;
}
/** @return array{access_token: string, refresh_token: string, expires_at: int}|null */
public function read(): ?array
{
if (!is_file($this->file)) {
return null;
}
$raw = json_decode((string) file_get_contents($this->file), true);
if (!is_array($raw) || !is_string($raw['refresh_token'] ?? null) || $raw['refresh_token'] === '') {
return null;
}
return [
'access_token' => is_string($raw['access_token'] ?? null) ? $raw['access_token'] : '',
'refresh_token' => $raw['refresh_token'],
'expires_at' => is_int($raw['expires_at'] ?? null) ? $raw['expires_at'] : 0,
];
}
/** @param array{access_token: string, refresh_token: string, expires_at: int} $token */
public function write(array $token): void
{
$dir = dirname($this->file);
if (!is_dir($dir) && !mkdir($dir, 0750, true) && !is_dir($dir)) {
throw new \RuntimeException("Map kan niet worden aangemaakt: $dir");
}
$tmp = $this->file . '.tmp.' . uniqid('', true);
$json = json_encode([
'access_token' => $token['access_token'],
'refresh_token' => $token['refresh_token'],
'expires_at' => $token['expires_at'],
], JSON_THROW_ON_ERROR | JSON_PRETTY_PRINT);
if (file_put_contents($tmp, $json) === false) {
throw new \RuntimeException("Tokenbestand kan niet worden geschreven: $tmp");
}
chmod($tmp, 0600);
if (!rename($tmp, $this->file)) {
@unlink($tmp);
throw new \RuntimeException("Tokenbestand kan niet worden vervangen: {$this->file}");
}
}
/**
* Voert $fn uit onder een exclusieve lock. Ook bij een exception wordt de lock losgelaten.
*
* @template T
* @param callable(): T $fn
* @return T
*/
public function withLock(callable $fn): mixed
{
$dir = dirname($this->file);
if (!is_dir($dir) && !mkdir($dir, 0750, true) && !is_dir($dir)) {
throw new \RuntimeException("Map kan niet worden aangemaakt: $dir");
}
$handle = fopen($this->file . '.lock', 'c');
if ($handle === false) {
throw new \RuntimeException("Lockbestand kan niet worden geopend: {$this->file}.lock");
}
try {
if (!flock($handle, LOCK_EX)) {
throw new \RuntimeException('Lock kan niet worden verkregen');
}
try {
return $fn();
} finally {
flock($handle, LOCK_UN);
}
} finally {
fclose($handle);
}
}
}
+7 -1
View File
@@ -13,6 +13,12 @@ interface WeatherSource
/** @return array<string|int, mixed> de "body" van Netatmo getmeasure (timestamp => [mm]) */ /** @return array<string|int, mixed> de "body" van Netatmo getmeasure (timestamp => [mm]) */
public function measure(string $deviceId, string $moduleId, int $begin, int $end): array; public function measure(string $deviceId, string $moduleId, int $begin, int $end): array;
/** @return array<string, mixed> het "daily"-blok van Open-Meteo */ /** @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|int, mixed> de "body" van Netatmo getmeasure (timestamp => [°C]) voor de buitenmodule */
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; public function forecast(float $lat, float $lon, string $timezone): array;
} }
+24
View File
@@ -0,0 +1,24 @@
<?php
declare(strict_types=1);
/**
* Autoloader: Composer als die er is (nodig voor PHPUnit), anders een minimale PSR-4-lader voor src/.
* Zo werkt een deploy ook zonder `composer install`; er zijn immers geen runtime-dependencies.
*/
$composer = dirname(__DIR__) . '/vendor/autoload.php';
if (is_file($composer)) {
require $composer;
return;
}
spl_autoload_register(static function (string $class): void {
$prefix = 'Weerscherm\\';
if (!str_starts_with($class, $prefix)) {
return;
}
$file = __DIR__ . '/' . str_replace('\\', '/', substr($class, strlen($prefix))) . '.php';
if (is_file($file)) {
require $file;
}
});
+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('Europe/Berlin', $config->get('timezone'));
self::assertSame('fixture', $config->get('data_source')); self::assertSame('fixture', $config->get('data_source'));
self::assertSame(['stations' => 300, 'measure' => 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::assertSame([], $config->get('indoor_modules'));
self::assertNull($config->get('netatmo_device_id')); self::assertNull($config->get('netatmo_device_id'));
} }
@@ -39,7 +39,7 @@ final class ConfigTest extends TestCase
$config = Config::load($this->file); $config = Config::load($this->file);
self::assertSame('Europe/Amsterdam', $config->get('timezone')); self::assertSame('Europe/Amsterdam', $config->get('timezone'));
self::assertSame(['stations' => 60, 'measure' => 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 public function testUnknownKeyReturnsGivenDefault(): void
+85 -6
View File
@@ -32,7 +32,7 @@ final class DashboardServiceTest extends TestCase
return new DashboardService( return new DashboardService(
$this->source, $this->source,
new Cache($this->dir), new Cache($this->dir),
['stations' => 300, 'measure' => 900, 'forecast' => 3600], ['stations' => 300, 'measure' => 900, 'pressure' => 900, 'temperature' => 900, 'forecast' => 3600],
'Europe/Amsterdam', 'Europe/Amsterdam',
$lat, $lat,
$lon, $lon,
@@ -46,18 +46,28 @@ final class DashboardServiceTest extends TestCase
{ {
$p = $this->service()->build(); $p = $this->service()->build();
self::assertSame(['generated_at', 'timezone', 'station', 'outdoor', 'indoor', 'rain', '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('2026-09-11T14:32:05+02:00', $p['generated_at']);
self::assertSame('Europe/Berlin', $p['timezone']); self::assertSame('Europe/Berlin', $p['timezone']);
self::assertSame(['measured_at' => '2026-09-11T14:30:12+02:00', 'stale' => false], $p['station']); self::assertSame(['measured_at' => '2026-09-11T14:30:12+02:00', 'stale' => false], $p['station']);
self::assertSame(['temp' => 16.4, 'trend' => 'down', 'min' => 9.2, 'min_at' => '2026-09-11T06:10:00+02:00', 'max' => 19.8, 'max_at' => '2026-09-11T13:05:00+02:00'], $p['outdoor']); self::assertSame(['temp' => 16.4, 'trend' => 'down', 'min' => 9.2, 'min_at' => '2026-09-11T06:10:00+02:00', 'max' => 19.8, 'max_at' => '2026-09-11T13:05:00+02:00', 'humidity' => 71.0], $p['outdoor']);
self::assertCount(3, $p['indoor']); self::assertCount(3, $p['indoor']);
self::assertSame(612, $p['indoor'][0]['co2']);
self::assertSame(48.0, $p['indoor'][0]['humidity']);
self::assertSame(1014.2, $p['pressure']['current']);
self::assertSame('up', $p['pressure']['trend']);
self::assertCount(49, $p['pressure']['history']);
self::assertSame('2026-09-10T14:30:00+02:00', $p['pressure']['history'][0]['at']);
self::assertFalse($p['pressure']['stale']);
self::assertSame(2.4, $p['rain']['today']); self::assertSame(2.4, $p['rain']['today']);
self::assertSame(['2026-09-08', '2026-09-09', '2026-09-10'], array_column($p['rain']['previous_days'], 'date')); 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::assertEqualsWithDelta(38.5, $p['rain']['month_total'], 0.0001);
self::assertFalse($p['rain']['stale']); self::assertFalse($p['rain']['stale']);
self::assertCount(5, $p['forecast']); self::assertCount(6, $p['forecast']);
self::assertSame('2026-09-12', $p['forecast'][0]['date']); 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::assertFalse($p['forecast_stale']);
self::assertSame([], $p['errors']); self::assertSame([], $p['errors']);
} }
@@ -73,6 +83,55 @@ final class DashboardServiceTest extends TestCase
self::assertSame((new \DateTimeImmutable('2026-09-11 12:32:05 UTC'))->getTimestamp(), $end); 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->lastArgs['temperature'];
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 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();
[$deviceId, $begin, $end] = $this->source->lastArgs['pressure'];
$now = (new \DateTimeImmutable('2026-09-11 12:32:05 UTC'))->getTimestamp();
self::assertSame('70:ee:50:00:00:01', $deviceId);
self::assertSame($now - 86400, $begin);
self::assertSame($now, $end);
}
public function testPressureFailureKeepsCurrentValueAndMarksStale(): void
{
$this->source->responses['pressure'] = new UpstreamException('500');
$p = $this->service()->build();
self::assertSame(1014.2, $p['pressure']['current']);
self::assertSame([], $p['pressure']['history']);
self::assertTrue($p['pressure']['stale']);
self::assertSame(['netatmo_unavailable'], $p['errors']);
}
public function testForecastUsesStationLocationUnlessConfigured(): void public function testForecastUsesStationLocationUnlessConfigured(): void
{ {
$this->service()->build(); $this->service()->build();
@@ -96,6 +155,10 @@ final class DashboardServiceTest extends TestCase
self::assertSame([], $p['indoor']); self::assertSame([], $p['indoor']);
self::assertNull($p['rain']['today']); self::assertNull($p['rain']['today']);
self::assertSame(0, $this->source->calls['measure'], 'zonder regenmeter-id geen getmeasure-call'); 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('Europe/Amsterdam', $p['timezone'], 'fallback-tijdzone uit config');
self::assertSame(['netatmo_unavailable', 'forecast_unavailable'], $p['errors']); self::assertSame(['netatmo_unavailable', 'forecast_unavailable'], $p['errors']);
} }
@@ -147,12 +210,28 @@ final class DashboardServiceTest extends TestCase
self::assertSame(16.4, $p['outdoor']['temp']); 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 public function testSecondBuildWithinTtlUsesCache(): void
{ {
$this->service()->build(); $this->service()->build();
$this->service('2026-09-11 12:36:05 UTC')->build(); $this->service('2026-09-11 12:36:05 UTC')->build();
self::assertSame(['stations' => 1, 'measure' => 1, 'forecast' => 1], $this->source->calls); self::assertSame(['stations' => 1, 'measure' => 1, 'pressure' => 1, 'temperature' => 1, 'forecast' => 1], $this->source->calls);
$this->service('2026-09-11 12:38:05 UTC')->build(); $this->service('2026-09-11 12:38:05 UTC')->build();
self::assertSame(2, $this->source->calls['stations'], 'stations na 5 min opnieuw'); self::assertSame(2, $this->source->calls['stations'], 'stations na 5 min opnieuw');
+49
View File
@@ -0,0 +1,49 @@
<?php
declare(strict_types=1);
namespace Weerscherm\Tests;
use Weerscherm\HttpClient;
use Weerscherm\HttpResponse;
/** Testdubbel: geeft geplande responses in volgorde terug en registreert elk request. */
final class FakeHttp implements HttpClient
{
/** @var list<HttpResponse> */
private array $queue = [];
/** @var list<array{method: string, url: string, params: array, headers: array}> */
public array $requests = [];
public function queue(int $status, array|string $body): self
{
$this->queue[] = new HttpResponse($status, is_string($body) ? $body : json_encode($body, JSON_THROW_ON_ERROR));
return $this;
}
public function get(string $url, array $query = [], array $headers = []): HttpResponse
{
return $this->take('GET', $url, $query, $headers);
}
public function postForm(string $url, array $fields): HttpResponse
{
return $this->take('POST', $url, $fields, []);
}
/** @return list<array> */
public function requestsOf(string $method): array
{
return array_values(array_filter($this->requests, fn(array $r) => $r['method'] === $method));
}
private function take(string $method, string $url, array $params, array $headers): HttpResponse
{
$this->requests[] = ['method' => $method, 'url' => $url, 'params' => $params, 'headers' => $headers];
if ($this->queue === []) {
throw new \LogicException("Geen response gepland voor $method $url");
}
return array_shift($this->queue);
}
}
+27
View File
@@ -0,0 +1,27 @@
<?php
declare(strict_types=1);
namespace Weerscherm\Tests;
use Weerscherm\HttpClient;
use Weerscherm\HttpResponse;
use Weerscherm\TokenStore;
/** Basis om in een test gedrag rond een FakeHttp te hangen (bijvoorbeeld een ander proces nabootsen). */
abstract class FakeHttpDecorator implements HttpClient
{
public function __construct(protected readonly FakeHttp $inner, protected readonly TokenStore $store)
{
}
public function get(string $url, array $query = [], array $headers = []): HttpResponse
{
return $this->inner->get($url, $query, $headers);
}
public function postForm(string $url, array $fields): HttpResponse
{
return $this->inner->postForm($url, $fields);
}
}
+17 -2
View File
@@ -13,7 +13,7 @@ final class FakeSource implements WeatherSource
public array $responses = []; public array $responses = [];
/** @var array<string, int> */ /** @var array<string, int> */
public array $calls = ['stations' => 0, 'measure' => 0, 'forecast' => 0]; public array $calls = ['stations' => 0, 'measure' => 0, 'pressure' => 0, 'temperature' => 0, 'forecast' => 0];
/** @var array<string, array> */ /** @var array<string, array> */
public array $lastArgs = []; public array $lastArgs = [];
@@ -24,7 +24,12 @@ final class FakeSource implements WeatherSource
$s->responses = [ $s->responses = [
'stations' => json_decode((string) file_get_contents(__DIR__ . '/fixtures/getstationsdata.json'), true)['body'], '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'], 'measure' => json_decode((string) file_get_contents(__DIR__ . '/fixtures/getmeasure.json'), true)['body'],
'forecast' => json_decode((string) file_get_contents(__DIR__ . '/fixtures/forecast.json'), true)['daily'], 'pressure' => json_decode((string) file_get_contents(__DIR__ . '/fixtures/getmeasure-pressure.json'), true)['body'],
'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; return $s;
} }
@@ -39,6 +44,16 @@ final class FakeSource implements WeatherSource
return $this->answer('measure', func_get_args()); return $this->answer('measure', func_get_args());
} }
public function pressureHistory(string $deviceId, int $begin, int $end): array
{
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 public function forecast(float $lat, float $lon, string $timezone): array
{ {
return $this->answer('forecast', func_get_args()); return $this->answer('forecast', func_get_args());
+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']; 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()); $f = ForecastMapper::map($this->daily());
self::assertCount(5, $f); self::assertCount(6, $f);
self::assertSame([ self::assertSame([
'date' => '2026-09-12', 'weather_code' => 2, 'temp_max' => 18.3, 'temp_min' => 10.1, 'date' => '2026-09-11', 'weather_code' => 3, 'temp_max' => 17.9, 'temp_min' => 11.0,
'precip_mm' => 0.4, 'precip_prob' => 20, 'precip_mm' => 0.0, 'precip_prob' => 10,
], $f[0]); ], $f[0]);
self::assertSame('2026-09-16', $f[4]['date']); self::assertSame('2026-09-16', $f[5]['date']);
} }
public function testNullValuesStayNull(): void public function testNullValuesStayNull(): void
@@ -35,9 +35,9 @@ final class ForecastMapperTest extends TestCase
$f = ForecastMapper::map($daily); $f = ForecastMapper::map($daily);
self::assertNull($f[2]['weather_code']); self::assertNull($f[3]['weather_code']);
self::assertNull($f[2]['precip_prob']); self::assertNull($f[3]['precip_prob']);
self::assertSame(17.4, $f[2]['temp_max']); self::assertSame(17.4, $f[3]['temp_max']);
} }
public function testShorterResponseGivesFewerDays(): void public function testShorterResponseGivesFewerDays(): void
@@ -47,7 +47,7 @@ final class ForecastMapperTest extends TestCase
$daily[$key] = array_slice($values, 0, 3); $daily[$key] = array_slice($values, 0, 3);
} }
self::assertCount(2, ForecastMapper::map($daily)); self::assertCount(3, ForecastMapper::map($daily));
} }
public function testMissingTimeArrayGivesEmptyList(): void 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));
}
}
+43
View File
@@ -0,0 +1,43 @@
<?php
declare(strict_types=1);
namespace Weerscherm\Tests;
use PHPUnit\Framework\TestCase;
use Weerscherm\MeasureSeries;
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 = MeasureSeries::map($shuffled, new \DateTimeZone('Europe/Berlin'), 'hpa');
self::assertCount(49, $points);
self::assertSame('2026-09-10T14:30:00+02:00', $points[0]['at']);
self::assertSame('2026-09-11T14:30:00+02:00', $points[48]['at']);
self::assertIsFloat($points[0]['hpa']);
self::assertSame(['at', 'hpa'], array_keys($points[0]));
}
public function testSkipsGarbage(): void
{
$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']);
}
}
+215
View File
@@ -0,0 +1,215 @@
<?php
declare(strict_types=1);
namespace Weerscherm\Tests;
use PHPUnit\Framework\TestCase;
use Weerscherm\NetatmoAuthException;
use Weerscherm\NetatmoClient;
use Weerscherm\TokenStore;
use Weerscherm\UpstreamException;
final class NetatmoClientTest extends TestCase
{
private const NOW = 1_800_000_000;
private string $dir;
private TokenStore $store;
private FakeHttp $http;
protected function setUp(): void
{
$this->dir = sys_get_temp_dir() . '/weerscherm-netatmo-' . uniqid();
$this->store = new TokenStore($this->dir . '/netatmo-token.json');
$this->http = new FakeHttp();
}
protected function tearDown(): void
{
array_map('unlink', glob($this->dir . '/*') ?: []);
@rmdir($this->dir);
}
private function client(): NetatmoClient
{
return new NetatmoClient($this->http, $this->store, 'cid', 'csecret', static fn(): int => self::NOW);
}
private function stationsBody(): array
{
return json_decode((string) file_get_contents(__DIR__ . '/fixtures/getstationsdata.json'), true);
}
public function testValidTokenIsUsedWithoutRefresh(): void
{
$this->store->write(['access_token' => 'A1', 'refresh_token' => 'R1', 'expires_at' => self::NOW + 3600]);
$this->http->queue(200, $this->stationsBody());
$body = $this->client()->getStationsData();
self::assertSame('70:ee:50:00:00:01', $body['devices'][0]['_id']);
self::assertCount(1, $this->http->requests);
self::assertSame('https://api.netatmo.com/api/getstationsdata', $this->http->requests[0]['url']);
self::assertContains('Authorization: Bearer A1', $this->http->requests[0]['headers']);
}
public function testExpiringTokenIsRefreshedAndRotatedRefreshTokenStored(): void
{
$this->store->write(['access_token' => 'A1', 'refresh_token' => 'R1', 'expires_at' => self::NOW + 200]);
$this->http
->queue(200, ['access_token' => 'A2', 'refresh_token' => 'R2', 'expires_in' => 10800])
->queue(200, $this->stationsBody());
$this->client()->getStationsData();
[$post, $get] = $this->http->requests;
self::assertSame('POST', $post['method']);
self::assertSame('https://api.netatmo.com/oauth2/token', $post['url']);
self::assertSame(['grant_type' => 'refresh_token', 'refresh_token' => 'R1', 'client_id' => 'cid', 'client_secret' => 'csecret'], $post['params']);
self::assertContains('Authorization: Bearer A2', $get['headers']);
self::assertSame(['access_token' => 'A2', 'refresh_token' => 'R2', 'expires_at' => self::NOW + 10800], $this->store->read());
}
public function testFreshlyInitialisedStoreWithoutAccessTokenRefreshesFirst(): void
{
$this->store->write(['access_token' => '', 'refresh_token' => 'R1', 'expires_at' => 0]);
$this->http
->queue(200, ['access_token' => 'A2', 'refresh_token' => 'R2', 'expires_in' => 10800])
->queue(200, $this->stationsBody());
$this->client()->getStationsData();
self::assertCount(1, $this->http->requestsOf('POST'));
}
public function testUnauthorizedResponseTriggersOneRefreshAndRetry(): void
{
$this->store->write(['access_token' => 'A1', 'refresh_token' => 'R1', 'expires_at' => self::NOW + 3600]);
$this->http
->queue(403, ['error' => ['code' => 3, 'message' => 'Access token expired']])
->queue(200, ['access_token' => 'A2', 'refresh_token' => 'R2', 'expires_in' => 10800])
->queue(200, $this->stationsBody());
$this->client()->getStationsData();
self::assertSame(['GET', 'POST', 'GET'], array_column($this->http->requests, 'method'));
self::assertContains('Authorization: Bearer A2', $this->http->requests[2]['headers']);
}
public function testSecondUnauthorizedResponseIsAuthError(): void
{
$this->store->write(['access_token' => 'A1', 'refresh_token' => 'R1', 'expires_at' => self::NOW + 3600]);
$this->http
->queue(401, '')
->queue(200, ['access_token' => 'A2', 'refresh_token' => 'R2', 'expires_in' => 10800])
->queue(401, '');
$this->expectException(NetatmoAuthException::class);
$this->client()->getStationsData();
}
public function testInvalidGrantIsAuthError(): void
{
$this->store->write(['access_token' => '', 'refresh_token' => 'R-oud', 'expires_at' => 0]);
$this->http->queue(400, ['error' => 'invalid_grant']);
$this->expectException(NetatmoAuthException::class);
$this->client()->getStationsData();
}
public function testMissingTokenFileIsAuthError(): void
{
$this->expectException(NetatmoAuthException::class);
$this->client()->getStationsData();
}
public function testServerErrorIsUpstreamError(): void
{
$this->store->write(['access_token' => 'A1', 'refresh_token' => 'R1', 'expires_at' => self::NOW + 3600]);
$this->http->queue(500, 'kapot');
$this->expectException(UpstreamException::class);
$this->client()->getStationsData();
}
public function testRefreshServerErrorIsUpstreamNotAuth(): void
{
$this->store->write(['access_token' => '', 'refresh_token' => 'R1', 'expires_at' => 0]);
$this->http->queue(503, '');
$this->expectException(UpstreamException::class);
$this->expectExceptionMessage('HTTP 503');
$this->client()->getStationsData();
}
public function testConcurrentClientsShareOneRefresh(): void
{
$this->store->write(['access_token' => 'A1', 'refresh_token' => 'R1', 'expires_at' => self::NOW + 100]);
$this->http
->queue(200, ['access_token' => 'A2', 'refresh_token' => 'R2', 'expires_in' => 10800])
->queue(200, $this->stationsBody())
->queue(200, $this->stationsBody());
$first = $this->client();
$second = $this->client();
$first->getStationsData();
$second->getStationsData();
self::assertCount(1, $this->http->requestsOf('POST'), 'de tweede client leest het verse token en ververst niet');
self::assertSame('R2', $this->store->read()['refresh_token']);
}
public function testRejectedTokenAlreadyReplacedByOtherProcessIsReusedWithoutRefresh(): void
{
$this->store->write(['access_token' => 'A1', 'refresh_token' => 'R1', 'expires_at' => self::NOW + 3600]);
$this->http->queue(403, '')->queue(200, $this->stationsBody());
// Nabootsen dat een ander proces al ververst heeft zodra ons token geweigerd wordt.
$http = new class($this->http, $this->store) extends FakeHttpDecorator {
public function get(string $url, array $query = [], array $headers = []): \Weerscherm\HttpResponse
{
$res = parent::get($url, $query, $headers);
if ($res->status === 403) {
$this->store->write(['access_token' => 'A9', 'refresh_token' => 'R9', 'expires_at' => 1_800_000_000 + 3600]);
}
return $res;
}
};
$client = new NetatmoClient($http, $this->store, 'cid', 'csecret', static fn(): int => self::NOW);
$client->getStationsData();
self::assertSame([], $this->http->requestsOf('POST'), 'geen eigen refresh nodig');
self::assertContains('Authorization: Bearer A9', $this->http->requests[1]['headers']);
}
public function testGetMeasureQuery(): void
{
$this->store->write(['access_token' => 'A1', 'refresh_token' => 'R1', 'expires_at' => self::NOW + 3600]);
$this->http->queue(200, json_decode((string) file_get_contents(__DIR__ . '/fixtures/getmeasure.json'), true));
$body = $this->client()->getMeasure('dev', 'mod', 1000, 2000);
self::assertCount(11, $body);
self::assertSame('https://api.netatmo.com/api/getmeasure', $this->http->requests[0]['url']);
self::assertSame([
'device_id' => 'dev', 'module_id' => 'mod', 'scale' => '1day', 'type' => 'sum_rain',
'date_begin' => 1000, 'date_end' => 2000, 'optimize' => 'false', 'real_time' => 'false',
], $this->http->requests[0]['params']);
}
public function testGetPressureHistoryQuery(): void
{
$this->store->write(['access_token' => 'A1', 'refresh_token' => 'R1', 'expires_at' => self::NOW + 3600]);
$this->http->queue(200, json_decode((string) file_get_contents(__DIR__ . '/fixtures/getmeasure-pressure.json'), true));
$body = $this->client()->getPressureHistory('dev', 1000, 2000);
self::assertCount(49, $body);
self::assertSame([
'device_id' => 'dev', 'scale' => '30min', 'type' => 'Pressure',
'date_begin' => 1000, 'date_end' => 2000, 'optimize' => 'false', 'real_time' => 'false',
], $this->http->requests[0]['params']);
}
}
+55
View File
@@ -0,0 +1,55 @@
<?php
declare(strict_types=1);
namespace Weerscherm\Tests;
use PHPUnit\Framework\TestCase;
use Weerscherm\OpenMeteoClient;
use Weerscherm\UpstreamException;
final class OpenMeteoClientTest extends TestCase
{
public function testRequestsSixDaysWithDailyAndHourlyVariables(): void
{
$http = new FakeHttp();
$http->queue(200, json_decode((string) file_get_contents(__DIR__ . '/fixtures/forecast.json'), true));
$result = (new OpenMeteoClient($http))->forecast(51.82, 6.55, 'Europe/Berlin');
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']);
}
public function testErrorStatusIsUpstreamException(): void
{
$http = (new FakeHttp())->queue(429, ['reason' => 'rate limit']);
$this->expectException(UpstreamException::class);
(new OpenMeteoClient($http))->forecast(1, 2, 'UTC');
}
public function testMissingDailyIsUpstreamException(): void
{
$http = (new FakeHttp())->queue(200, ['hourly' => []]);
$this->expectException(UpstreamException::class);
(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');
}
}
+23 -3
View File
@@ -38,6 +38,7 @@ final class StationMapperTest extends TestCase
'min_at' => 1789099800, 'min_at' => 1789099800,
'max' => 19.8, 'max' => 19.8,
'max_at' => 1789124700, 'max_at' => 1789124700,
'humidity' => 71.0,
], $m['outdoor']); ], $m['outdoor']);
} }
@@ -47,8 +48,8 @@ final class StationMapperTest extends TestCase
self::assertCount(3, $m['indoor']); self::assertCount(3, $m['indoor']);
self::assertSame(['Woonkamer', 'Slaapkamer', 'Kantoor'], array_column($m['indoor'], 'name')); self::assertSame(['Woonkamer', 'Slaapkamer', 'Kantoor'], array_column($m['indoor'], 'name'));
self::assertSame(['id' => '70:ee:50:00:00:01', 'name' => 'Woonkamer', 'temp' => 21.3, 'min' => 20.4, 'max' => 22.1, 'reachable' => true], $m['indoor'][0]); self::assertSame(['id' => '70:ee:50:00:00:01', 'name' => 'Woonkamer', 'temp' => 21.3, 'min' => 20.4, 'max' => 22.1, 'humidity' => 48.0, 'co2' => 612, 'reachable' => true], $m['indoor'][0]);
self::assertSame(['id' => '03:00:00:00:00:03', 'name' => 'Kantoor', 'temp' => null, 'min' => null, 'max' => null, 'reachable' => false], $m['indoor'][2]); self::assertSame(['id' => '03:00:00:00:00:03', 'name' => 'Kantoor', 'temp' => null, 'min' => null, 'max' => null, 'humidity' => null, 'co2' => null, 'reachable' => false], $m['indoor'][2]);
} }
public function testIndoorConfigDeterminesOrderAndLabels(): void public function testIndoorConfigDeterminesOrderAndLabels(): void
@@ -69,6 +70,24 @@ final class StationMapperTest extends TestCase
self::assertSame(2.4, $m['sum_rain_24']); self::assertSame(2.4, $m['sum_rain_24']);
} }
public function testPressureFromBaseStation(): void
{
$m = StationMapper::map($this->body(), null, []);
self::assertSame(['current' => 1014.2, 'trend' => 'up'], $m['pressure']);
}
public function testPressureNullWhenBaseStationUnreachable(): void
{
$body = $this->body();
$body['devices'][0]['reachable'] = false;
unset($body['devices'][0]['dashboard_data']);
$m = StationMapper::map($body, null, []);
self::assertSame(['current' => null, 'trend' => null], $m['pressure']);
self::assertSame(1789129780, $m['measured_at'], 'valt terug op time_utc van de buitenmodule');
}
public function testWithoutRainModule(): void public function testWithoutRainModule(): void
{ {
$body = $this->body(); $body = $this->body();
@@ -89,7 +108,7 @@ final class StationMapperTest extends TestCase
unset($body['devices'][0]['modules'][0]['dashboard_data']); unset($body['devices'][0]['modules'][0]['dashboard_data']);
$m = StationMapper::map($body, null, []); $m = StationMapper::map($body, null, []);
self::assertSame(['temp' => null, 'trend' => null, 'min' => null, 'min_at' => null, 'max' => null, 'max_at' => null], $m['outdoor']); self::assertSame(['temp' => null, 'trend' => null, 'min' => null, 'min_at' => null, 'max' => null, 'max_at' => null, 'humidity' => null], $m['outdoor']);
} }
public function testUnknownDeviceIdFallsBackToFirstStation(): void public function testUnknownDeviceIdFallsBackToFirstStation(): void
@@ -108,5 +127,6 @@ final class StationMapperTest extends TestCase
self::assertSame([], $m['indoor']); self::assertSame([], $m['indoor']);
self::assertNull($m['outdoor']['temp']); self::assertNull($m['outdoor']['temp']);
self::assertNull($m['rain_module_id']); self::assertNull($m['rain_module_id']);
self::assertSame(['current' => null, 'trend' => null], $m['pressure']);
} }
} }
+87
View File
@@ -0,0 +1,87 @@
<?php
declare(strict_types=1);
namespace Weerscherm\Tests;
use PHPUnit\Framework\TestCase;
use Weerscherm\TokenStore;
final class TokenStoreTest extends TestCase
{
private string $dir;
private string $file;
protected function setUp(): void
{
$this->dir = sys_get_temp_dir() . '/weerscherm-token-' . uniqid();
$this->file = $this->dir . '/netatmo-token.json';
}
protected function tearDown(): void
{
array_map('unlink', glob($this->dir . '/*') ?: []);
@rmdir($this->dir);
}
public function testWriteReadRoundTripAndMode(): void
{
$store = new TokenStore($this->file);
$store->write(['access_token' => 'a', 'refresh_token' => 'r', 'expires_at' => 123]);
self::assertSame(['access_token' => 'a', 'refresh_token' => 'r', 'expires_at' => 123], $store->read());
self::assertSame('0600', substr(sprintf('%o', fileperms($this->file)), -4));
self::assertSame([], glob($this->dir . '/*.tmp.*') ?: [], 'geen tijdelijke bestanden achtergelaten');
}
public function testMissingOrInvalidFileReadsNull(): void
{
self::assertNull((new TokenStore($this->file))->read());
mkdir($this->dir, 0700, true);
file_put_contents($this->file, '{"access_token":"a"}');
self::assertNull((new TokenStore($this->file))->read(), 'zonder refresh token is het bestand onbruikbaar');
}
public function testWithLockReturnsValueAndReleasesAfterException(): void
{
$store = new TokenStore($this->file);
self::assertSame(42, $store->withLock(fn() => 42));
try {
$store->withLock(function (): void {
throw new \RuntimeException('boem');
});
self::fail('exception verwacht');
} catch (\RuntimeException) {
}
$handle = fopen($this->file . '.lock', 'c');
self::assertTrue(flock($handle, LOCK_EX | LOCK_NB), 'lock is weer vrij');
fclose($handle);
}
public function testConcurrentProcessesAreSerialised(): void
{
$store = new TokenStore($this->file);
$script = sprintf(
'<?php require %s; $s = new Weerscherm\TokenStore(%s); $s->withLock(function () { usleep(400000); });',
var_export(__DIR__ . '/../vendor/autoload.php', true),
var_export($this->file, true),
);
$scriptFile = $this->dir . '/hold.php';
mkdir($this->dir, 0700, true);
file_put_contents($scriptFile, $script);
$proc = proc_open([PHP_BINARY, $scriptFile], [], $pipes);
usleep(150000); // geef het andere proces tijd om de lock te pakken
$start = microtime(true);
$store->withLock(fn() => null);
$waited = microtime(true) - $start;
proc_close($proc);
self::assertGreaterThan(0.15, $waited, 'tweede proces moest wachten op de lock');
}
}
+804 -23
View File
@@ -1,25 +1,806 @@
{ {
"latitude": 51.82, "latitude": 51.82,
"longitude": 6.56, "longitude": 6.56,
"generationtime_ms": 0.2, "generationtime_ms": 0.2,
"utc_offset_seconds": 7200, "utc_offset_seconds": 7200,
"timezone": "Europe/Berlin", "timezone": "Europe/Berlin",
"timezone_abbreviation": "CEST", "timezone_abbreviation": "CEST",
"elevation": 20.0, "elevation": 20,
"daily_units": { "daily_units": {
"time": "iso8601", "time": "iso8601",
"weather_code": "wmo code", "weather_code": "wmo code",
"temperature_2m_max": "°C", "temperature_2m_max": "\u00b0C",
"temperature_2m_min": "°C", "temperature_2m_min": "\u00b0C",
"precipitation_sum": "mm", "precipitation_sum": "mm",
"precipitation_probability_max": "%" "precipitation_probability_max": "%"
}, },
"daily": { "daily": {
"time": ["2026-09-11", "2026-09-12", "2026-09-13", "2026-09-14", "2026-09-15", "2026-09-16"], "time": [
"weather_code": [3, 2, 0, 61, 80, 2], "2026-09-11",
"temperature_2m_max": [17.9, 18.3, 20.1, 17.4, 15.2, 16.0], "2026-09-12",
"temperature_2m_min": [11.0, 10.1, 11.3, 12.0, 9.4, 8.1], "2026-09-13",
"precipitation_sum": [0.0, 0.4, 0.0, 8.2, 3.1, 0.2], "2026-09-14",
"precipitation_probability_max": [10, 20, 5, 80, 60, 15] "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
]
}
} }
+152
View File
@@ -0,0 +1,152 @@
{
"body": {
"1789043400": [
1011.5
],
"1789045200": [
1011
],
"1789047000": [
1010.6
],
"1789048800": [
1010.2
],
"1789050600": [
1010.3
],
"1789052400": [
1009.9
],
"1789054200": [
1009.6
],
"1789056000": [
1009.4
],
"1789057800": [
1009.1
],
"1789059600": [
1009.4
],
"1789061400": [
1009.3
],
"1789063200": [
1009.2
],
"1789065000": [
1009.2
],
"1789066800": [
1009.2
],
"1789068600": [
1009.7
],
"1789070400": [
1009.8
],
"1789072200": [
1009.9
],
"1789074000": [
1010.1
],
"1789075800": [
1010.3
],
"1789077600": [
1011
],
"1789079400": [
1011.2
],
"1789081200": [
1011.5
],
"1789083000": [
1011.8
],
"1789084800": [
1012
],
"1789086600": [
1012.8
],
"1789088400": [
1013.1
],
"1789090200": [
1013.3
],
"1789092000": [
1013.6
],
"1789093800": [
1013.8
],
"1789095600": [
1014.5
],
"1789097400": [
1014.6
],
"1789099200": [
1014.8
],
"1789101000": [
1014.8
],
"1789102800": [
1014.9
],
"1789104600": [
1015.4
],
"1789106400": [
1015.3
],
"1789108200": [
1015.2
],
"1789110000": [
1015
],
"1789111800": [
1014.8
],
"1789113600": [
1015.1
],
"1789115400": [
1014.8
],
"1789117200": [
1014.5
],
"1789119000": [
1014.1
],
"1789120800": [
1013.8
],
"1789122600": [
1013.8
],
"1789124400": [
1013.4
],
"1789126200": [
1012.9
],
"1789128000": [
1012.5
],
"1789129800": [
1012
]
},
"status": "ok"
}
+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
]
}
+246
View File
@@ -0,0 +1,246 @@
import { test } from 'node:test';
import assert from 'node:assert/strict';
import {
f1, fInt, fmm, markerPos, barHeights, wmoIcon, trendLabel,
formatDate, formatTime, dayAbbr, monthName, isOlderThan, msUntil,
} from '../../public/format.js';
const TZ = 'Europe/Berlin';
test('f1 uses a decimal comma and one decimal', () => {
assert.equal(f1(16.4), '16,4');
assert.equal(f1(0), '0,0');
assert.equal(f1(19.75), '19,8');
assert.equal(f1(null), '–');
assert.equal(f1(undefined), '–');
});
test('fInt rounds to whole degrees', () => {
assert.equal(fInt(18.3), '18');
assert.equal(fInt(9.5), '10');
assert.equal(fInt(null), '–');
});
test('fmm shows "0 mm" for zero', () => {
assert.equal(fmm(0), '0 mm');
assert.equal(fmm(0.4), '0,4 mm');
assert.equal(fmm(8.25), '8,3 mm');
assert.equal(fmm(null), '–');
});
test('markerPos interpolates and clamps', () => {
assert.ok(Math.abs(markerPos(16.4, 9.2, 19.8) - 67.92) < 0.01);
assert.equal(markerPos(5, 9.2, 19.8), 0);
assert.equal(markerPos(25, 9.2, 19.8), 100);
assert.equal(markerPos(10, 10, 10), 50);
assert.equal(markerPos(null, 1, 2), 50);
});
test('barHeights scales to the tallest bar with a 2px floor', () => {
const h = barHeights([1.2, 6.8, 0, 2.4]);
assert.ok(Math.abs(h[0] - 12.35) < 0.01);
assert.equal(h[1], 70);
assert.equal(h[2], 2);
assert.ok(Math.abs(h[3] - 24.7) < 0.01);
assert.deepEqual(barHeights([0, 0, 0, 0]), [2, 2, 2, 2]);
assert.deepEqual(barHeights([null, 0, undefined, 0]), [2, 2, 2, 2]);
assert.deepEqual(barHeights([null, 5]), [2, 70]);
});
test('wmoIcon maps every documented code', () => {
assert.equal(wmoIcon(0), 'sun');
assert.equal(wmoIcon(1), 'partly');
assert.equal(wmoIcon(2), 'partly');
assert.equal(wmoIcon(3), 'cloud');
assert.equal(wmoIcon(45), 'fog');
assert.equal(wmoIcon(48), 'fog');
assert.equal(wmoIcon(51), 'drizzle');
assert.equal(wmoIcon(57), 'drizzle');
assert.equal(wmoIcon(61), 'rain');
assert.equal(wmoIcon(67), 'rain');
assert.equal(wmoIcon(80), 'showers');
assert.equal(wmoIcon(82), 'showers');
assert.equal(wmoIcon(71), 'snow');
assert.equal(wmoIcon(77), 'snow');
assert.equal(wmoIcon(85), 'snow');
assert.equal(wmoIcon(86), 'snow');
assert.equal(wmoIcon(95), 'thunder');
assert.equal(wmoIcon(99), 'thunder');
assert.equal(wmoIcon(42), 'cloud');
assert.equal(wmoIcon(null), 'cloud');
});
test('trendLabel', () => {
assert.equal(trendLabel('up'), '↗ stijgend');
assert.equal(trendLabel('down'), '↘ dalend');
assert.equal(trendLabel('stable'), '→ stabiel');
assert.equal(trendLabel(null), '');
});
test('formatDate gives a capitalised Dutch date in the station timezone', () => {
assert.equal(formatDate(new Date('2026-09-11T12:32:05Z'), TZ), 'Vrijdag 11 september');
// 23:30 UTC is al de volgende dag in Berlijn
assert.equal(formatDate(new Date('2026-09-11T23:30:00Z'), TZ), 'Zaterdag 12 september');
});
test('formatTime uses HH:MM in the station timezone', () => {
assert.equal(formatTime('2026-09-11T06:10:00+02:00', TZ), '06:10');
assert.equal(formatTime('2026-09-11T22:05:00Z', TZ), '00:05');
assert.equal(formatTime(null, TZ), '–');
assert.equal(formatTime('kapot', TZ), '–');
});
test('dayAbbr gives two-letter Dutch weekday', () => {
assert.equal(dayAbbr('2026-09-12', TZ), 'Za');
assert.equal(dayAbbr('2026-09-13', TZ), 'Zo');
assert.equal(dayAbbr('2026-09-14', TZ), 'Ma');
assert.equal(dayAbbr('2026-09-15', TZ), 'Di');
assert.equal(dayAbbr('2026-09-16', TZ), 'Wo');
assert.equal(dayAbbr('2026-09-17', TZ), 'Do');
assert.equal(dayAbbr('2026-09-18', TZ), 'Vr');
});
test('monthName', () => {
assert.equal(monthName(new Date('2026-09-11T12:00:00Z'), TZ), 'September');
});
test('isOlderThan', () => {
const now = new Date('2026-09-11T12:32:00Z');
assert.equal(isOlderThan('2026-09-11T12:10:00Z', now, 30), false);
assert.equal(isOlderThan('2026-09-11T11:50:00Z', now, 30), true);
assert.equal(isOlderThan(null, now, 30), true);
});
test('msUntil computes the wait until the next local time', () => {
const now = new Date('2026-09-11T12:32:05.500Z'); // 14:32:05 in Berlijn
assert.equal(msUntil(now, TZ, 0), ((24 - 14) * 3600 - 32 * 60 - 5) * 1000 - 500);
assert.equal(msUntil(now, TZ, 4), ((24 - 14 + 4) * 3600 - 32 * 60 - 5) * 1000 - 500);
assert.equal(msUntil(now, TZ, 15), (28 * 60 - 5) * 1000 - 500);
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, sparklinePoints,
windArrowDeg, fKmh, formatHour, formatFullDate, pinRoom, hourTicks,
} from '../../public/format.js';
test('co2Level uses the Netatmo thresholds', () => {
assert.deepEqual(co2Level(612), { level: 'good', label: 'goed' });
assert.deepEqual(co2Level(1000), { level: 'good', label: 'goed' });
assert.deepEqual(co2Level(1001), { level: 'medium', label: 'matig' });
assert.deepEqual(co2Level(1500), { level: 'medium', label: 'matig' });
assert.deepEqual(co2Level(1501), { level: 'bad', label: 'slecht' });
assert.deepEqual(co2Level(null), { level: 'unknown', label: '–' });
});
test('fPct and fHpa', () => {
assert.equal(fPct(48), '48%');
assert.equal(fPct(47.6), '48%');
assert.equal(fPct(null), '–');
assert.equal(fHpa(1014.2), '1014 hPa');
assert.equal(fHpa(null), '–');
});
test('seriesRange ignores nulls', () => {
assert.deepEqual(seriesRange([1010, null, 1015.5, 1008]), { min: 1008, max: 1015.5 });
assert.equal(seriesRange([null, undefined]), null);
});
test('sparklinePath scales points into the box', () => {
assert.equal(sparklinePath([1000, 1010, 1005], 100, 50, 0), 'M0.0,50.0 L50.0,0.0 L100.0,25.0');
assert.equal(sparklinePath([1000, 1010], 100, 50, 5), 'M5.0,45.0 L95.0,5.0');
assert.equal(sparklinePath([1010, 1010, 1010], 100, 50, 0), 'M0.0,25.0 L50.0,25.0 L100.0,25.0', 'vlakke reeks in het midden');
assert.equal(sparklinePath([1000, null, 1010], 100, 50, 0), 'M0.0,50.0 L100.0,0.0', 'null-punt overgeslagen');
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), []);
});
+138
View File
@@ -0,0 +1,138 @@
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { readFileSync } from 'node:fs';
import vm from 'node:vm';
/** Laadt sw.js in een minimale nep-serviceworker-omgeving en geeft de geregistreerde handlers terug. */
function loadServiceWorker({ fetchImpl }) {
const listeners = {};
const store = new Map();
const cache = {
async addAll(urls) { urls.forEach((u) => store.set(new URL(u, 'http://x/').href, new Response('shell'))); },
// 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)); },
};
const sandbox = {
self: {
addEventListener: (type, fn) => { listeners[type] = fn; },
skipWaiting: async () => {},
clients: { claim: async () => {} },
location: new URL('http://x/'),
},
caches: { open: async () => cache, keys: async () => ['weerscherm-v0', 'weerscherm-v1'], delete: async () => true },
fetch: fetchImpl,
Response, Request, Headers, URL, AbortController, setTimeout, clearTimeout, console,
};
vm.createContext(sandbox);
vm.runInContext(readFileSync(new URL('../../public/sw.js', import.meta.url), 'utf8'), sandbox);
return { listeners, store };
}
function dispatchFetch(listeners, url) {
let promise = null;
listeners.fetch({
request: new Request(url),
respondWith: (p) => { promise = p; },
});
return promise;
}
test('install caches the app shell', async () => {
const { listeners, store } = loadServiceWorker({ fetchImpl: async () => new Response('x') });
let done = null;
listeners.install({ waitUntil: (p) => { done = p; } });
await done;
assert.ok(store.has('http://x/app.js'));
assert.ok(store.has('http://x/index.html'));
});
test('API request goes to the network first and is cached', async () => {
const { listeners, store } = loadServiceWorker({
fetchImpl: async () => new Response('{"station":{}}', { status: 200, headers: { 'Content-Type': 'application/json' } }),
});
const res = await dispatchFetch(listeners, 'http://x/api/data.php');
assert.equal(res.status, 200);
assert.equal(res.headers.get('X-From-Cache'), null);
assert.ok(store.has('http://x/api/data.php'));
});
test('API request falls back to the cached response with X-From-Cache when offline', async () => {
let online = true;
const { listeners } = loadServiceWorker({
fetchImpl: async () => {
if (!online) throw new TypeError('Failed to fetch');
return new Response('{"station":{"stale":false}}', { status: 200, headers: { 'Content-Type': 'application/json' } });
},
});
await dispatchFetch(listeners, 'http://x/api/data.php');
online = false;
const res = await dispatchFetch(listeners, 'http://x/api/data.php');
assert.equal(res.status, 200);
assert.equal(res.headers.get('X-From-Cache'), '1');
assert.equal(await res.text(), '{"station":{"stale":false}}');
});
test('API request without any cache gives a 503 offline JSON', async () => {
const { listeners } = loadServiceWorker({ fetchImpl: async () => { throw new TypeError('offline'); } });
const res = await dispatchFetch(listeners, 'http://x/api/data.php');
assert.equal(res.status, 503);
assert.deepEqual(await res.json(), { errors: ['offline'] });
});
test('shell request is served from cache without hitting the network', async () => {
let calls = 0;
const { listeners, store } = loadServiceWorker({ fetchImpl: async () => { calls++; return new Response('net'); } });
store.set('http://x/styles.css', new Response('cached css'));
const res = await dispatchFetch(listeners, 'http://x/styles.css');
assert.equal(await res.text(), 'cached css');
assert.equal(calls, 0);
});
test('cross-origin and non-GET requests are left alone', () => {
const { listeners } = loadServiceWorker({ fetchImpl: async () => new Response('x') });
assert.equal(dispatchFetch(listeners, 'http://elders/api/data.php'), null);
let handled = false;
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');
});