Merge feature/weerscherm: Weerscherm v1 (backend, frontend, Netatmo, Open-Meteo, PWA, docs)
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1 @@
|
||||
{"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}}
|
||||
@@ -0,0 +1,154 @@
|
||||
# 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/`
|
||||
|
||||
- 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.
|
||||
|
||||
Nieuwe versie van de frontend? Verhoog `VERSION` in `public/sw.js`; de app herlaadt zichzelf bij `controllerchange`
|
||||
en in elk geval dagelijks rond 04:00.
|
||||
|
||||
## 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
|
||||
```
|
||||
Executable
+42
@@ -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";
|
||||
@@ -0,0 +1,26 @@
|
||||
{
|
||||
"name": "pensare/weerscherm",
|
||||
"description": "Weerscherm voor een iPad mini: Netatmo-stationsdata en Open-Meteo-verwachting",
|
||||
"type": "project",
|
||||
"license": "proprietary",
|
||||
"require": {
|
||||
"php": ">=8.3",
|
||||
"ext-curl": "*",
|
||||
"ext-json": "*"
|
||||
},
|
||||
"require-dev": {
|
||||
"phpunit/phpunit": "^11.5"
|
||||
},
|
||||
"autoload": {
|
||||
"psr-4": { "Weerscherm\\": "src/" }
|
||||
},
|
||||
"autoload-dev": {
|
||||
"psr-4": { "Weerscherm\\Tests\\": "tests/" }
|
||||
},
|
||||
"scripts": {
|
||||
"test": "phpunit"
|
||||
},
|
||||
"config": {
|
||||
"sort-packages": true
|
||||
}
|
||||
}
|
||||
Generated
+1787
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,38 @@
|
||||
<?php
|
||||
|
||||
/**
|
||||
* Kopieer dit bestand naar config.php en vul de waarden in.
|
||||
* config.php staat in .gitignore en mag nooit in git komen.
|
||||
*/
|
||||
return [
|
||||
// 'live' haalt data op bij Netatmo en Open-Meteo, 'fixture' levert vaste voorbeelddata.
|
||||
'data_source' => 'fixture',
|
||||
|
||||
// Uit de app op dev.netatmo.com.
|
||||
'netatmo_client_id' => '',
|
||||
'netatmo_client_secret' => '',
|
||||
|
||||
// Optioneel: het _id (MAC-adres) van het basisstation. Leeg = eerste station.
|
||||
'netatmo_device_id' => null,
|
||||
|
||||
// Fallback als het station geen tijdzone meldt.
|
||||
'timezone' => 'Europe/Berlin',
|
||||
|
||||
// Optioneel: locatie voor de verwachting. Leeg = locatie van het station.
|
||||
'forecast_lat' => null,
|
||||
'forecast_lon' => null,
|
||||
|
||||
// Optioneel: volgorde en labels van de binnenruimtes, op basis van het _id van de module.
|
||||
// Voorbeeld: ['70:ee:50:00:00:01' => 'Woonkamer', '03:00:00:00:00:02' => 'Slaapkamer']
|
||||
'indoor_modules' => [],
|
||||
|
||||
// Cache per bron, in seconden.
|
||||
'cache_ttl' => [
|
||||
'stations' => 300,
|
||||
'measure' => 900,
|
||||
'forecast' => 3600,
|
||||
],
|
||||
|
||||
// Map voor tokens en cache. Moet schrijfbaar zijn voor de webserver en buiten de docroot liggen.
|
||||
'storage_path' => __DIR__ . '/storage',
|
||||
];
|
||||
@@ -57,7 +57,7 @@ src/
|
||||
RainCalculator.php # getmeasure-body -> today/previous_days/month_total
|
||||
ForecastMapper.php # open-meteo daily -> 5 dagen
|
||||
DashboardService.php # orkestratie, cache, stale-fallback, errors
|
||||
Exceptions.php # UpstreamException, NetatmoAuthException
|
||||
UpstreamException.php, NetatmoAuthException.php
|
||||
bin/netatmo-init.php # slaat refresh token op in storage/netatmo-token.json
|
||||
tests/
|
||||
bootstrap.php
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"name": "weerscherm",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"test": "node --test \"tests/js/**/*.test.js\""
|
||||
}
|
||||
}
|
||||
+18
@@ -0,0 +1,18 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<phpunit xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
|
||||
xsi:noNamespaceSchemaLocation="vendor/phpunit/phpunit/phpunit.xsd"
|
||||
bootstrap="tests/bootstrap.php"
|
||||
colors="true"
|
||||
failOnWarning="true"
|
||||
displayDetailsOnTestsThatTriggerWarnings="true">
|
||||
<testsuites>
|
||||
<testsuite name="weerscherm">
|
||||
<directory>tests</directory>
|
||||
</testsuite>
|
||||
</testsuites>
|
||||
<source>
|
||||
<include>
|
||||
<directory>src</directory>
|
||||
</include>
|
||||
</source>
|
||||
</phpunit>
|
||||
@@ -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
|
||||
@@ -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']]);
|
||||
}
|
||||
+253
@@ -0,0 +1,253 @@
|
||||
import {
|
||||
f1, fInt, fmm, markerPos, barHeights, wmoIcon, trendLabel,
|
||||
formatDate, formatTime, dayAbbr, monthName, isOlderThan, msUntil,
|
||||
} from './format.js';
|
||||
import { icon } 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 $ = (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
|
||||
};
|
||||
|
||||
/* ---------- 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('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)}`);
|
||||
}
|
||||
|
||||
function renderRooms(rooms) {
|
||||
const html = rooms.map((r) => {
|
||||
const off = !r.reachable || r.temp === null;
|
||||
return `<div class="a-room${off ? ' off' : ''}">
|
||||
<p class="a-rname">${esc(r.name)}</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>
|
||||
</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 renderForecast(days) {
|
||||
$('forecast').innerHTML = days.map((d) => `<div class="a-day">
|
||||
<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('');
|
||||
}
|
||||
|
||||
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);
|
||||
renderRooms(d.indoor);
|
||||
renderRain(d.rain, now);
|
||||
renderForecast(d.forecast);
|
||||
}
|
||||
renderBanner();
|
||||
renderStatus();
|
||||
}
|
||||
|
||||
function esc(s) {
|
||||
return String(s).replace(/[&<>"]/g, (c) => ({ '&': '&', '<': '<', '>': '>', '"': '"' }[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 ---------- */
|
||||
|
||||
function registerServiceWorker() {
|
||||
if (!('serviceWorker' in navigator)) return;
|
||||
navigator.serviceWorker.register('./sw.js').catch((err) => console.warn('weerscherm: service worker', err));
|
||||
let reloading = false;
|
||||
navigator.serviceWorker.addEventListener('controllerchange', () => {
|
||||
if (reloading) return;
|
||||
reloading = true;
|
||||
location.reload();
|
||||
});
|
||||
}
|
||||
|
||||
/* ---------- start ---------- */
|
||||
|
||||
window.addEventListener('resize', fit);
|
||||
window.addEventListener('orientationchange', () => setTimeout(fit, 50));
|
||||
document.addEventListener('visibilitychange', () => {
|
||||
if (document.visibilityState === 'visible') {
|
||||
fit();
|
||||
refresh();
|
||||
requestWakeLock();
|
||||
registerServiceWorker();
|
||||
}
|
||||
});
|
||||
|
||||
fit();
|
||||
render();
|
||||
refresh();
|
||||
setInterval(refresh, REFRESH_MS);
|
||||
scheduleClock();
|
||||
scheduleMidnight();
|
||||
scheduleReload();
|
||||
requestWakeLock();
|
||||
registerServiceWorker();
|
||||
@@ -0,0 +1,124 @@
|
||||
/**
|
||||
* 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());
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
/**
|
||||
* 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>`;
|
||||
}
|
||||
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 |
@@ -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 |
@@ -0,0 +1,63 @@
|
||||
<!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"> </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></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>
|
||||
<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>
|
||||
</section>
|
||||
|
||||
<section class="a-inside" id="rooms"></section>
|
||||
|
||||
<section class="a-bottom">
|
||||
<div>
|
||||
<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>
|
||||
</main>
|
||||
</div>
|
||||
<div id="rotate">Draai de iPad een kwartslag</div>
|
||||
<script type="module" src="app.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -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" }
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
User-agent: *
|
||||
Disallow: /
|
||||
@@ -0,0 +1,159 @@
|
||||
/* 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;
|
||||
--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 { 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-temp { font-size: 200px; font-weight: 200; letter-spacing: -.045em; line-height: .95; margin: 4px 0 24px -8px; }
|
||||
.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; }
|
||||
|
||||
/* 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-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; }
|
||||
|
||||
/* 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(5, 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-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); }
|
||||
|
||||
#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; }
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
/* Service worker: app-shell cache-first, API network-first met timeout en fallback op de laatste response. */
|
||||
|
||||
const VERSION = 'weerscherm-v1';
|
||||
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_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.endsWith(API_PATH)) {
|
||||
event.respondWith(networkFirst(req));
|
||||
} else {
|
||||
event.respondWith(cacheFirst(req));
|
||||
}
|
||||
});
|
||||
|
||||
async function networkFirst(req) {
|
||||
const cache = await caches.open(VERSION);
|
||||
const controller = new AbortController();
|
||||
const timer = setTimeout(() => controller.abort(), API_TIMEOUT_MS);
|
||||
try {
|
||||
const res = await fetch(req, { signal: controller.signal, cache: 'no-store' });
|
||||
if (res.ok) {
|
||||
cache.put(req, res.clone());
|
||||
}
|
||||
return res;
|
||||
} catch (err) {
|
||||
const cached = await cache.match(req);
|
||||
if (cached) {
|
||||
const headers = new Headers(cached.headers);
|
||||
headers.set('X-From-Cache', '1');
|
||||
return new Response(await cached.arrayBuffer(), { status: 200, statusText: 'OK', headers });
|
||||
}
|
||||
return new Response(JSON.stringify({ errors: ['offline'] }), {
|
||||
status: 503,
|
||||
headers: { 'Content-Type': 'application/json; charset=utf-8', 'Cache-Control': 'no-store' },
|
||||
});
|
||||
} 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;
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Weerscherm;
|
||||
|
||||
/** Eenvoudige bestandscache per sleutel: één JSON-bestand met stored_at en data. Schrijft atomisch. */
|
||||
final class Cache
|
||||
{
|
||||
public function __construct(private readonly string $dir)
|
||||
{
|
||||
}
|
||||
|
||||
/** @return array{data: array, stored_at: int}|null */
|
||||
public function read(string $key): ?array
|
||||
{
|
||||
$file = $this->file($key);
|
||||
if (!is_file($file)) {
|
||||
return null;
|
||||
}
|
||||
$raw = json_decode((string) file_get_contents($file), true);
|
||||
if (!is_array($raw) || !is_array($raw['data'] ?? null) || !is_int($raw['stored_at'] ?? null)) {
|
||||
return null;
|
||||
}
|
||||
return ['data' => $raw['data'], 'stored_at' => $raw['stored_at']];
|
||||
}
|
||||
|
||||
public function isFresh(string $key, int $ttl, int $now): bool
|
||||
{
|
||||
$entry = $this->read($key);
|
||||
return $entry !== null && ($now - $entry['stored_at']) < $ttl;
|
||||
}
|
||||
|
||||
public function write(string $key, array $data, int $now): void
|
||||
{
|
||||
if (!is_dir($this->dir) && !mkdir($this->dir, 0750, true) && !is_dir($this->dir)) {
|
||||
throw new \RuntimeException("Cachemap kan niet worden aangemaakt: {$this->dir}");
|
||||
}
|
||||
$file = $this->file($key);
|
||||
$tmp = $file . '.tmp.' . uniqid('', true);
|
||||
$json = json_encode(['stored_at' => $now, 'data' => $data], JSON_THROW_ON_ERROR);
|
||||
if (file_put_contents($tmp, $json, LOCK_EX) === false || !rename($tmp, $file)) {
|
||||
@unlink($tmp);
|
||||
throw new \RuntimeException("Cachebestand kan niet worden geschreven: $file");
|
||||
}
|
||||
}
|
||||
|
||||
private function file(string $key): string
|
||||
{
|
||||
$safe = strtolower((string) preg_replace('/[^a-z0-9_-]+/i', '_', $key));
|
||||
return $this->dir . '/' . trim($safe, '_') . '.json';
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Weerscherm;
|
||||
|
||||
final class Config
|
||||
{
|
||||
private const DEFAULTS = [
|
||||
'data_source' => 'fixture',
|
||||
'netatmo_client_id' => '',
|
||||
'netatmo_client_secret' => '',
|
||||
'netatmo_device_id' => null,
|
||||
'timezone' => 'Europe/Berlin',
|
||||
'forecast_lat' => null,
|
||||
'forecast_lon' => null,
|
||||
'indoor_modules' => [],
|
||||
'cache_ttl' => [
|
||||
'stations' => 300,
|
||||
'measure' => 900,
|
||||
'forecast' => 3600,
|
||||
],
|
||||
'storage_path' => null,
|
||||
];
|
||||
|
||||
/** @param array<string, mixed> $values */
|
||||
private function __construct(private readonly array $values)
|
||||
{
|
||||
}
|
||||
|
||||
public static function load(string $file): self
|
||||
{
|
||||
if (!is_file($file)) {
|
||||
throw new \RuntimeException("Configuratiebestand ontbreekt: $file");
|
||||
}
|
||||
$raw = require $file;
|
||||
if (!is_array($raw)) {
|
||||
throw new \RuntimeException("Configuratiebestand moet een array teruggeven: $file");
|
||||
}
|
||||
|
||||
$values = array_replace(self::DEFAULTS, $raw);
|
||||
$values['cache_ttl'] = array_replace(self::DEFAULTS['cache_ttl'], is_array($raw['cache_ttl'] ?? null) ? $raw['cache_ttl'] : []);
|
||||
$values['storage_path'] ??= dirname($file) . '/storage';
|
||||
|
||||
return new self($values);
|
||||
}
|
||||
|
||||
public function get(string $key, mixed $default = null): mixed
|
||||
{
|
||||
return array_key_exists($key, $this->values) ? $this->values[$key] : $default;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,160 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Weerscherm;
|
||||
|
||||
/**
|
||||
* Bouwt de JSON-payload voor /api/data.php: haalt per bron data op (via cache of de WeatherSource),
|
||||
* valt bij een storing terug op de laatst gecachte data en markeert die als stale.
|
||||
*/
|
||||
final class DashboardService
|
||||
{
|
||||
private const ERR_NETATMO_AUTH = 'netatmo_auth';
|
||||
private const ERR_NETATMO = 'netatmo_unavailable';
|
||||
private const ERR_FORECAST = 'forecast_unavailable';
|
||||
|
||||
/** @var list<string> */
|
||||
private array $errors = [];
|
||||
|
||||
/**
|
||||
* @param array{stations: int, measure: int, forecast: int} $ttl
|
||||
* @param array<string, string> $indoorModules
|
||||
*/
|
||||
public function __construct(
|
||||
private readonly WeatherSource $source,
|
||||
private readonly Cache $cache,
|
||||
private readonly array $ttl,
|
||||
private readonly string $timezoneFallback,
|
||||
private readonly ?float $forecastLat,
|
||||
private readonly ?float $forecastLon,
|
||||
private readonly array $indoorModules,
|
||||
private readonly ?string $deviceId,
|
||||
private readonly ?\DateTimeImmutable $now = null,
|
||||
) {
|
||||
}
|
||||
|
||||
/** @return array<string, mixed> */
|
||||
public function build(): array
|
||||
{
|
||||
$this->errors = [];
|
||||
$nowUtc = $this->now ?? new \DateTimeImmutable('now');
|
||||
|
||||
$stations = $this->fetch('stations', fn() => $this->source->stationsData(), self::ERR_NETATMO);
|
||||
$station = StationMapper::map($stations['data'] ?? [], $this->deviceId, $this->indoorModules);
|
||||
|
||||
$tz = $this->timezone($station['timezone']);
|
||||
$now = $nowUtc->setTimezone($tz);
|
||||
|
||||
$rain = $this->rain($station, $now);
|
||||
$forecast = $this->forecast($station, $tz);
|
||||
|
||||
return [
|
||||
'generated_at' => $now->format(\DATE_ATOM),
|
||||
'timezone' => $tz->getName(),
|
||||
'station' => [
|
||||
'measured_at' => $this->iso($station['measured_at'], $tz),
|
||||
'stale' => $stations['stale'],
|
||||
],
|
||||
'outdoor' => [
|
||||
'temp' => $station['outdoor']['temp'],
|
||||
'trend' => $station['outdoor']['trend'],
|
||||
'min' => $station['outdoor']['min'],
|
||||
'min_at' => $this->iso($station['outdoor']['min_at'], $tz),
|
||||
'max' => $station['outdoor']['max'],
|
||||
'max_at' => $this->iso($station['outdoor']['max_at'], $tz),
|
||||
],
|
||||
'indoor' => $station['indoor'],
|
||||
'rain' => $rain,
|
||||
'forecast' => $forecast['days'],
|
||||
'forecast_stale' => $forecast['stale'],
|
||||
'errors' => $this->errors,
|
||||
];
|
||||
}
|
||||
|
||||
/** @return array{today: ?float, previous_days: list<array{date: string, mm: ?float}>, month_total: ?float, stale: bool} */
|
||||
private function rain(array $station, \DateTimeImmutable $now): array
|
||||
{
|
||||
$empty = RainCalculator::compute([], $now, null) + ['stale' => false];
|
||||
if ($station['device_id'] === null || $station['rain_module_id'] === null) {
|
||||
return $empty;
|
||||
}
|
||||
$begin = RainCalculator::dateBegin($now)->getTimestamp();
|
||||
$measure = $this->fetch(
|
||||
'measure',
|
||||
fn() => $this->source->measure($station['device_id'], $station['rain_module_id'], $begin, $now->getTimestamp()),
|
||||
self::ERR_NETATMO,
|
||||
);
|
||||
if ($measure['data'] === null) {
|
||||
return ['today' => $station['sum_rain_24'], 'stale' => true] + $empty;
|
||||
}
|
||||
return RainCalculator::compute($measure['data'], $now, $station['sum_rain_24']) + ['stale' => $measure['stale']];
|
||||
}
|
||||
|
||||
/** @return array{days: list<array>, stale: bool} */
|
||||
private function forecast(array $station, \DateTimeZone $tz): array
|
||||
{
|
||||
$lat = $this->forecastLat ?? $station['lat'];
|
||||
$lon = $this->forecastLon ?? $station['lon'];
|
||||
if ($lat === null || $lon === null) {
|
||||
$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'],
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Cache-first per bron. Bij een fout: stale cache (ongeacht leeftijd) en een foutcode, anders null.
|
||||
*
|
||||
* @param callable(): array $fetcher
|
||||
* @return array{data: ?array, stale: bool}
|
||||
*/
|
||||
private function fetch(string $key, callable $fetcher, string $errorCode): array
|
||||
{
|
||||
$now = ($this->now ?? new \DateTimeImmutable('now'))->getTimestamp();
|
||||
$ttl = $this->ttl[$key] ?? 0;
|
||||
if ($ttl > 0 && $this->cache->isFresh($key, $ttl, $now)) {
|
||||
return ['data' => $this->cache->read($key)['data'], 'stale' => false];
|
||||
}
|
||||
try {
|
||||
$data = $fetcher();
|
||||
if ($ttl > 0) {
|
||||
$this->cache->write($key, $data, $now);
|
||||
}
|
||||
return ['data' => $data, 'stale' => false];
|
||||
} catch (NetatmoAuthException) {
|
||||
$this->addError(self::ERR_NETATMO_AUTH);
|
||||
} catch (\Throwable $e) {
|
||||
error_log("weerscherm: $key: " . $e->getMessage());
|
||||
$this->addError($errorCode);
|
||||
}
|
||||
$cached = $this->cache->read($key);
|
||||
return ['data' => $cached['data'] ?? null, 'stale' => true];
|
||||
}
|
||||
|
||||
private function addError(string $code): void
|
||||
{
|
||||
if (!in_array($code, $this->errors, true)) {
|
||||
$this->errors[] = $code;
|
||||
}
|
||||
}
|
||||
|
||||
private function timezone(?string $name): \DateTimeZone
|
||||
{
|
||||
foreach ([$name, $this->timezoneFallback, 'UTC'] as $candidate) {
|
||||
if ($candidate !== null && in_array($candidate, \DateTimeZone::listIdentifiers(), true)) {
|
||||
return new \DateTimeZone($candidate);
|
||||
}
|
||||
}
|
||||
return new \DateTimeZone('UTC');
|
||||
}
|
||||
|
||||
private function iso(?int $timestamp, \DateTimeZone $tz): ?string
|
||||
{
|
||||
return $timestamp === null ? null : (new \DateTimeImmutable('@' . $timestamp))->setTimezone($tz)->format(\DATE_ATOM);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Weerscherm;
|
||||
|
||||
/**
|
||||
* Vaste voorbeelddata, relatief aan het huidige moment, zodat de frontend zonder Netatmo-koppeling
|
||||
* gebouwd en bekeken kan worden en altijd "vandaag" laat zien.
|
||||
*/
|
||||
final class FixtureSource implements WeatherSource
|
||||
{
|
||||
private const RAIN_PER_DAY = [4.3, 0, 3.2, 5.1, 0, 0, 15.5, 1.2, 6.8, 0.0, 2.4, 0.6, 3.9, 0, 1.1];
|
||||
|
||||
private readonly \DateTimeImmutable $now;
|
||||
|
||||
public function __construct(?\DateTimeImmutable $now = null, private readonly string $timezone = 'Europe/Berlin')
|
||||
{
|
||||
$this->now = ($now ?? new \DateTimeImmutable('now'))->setTimezone(new \DateTimeZone($this->timezone));
|
||||
}
|
||||
|
||||
public function stationsData(): array
|
||||
{
|
||||
$at = fn(string $time) => (int) $this->now->modify($time)->format('U');
|
||||
$today = fn(string $hm) => (int) $this->now->setTime((int) substr($hm, 0, 2), (int) substr($hm, 3, 2))->format('U');
|
||||
|
||||
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'],
|
||||
'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,
|
||||
'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']],
|
||||
['_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']],
|
||||
['_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]],
|
||||
],
|
||||
]]];
|
||||
}
|
||||
|
||||
public function measure(string $deviceId, string $moduleId, int $begin, int $end): array
|
||||
{
|
||||
$body = [];
|
||||
$count = count(self::RAIN_PER_DAY);
|
||||
foreach (self::RAIN_PER_DAY as $i => $mm) {
|
||||
$day = $this->now->setTime(3, 17)->modify('-' . ($count - 1 - $i) . ' days');
|
||||
if ($day->getTimestamp() >= $begin) {
|
||||
$body[(string) $day->getTimestamp()] = [$mm];
|
||||
}
|
||||
}
|
||||
return $body;
|
||||
}
|
||||
|
||||
public function forecast(float $lat, float $lon, string $timezone): array
|
||||
{
|
||||
$dates = [];
|
||||
for ($i = 0; $i < 6; $i++) {
|
||||
$dates[] = $this->now->modify("+$i days")->format('Y-m-d');
|
||||
}
|
||||
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],
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Weerscherm;
|
||||
|
||||
/** Zet het "daily"-blok van Open-Meteo om naar 5 dagen vanaf morgen (index 0 is vandaag). */
|
||||
final class ForecastMapper
|
||||
{
|
||||
private const DAYS = 5;
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $daily
|
||||
* @return list<array{date: string, weather_code: ?int, temp_max: ?float, temp_min: ?float, precip_mm: ?float, precip_prob: ?int}>
|
||||
*/
|
||||
public static function map(array $daily): array
|
||||
{
|
||||
$dates = is_array($daily['time'] ?? null) ? $daily['time'] : [];
|
||||
$out = [];
|
||||
for ($i = 1; $i <= self::DAYS && isset($dates[$i]); $i++) {
|
||||
if (!is_string($dates[$i])) {
|
||||
continue;
|
||||
}
|
||||
$out[] = [
|
||||
'date' => $dates[$i],
|
||||
'weather_code' => self::intOrNull($daily['weather_code'][$i] ?? null),
|
||||
'temp_max' => self::floatOrNull($daily['temperature_2m_max'][$i] ?? null),
|
||||
'temp_min' => self::floatOrNull($daily['temperature_2m_min'][$i] ?? null),
|
||||
'precip_mm' => self::floatOrNull($daily['precipitation_sum'][$i] ?? null),
|
||||
'precip_prob' => self::intOrNull($daily['precipitation_probability_max'][$i] ?? null),
|
||||
];
|
||||
}
|
||||
return $out;
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Weerscherm;
|
||||
|
||||
/** Dunne curl-wrapper met korte timeouts. Netwerkfouten worden UpstreamException. */
|
||||
final class Http implements HttpClient
|
||||
{
|
||||
public function __construct(private readonly int $timeout = 8, private readonly int $connectTimeout = 5)
|
||||
{
|
||||
}
|
||||
|
||||
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 => false,
|
||||
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);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
<?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 forecast(float $lat, float $lon, string $timezone): array
|
||||
{
|
||||
return $this->openMeteo->dailyForecast($lat, $lon, $timezone);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Weerscherm;
|
||||
|
||||
/** De Netatmo-koppeling is verlopen: de refresh token is ongeldig. Herstel via bin/netatmo-init.php. */
|
||||
final class NetatmoAuthException extends UpstreamException
|
||||
{
|
||||
}
|
||||
@@ -0,0 +1,135 @@
|
||||
<?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'];
|
||||
}
|
||||
|
||||
/** @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;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
<?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<string, mixed> het "daily"-blok */
|
||||
public function dailyForecast(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',
|
||||
'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 $json['daily'];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Weerscherm;
|
||||
|
||||
/**
|
||||
* Berekent regen vandaag, de 3 vorige dagen en het maandtotaal uit een Netatmo getmeasure-body
|
||||
* (scale=1day, type=sum_rain, optimize=false): een map van unix-timestamp naar [mm].
|
||||
*
|
||||
* Elke timestamp wordt gekoppeld aan een lokale datum in de tijdzone van $now. Bij real_time=false
|
||||
* liggen de timestamps van dagwaarden niet op middernacht, dus daar wordt niet van uitgegaan.
|
||||
*/
|
||||
final class RainCalculator
|
||||
{
|
||||
private const PREVIOUS_DAYS = 3;
|
||||
|
||||
/** 00:00 lokale tijd van min(1e van de maand, vandaag − 3 dagen). */
|
||||
public static function dateBegin(\DateTimeImmutable $now): \DateTimeImmutable
|
||||
{
|
||||
$midnight = $now->setTime(0, 0, 0);
|
||||
$firstOfMonth = $midnight->setDate((int) $now->format('Y'), (int) $now->format('n'), 1);
|
||||
$threeDaysAgo = $midnight->modify('-' . self::PREVIOUS_DAYS . ' days');
|
||||
|
||||
return $firstOfMonth < $threeDaysAgo ? $firstOfMonth : $threeDaysAgo;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string|int, mixed> $body
|
||||
* @return array{today: ?float, previous_days: list<array{date: string, mm: ?float}>, month_total: ?float}
|
||||
*/
|
||||
public static function compute(array $body, \DateTimeImmutable $now, ?float $sumRain24): array
|
||||
{
|
||||
$tz = $now->getTimezone();
|
||||
$perDate = [];
|
||||
foreach ($body as $timestamp => $values) {
|
||||
if (!is_numeric($timestamp) || !is_array($values) || !isset($values[0]) || !is_numeric($values[0])) {
|
||||
continue;
|
||||
}
|
||||
$date = (new \DateTimeImmutable('@' . (int) $timestamp))->setTimezone($tz)->format('Y-m-d');
|
||||
$perDate[$date] = ($perDate[$date] ?? 0.0) + (float) $values[0];
|
||||
}
|
||||
|
||||
$today = $now->format('Y-m-d');
|
||||
$previous = [];
|
||||
for ($i = self::PREVIOUS_DAYS; $i >= 1; $i--) {
|
||||
$date = $now->modify("-$i days")->format('Y-m-d');
|
||||
$previous[] = ['date' => $date, 'mm' => $perDate[$date] ?? null];
|
||||
}
|
||||
|
||||
$monthPrefix = $now->format('Y-m') . '-';
|
||||
$monthTotal = null;
|
||||
foreach ($perDate as $date => $mm) {
|
||||
if (str_starts_with($date, $monthPrefix) && $date <= $today) {
|
||||
$monthTotal = ($monthTotal ?? 0.0) + $mm;
|
||||
}
|
||||
}
|
||||
|
||||
return [
|
||||
'today' => $perDate[$today] ?? $sumRain24,
|
||||
'previous_days' => $previous,
|
||||
'month_total' => $monthTotal === null ? null : round($monthTotal, 2),
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,149 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Weerscherm;
|
||||
|
||||
/**
|
||||
* Zet de body van Netatmo getstationsdata om naar een platte tussenstructuur.
|
||||
* Ruwe waarden, unix-timestamps; de formattering gebeurt later.
|
||||
*/
|
||||
final class StationMapper
|
||||
{
|
||||
private const EMPTY_OUTDOOR = ['temp' => null, 'trend' => null, 'min' => null, 'min_at' => null, 'max' => null, 'max_at' => 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}>,
|
||||
* rain_module_id: ?string, sum_rain_24: ?float}
|
||||
*/
|
||||
public static function map(array $body, ?string $deviceId, array $indoorModules): array
|
||||
{
|
||||
$device = self::pickDevice($body['devices'] ?? [], $deviceId);
|
||||
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,
|
||||
];
|
||||
}
|
||||
|
||||
$modules = is_array($device['modules'] ?? null) ? $device['modules'] : [];
|
||||
$outdoor = self::firstOfType($modules, 'NAModule1');
|
||||
$rain = self::firstOfType($modules, 'NAModule3');
|
||||
$location = $device['place']['location'] ?? null;
|
||||
|
||||
return [
|
||||
'timezone' => self::stringOrNull($device['place']['timezone'] ?? null),
|
||||
'lat' => is_array($location) && isset($location[1]) ? (float) $location[1] : null,
|
||||
'lon' => is_array($location) && isset($location[0]) ? (float) $location[0] : null,
|
||||
'device_id' => self::stringOrNull($device['_id'] ?? null),
|
||||
'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),
|
||||
'rain_module_id' => $rain === null ? null : self::stringOrNull($rain['_id'] ?? null),
|
||||
'sum_rain_24' => self::floatOrNull(self::dashboard($rain)['sum_rain_24'] ?? null),
|
||||
];
|
||||
}
|
||||
|
||||
/** @param list<array<string, mixed>> $devices */
|
||||
private static function pickDevice(array $devices, ?string $deviceId): ?array
|
||||
{
|
||||
foreach ($devices as $device) {
|
||||
if ($deviceId !== null && ($device['_id'] ?? null) === $deviceId) {
|
||||
return $device;
|
||||
}
|
||||
}
|
||||
return $devices[0] ?? null;
|
||||
}
|
||||
|
||||
/** @param list<array<string, mixed>> $modules */
|
||||
private static function firstOfType(array $modules, string $type): ?array
|
||||
{
|
||||
foreach ($modules as $module) {
|
||||
if (($module['type'] ?? null) === $type) {
|
||||
return $module;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/** Dashboard-data van een module, of leeg als de module onbereikbaar is. */
|
||||
private static function dashboard(?array $module): array
|
||||
{
|
||||
if ($module === null || ($module['reachable'] ?? true) === false) {
|
||||
return [];
|
||||
}
|
||||
return is_array($module['dashboard_data'] ?? null) ? $module['dashboard_data'] : [];
|
||||
}
|
||||
|
||||
private static function mapOutdoor(?array $module): array
|
||||
{
|
||||
$d = self::dashboard($module);
|
||||
if ($d === []) {
|
||||
return self::EMPTY_OUTDOOR;
|
||||
}
|
||||
return [
|
||||
'temp' => self::floatOrNull($d['Temperature'] ?? null),
|
||||
'trend' => self::stringOrNull($d['temp_trend'] ?? null),
|
||||
'min' => self::floatOrNull($d['min_temp'] ?? null),
|
||||
'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),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @param list<array<string, mixed>> $modules
|
||||
* @param array<string, string> $indoorModules
|
||||
*/
|
||||
private static function mapIndoor(array $device, array $modules, array $indoorModules): array
|
||||
{
|
||||
$candidates = [];
|
||||
foreach (array_merge([$device], $modules) as $module) {
|
||||
$type = $module['type'] ?? null;
|
||||
if ($type !== 'NAMain' && $type !== 'NAModule4') {
|
||||
continue;
|
||||
}
|
||||
$id = self::stringOrNull($module['_id'] ?? null);
|
||||
if ($id === null) {
|
||||
continue;
|
||||
}
|
||||
$d = self::dashboard($module);
|
||||
$candidates[$id] = [
|
||||
'id' => $id,
|
||||
'name' => $indoorModules[$id] ?? self::stringOrNull($module['module_name'] ?? null) ?? $id,
|
||||
'temp' => self::floatOrNull($d['Temperature'] ?? null),
|
||||
'min' => self::floatOrNull($d['min_temp'] ?? null),
|
||||
'max' => self::floatOrNull($d['max_temp'] ?? null),
|
||||
'reachable' => $d !== [],
|
||||
];
|
||||
}
|
||||
|
||||
$ordered = [];
|
||||
foreach (array_keys($indoorModules) as $id) {
|
||||
if (isset($candidates[$id])) {
|
||||
$ordered[] = $candidates[$id];
|
||||
unset($candidates[$id]);
|
||||
}
|
||||
}
|
||||
return array_merge($ordered, array_values($candidates));
|
||||
}
|
||||
|
||||
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) $v : null;
|
||||
}
|
||||
|
||||
private static function stringOrNull(mixed $v): ?string
|
||||
{
|
||||
return is_string($v) && $v !== '' ? $v : null;
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Weerscherm;
|
||||
|
||||
/** Een upstream-bron (Netatmo, Open-Meteo) is niet bereikbaar of gaf een onbruikbaar antwoord. */
|
||||
class UpstreamException extends \RuntimeException
|
||||
{
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Weerscherm;
|
||||
|
||||
/** Ruwe data van de drie upstream-bronnen. Implementaties: LiveSource (echte API's) en FixtureSource (voorbeelddata). */
|
||||
interface WeatherSource
|
||||
{
|
||||
/** @return array<string, mixed> de "body" van Netatmo getstationsdata */
|
||||
public function stationsData(): array;
|
||||
|
||||
/** @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 */
|
||||
public function forecast(float $lat, float $lon, string $timezone): array;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,72 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Weerscherm\Tests;
|
||||
|
||||
use PHPUnit\Framework\TestCase;
|
||||
use Weerscherm\Cache;
|
||||
|
||||
final class CacheTest extends TestCase
|
||||
{
|
||||
private string $dir;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
$this->dir = sys_get_temp_dir() . '/weerscherm-cache-' . uniqid();
|
||||
}
|
||||
|
||||
protected function tearDown(): void
|
||||
{
|
||||
array_map('unlink', glob($this->dir . '/*') ?: []);
|
||||
@rmdir($this->dir);
|
||||
}
|
||||
|
||||
public function testWriteThenReadRoundTrip(): void
|
||||
{
|
||||
$cache = new Cache($this->dir);
|
||||
$cache->write('stations', ['a' => 1.5, 'b' => null], 1000);
|
||||
|
||||
self::assertSame(['data' => ['a' => 1.5, 'b' => null], 'stored_at' => 1000], $cache->read('stations'));
|
||||
}
|
||||
|
||||
public function testMissingKeyReadsNull(): void
|
||||
{
|
||||
self::assertNull((new Cache($this->dir))->read('nope'));
|
||||
}
|
||||
|
||||
public function testCorruptFileReadsNull(): void
|
||||
{
|
||||
mkdir($this->dir, 0700, true);
|
||||
file_put_contents($this->dir . '/broken.json', '{not json');
|
||||
|
||||
self::assertNull((new Cache($this->dir))->read('broken'));
|
||||
}
|
||||
|
||||
public function testFreshness(): void
|
||||
{
|
||||
$cache = new Cache($this->dir);
|
||||
$cache->write('k', [], 1000);
|
||||
|
||||
self::assertTrue($cache->isFresh('k', 300, 1299));
|
||||
self::assertFalse($cache->isFresh('k', 300, 1300));
|
||||
self::assertFalse($cache->isFresh('missing', 300, 1000));
|
||||
}
|
||||
|
||||
public function testKeyIsSanitised(): void
|
||||
{
|
||||
$cache = new Cache($this->dir);
|
||||
$cache->write('../Evil Key', ['x' => 1], 1);
|
||||
|
||||
self::assertFileExists($this->dir . '/evil_key.json');
|
||||
self::assertSame(['x' => 1], $cache->read('../Evil Key')['data']);
|
||||
}
|
||||
|
||||
public function testWriteCreatesDirectory(): void
|
||||
{
|
||||
(new Cache($this->dir . '/nested'))->write('k', [], 1);
|
||||
self::assertDirectoryExists($this->dir . '/nested');
|
||||
unlink($this->dir . '/nested/k.json');
|
||||
rmdir($this->dir . '/nested');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Weerscherm\Tests;
|
||||
|
||||
use PHPUnit\Framework\TestCase;
|
||||
use Weerscherm\Config;
|
||||
|
||||
final class ConfigTest extends TestCase
|
||||
{
|
||||
private string $file;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
$this->file = tempnam(sys_get_temp_dir(), 'cfg');
|
||||
}
|
||||
|
||||
protected function tearDown(): void
|
||||
{
|
||||
@unlink($this->file);
|
||||
}
|
||||
|
||||
public function testDefaultsApplyWhenKeysAreMissing(): void
|
||||
{
|
||||
file_put_contents($this->file, '<?php return [];');
|
||||
$config = Config::load($this->file);
|
||||
|
||||
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([], $config->get('indoor_modules'));
|
||||
self::assertNull($config->get('netatmo_device_id'));
|
||||
}
|
||||
|
||||
public function testFileValuesOverrideDefaults(): void
|
||||
{
|
||||
file_put_contents($this->file, '<?php return ["timezone" => "Europe/Amsterdam", "cache_ttl" => ["stations" => 60]];');
|
||||
$config = Config::load($this->file);
|
||||
|
||||
self::assertSame('Europe/Amsterdam', $config->get('timezone'));
|
||||
self::assertSame(['stations' => 60, 'measure' => 900, 'forecast' => 3600], $config->get('cache_ttl'));
|
||||
}
|
||||
|
||||
public function testUnknownKeyReturnsGivenDefault(): void
|
||||
{
|
||||
file_put_contents($this->file, '<?php return [];');
|
||||
self::assertSame('x', Config::load($this->file)->get('nope', 'x'));
|
||||
}
|
||||
|
||||
public function testFileMustReturnArray(): void
|
||||
{
|
||||
file_put_contents($this->file, '<?php return "nee";');
|
||||
$this->expectException(\RuntimeException::class);
|
||||
Config::load($this->file);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,161 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Weerscherm\Tests;
|
||||
|
||||
use PHPUnit\Framework\TestCase;
|
||||
use Weerscherm\Cache;
|
||||
use Weerscherm\DashboardService;
|
||||
use Weerscherm\NetatmoAuthException;
|
||||
use Weerscherm\UpstreamException;
|
||||
|
||||
final class DashboardServiceTest extends TestCase
|
||||
{
|
||||
private string $dir;
|
||||
private FakeSource $source;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
$this->dir = sys_get_temp_dir() . '/weerscherm-dash-' . uniqid();
|
||||
$this->source = FakeSource::fromFixtures();
|
||||
}
|
||||
|
||||
protected function tearDown(): void
|
||||
{
|
||||
array_map('unlink', glob($this->dir . '/*') ?: []);
|
||||
@rmdir($this->dir);
|
||||
}
|
||||
|
||||
private function service(?string $now = '2026-09-11 12:32:05 UTC', ?float $lat = null, ?float $lon = null): DashboardService
|
||||
{
|
||||
return new DashboardService(
|
||||
$this->source,
|
||||
new Cache($this->dir),
|
||||
['stations' => 300, 'measure' => 900, 'forecast' => 3600],
|
||||
'Europe/Amsterdam',
|
||||
$lat,
|
||||
$lon,
|
||||
[],
|
||||
null,
|
||||
$now === null ? null : new \DateTimeImmutable($now),
|
||||
);
|
||||
}
|
||||
|
||||
public function testHappyPathMatchesContract(): void
|
||||
{
|
||||
$p = $this->service()->build();
|
||||
|
||||
self::assertSame(['generated_at', 'timezone', 'station', 'outdoor', 'indoor', 'rain', '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::assertCount(3, $p['indoor']);
|
||||
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::assertFalse($p['forecast_stale']);
|
||||
self::assertSame([], $p['errors']);
|
||||
}
|
||||
|
||||
public function testMeasureIsRequestedForRainModuleFromMidnightOfDateBegin(): void
|
||||
{
|
||||
$this->service()->build();
|
||||
|
||||
[$deviceId, $moduleId, $begin, $end] = $this->source->lastArgs['measure'];
|
||||
self::assertSame('70:ee:50:00:00:01', $deviceId);
|
||||
self::assertSame('05:00:00:00:00:04', $moduleId);
|
||||
self::assertSame((new \DateTimeImmutable('2026-09-01 00:00', new \DateTimeZone('Europe/Berlin')))->getTimestamp(), $begin);
|
||||
self::assertSame((new \DateTimeImmutable('2026-09-11 12:32:05 UTC'))->getTimestamp(), $end);
|
||||
}
|
||||
|
||||
public function testForecastUsesStationLocationUnlessConfigured(): void
|
||||
{
|
||||
$this->service()->build();
|
||||
self::assertSame([51.82, 6.55, 'Europe/Berlin'], $this->source->lastArgs['forecast']);
|
||||
|
||||
$this->source = FakeSource::fromFixtures();
|
||||
array_map('unlink', glob($this->dir . '/*') ?: []);
|
||||
$this->service(lat: 52.1, lon: 5.1)->build();
|
||||
self::assertSame([52.1, 5.1, 'Europe/Berlin'], $this->source->lastArgs['forecast']);
|
||||
}
|
||||
|
||||
public function testStationsFailureWithoutCacheGivesNullsAndError(): void
|
||||
{
|
||||
$this->source->responses['stations'] = new UpstreamException('timeout');
|
||||
|
||||
$p = $this->service()->build();
|
||||
|
||||
self::assertNull($p['outdoor']['temp']);
|
||||
self::assertNull($p['station']['measured_at']);
|
||||
self::assertTrue($p['station']['stale']);
|
||||
self::assertSame([], $p['indoor']);
|
||||
self::assertNull($p['rain']['today']);
|
||||
self::assertSame(0, $this->source->calls['measure'], 'zonder regenmeter-id geen getmeasure-call');
|
||||
self::assertSame('Europe/Amsterdam', $p['timezone'], 'fallback-tijdzone uit config');
|
||||
self::assertSame(['netatmo_unavailable', 'forecast_unavailable'], $p['errors']);
|
||||
}
|
||||
|
||||
public function testStationsFailureWithCacheServesStaleData(): void
|
||||
{
|
||||
$this->service()->build();
|
||||
$this->source->responses['stations'] = new UpstreamException('timeout');
|
||||
|
||||
$p = $this->service('2026-09-11 13:00:00 UTC')->build();
|
||||
|
||||
self::assertSame(16.4, $p['outdoor']['temp']);
|
||||
self::assertTrue($p['station']['stale']);
|
||||
self::assertSame(['netatmo_unavailable'], $p['errors']);
|
||||
}
|
||||
|
||||
public function testAuthFailureGivesNetatmoAuthError(): void
|
||||
{
|
||||
$this->source->responses['stations'] = new NetatmoAuthException('invalid_grant');
|
||||
|
||||
$p = $this->service()->build();
|
||||
|
||||
self::assertContains('netatmo_auth', $p['errors']);
|
||||
self::assertNotContains('netatmo_unavailable', $p['errors']);
|
||||
}
|
||||
|
||||
public function testMeasureFailureFallsBackToSumRain24(): void
|
||||
{
|
||||
$this->source->responses['measure'] = new UpstreamException('500');
|
||||
|
||||
$p = $this->service()->build();
|
||||
|
||||
self::assertSame(2.4, $p['rain']['today']);
|
||||
self::assertSame([null, null, null], array_column($p['rain']['previous_days'], 'mm'));
|
||||
self::assertNull($p['rain']['month_total']);
|
||||
self::assertTrue($p['rain']['stale']);
|
||||
self::assertSame(['netatmo_unavailable'], $p['errors']);
|
||||
}
|
||||
|
||||
public function testForecastFailureLeavesRestIntact(): void
|
||||
{
|
||||
$this->source->responses['forecast'] = new UpstreamException('down');
|
||||
|
||||
$p = $this->service()->build();
|
||||
|
||||
self::assertSame([], $p['forecast']);
|
||||
self::assertTrue($p['forecast_stale']);
|
||||
self::assertSame(['forecast_unavailable'], $p['errors']);
|
||||
self::assertSame(16.4, $p['outdoor']['temp']);
|
||||
}
|
||||
|
||||
public function testSecondBuildWithinTtlUsesCache(): void
|
||||
{
|
||||
$this->service()->build();
|
||||
$this->service('2026-09-11 12:36:05 UTC')->build();
|
||||
|
||||
self::assertSame(['stations' => 1, 'measure' => 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');
|
||||
self::assertSame(1, $this->source->calls['measure'], 'measure nog binnen 15 min');
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Weerscherm\Tests;
|
||||
|
||||
use Weerscherm\WeatherSource;
|
||||
|
||||
/** Testdubbel: geeft per methode een array terug of gooit de ingestelde exception, en telt de aanroepen. */
|
||||
final class FakeSource implements WeatherSource
|
||||
{
|
||||
/** @var array<string, array|\Throwable> */
|
||||
public array $responses = [];
|
||||
|
||||
/** @var array<string, int> */
|
||||
public array $calls = ['stations' => 0, 'measure' => 0, 'forecast' => 0];
|
||||
|
||||
/** @var array<string, array> */
|
||||
public array $lastArgs = [];
|
||||
|
||||
public static function fromFixtures(): self
|
||||
{
|
||||
$s = new self();
|
||||
$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'],
|
||||
];
|
||||
return $s;
|
||||
}
|
||||
|
||||
public function stationsData(): array
|
||||
{
|
||||
return $this->answer('stations', []);
|
||||
}
|
||||
|
||||
public function measure(string $deviceId, string $moduleId, int $begin, int $end): array
|
||||
{
|
||||
return $this->answer('measure', func_get_args());
|
||||
}
|
||||
|
||||
public function forecast(float $lat, float $lon, string $timezone): array
|
||||
{
|
||||
return $this->answer('forecast', func_get_args());
|
||||
}
|
||||
|
||||
private function answer(string $key, array $args): array
|
||||
{
|
||||
$this->calls[$key]++;
|
||||
$this->lastArgs[$key] = $args;
|
||||
$r = $this->responses[$key] ?? new \RuntimeException("geen response voor $key");
|
||||
if ($r instanceof \Throwable) {
|
||||
throw $r;
|
||||
}
|
||||
return $r;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Weerscherm\Tests;
|
||||
|
||||
use PHPUnit\Framework\TestCase;
|
||||
use Weerscherm\ForecastMapper;
|
||||
|
||||
final class ForecastMapperTest extends TestCase
|
||||
{
|
||||
/** @return array<string, mixed> */
|
||||
private function daily(): array
|
||||
{
|
||||
return json_decode((string) file_get_contents(__DIR__ . '/fixtures/forecast.json'), true)['daily'];
|
||||
}
|
||||
|
||||
public function testSkipsTodayAndReturnsFiveDays(): void
|
||||
{
|
||||
$f = ForecastMapper::map($this->daily());
|
||||
|
||||
self::assertCount(5, $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,
|
||||
], $f[0]);
|
||||
self::assertSame('2026-09-16', $f[4]['date']);
|
||||
}
|
||||
|
||||
public function testNullValuesStayNull(): void
|
||||
{
|
||||
$daily = $this->daily();
|
||||
$daily['weather_code'][3] = null;
|
||||
$daily['precipitation_probability_max'][3] = null;
|
||||
|
||||
$f = ForecastMapper::map($daily);
|
||||
|
||||
self::assertNull($f[2]['weather_code']);
|
||||
self::assertNull($f[2]['precip_prob']);
|
||||
self::assertSame(17.4, $f[2]['temp_max']);
|
||||
}
|
||||
|
||||
public function testShorterResponseGivesFewerDays(): void
|
||||
{
|
||||
$daily = $this->daily();
|
||||
foreach ($daily as $key => $values) {
|
||||
$daily[$key] = array_slice($values, 0, 3);
|
||||
}
|
||||
|
||||
self::assertCount(2, ForecastMapper::map($daily));
|
||||
}
|
||||
|
||||
public function testMissingTimeArrayGivesEmptyList(): void
|
||||
{
|
||||
self::assertSame([], ForecastMapper::map([]));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,201 @@
|
||||
<?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']);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
<?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 testRequestsSixDaysWithAllDailyVariables(): 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');
|
||||
|
||||
self::assertCount(6, $daily['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',
|
||||
'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))->dailyForecast(1, 2, 'UTC');
|
||||
}
|
||||
|
||||
public function testMissingDailyIsUpstreamException(): void
|
||||
{
|
||||
$http = (new FakeHttp())->queue(200, ['hourly' => []]);
|
||||
$this->expectException(UpstreamException::class);
|
||||
(new OpenMeteoClient($http))->dailyForecast(1, 2, 'UTC');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,106 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Weerscherm\Tests;
|
||||
|
||||
use PHPUnit\Framework\TestCase;
|
||||
use Weerscherm\RainCalculator;
|
||||
|
||||
final class RainCalculatorTest extends TestCase
|
||||
{
|
||||
private \DateTimeZone $tz;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
$this->tz = new \DateTimeZone('Europe/Berlin');
|
||||
}
|
||||
|
||||
/** @return array<string, mixed> */
|
||||
private function fixture(string $name): array
|
||||
{
|
||||
return json_decode((string) file_get_contents(__DIR__ . "/fixtures/$name.json"), true)['body'];
|
||||
}
|
||||
|
||||
private function now(string $s): \DateTimeImmutable
|
||||
{
|
||||
return new \DateTimeImmutable($s, $this->tz);
|
||||
}
|
||||
|
||||
public function testNormalWeek(): void
|
||||
{
|
||||
$r = RainCalculator::compute($this->fixture('getmeasure'), $this->now('2026-09-11 14:32'), 9.9);
|
||||
|
||||
self::assertSame(2.4, $r['today']);
|
||||
self::assertSame([
|
||||
['date' => '2026-09-08', 'mm' => 1.2],
|
||||
['date' => '2026-09-09', 'mm' => 6.8],
|
||||
['date' => '2026-09-10', 'mm' => 0.0],
|
||||
], $r['previous_days']);
|
||||
self::assertEqualsWithDelta(38.5, $r['month_total'], 0.0001);
|
||||
}
|
||||
|
||||
public function testMissingPreviousDayIsNullAndExcludedFromTotal(): void
|
||||
{
|
||||
$body = $this->fixture('getmeasure');
|
||||
$sep9 = (string) $this->now('2026-09-09 03:17')->getTimestamp();
|
||||
unset($body[$sep9]);
|
||||
|
||||
$r = RainCalculator::compute($body, $this->now('2026-09-11 14:32'), null);
|
||||
|
||||
self::assertNull($r['previous_days'][1]['mm']);
|
||||
self::assertSame('2026-09-09', $r['previous_days'][1]['date']);
|
||||
self::assertEqualsWithDelta(31.7, $r['month_total'], 0.0001);
|
||||
}
|
||||
|
||||
public function testMissingTodayFallsBackToSumRain24(): void
|
||||
{
|
||||
$body = $this->fixture('getmeasure');
|
||||
unset($body[(string) $this->now('2026-09-11 03:17')->getTimestamp()]);
|
||||
|
||||
self::assertSame(2.4, RainCalculator::compute($body, $this->now('2026-09-11 14:32'), 2.4)['today']);
|
||||
self::assertNull(RainCalculator::compute($body, $this->now('2026-09-11 14:32'), null)['today']);
|
||||
}
|
||||
|
||||
public function testMonthBoundaryKeepsPreviousDaysButNotInMonthTotal(): void
|
||||
{
|
||||
$r = RainCalculator::compute($this->fixture('getmeasure-month'), $this->now('2026-10-02 09:00'), null);
|
||||
|
||||
self::assertSame(5.0, $r['today']);
|
||||
self::assertSame([
|
||||
['date' => '2026-09-29', 'mm' => 2.0],
|
||||
['date' => '2026-09-30', 'mm' => 3.0],
|
||||
['date' => '2026-10-01', 'mm' => 4.0],
|
||||
], $r['previous_days']);
|
||||
self::assertEqualsWithDelta(9.0, $r['month_total'], 0.0001);
|
||||
}
|
||||
|
||||
public function testDaylightSavingTransitionMapsTimestampsToLocalDates(): void
|
||||
{
|
||||
$r = RainCalculator::compute($this->fixture('getmeasure-dst'), $this->now('2026-10-25 12:00'), null);
|
||||
|
||||
self::assertSame(4.0, $r['today'], 'meting om 01:30 lokaal (nog zomertijd) hoort bij 25 oktober');
|
||||
self::assertSame([
|
||||
['date' => '2026-10-22', 'mm' => 1.0],
|
||||
['date' => '2026-10-23', 'mm' => 2.0],
|
||||
['date' => '2026-10-24', 'mm' => 3.0],
|
||||
], $r['previous_days']);
|
||||
self::assertEqualsWithDelta(10.0, $r['month_total'], 0.0001, '26 oktober telt niet mee, ook al ligt de timestamp op 25 oktober in UTC');
|
||||
}
|
||||
|
||||
public function testEmptyBodyGivesNulls(): void
|
||||
{
|
||||
$r = RainCalculator::compute([], $this->now('2026-09-11 14:32'), null);
|
||||
|
||||
self::assertNull($r['today']);
|
||||
self::assertSame([null, null, null], array_column($r['previous_days'], 'mm'));
|
||||
self::assertNull($r['month_total']);
|
||||
}
|
||||
|
||||
public function testDateBeginIsMinOfFirstOfMonthAndThreeDaysAgo(): void
|
||||
{
|
||||
self::assertSame('2026-09-01 00:00:00', RainCalculator::dateBegin($this->now('2026-09-15 14:32'))->format('Y-m-d H:i:s'));
|
||||
self::assertSame('2026-09-29 00:00:00', RainCalculator::dateBegin($this->now('2026-10-02 09:00'))->format('Y-m-d H:i:s'));
|
||||
self::assertSame('Europe/Berlin', RainCalculator::dateBegin($this->now('2026-10-02 09:00'))->getTimezone()->getName());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,112 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Weerscherm\Tests;
|
||||
|
||||
use PHPUnit\Framework\TestCase;
|
||||
use Weerscherm\StationMapper;
|
||||
|
||||
final class StationMapperTest extends TestCase
|
||||
{
|
||||
/** @return array<string, mixed> */
|
||||
private function body(): array
|
||||
{
|
||||
$json = json_decode((string) file_get_contents(__DIR__ . '/fixtures/getstationsdata.json'), true);
|
||||
return $json['body'];
|
||||
}
|
||||
|
||||
public function testStationMetadata(): void
|
||||
{
|
||||
$m = StationMapper::map($this->body(), null, []);
|
||||
|
||||
self::assertSame('Europe/Berlin', $m['timezone']);
|
||||
self::assertSame(51.82, $m['lat']);
|
||||
self::assertSame(6.55, $m['lon']);
|
||||
self::assertSame('70:ee:50:00:00:01', $m['device_id']);
|
||||
self::assertSame(1789129812, $m['measured_at']);
|
||||
}
|
||||
|
||||
public function testOutdoor(): void
|
||||
{
|
||||
$m = StationMapper::map($this->body(), null, []);
|
||||
|
||||
self::assertSame([
|
||||
'temp' => 16.4,
|
||||
'trend' => 'down',
|
||||
'min' => 9.2,
|
||||
'min_at' => 1789099800,
|
||||
'max' => 19.8,
|
||||
'max_at' => 1789124700,
|
||||
], $m['outdoor']);
|
||||
}
|
||||
|
||||
public function testIndoorDefaultOrderAndUnreachableModule(): void
|
||||
{
|
||||
$m = StationMapper::map($this->body(), null, []);
|
||||
|
||||
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]);
|
||||
}
|
||||
|
||||
public function testIndoorConfigDeterminesOrderAndLabels(): void
|
||||
{
|
||||
$m = StationMapper::map($this->body(), null, [
|
||||
'03:00:00:00:00:03' => 'Studeerkamer',
|
||||
'70:ee:50:00:00:01' => 'Living',
|
||||
]);
|
||||
|
||||
self::assertSame(['Studeerkamer', 'Living', 'Slaapkamer'], array_column($m['indoor'], 'name'));
|
||||
}
|
||||
|
||||
public function testRainModule(): void
|
||||
{
|
||||
$m = StationMapper::map($this->body(), null, []);
|
||||
|
||||
self::assertSame('05:00:00:00:00:04', $m['rain_module_id']);
|
||||
self::assertSame(2.4, $m['sum_rain_24']);
|
||||
}
|
||||
|
||||
public function testWithoutRainModule(): void
|
||||
{
|
||||
$body = $this->body();
|
||||
$body['devices'][0]['modules'] = array_values(array_filter(
|
||||
$body['devices'][0]['modules'],
|
||||
fn(array $mod) => $mod['type'] !== 'NAModule3',
|
||||
));
|
||||
$m = StationMapper::map($body, null, []);
|
||||
|
||||
self::assertNull($m['rain_module_id']);
|
||||
self::assertNull($m['sum_rain_24']);
|
||||
}
|
||||
|
||||
public function testUnreachableOutdoorModuleGivesNulls(): void
|
||||
{
|
||||
$body = $this->body();
|
||||
$body['devices'][0]['modules'][0]['reachable'] = false;
|
||||
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']);
|
||||
}
|
||||
|
||||
public function testUnknownDeviceIdFallsBackToFirstStation(): void
|
||||
{
|
||||
$m = StationMapper::map($this->body(), 'ff:ff:ff:ff:ff:ff', []);
|
||||
self::assertSame('70:ee:50:00:00:01', $m['device_id']);
|
||||
}
|
||||
|
||||
public function testEmptyDevicesGivesNullsWithoutCrashing(): void
|
||||
{
|
||||
$m = StationMapper::map(['devices' => []], null, []);
|
||||
|
||||
self::assertNull($m['device_id']);
|
||||
self::assertNull($m['timezone']);
|
||||
self::assertNull($m['measured_at']);
|
||||
self::assertSame([], $m['indoor']);
|
||||
self::assertNull($m['outdoor']['temp']);
|
||||
self::assertNull($m['rain_module_id']);
|
||||
}
|
||||
}
|
||||
@@ -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');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
require __DIR__ . '/../vendor/autoload.php';
|
||||
|
||||
date_default_timezone_set('UTC');
|
||||
Vendored
+25
@@ -0,0 +1,25 @@
|
||||
{
|
||||
"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]
|
||||
}
|
||||
}
|
||||
Vendored
+20
@@ -0,0 +1,20 @@
|
||||
{
|
||||
"body": {
|
||||
"1792621800": [
|
||||
1
|
||||
],
|
||||
"1792708200": [
|
||||
2
|
||||
],
|
||||
"1792794600": [
|
||||
3
|
||||
],
|
||||
"1792884600": [
|
||||
4
|
||||
],
|
||||
"1792971000": [
|
||||
9
|
||||
]
|
||||
},
|
||||
"status": "ok"
|
||||
}
|
||||
Vendored
+20
@@ -0,0 +1,20 @@
|
||||
{
|
||||
"body": {
|
||||
"1790558220": [
|
||||
1
|
||||
],
|
||||
"1790644620": [
|
||||
2
|
||||
],
|
||||
"1790731020": [
|
||||
3
|
||||
],
|
||||
"1790817420": [
|
||||
4
|
||||
],
|
||||
"1790903820": [
|
||||
5
|
||||
]
|
||||
},
|
||||
"status": "ok"
|
||||
}
|
||||
Vendored
+40
@@ -0,0 +1,40 @@
|
||||
{
|
||||
"body": {
|
||||
"1788225420": [
|
||||
4.3
|
||||
],
|
||||
"1788311820": [
|
||||
0
|
||||
],
|
||||
"1788398220": [
|
||||
3.2
|
||||
],
|
||||
"1788484620": [
|
||||
5.1
|
||||
],
|
||||
"1788571020": [
|
||||
0
|
||||
],
|
||||
"1788657420": [
|
||||
0
|
||||
],
|
||||
"1788743820": [
|
||||
15.5
|
||||
],
|
||||
"1788830220": [
|
||||
1.2
|
||||
],
|
||||
"1788916620": [
|
||||
6.8
|
||||
],
|
||||
"1789003020": [
|
||||
0
|
||||
],
|
||||
"1789089420": [
|
||||
2.4
|
||||
]
|
||||
},
|
||||
"status": "ok",
|
||||
"time_exec": 0.02,
|
||||
"time_server": 1789129925
|
||||
}
|
||||
Vendored
+116
@@ -0,0 +1,116 @@
|
||||
{
|
||||
"body": {
|
||||
"devices": [
|
||||
{
|
||||
"_id": "70:ee:50:00:00:01",
|
||||
"type": "NAMain",
|
||||
"station_name": "Thuis",
|
||||
"module_name": "Woonkamer",
|
||||
"reachable": true,
|
||||
"firmware": 204,
|
||||
"wifi_status": 50,
|
||||
"co2_calibrating": false,
|
||||
"place": {
|
||||
"altitude": 20,
|
||||
"city": "Voorbeeldstad",
|
||||
"country": "DE",
|
||||
"timezone": "Europe/Berlin",
|
||||
"location": [
|
||||
6.55,
|
||||
51.82
|
||||
]
|
||||
},
|
||||
"dashboard_data": {
|
||||
"time_utc": 1789129812,
|
||||
"Temperature": 21.3,
|
||||
"CO2": 612,
|
||||
"Humidity": 48,
|
||||
"Noise": 38,
|
||||
"Pressure": 1014.2,
|
||||
"AbsolutePressure": 1011.8,
|
||||
"min_temp": 20.4,
|
||||
"max_temp": 22.1,
|
||||
"date_max_temp": 1789126800,
|
||||
"date_min_temp": 1789098600,
|
||||
"temp_trend": "stable",
|
||||
"pressure_trend": "up"
|
||||
},
|
||||
"modules": [
|
||||
{
|
||||
"_id": "02:00:00:00:00:01",
|
||||
"type": "NAModule1",
|
||||
"module_name": "Buiten",
|
||||
"reachable": true,
|
||||
"battery_percent": 71,
|
||||
"rf_status": 70,
|
||||
"dashboard_data": {
|
||||
"time_utc": 1789129780,
|
||||
"Temperature": 16.4,
|
||||
"Humidity": 71,
|
||||
"min_temp": 9.2,
|
||||
"max_temp": 19.8,
|
||||
"date_min_temp": 1789099800,
|
||||
"date_max_temp": 1789124700,
|
||||
"temp_trend": "down"
|
||||
}
|
||||
},
|
||||
{
|
||||
"_id": "03:00:00:00:00:02",
|
||||
"type": "NAModule4",
|
||||
"module_name": "Slaapkamer",
|
||||
"reachable": true,
|
||||
"battery_percent": 64,
|
||||
"rf_status": 66,
|
||||
"dashboard_data": {
|
||||
"time_utc": 1789129680,
|
||||
"Temperature": 18.9,
|
||||
"CO2": 540,
|
||||
"Humidity": 52,
|
||||
"min_temp": 18.1,
|
||||
"max_temp": 19.6,
|
||||
"date_min_temp": 1789101000,
|
||||
"date_max_temp": 1789120800,
|
||||
"temp_trend": "up"
|
||||
}
|
||||
},
|
||||
{
|
||||
"_id": "03:00:00:00:00:03",
|
||||
"type": "NAModule4",
|
||||
"module_name": "Kantoor",
|
||||
"reachable": false,
|
||||
"battery_percent": 5,
|
||||
"rf_status": 90
|
||||
},
|
||||
{
|
||||
"_id": "05:00:00:00:00:04",
|
||||
"type": "NAModule3",
|
||||
"module_name": "Regenmeter",
|
||||
"reachable": true,
|
||||
"battery_percent": 80,
|
||||
"rf_status": 68,
|
||||
"dashboard_data": {
|
||||
"time_utc": 1789129780,
|
||||
"Rain": 0,
|
||||
"sum_rain_1": 0,
|
||||
"sum_rain_24": 2.4
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"user": {
|
||||
"mail": "x@example.org",
|
||||
"administrative": {
|
||||
"lang": "nl-NL",
|
||||
"reg_locale": "nl-NL",
|
||||
"unit": 0,
|
||||
"windunit": 0,
|
||||
"pressureunit": 0,
|
||||
"feel_like_algo": 0
|
||||
}
|
||||
}
|
||||
},
|
||||
"status": "ok",
|
||||
"time_exec": 0.05,
|
||||
"time_server": 1789129925
|
||||
}
|
||||
@@ -0,0 +1,120 @@
|
||||
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');
|
||||
});
|
||||
@@ -0,0 +1,98 @@
|
||||
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'))); },
|
||||
async put(req, res) { store.set(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);
|
||||
});
|
||||
Reference in New Issue
Block a user