Author SHA1 Message Date
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
35 changed files with 1983 additions and 114 deletions
+1
View File
@@ -3,3 +3,4 @@ config.php
/vendor/
/node_modules/
.DS_Store
.phpunit.result.cache
-1
View File
@@ -1 +0,0 @@
{"version":2,"defects":{"Weerscherm\\Tests\\DashboardServiceTest::testForecastUsesStationLocationUnlessConfigured":7,"Weerscherm\\Tests\\DashboardServiceTest::testMeasureFailureFallsBackToSumRain24":7,"Weerscherm\\Tests\\NetatmoClientTest::testRejectedTokenAlreadyReplacedByOtherProcessIsReusedWithoutRefresh":8},"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.003,"Weerscherm\\Tests\\CacheTest::testMissingKeyReadsNull":0.004,"Weerscherm\\Tests\\CacheTest::testCorruptFileReadsNull":0,"Weerscherm\\Tests\\CacheTest::testFreshness":0.001,"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.002,"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.001,"Weerscherm\\Tests\\DashboardServiceTest::testMeasureFailureFallsBackToSumRain24":0,"Weerscherm\\Tests\\DashboardServiceTest::testForecastFailureLeavesRestIntact":0.001,"Weerscherm\\Tests\\DashboardServiceTest::testSecondBuildWithinTtlUsesCache":0.001,"Weerscherm\\Tests\\TokenStoreTest::testWriteReadRoundTripAndMode":0,"Weerscherm\\Tests\\TokenStoreTest::testMissingOrInvalidFileReadsNull":0,"Weerscherm\\Tests\\TokenStoreTest::testWithLockReturnsValueAndReleasesAfterException":0,"Weerscherm\\Tests\\TokenStoreTest::testConcurrentProcessesAreSerialised":0.438,"Weerscherm\\Tests\\NetatmoClientTest::testValidTokenIsUsedWithoutRefresh":0.001,"Weerscherm\\Tests\\NetatmoClientTest::testExpiringTokenIsRefreshedAndRotatedRefreshTokenStored":0.001,"Weerscherm\\Tests\\NetatmoClientTest::testFreshlyInitialisedStoreWithoutAccessTokenRefreshesFirst":0.001,"Weerscherm\\Tests\\NetatmoClientTest::testUnauthorizedResponseTriggersOneRefreshAndRetry":0.001,"Weerscherm\\Tests\\NetatmoClientTest::testSecondUnauthorizedResponseIsAuthError":0.001,"Weerscherm\\Tests\\NetatmoClientTest::testInvalidGrantIsAuthError":0,"Weerscherm\\Tests\\NetatmoClientTest::testMissingTokenFileIsAuthError":0,"Weerscherm\\Tests\\NetatmoClientTest::testServerErrorIsUpstreamError":0,"Weerscherm\\Tests\\NetatmoClientTest::testRefreshServerErrorIsUpstreamNotAuth":0.001,"Weerscherm\\Tests\\NetatmoClientTest::testConcurrentClientsShareOneRefresh":0.001,"Weerscherm\\Tests\\NetatmoClientTest::testRejectedTokenAlreadyReplacedByOtherProcessIsReusedWithoutRefresh":0.001,"Weerscherm\\Tests\\NetatmoClientTest::testGetMeasureQuery":0,"Weerscherm\\Tests\\OpenMeteoClientTest::testRequestsSixDaysWithAllDailyVariables":0,"Weerscherm\\Tests\\OpenMeteoClientTest::testErrorStatusIsUpstreamException":0,"Weerscherm\\Tests\\OpenMeteoClientTest::testMissingDailyIsUpstreamException":0}}
+31 -15
View File
@@ -92,25 +92,41 @@ De bestanden staan in `orbstack/divers/weerscherm/` (Dockerfile, compose) en `or
## Deploy (productie)
Productie-URL: `https://homedisplay.bahne-drei.de/`
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`).
- Git op de self-hosted Gitea, squash-and-merge naar `main`, deploy via DeployPad.
- Document root is `public/`. `src/`, `storage/`, `config.php` en `bin/` zijn dan niet via HTTP bereikbaar.
- `storage/` moet deploys overleven: maak er in DeployPad een gedeelde map van (shared folder) of zet
`storage_path` in `config.php` naar een map buiten de release-map, bijvoorbeeld `/var/www/weerscherm-storage`.
De map moet schrijfbaar zijn voor de webserver-gebruiker (`www-data`).
- `config.php` is per omgeving en staat niet in git; zet hem ook in de gedeelde map en symlink, of maak hem
eenmalig aan in de release-root.
- `composer install` is op de server niet nodig: `src/autoload.php` laadt de klassen zelf als `vendor/` ontbreekt.
- HTTPS is vereist voor de service worker en "Zet op beginscherm".
- Na de eerste deploy: `php bin/netatmo-init.php '<refresh_token>'` op de server, en controleer
`https://homedisplay.bahne-drei.de/api/data.php` op `"errors": []`.
- De app staat bewust zonder login. Kies een niet-voor-de-hand-liggend (sub)domein; `robots.txt` en
`X-Robots-Tag: noindex` houden zoekmachines buiten de deur.
- 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 van de frontend? Verhoog `VERSION` in `public/sw.js`; de app herlaadt zichzelf bij `controllerchange`
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
+1
View File
@@ -30,6 +30,7 @@ return [
'cache_ttl' => [
'stations' => 300,
'measure' => 900,
'pressure' => 900,
'forecast' => 3600,
],
+12 -4
View File
@@ -1,6 +1,6 @@
# Weerscherm: bouwspecificatie v1
Opdracht voor Claude Code: bouw een PWA die fullscreen draait op een iPad mini (liggend) in de woonkamer. Het scherm toont actuele gegevens van een Netatmo-weerstation en een weersverwachting voor 5 dagen.
Opdracht voor Claude Code: bouw een PWA die fullscreen draait op een iPad mini (liggend) in de woonkamer. Het scherm toont actuele gegevens van een Netatmo-weerstation en een weersverwachting voor 6 dagen.
**Ontwerp A** in `docs/design/weerscherm-layouts.html` is leidend voor de layout. Dat bestand is een statische mockup met voorbeelddata. De CSS van ontwerp A (klasse `.da` en alle `.a-*`-klassen) is getest op 1133 × 744 punten en dient als uitgangspunt. De tabbladen B en C zijn niet in scope.
@@ -26,7 +26,8 @@ Werkwijze: lees eerst dit document en de mockup. Stel daarna een plan voor per f
- Buitentemperatuur, met de laagste en hoogste temperatuur van vandaag (inclusief tijdstippen) en de trend.
- De 3 binnensensoren, elk met huidige, laagste en hoogste temperatuur van vandaag.
- Regen: vandaag, de afgelopen 3 dagen per dag, en het totaal van de huidige maand.
- Verwachting voor de komende 5 dagen (vanaf morgen): max/min-temperatuur, neerslag in mm, neerslagkans en een weericoon.
- Verwachting voor 6 dagen (vandaag t/m 5 dagen verder): max/min-temperatuur, neerslag in mm, neerslagkans en een weericoon.
_Wijziging 2026-09-13: was oorspronkelijk 5 dagen vanaf morgen; op verzoek van Theo toegevoegd: vandaag als eerste kolom._
- Installeerbaar als PWA op het beginscherm, blijft werken met de laatst bekende data als de verbinding wegvalt.
**Niet in v1:** nachtmodus (ontwerp C), andere meetwaarden (CO₂, luchtvochtigheid, wind), meerdere stations, een instellingenscherm, pushmeldingen.
@@ -255,7 +256,13 @@ De iconen `sun`, `partly`, `rain` en `showers` staan in de mockup. Maak `cloud`,
- "Vandaag" groot, in mm met 1 decimaal.
- Daarnaast 4 staafjes: de 3 vorige dagen (labels "Di", "Wo", "Do") en vandaag, uitgelicht. De hoogte is `mm / max(4 waarden) × 70px`, met een minimum van 2px. Zijn alle waarden 0, dan zijn alle staafjes 2px.
- Daaronder "September 38,5 mm".
- **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".
- **Verwachting (rechtsonder):** 6 kolommen met dagafkorting ("Za"), icoon (50px), "max° min°" als hele getallen (min in `--muted`) en "x,x mm" plus neerslagkans in %. Geen neerslag toont "0 mm". Eerste kolom is vandaag.
_Toegevoegd 2026-09-13: elke dagkolom is tikbaar. Een tik opent een uuroverzicht voor die dag (temperatuur,
regen en wind, elk om de 2 uur — 12 punten), dat na 20 seconden vanzelf sluit of eerder bij een tik erop.
De uurdata komt in dezelfde Open-Meteo-call mee (`hourly=temperature_2m,precipitation,wind_speed_10m,
wind_direction_10m`), dus geen extra netwerkverzoek bij het openen. Windpijl wijst waarheen de wind waait
(richting + 180°). Zie `src/HourlyForecastMapper.php` en `renderHourlyCharts()`/`openHourOverlay()` in
`public/app.js`._
### 6.4 Verversen
@@ -342,7 +349,8 @@ Hangt af van de iPadOS-versie in §0:
- [ ] Op de iPad mini, gestart vanaf het beginscherm, draait de app fullscreen zonder Safari-balken en klopt de layout visueel met ontwerp A.
- [ ] Alle waarden komen overeen met de Netatmo-app: buiten, 3× binnen, min/max met tijdstippen, regen vandaag en de afgelopen 3 dagen, en het maandtotaal.
- [ ] De verwachting toont 5 dagen vanaf morgen met het juiste icoon per WMO-code.
- [ ] De verwachting toont 6 dagen (vandaag t/m 5 dagen verder) met het juiste icoon per WMO-code.
- [ ] Tikken op een dag opent het uuroverzicht (temperatuur, regen, wind) en sluit na 20 seconden vanzelf of bij een tik erop.
- [ ] De data ververst zichzelf. Na middernacht staat de nieuwe datum er binnen 5 minuten.
- [ ] Wifi uit: de laatste data blijft staan, met een melding "geen verbinding sinds". Wifi aan: herstelt vanzelf.
- [ ] De Netatmo-token ververst meerdere dagen achter elkaar zonder handmatig ingrijpen.
+94 -6
View File
@@ -1,8 +1,10 @@
import {
f1, fInt, fmm, markerPos, barHeights, wmoIcon, trendLabel,
formatDate, formatTime, dayAbbr, monthName, isOlderThan, msUntil,
co2Level, fPct, fHpa, seriesRange, sparklinePath,
windArrowDeg, fKmh, formatHour, formatFullDate,
} from './format.js';
import { icon } from './icons.js';
import { icon, windArrow } from './icons.js';
const CANVAS_W = 1133;
const CANVAS_H = 744;
@@ -12,6 +14,8 @@ const FETCH_TIMEOUT_MS = 10000;
const STALE_MINUTES = 30;
const RELOAD_HOUR = 4;
const FALLBACK_TZ = 'Europe/Berlin';
const HOURLY_OVERLAY_MS = 20000;
const 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');
@@ -57,6 +61,7 @@ function renderMeasurement(now) {
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)}°`);
@@ -68,10 +73,12 @@ function renderOutdoor(o) {
function renderRooms(rooms) {
const html = rooms.map((r) => {
const off = !r.reachable || r.temp === null;
const co2 = co2Level(off ? null : r.co2);
const pill = `<span class="pill ${co2.level} num" title="${co2.label}">${co2.level === 'unknown' ? '–' : r.co2 + ' ppm'}</span>`;
return `<div class="a-room${off ? ' off' : ''}">
<p class="a-rname">${esc(r.name)}</p>
<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></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('');
@@ -90,8 +97,17 @@ function renderRain(rain, now) {
</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-range', range ? `${Math.round(range.min)} – ${Math.round(range.max)} hPa in 24 u` : '');
$('press-path').setAttribute('d', sparklinePath(values, 560, 56));
}
function renderForecast(days) {
$('forecast').innerHTML = days.map((d) => `<div class="a-day">
$('forecast').innerHTML = days.map((d) => `<div class="a-day" data-date="${d.date}">
<p class="a-dname">${dayAbbr(d.date, state.tz)}</p>
<div class="a-icon">${icon(wmoIcon(d.weather_code))}</div>
<p class="a-hl num">${fInt(d.temp_max)}°<span>${fInt(d.temp_min)}°</span></p>
@@ -99,6 +115,56 @@ function renderForecast(days) {
</div>`).join('');
}
/* ---------- uuroverzicht per dag ---------- */
let hourlyOverlayTimer = 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;
clearTimeout(hourlyOverlayTimer);
setText('ho-date', formatFullDate(day.date, state.tz));
renderHourlyCharts(day.hourly || []);
const fill = $('ho-bar-fill');
fill.style.transition = 'none';
fill.style.transform = 'scaleX(1)';
fill.getBoundingClientRect(); // forceer reflow zodat de volgende transitie opnieuw start
fill.style.transition = `transform ${HOURLY_OVERLAY_MS}ms linear`;
fill.style.transform = 'scaleX(0)';
$('hour-overlay').hidden = false;
hourlyOverlayTimer = setTimeout(closeHourOverlay, HOURLY_OVERLAY_MS);
}
function closeHourOverlay() {
clearTimeout(hourlyOverlayTimer);
hourlyOverlayTimer = null;
$('hour-overlay').hidden = true;
}
function renderBanner() {
const banner = $('banner');
const errors = state.data ? state.data.errors : [];
@@ -131,6 +197,7 @@ function render() {
renderOutdoor(d.outdoor);
renderRooms(d.indoor);
renderRain(d.rain, now);
renderPressure(d.pressure || { current: null, trend: null, history: [] });
renderForecast(d.forecast);
}
renderBanner();
@@ -218,9 +285,13 @@ async function requestWakeLock() {
/* ---------- service worker ---------- */
let swRegistration = null;
function registerServiceWorker() {
if (!('serviceWorker' in navigator)) return;
navigator.serviceWorker.register('./sw.js').catch((err) => console.warn('weerscherm: service worker', err));
navigator.serviceWorker.register('./sw.js')
.then((reg) => { swRegistration = reg; })
.catch((err) => console.warn('weerscherm: service worker', err));
let reloading = false;
navigator.serviceWorker.addEventListener('controllerchange', () => {
if (reloading) return;
@@ -229,8 +300,24 @@ function registerServiceWorker() {
});
}
/**
* Vraagt de browser expliciet een nieuwe sw.js te controleren. Op een kiosk-scherm dat nooit navigeert
* (geen tabwissel, geen echte reload) gebeurt dat anders alleen impliciet en onvoorspelbaar laat — soms
* pas bij de volgende dagelijkse 04:00-reload. Met skipWaiting + clients.claim in sw.js activeert een
* gevonden update zich meteen, en de controllerchange-listener hierboven herlaadt de pagina dan vanzelf.
*/
function checkForUpdate() {
swRegistration?.update().catch(() => {});
}
/* ---------- start ---------- */
$('forecast').addEventListener('click', (e) => {
const day = e.target.closest('.a-day');
if (day) openHourOverlay(day.dataset.date);
});
$('hour-overlay').addEventListener('click', closeHourOverlay);
window.addEventListener('resize', fit);
window.addEventListener('orientationchange', () => setTimeout(fit, 50));
document.addEventListener('visibilitychange', () => {
@@ -238,7 +325,7 @@ document.addEventListener('visibilitychange', () => {
fit();
refresh();
requestWakeLock();
registerServiceWorker();
checkForUpdate();
}
});
@@ -246,6 +333,7 @@ fit();
render();
refresh();
setInterval(refresh, REFRESH_MS);
setInterval(checkForUpdate, REFRESH_MS);
scheduleClock();
scheduleMidnight();
scheduleReload();
+83
View File
@@ -122,3 +122,86 @@ export function msUntil(now, tz, hour, minute = 0) {
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);
}
+5
View File
@@ -30,3 +30,8 @@ export function icon(name) {
const inner = ICONS[name] || ICONS.cloud;
return `<svg class="ic" viewBox="0 0 48 48" aria-hidden="true">${inner}</svg>`;
}
/** Compacte windpijl, wijst standaard naar boven. Draai 'm met een inline transform naar de juiste hoek. */
export function windArrow() {
return '<svg class="wind-ic" viewBox="0 0 24 24" aria-hidden="true"><path d="M12 19V5M6.5 10.5 12 5l5.5 5.5"/></svg>';
}
+25 -1
View File
@@ -26,7 +26,7 @@
</header>
<section class="a-hero">
<p class="a-label">Buiten <span class="a-trend" id="trend"></span></p>
<p class="a-label">Buiten <span class="a-trend" id="trend"></span><span class="a-hum" id="out-hum"></span></p>
<p class="a-temp num" id="temp">–</p>
<div class="a-range">
<div class="a-track"><span class="a-mark" id="mark" style="left:50%"></span></div>
@@ -35,6 +35,13 @@
<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><span class="a-prange num" id="press-range"></span></p>
<svg class="a-spark" viewBox="0 0 560 56" preserveAspectRatio="none" aria-hidden="true">
<line class="a-spark-base" x1="0" y1="55.5" x2="560" y2="55.5"/>
<path class="a-spark-line" id="press-path" d=""/>
</svg>
</div>
</section>
<section class="a-inside" id="rooms"></section>
@@ -55,6 +62,23 @@
</section>
<div class="banner" id="banner">Gegevens laden…</div>
<div class="hour-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>
</main>
</div>
<div id="rotate">Draai de iPad een kwartslag</div>
+68 -5
View File
@@ -9,6 +9,8 @@
--cold: #3A75A3;
--rain: #2E6A99;
--sun: #D39A2C;
--good: #4E8F5A;
--bad: #B5433B;
--iconfill: #E8EDF1;
--w: 1133px;
--h: 744px;
@@ -60,7 +62,7 @@ html, body {
.warm { color: var(--warm); }
.muted { color: var(--muted); }
.ic { fill: none; stroke: currentColor; stroke-width: 2.4; stroke-linecap: round; stroke-linejoin: round; width: 100%; height: 100%; display: block; }
.ic, .wind-ic { fill: none; stroke: currentColor; stroke-width: 2.4; stroke-linecap: round; stroke-linejoin: round; width: 100%; height: 100%; display: block; }
.i-sun { stroke: var(--sun); }
.i-drop { stroke: var(--rain); }
.i-cloud { fill: var(--iconfill); }
@@ -78,21 +80,38 @@ html, body {
.a-hero { align-self: center; }
.a-label { font-size: 22px; color: var(--muted); font-weight: 500; display: flex; gap: 14px; align-items: baseline; }
.a-trend { font-size: 17px; font-weight: 400; }
.a-temp { font-size: 200px; font-weight: 200; letter-spacing: -.045em; line-height: .95; margin: 4px 0 24px -8px; }
.a-temp { font-size: 200px; font-weight: 200; letter-spacing: -.045em; line-height: .95; margin: 4px 0 18px -8px; }
.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 */
.a-pressure { width: 560px; margin-top: 18px; }
.a-plabel { font-size: 17px; color: var(--muted); display: flex; gap: 12px; align-items: baseline; margin-bottom: 6px; }
.a-plabel b { font-size: 20px; font-weight: 600; color: var(--ink); }
.a-prange { margin-left: auto; font-size: 14px; }
.a-spark { display: block; width: 560px; height: 56px; overflow: visible; }
.a-spark-base { stroke: var(--line); stroke-width: 1; }
.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); }
.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; }
.a-room.off .a-rtemp { color: var(--muted); }
.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; }
@@ -110,11 +129,11 @@ html, body {
.a-bar.now span { color: var(--ink); font-weight: 600; }
/* Verwachting */
.a-fc { display: grid; grid-template-columns: repeat(5, 1fr); text-align: center; }
.a-fc { display: grid; grid-template-columns: repeat(6, 1fr); text-align: center; }
.a-day { display: flex; flex-direction: column; align-items: center; gap: 6px; border-left: 1px solid var(--line); }
.a-day:first-child { border-left: 0; }
.a-dname { font-size: 19px; font-weight: 600; }
.a-icon { width: 58px; height: 58px; }
.a-icon { width: 50px; height: 50px; }
.a-hl { font-size: 28px; font-weight: 500; }
.a-hl span { font-size: 22px; color: var(--muted); font-weight: 400; margin-left: 6px; }
.a-fr { font-size: 16px; color: var(--rain); }
@@ -143,6 +162,50 @@ html, body {
}
.banner small { display: block; margin-top: 16px; font-size: 20px; color: var(--muted); }
/* Uuroverzicht per dag: tik op een dagkolom om te openen, tik erop of wacht 20s om te sluiten. */
.hour-overlay {
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;
}
.hour-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); }
#rotate {
display: none;
position: fixed;
+1 -1
View File
@@ -1,6 +1,6 @@
/* Service worker: app-shell cache-first, API network-first met timeout en fallback op de laatste response. */
const VERSION = 'weerscherm-v1';
const VERSION = 'weerscherm-v5';
const SHELL = [
'./',
'./index.html',
+1
View File
@@ -18,6 +18,7 @@ final class Config
'cache_ttl' => [
'stations' => 300,
'measure' => 900,
'pressure' => 900,
'forecast' => 3600,
],
'storage_path' => null,
+36 -6
View File
@@ -18,7 +18,7 @@ final class DashboardService
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
*/
public function __construct(
@@ -47,6 +47,7 @@ final class DashboardService
$now = $nowUtc->setTimezone($tz);
$rain = $this->rain($station, $now);
$pressure = $this->pressure($station, $now);
$forecast = $this->forecast($station, $tz);
return [
@@ -63,9 +64,11 @@ final class DashboardService
'min_at' => $this->iso($station['outdoor']['min_at'], $tz),
'max' => $station['outdoor']['max'],
'max_at' => $this->iso($station['outdoor']['max_at'], $tz),
'humidity' => $station['outdoor']['humidity'],
],
'indoor' => $station['indoor'],
'rain' => $rain,
'pressure' => $pressure,
'forecast' => $forecast['days'],
'forecast_stale' => $forecast['stale'],
'errors' => $this->errors,
@@ -91,6 +94,25 @@ final class DashboardService
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() - PressureHistory::HOURS * 3600;
$measure = $this->fetch(
'pressure',
fn() => $this->source->pressureHistory($station['device_id'], $begin, $now->getTimestamp()),
self::ERR_NETATMO,
);
return $base + [
'history' => $measure['data'] === null ? [] : PressureHistory::map($measure['data'], $now->getTimezone()),
'stale' => $measure['stale'],
];
}
/** @return array{days: list<array>, stale: bool} */
private function forecast(array $station, \DateTimeZone $tz): array
{
@@ -100,11 +122,19 @@ final class DashboardService
$this->addError(self::ERR_FORECAST);
return ['days' => [], 'stale' => true];
}
$daily = $this->fetch('forecast', fn() => $this->source->forecast($lat, $lon, $tz->getName()), self::ERR_FORECAST);
return [
'days' => $daily['data'] === null ? [] : ForecastMapper::map($daily['data']),
'stale' => $daily['stale'],
];
$result = $this->fetch('forecast', fn() => $this->source->forecast($lat, $lon, $tz->getName()), self::ERR_FORECAST);
if ($result['data'] === null) {
return ['days' => [], 'stale' => $result['stale']];
}
$days = ForecastMapper::map($result['data']['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']];
}
/**
+53 -13
View File
@@ -27,16 +27,17 @@ final class FixtureSource implements WeatherSource
return ['devices' => [[
'_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]],
'dashboard_data' => ['time_utc' => $at('-2 minutes'), 'Temperature' => 21.3, 'min_temp' => 20.4, 'max_temp' => 22.1,
'date_min_temp' => $today('05:50'), 'date_max_temp' => $today('13:40'), 'temp_trend' => 'stable'],
'dashboard_data' => ['time_utc' => $at('-2 minutes'), 'Temperature' => 21.3, 'CO2' => 612, 'Humidity' => 48,
'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' => [
['_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']],
['_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,
'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,
'dashboard_data' => ['time_utc' => $at('-3 minutes'), 'Rain' => 0, 'sum_rain_1' => 0, 'sum_rain_24' => 2.4]],
],
@@ -56,19 +57,58 @@ final class FixtureSource implements WeatherSource
return $body;
}
public function pressureHistory(string $deviceId, int $begin, int $end): array
{
$body = [];
for ($ts = $begin - ($begin % 1800); $ts <= $end; $ts += 1800) {
$i = ($end - $ts) / 1800;
$body[(string) $ts] = [round(1012 + 3 * sin($i / 8) + ($i % 5) * 0.1, 1)];
}
return $body;
}
public function forecast(float $lat, float $lon, string $timezone): array
{
$tmin = [11.0, 10.1, 11.3, 12.0, 9.4, 8.1];
$tmax = [17.9, 18.3, 20.1, 17.4, 15.2, 16.0];
$precipDay = [0.0, 0.4, 0.0, 8.2, 3.1, 0.2];
$dates = [];
for ($i = 0; $i < 6; $i++) {
$dates[] = $this->now->modify("+$i days")->format('Y-m-d');
$hourlyTime = [];
$hourlyTemp = [];
$hourlyPrecip = [];
$hourlyWind = [];
$hourlyDir = [];
for ($d = 0; $d < 6; $d++) {
$day = $this->now->modify("+$d days");
$dates[] = $day->format('Y-m-d');
for ($h = 0; $h < 24; $h++) {
$hourlyTime[] = $day->setTime($h, 0)->format('Y-m-d\TH:i');
$t = $tmin[$d] + ($tmax[$d] - $tmin[$d]) * (0.5 + 0.5 * sin((($h - 9) / 24) * 2 * M_PI));
$hourlyTemp[] = round($t, 1);
$p = ($precipDay[$d] > 0 && $h >= 14 && $h <= 16) ? round($precipDay[$d] / 3, 1) : 0.0;
$hourlyPrecip[] = $p;
$hourlyWind[] = round(3 + 6 * (0.5 + 0.5 * sin((($h + $d * 3) / 24) * 2 * M_PI)), 1);
$hourlyDir[] = ($d * 40 + $h * 7) % 360;
}
}
return [
'time' => $dates,
'weather_code' => [3, 2, 0, 61, 80, 95],
'temperature_2m_max' => [17.9, 18.3, 20.1, 17.4, 15.2, 16.0],
'temperature_2m_min' => [11.0, 10.1, 11.3, 12.0, 9.4, 8.1],
'precipitation_sum' => [0.0, 0.4, 0.0, 8.2, 3.1, 0.2],
'precipitation_probability_max' => [10, 20, 5, 80, 60, 15],
'daily' => [
'time' => $dates,
'weather_code' => [3, 2, 0, 61, 80, 95],
'temperature_2m_max' => $tmax,
'temperature_2m_min' => $tmin,
'precipitation_sum' => $precipDay,
'precipitation_probability_max' => [10, 20, 5, 80, 60, 15],
],
'hourly' => [
'time' => $hourlyTime,
'temperature_2m' => $hourlyTemp,
'precipitation' => $hourlyPrecip,
'wind_speed_10m' => $hourlyWind,
'wind_direction_10m' => $hourlyDir,
],
];
}
}
+3 -3
View File
@@ -4,10 +4,10 @@ declare(strict_types=1);
namespace Weerscherm;
/** Zet het "daily"-blok van Open-Meteo om naar 5 dagen vanaf morgen (index 0 is vandaag). */
/** Zet het "daily"-blok van Open-Meteo om naar 6 dagen, inclusief vandaag (index 0). */
final class ForecastMapper
{
private const DAYS = 5;
private const DAYS = 6;
/**
* @param array<string, mixed> $daily
@@ -17,7 +17,7 @@ final class ForecastMapper
{
$dates = is_array($daily['time'] ?? null) ? $daily['time'] : [];
$out = [];
for ($i = 1; $i <= self::DAYS && isset($dates[$i]); $i++) {
for ($i = 0; $i < self::DAYS && isset($dates[$i]); $i++) {
if (!is_string($dates[$i])) {
continue;
}
+74
View File
@@ -0,0 +1,74 @@
<?php
declare(strict_types=1);
namespace Weerscherm;
/**
* Zet het "hourly"-blok van Open-Meteo om naar uurpunten per kalenderdatum, om het andere uur.
*
* Groepeert eerst chronologisch per datum in de gegeven tijdzone, en neemt daarna om het andere
* punt (index 0, 2, 4, …) binnen die dag. Dat is robuust voor de zomertijddag: een dag met 23 of 25
* uren levert vanzelf iets minder of meer dan 12 punten op, zonder speciale wissel-logica.
*/
final class HourlyForecastMapper
{
private const STEP = 2;
/**
* @param array<string, mixed> $hourly
* @return array<string, list<array{at: string, temp: ?float, precip_mm: ?float, wind_kmh: ?float, wind_dir: ?int}>>
* sleutel is de kalenderdatum (Y-m-d) in $tz
*/
public static function mapByDate(array $hourly, \DateTimeZone $tz): array
{
$times = is_array($hourly['time'] ?? null) ? $hourly['time'] : [];
$perDate = [];
foreach ($times as $i => $iso) {
if (!is_string($iso)) {
continue;
}
$at = self::parse($iso, $tz);
if ($at === null) {
continue;
}
$perDate[$at->format('Y-m-d')][] = [
'at' => $at->format(\DATE_ATOM),
'temp' => self::floatOrNull($hourly['temperature_2m'][$i] ?? null),
'precip_mm' => self::floatOrNull($hourly['precipitation'][$i] ?? null),
'wind_kmh' => self::floatOrNull($hourly['wind_speed_10m'][$i] ?? null),
'wind_dir' => self::intOrNull($hourly['wind_direction_10m'][$i] ?? null),
];
}
$out = [];
foreach ($perDate as $date => $points) {
$sampled = [];
foreach (array_values($points) as $index => $point) {
if ($index % self::STEP === 0) {
$sampled[] = $point;
}
}
$out[$date] = $sampled;
}
return $out;
}
/** Open-Meteo levert lokale tijd zonder offset, bv. "2026-09-14T00:00". */
private static function parse(string $iso, \DateTimeZone $tz): ?\DateTimeImmutable
{
$dt = \DateTimeImmutable::createFromFormat('Y-m-d\TH:i', $iso, $tz);
return $dt === false ? null : $dt;
}
private static function floatOrNull(mixed $v): ?float
{
return is_int($v) || is_float($v) ? (float) $v : null;
}
private static function intOrNull(mixed $v): ?int
{
return is_int($v) || is_float($v) ? (int) round($v) : null;
}
}
+6 -1
View File
@@ -34,8 +34,13 @@ final class LiveSource implements WeatherSource
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 forecast(float $lat, float $lon, string $timezone): array
{
return $this->openMeteo->dailyForecast($lat, $lon, $timezone);
return $this->openMeteo->forecast($lat, $lon, $timezone);
}
}
+18
View File
@@ -57,6 +57,24 @@ final class NetatmoClient
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
{
$json = $this->apiGet('getmeasure', [
'device_id' => $deviceId,
'scale' => '30min',
'type' => 'Pressure',
'date_begin' => $begin,
'date_end' => $end,
'optimize' => 'false',
'real_time' => 'false',
]);
if (!is_array($json['body'] ?? null)) {
throw new UpstreamException('getmeasure (Pressure): geen body in antwoord');
}
return $json['body'];
}
/** @param array<string, string|int|float> $query */
private function apiGet(string $path, array $query): array
{
+7 -3
View File
@@ -13,13 +13,14 @@ final class OpenMeteoClient
{
}
/** @return array<string, mixed> het "daily"-blok */
public function dailyForecast(float $lat, float $lon, string $timezone): array
/** @return array{daily: array<string, mixed>, hourly: array<string, mixed>} */
public function forecast(float $lat, float $lon, string $timezone): array
{
$res = $this->http->get(self::URL, [
'latitude' => $lat,
'longitude' => $lon,
'daily' => 'weather_code,temperature_2m_max,temperature_2m_min,precipitation_sum,precipitation_probability_max',
'hourly' => 'temperature_2m,precipitation,wind_speed_10m,wind_direction_10m',
'timezone' => $timezone,
'forecast_days' => 6,
]);
@@ -30,6 +31,9 @@ final class OpenMeteoClient
if (!is_array($json['daily'] ?? null)) {
throw new UpstreamException('Open-Meteo: geen daily-blok in antwoord');
}
return $json['daily'];
return [
'daily' => $json['daily'],
'hourly' => is_array($json['hourly'] ?? null) ? $json['hourly'] : [],
];
}
}
+33
View File
@@ -0,0 +1,33 @@
<?php
declare(strict_types=1);
namespace Weerscherm;
/** Zet een getmeasure-body (scale=30min, type=Pressure) om naar een gesorteerde reeks punten. */
final class PressureHistory
{
public const HOURS = 24;
/**
* @param array<string|int, mixed> $body
* @return list<array{at: string, hpa: float}>
*/
public static function map(array $body, \DateTimeZone $tz): array
{
$points = [];
foreach ($body as $timestamp => $values) {
if (!is_numeric($timestamp) || !is_array($values) || !isset($values[0]) || !is_numeric($values[0])) {
continue;
}
$points[(int) $timestamp] = (float) $values[0];
}
ksort($points);
$out = [];
foreach ($points as $ts => $hpa) {
$out[] = ['at' => (new \DateTimeImmutable('@' . $ts))->setTimezone($tz)->format(\DATE_ATOM), 'hpa' => $hpa];
}
return $out;
}
}
+14 -4
View File
@@ -10,14 +10,16 @@ namespace Weerscherm;
*/
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, string> $indoorModules _id => label, in de gewenste volgorde
* @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},
* indoor: list<array{id: string, name: string, temp: ?float, min: ?float, max: ?float, reachable: bool}>,
* outdoor: array{temp: ?float, trend: ?string, min: ?float, min_at: ?int, max: ?float, max_at: ?int, humidity: ?float},
* indoor: list<array{id: string, name: string, temp: ?float, min: ?float, max: ?float, humidity: ?float, co2: ?int, reachable: bool}>,
* pressure: array{current: ?float, trend: ?string},
* rain_module_id: ?string, sum_rain_24: ?float}
*/
public static function map(array $body, ?string $deviceId, array $indoorModules): array
@@ -26,7 +28,8 @@ final class StationMapper
if ($device === null) {
return [
'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,
'rain_module_id' => null, 'sum_rain_24' => null,
];
}
@@ -43,6 +46,10 @@ final class StationMapper
'measured_at' => self::intOrNull(self::dashboard($device)['time_utc'] ?? self::dashboard($outdoor)['time_utc'] ?? null),
'outdoor' => self::mapOutdoor($outdoor),
'indoor' => self::mapIndoor($device, $modules, $indoorModules),
'pressure' => [
'current' => self::floatOrNull(self::dashboard($device)['Pressure'] ?? null),
'trend' => self::stringOrNull(self::dashboard($device)['pressure_trend'] ?? null),
],
'rain_module_id' => $rain === null ? null : self::stringOrNull($rain['_id'] ?? null),
'sum_rain_24' => self::floatOrNull(self::dashboard($rain)['sum_rain_24'] ?? null),
];
@@ -92,6 +99,7 @@ final class StationMapper
'min_at' => self::intOrNull($d['date_min_temp'] ?? null),
'max' => self::floatOrNull($d['max_temp'] ?? null),
'max_at' => self::intOrNull($d['date_max_temp'] ?? null),
'humidity' => self::floatOrNull($d['Humidity'] ?? null),
];
}
@@ -118,6 +126,8 @@ final class StationMapper
'temp' => self::floatOrNull($d['Temperature'] ?? null),
'min' => self::floatOrNull($d['min_temp'] ?? null),
'max' => self::floatOrNull($d['max_temp'] ?? null),
'humidity' => self::floatOrNull($d['Humidity'] ?? null),
'co2' => self::intOrNull($d['CO2'] ?? null),
'reachable' => $d !== [],
];
}
+4 -1
View File
@@ -13,6 +13,9 @@ interface WeatherSource
/** @return array<string|int, mixed> de "body" van Netatmo getmeasure (timestamp => [mm]) */
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{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;
}
+2 -2
View File
@@ -28,7 +28,7 @@ final class ConfigTest extends TestCase
self::assertSame('Europe/Berlin', $config->get('timezone'));
self::assertSame('fixture', $config->get('data_source'));
self::assertSame(['stations' => 300, 'measure' => 900, 'forecast' => 3600], $config->get('cache_ttl'));
self::assertSame(['stations' => 300, 'measure' => 900, 'pressure' => 900, 'forecast' => 3600], $config->get('cache_ttl'));
self::assertSame([], $config->get('indoor_modules'));
self::assertNull($config->get('netatmo_device_id'));
}
@@ -39,7 +39,7 @@ final class ConfigTest extends TestCase
$config = Config::load($this->file);
self::assertSame('Europe/Amsterdam', $config->get('timezone'));
self::assertSame(['stations' => 60, 'measure' => 900, 'forecast' => 3600], $config->get('cache_ttl'));
self::assertSame(['stations' => 60, 'measure' => 900, 'pressure' => 900, 'forecast' => 3600], $config->get('cache_ttl'));
}
public function testUnknownKeyReturnsGivenDefault(): void
+41 -6
View File
@@ -32,7 +32,7 @@ final class DashboardServiceTest extends TestCase
return new DashboardService(
$this->source,
new Cache($this->dir),
['stations' => 300, 'measure' => 900, 'forecast' => 3600],
['stations' => 300, 'measure' => 900, 'pressure' => 900, 'forecast' => 3600],
'Europe/Amsterdam',
$lat,
$lon,
@@ -46,18 +46,28 @@ final class DashboardServiceTest extends TestCase
{
$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', 'indoor', 'rain', 'pressure', 'forecast', 'forecast_stale', 'errors'], array_keys($p));
self::assertSame('2026-09-11T14:32:05+02:00', $p['generated_at']);
self::assertSame('Europe/Berlin', $p['timezone']);
self::assertSame(['measured_at' => '2026-09-11T14:30:12+02:00', 'stale' => false], $p['station']);
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::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(['2026-09-08', '2026-09-09', '2026-09-10'], array_column($p['rain']['previous_days'], 'date'));
self::assertEqualsWithDelta(38.5, $p['rain']['month_total'], 0.0001);
self::assertFalse($p['rain']['stale']);
self::assertCount(5, $p['forecast']);
self::assertSame('2026-09-12', $p['forecast'][0]['date']);
self::assertCount(6, $p['forecast']);
self::assertSame('2026-09-11', $p['forecast'][0]['date']);
self::assertCount(12, $p['forecast'][0]['hourly']);
self::assertSame(['at', 'temp', 'precip_mm', 'wind_kmh', 'wind_dir'], array_keys($p['forecast'][0]['hourly'][0]));
self::assertSame('2026-09-11T00:00:00+02:00', $p['forecast'][0]['hourly'][0]['at']);
self::assertFalse($p['forecast_stale']);
self::assertSame([], $p['errors']);
}
@@ -73,6 +83,29 @@ final class DashboardServiceTest extends TestCase
self::assertSame((new \DateTimeImmutable('2026-09-11 12:32:05 UTC'))->getTimestamp(), $end);
}
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
{
$this->service()->build();
@@ -96,6 +129,8 @@ final class DashboardServiceTest extends TestCase
self::assertSame([], $p['indoor']);
self::assertNull($p['rain']['today']);
self::assertSame(0, $this->source->calls['measure'], 'zonder regenmeter-id geen getmeasure-call');
self::assertSame(0, $this->source->calls['pressure'], 'zonder device-id geen luchtdruk-call');
self::assertSame(['current' => null, 'trend' => null, 'history' => [], 'stale' => false], $p['pressure']);
self::assertSame('Europe/Amsterdam', $p['timezone'], 'fallback-tijdzone uit config');
self::assertSame(['netatmo_unavailable', 'forecast_unavailable'], $p['errors']);
}
@@ -152,7 +187,7 @@ final class DashboardServiceTest extends TestCase
$this->service()->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, 'forecast' => 1], $this->source->calls);
$this->service('2026-09-11 12:38:05 UTC')->build();
self::assertSame(2, $this->source->calls['stations'], 'stations na 5 min opnieuw');
+11 -2
View File
@@ -13,7 +13,7 @@ final class FakeSource implements WeatherSource
public array $responses = [];
/** @var array<string, int> */
public array $calls = ['stations' => 0, 'measure' => 0, 'forecast' => 0];
public array $calls = ['stations' => 0, 'measure' => 0, 'pressure' => 0, 'forecast' => 0];
/** @var array<string, array> */
public array $lastArgs = [];
@@ -24,7 +24,11 @@ final class FakeSource implements WeatherSource
$s->responses = [
'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'],
'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'],
'forecast' => array_intersect_key(
json_decode((string) file_get_contents(__DIR__ . '/fixtures/forecast.json'), true),
['daily' => null, 'hourly' => null],
),
];
return $s;
}
@@ -39,6 +43,11 @@ final class FakeSource implements WeatherSource
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 forecast(float $lat, float $lon, string $timezone): array
{
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'];
}
public function testSkipsTodayAndReturnsFiveDays(): void
public function testIncludesTodayAndReturnsSixDays(): void
{
$f = ForecastMapper::map($this->daily());
self::assertCount(5, $f);
self::assertCount(6, $f);
self::assertSame([
'date' => '2026-09-12', 'weather_code' => 2, 'temp_max' => 18.3, 'temp_min' => 10.1,
'precip_mm' => 0.4, 'precip_prob' => 20,
'date' => '2026-09-11', 'weather_code' => 3, 'temp_max' => 17.9, 'temp_min' => 11.0,
'precip_mm' => 0.0, 'precip_prob' => 10,
], $f[0]);
self::assertSame('2026-09-16', $f[4]['date']);
self::assertSame('2026-09-16', $f[5]['date']);
}
public function testNullValuesStayNull(): void
@@ -35,9 +35,9 @@ final class ForecastMapperTest extends TestCase
$f = ForecastMapper::map($daily);
self::assertNull($f[2]['weather_code']);
self::assertNull($f[2]['precip_prob']);
self::assertSame(17.4, $f[2]['temp_max']);
self::assertNull($f[3]['weather_code']);
self::assertNull($f[3]['precip_prob']);
self::assertSame(17.4, $f[3]['temp_max']);
}
public function testShorterResponseGivesFewerDays(): void
@@ -47,7 +47,7 @@ final class ForecastMapperTest extends TestCase
$daily[$key] = array_slice($values, 0, 3);
}
self::assertCount(2, ForecastMapper::map($daily));
self::assertCount(3, ForecastMapper::map($daily));
}
public function testMissingTimeArrayGivesEmptyList(): void
+103
View File
@@ -0,0 +1,103 @@
<?php
declare(strict_types=1);
namespace Weerscherm\Tests;
use PHPUnit\Framework\TestCase;
use Weerscherm\HourlyForecastMapper;
final class HourlyForecastMapperTest extends TestCase
{
private \DateTimeZone $tz;
protected function setUp(): void
{
$this->tz = new \DateTimeZone('Europe/Berlin');
}
/** @return array<string, mixed> */
private function hourly(): array
{
return json_decode((string) file_get_contents(__DIR__ . '/fixtures/forecast.json'), true)['hourly'];
}
public function testGroupsByDateAndSamplesEveryOtherHour(): void
{
$byDate = HourlyForecastMapper::mapByDate($this->hourly(), $this->tz);
self::assertSame(['2026-09-11', '2026-09-12', '2026-09-13', '2026-09-14', '2026-09-15', '2026-09-16'], array_keys($byDate));
self::assertCount(12, $byDate['2026-09-11']);
$hours = array_map(
fn(array $p) => (int) (new \DateTimeImmutable($p['at']))->format('H'),
$byDate['2026-09-11'],
);
self::assertSame([0, 2, 4, 6, 8, 10, 12, 14, 16, 18, 20, 22], $hours);
}
public function testPointShapeAndValues(): void
{
$byDate = HourlyForecastMapper::mapByDate($this->hourly(), $this->tz);
$noon = $byDate['2026-09-11'][6]; // index 6 = 12:00 (0,2,4,6,8,10,12,...)
self::assertSame(['at', 'temp', 'precip_mm', 'wind_kmh', 'wind_dir'], array_keys($noon));
self::assertSame('2026-09-11T12:00:00+02:00', $noon['at']);
self::assertIsFloat($noon['temp']);
self::assertIsFloat($noon['precip_mm']);
self::assertIsFloat($noon['wind_kmh']);
self::assertIsInt($noon['wind_dir']);
}
public function testRainOnlyOnTheHoursThatHaveIt(): void
{
$byDate = HourlyForecastMapper::mapByDate($this->hourly(), $this->tz);
// 2026-09-14 heeft 8,2 mm in de dagfixture, verdeeld over 14-16u; de bemonsterde uren zijn 0,2,4,..22.
$rainy = array_filter($byDate['2026-09-14'], fn(array $p) => $p['precip_mm'] > 0);
self::assertNotEmpty($rainy);
foreach ($rainy as $p) {
self::assertGreaterThanOrEqual(14, (int) (new \DateTimeImmutable($p['at']))->format('H'));
}
}
public function testDaylightSavingDayWithTwentyFiveHoursSamplesWithoutError(): void
{
$hourly = json_decode((string) file_get_contents(__DIR__ . '/fixtures/hourly-dst.json'), true);
$byDate = HourlyForecastMapper::mapByDate($hourly, $this->tz);
self::assertCount(13, $byDate['2026-10-25'], '25 punten, om de 2: index 0,2,...,24 = 13 stuks');
}
public function testNullValuesStayNull(): void
{
$hourly = $this->hourly();
$hourly['temperature_2m'][0] = null;
$hourly['wind_direction_10m'][0] = 'kapot';
$byDate = HourlyForecastMapper::mapByDate($hourly, $this->tz);
self::assertNull($byDate['2026-09-11'][0]['temp']);
self::assertNull($byDate['2026-09-11'][0]['wind_dir']);
}
public function testMalformedTimestampsAreSkipped(): void
{
$byDate = HourlyForecastMapper::mapByDate([
'time' => ['niet-een-datum', '2026-09-11T05:00'],
'temperature_2m' => [1.0, 2.0],
'precipitation' => [0.0, 0.0],
'wind_speed_10m' => [1.0, 1.0],
'wind_direction_10m' => [1, 1],
], $this->tz);
self::assertSame(['2026-09-11'], array_keys($byDate));
self::assertCount(1, $byDate['2026-09-11']);
}
public function testMissingTimeArrayGivesEmptyResult(): void
{
self::assertSame([], HourlyForecastMapper::mapByDate([], $this->tz));
}
}
+14
View File
@@ -198,4 +198,18 @@ final class NetatmoClientTest extends TestCase
'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']);
}
}
+16 -5
View File
@@ -10,19 +10,21 @@ use Weerscherm\UpstreamException;
final class OpenMeteoClientTest extends TestCase
{
public function testRequestsSixDaysWithAllDailyVariables(): void
public function testRequestsSixDaysWithDailyAndHourlyVariables(): void
{
$http = new FakeHttp();
$http->queue(200, json_decode((string) file_get_contents(__DIR__ . '/fixtures/forecast.json'), true));
$daily = (new OpenMeteoClient($http))->dailyForecast(51.82, 6.55, 'Europe/Berlin');
$result = (new OpenMeteoClient($http))->forecast(51.82, 6.55, 'Europe/Berlin');
self::assertCount(6, $daily['time']);
self::assertCount(6, $result['daily']['time']);
self::assertCount(144, $result['hourly']['time']);
self::assertSame('https://api.open-meteo.com/v1/forecast', $http->requests[0]['url']);
self::assertSame([
'latitude' => 51.82,
'longitude' => 6.55,
'daily' => 'weather_code,temperature_2m_max,temperature_2m_min,precipitation_sum,precipitation_probability_max',
'hourly' => 'temperature_2m,precipitation,wind_speed_10m,wind_direction_10m',
'timezone' => 'Europe/Berlin',
'forecast_days' => 6,
], $http->requests[0]['params']);
@@ -32,13 +34,22 @@ final class OpenMeteoClientTest extends TestCase
{
$http = (new FakeHttp())->queue(429, ['reason' => 'rate limit']);
$this->expectException(UpstreamException::class);
(new OpenMeteoClient($http))->dailyForecast(1, 2, 'UTC');
(new OpenMeteoClient($http))->forecast(1, 2, 'UTC');
}
public function testMissingDailyIsUpstreamException(): void
{
$http = (new FakeHttp())->queue(200, ['hourly' => []]);
$this->expectException(UpstreamException::class);
(new OpenMeteoClient($http))->dailyForecast(1, 2, 'UTC');
(new OpenMeteoClient($http))->forecast(1, 2, 'UTC');
}
public function testMissingHourlyGivesEmptyHourlyBlockInsteadOfError(): void
{
$http = (new FakeHttp())->queue(200, ['daily' => ['time' => ['2026-09-11']]]);
$result = (new OpenMeteoClient($http))->forecast(1, 2, 'UTC');
self::assertSame([], $result['hourly']);
}
}
+32
View File
@@ -0,0 +1,32 @@
<?php
declare(strict_types=1);
namespace Weerscherm\Tests;
use PHPUnit\Framework\TestCase;
use Weerscherm\PressureHistory;
final class PressureHistoryTest extends TestCase
{
public function testMapsAndSortsPoints(): void
{
$body = json_decode((string) file_get_contents(__DIR__ . '/fixtures/getmeasure-pressure.json'), true)['body'];
$shuffled = array_reverse($body, true);
$points = PressureHistory::map($shuffled, new \DateTimeZone('Europe/Berlin'));
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 = PressureHistory::map(['abc' => [1000.0], '1789129800' => ['x'], '1789133400' => [1013.5]], new \DateTimeZone('UTC'));
self::assertSame([['at' => '2026-09-11T13:30:00+00:00', 'hpa' => 1013.5]], $points);
}
}
+23 -3
View File
@@ -38,6 +38,7 @@ final class StationMapperTest extends TestCase
'min_at' => 1789099800,
'max' => 19.8,
'max_at' => 1789124700,
'humidity' => 71.0,
], $m['outdoor']);
}
@@ -47,8 +48,8 @@ final class StationMapperTest extends TestCase
self::assertCount(3, $m['indoor']);
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' => '03:00:00:00:00:03', 'name' => 'Kantoor', 'temp' => null, 'min' => null, 'max' => null, 'reachable' => false], $m['indoor'][2]);
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, 'humidity' => null, 'co2' => null, 'reachable' => false], $m['indoor'][2]);
}
public function testIndoorConfigDeterminesOrderAndLabels(): void
@@ -69,6 +70,24 @@ final class StationMapperTest extends TestCase
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
{
$body = $this->body();
@@ -89,7 +108,7 @@ final class StationMapperTest extends TestCase
unset($body['devices'][0]['modules'][0]['dashboard_data']);
$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
@@ -108,5 +127,6 @@ final class StationMapperTest extends TestCase
self::assertSame([], $m['indoor']);
self::assertNull($m['outdoor']['temp']);
self::assertNull($m['rain_module_id']);
self::assertSame(['current' => null, 'trend' => null], $m['pressure']);
}
}
+804 -23
View File
@@ -1,25 +1,806 @@
{
"latitude": 51.82,
"longitude": 6.56,
"generationtime_ms": 0.2,
"utc_offset_seconds": 7200,
"timezone": "Europe/Berlin",
"timezone_abbreviation": "CEST",
"elevation": 20.0,
"daily_units": {
"time": "iso8601",
"weather_code": "wmo code",
"temperature_2m_max": "°C",
"temperature_2m_min": "°C",
"precipitation_sum": "mm",
"precipitation_probability_max": "%"
},
"daily": {
"time": ["2026-09-11", "2026-09-12", "2026-09-13", "2026-09-14", "2026-09-15", "2026-09-16"],
"weather_code": [3, 2, 0, 61, 80, 2],
"temperature_2m_max": [17.9, 18.3, 20.1, 17.4, 15.2, 16.0],
"temperature_2m_min": [11.0, 10.1, 11.3, 12.0, 9.4, 8.1],
"precipitation_sum": [0.0, 0.4, 0.0, 8.2, 3.1, 0.2],
"precipitation_probability_max": [10, 20, 5, 80, 60, 15]
}
"latitude": 51.82,
"longitude": 6.56,
"generationtime_ms": 0.2,
"utc_offset_seconds": 7200,
"timezone": "Europe/Berlin",
"timezone_abbreviation": "CEST",
"elevation": 20,
"daily_units": {
"time": "iso8601",
"weather_code": "wmo code",
"temperature_2m_max": "\u00b0C",
"temperature_2m_min": "\u00b0C",
"precipitation_sum": "mm",
"precipitation_probability_max": "%"
},
"daily": {
"time": [
"2026-09-11",
"2026-09-12",
"2026-09-13",
"2026-09-14",
"2026-09-15",
"2026-09-16"
],
"weather_code": [
3,
2,
0,
61,
80,
2
],
"temperature_2m_max": [
17.9,
18.3,
20.1,
17.4,
15.2,
16
],
"temperature_2m_min": [
11,
10.1,
11.3,
12,
9.4,
8.1
],
"precipitation_sum": [
0,
0.4,
0,
8.2,
3.1,
0.2
],
"precipitation_probability_max": [
10,
20,
5,
80,
60,
15
]
},
"hourly_units": {
"time": "iso8601",
"temperature_2m": "\u00b0C",
"precipitation": "mm",
"wind_speed_10m": "km/h",
"wind_direction_10m": "\u00b0"
},
"hourly": {
"time": [
"2026-09-11T00:00",
"2026-09-11T01:00",
"2026-09-11T02:00",
"2026-09-11T03:00",
"2026-09-11T04:00",
"2026-09-11T05:00",
"2026-09-11T06:00",
"2026-09-11T07:00",
"2026-09-11T08:00",
"2026-09-11T09:00",
"2026-09-11T10:00",
"2026-09-11T11:00",
"2026-09-11T12:00",
"2026-09-11T13:00",
"2026-09-11T14:00",
"2026-09-11T15:00",
"2026-09-11T16:00",
"2026-09-11T17:00",
"2026-09-11T18:00",
"2026-09-11T19:00",
"2026-09-11T20:00",
"2026-09-11T21:00",
"2026-09-11T22:00",
"2026-09-11T23:00",
"2026-09-12T00:00",
"2026-09-12T01:00",
"2026-09-12T02:00",
"2026-09-12T03:00",
"2026-09-12T04:00",
"2026-09-12T05:00",
"2026-09-12T06:00",
"2026-09-12T07:00",
"2026-09-12T08:00",
"2026-09-12T09:00",
"2026-09-12T10:00",
"2026-09-12T11:00",
"2026-09-12T12:00",
"2026-09-12T13:00",
"2026-09-12T14:00",
"2026-09-12T15:00",
"2026-09-12T16:00",
"2026-09-12T17:00",
"2026-09-12T18:00",
"2026-09-12T19:00",
"2026-09-12T20:00",
"2026-09-12T21:00",
"2026-09-12T22:00",
"2026-09-12T23:00",
"2026-09-13T00:00",
"2026-09-13T01:00",
"2026-09-13T02:00",
"2026-09-13T03:00",
"2026-09-13T04:00",
"2026-09-13T05:00",
"2026-09-13T06:00",
"2026-09-13T07:00",
"2026-09-13T08:00",
"2026-09-13T09:00",
"2026-09-13T10:00",
"2026-09-13T11:00",
"2026-09-13T12:00",
"2026-09-13T13:00",
"2026-09-13T14:00",
"2026-09-13T15:00",
"2026-09-13T16:00",
"2026-09-13T17:00",
"2026-09-13T18:00",
"2026-09-13T19:00",
"2026-09-13T20:00",
"2026-09-13T21:00",
"2026-09-13T22:00",
"2026-09-13T23:00",
"2026-09-14T00:00",
"2026-09-14T01:00",
"2026-09-14T02:00",
"2026-09-14T03:00",
"2026-09-14T04:00",
"2026-09-14T05:00",
"2026-09-14T06:00",
"2026-09-14T07:00",
"2026-09-14T08:00",
"2026-09-14T09:00",
"2026-09-14T10:00",
"2026-09-14T11:00",
"2026-09-14T12:00",
"2026-09-14T13:00",
"2026-09-14T14:00",
"2026-09-14T15:00",
"2026-09-14T16:00",
"2026-09-14T17:00",
"2026-09-14T18:00",
"2026-09-14T19:00",
"2026-09-14T20:00",
"2026-09-14T21:00",
"2026-09-14T22:00",
"2026-09-14T23:00",
"2026-09-15T00:00",
"2026-09-15T01:00",
"2026-09-15T02:00",
"2026-09-15T03:00",
"2026-09-15T04:00",
"2026-09-15T05:00",
"2026-09-15T06:00",
"2026-09-15T07:00",
"2026-09-15T08:00",
"2026-09-15T09:00",
"2026-09-15T10:00",
"2026-09-15T11:00",
"2026-09-15T12:00",
"2026-09-15T13:00",
"2026-09-15T14:00",
"2026-09-15T15:00",
"2026-09-15T16:00",
"2026-09-15T17:00",
"2026-09-15T18:00",
"2026-09-15T19:00",
"2026-09-15T20:00",
"2026-09-15T21:00",
"2026-09-15T22:00",
"2026-09-15T23:00",
"2026-09-16T00:00",
"2026-09-16T01:00",
"2026-09-16T02:00",
"2026-09-16T03:00",
"2026-09-16T04:00",
"2026-09-16T05:00",
"2026-09-16T06:00",
"2026-09-16T07:00",
"2026-09-16T08:00",
"2026-09-16T09:00",
"2026-09-16T10:00",
"2026-09-16T11:00",
"2026-09-16T12:00",
"2026-09-16T13:00",
"2026-09-16T14:00",
"2026-09-16T15:00",
"2026-09-16T16:00",
"2026-09-16T17:00",
"2026-09-16T18:00",
"2026-09-16T19:00",
"2026-09-16T20:00",
"2026-09-16T21:00",
"2026-09-16T22:00",
"2026-09-16T23:00"
],
"temperature_2m": [
10.3,
9.7,
9.3,
9.2,
9.3,
9.7,
10.3,
11,
11.9,
12.8,
13.7,
14.6,
15.3,
15.9,
16.3,
16.4,
16.3,
15.9,
15.3,
14.6,
13.7,
12.8,
11.9,
11,
11.3,
10.6,
10.2,
10.1,
10.2,
10.6,
11.3,
12.2,
13.1,
14.2,
15.3,
16.3,
17.1,
17.8,
18.2,
18.3,
18.2,
17.8,
17.1,
16.3,
15.3,
14.2,
13.1,
12.2,
12.6,
11.9,
11.4,
11.3,
11.4,
11.9,
12.6,
13.5,
14.6,
15.7,
16.8,
17.9,
18.8,
19.5,
20,
20.1,
20,
19.5,
18.8,
17.9,
16.8,
15.7,
14.6,
13.5,
12.8,
12.4,
12.1,
12,
12.1,
12.4,
12.8,
13.4,
14,
14.7,
15.4,
16.1,
16.6,
17,
17.3,
17.4,
17.3,
17,
16.6,
16.1,
15.4,
14.7,
14,
13.4,
10.2,
9.8,
9.5,
9.4,
9.5,
9.8,
10.2,
10.9,
11.5,
12.3,
13.1,
13.8,
14.4,
14.8,
15.1,
15.2,
15.1,
14.8,
14.4,
13.8,
13.1,
12.3,
11.5,
10.9,
9.3,
8.6,
8.2,
8.1,
8.2,
8.6,
9.3,
10.1,
11,
12.1,
13.1,
14,
14.8,
15.5,
15.9,
16,
15.9,
15.5,
14.8,
14,
13.1,
12.1,
11,
10.1
],
"precipitation": [
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0.1,
0.1,
0.1,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
2.7,
2.7,
2.7,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
1,
1,
1,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0.1,
0.1,
0.1,
0,
0,
0,
0,
0,
0,
0
],
"wind_speed_10m": [
6,
6.8,
7.5,
8.1,
8.6,
8.9,
9,
8.9,
8.6,
8.1,
7.5,
6.8,
6,
5.2,
4.5,
3.9,
3.4,
3.1,
3,
3.1,
3.4,
3.9,
4.5,
5.2,
8.1,
8.6,
8.9,
9,
8.9,
8.6,
8.1,
7.5,
6.8,
6,
5.2,
4.5,
3.9,
3.4,
3.1,
3,
3.1,
3.4,
3.9,
4.5,
5.2,
6,
6.8,
7.5,
9,
8.9,
8.6,
8.1,
7.5,
6.8,
6,
5.2,
4.5,
3.9,
3.4,
3.1,
3,
3.1,
3.4,
3.9,
4.5,
5.2,
6,
6.8,
7.5,
8.1,
8.6,
8.9,
8.1,
7.5,
6.8,
6,
5.2,
4.5,
3.9,
3.4,
3.1,
3,
3.1,
3.4,
3.9,
4.5,
5.2,
6,
6.8,
7.5,
8.1,
8.6,
8.9,
9,
8.9,
8.6,
6,
5.2,
4.5,
3.9,
3.4,
3.1,
3,
3.1,
3.4,
3.9,
4.5,
5.2,
6,
6.8,
7.5,
8.1,
8.6,
8.9,
9,
8.9,
8.6,
8.1,
7.5,
6.8,
3.9,
3.4,
3.1,
3,
3.1,
3.4,
3.9,
4.5,
5.2,
6,
6.8,
7.5,
8.1,
8.6,
8.9,
9,
8.9,
8.6,
8.1,
7.5,
6.8,
6,
5.2,
4.5
],
"wind_direction_10m": [
0,
7,
14,
21,
28,
35,
42,
49,
56,
63,
70,
77,
84,
91,
98,
105,
112,
119,
126,
133,
140,
147,
154,
161,
40,
47,
54,
61,
68,
75,
82,
89,
96,
103,
110,
117,
124,
131,
138,
145,
152,
159,
166,
173,
180,
187,
194,
201,
80,
87,
94,
101,
108,
115,
122,
129,
136,
143,
150,
157,
164,
171,
178,
185,
192,
199,
206,
213,
220,
227,
234,
241,
120,
127,
134,
141,
148,
155,
162,
169,
176,
183,
190,
197,
204,
211,
218,
225,
232,
239,
246,
253,
260,
267,
274,
281,
160,
167,
174,
181,
188,
195,
202,
209,
216,
223,
230,
237,
244,
251,
258,
265,
272,
279,
286,
293,
300,
307,
314,
321,
200,
207,
214,
221,
228,
235,
242,
249,
256,
263,
270,
277,
284,
291,
298,
305,
312,
319,
326,
333,
340,
347,
354,
1
]
}
}
+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"
}
+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
]
}
+69
View File
@@ -118,3 +118,72 @@ test('msUntil computes the wait until the next local time', () => {
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,
} 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), '–');
});