diff --git a/.phpunit.result.cache b/.phpunit.result.cache new file mode 100644 index 0000000..db970fc --- /dev/null +++ b/.phpunit.result.cache @@ -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}} \ No newline at end of file diff --git a/README.md b/README.md new file mode 100644 index 0000000..d02d79a --- /dev/null +++ b/README.md @@ -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 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 . 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 '' +``` + +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 . 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 ''` 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 +``` diff --git a/bin/netatmo-init.php b/bin/netatmo-init.php new file mode 100755 index 0000000..f4edf06 --- /dev/null +++ b/bin/netatmo-init.php @@ -0,0 +1,42 @@ +#!/usr/bin/env php + [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 [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"; diff --git a/composer.json b/composer.json new file mode 100644 index 0000000..591c0fb --- /dev/null +++ b/composer.json @@ -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 + } +} diff --git a/composer.lock b/composer.lock new file mode 100644 index 0000000..9526b9d --- /dev/null +++ b/composer.lock @@ -0,0 +1,1787 @@ +{ + "_readme": [ + "This file locks the dependencies of your project to a known state", + "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", + "This file is @generated automatically" + ], + "content-hash": "6bad0c5bc1f2f5c9f087fa15c28327df", + "packages": [], + "packages-dev": [ + { + "name": "myclabs/deep-copy", + "version": "1.14.0", + "source": { + "type": "git", + "url": "https://github.com/myclabs/DeepCopy.git", + "reference": "8680aa248f8e07bc8fb43f56f0f5fc77a0c96aae" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/myclabs/DeepCopy/zipball/8680aa248f8e07bc8fb43f56f0f5fc77a0c96aae", + "reference": "8680aa248f8e07bc8fb43f56f0f5fc77a0c96aae", + "shasum": "" + }, + "require": { + "php": "^8.0" + }, + "conflict": { + "doctrine/collections": "<1.6.8", + "doctrine/common": "<2.13.3 || >=3 <3.2.2" + }, + "require-dev": { + "doctrine/collections": "^1.6.8", + "doctrine/common": "^2.13.3 || ^3.2.2", + "phpspec/prophecy": "^1.10", + "phpunit/phpunit": "^7.5.20 || ^8.5.23 || ^9.5.13" + }, + "type": "library", + "autoload": { + "files": [ + "src/DeepCopy/deep_copy.php" + ], + "psr-4": { + "DeepCopy\\": "src/DeepCopy/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "description": "Create deep copies (clones) of your objects", + "keywords": [ + "clone", + "copy", + "duplicate", + "object", + "object graph" + ], + "support": { + "issues": "https://github.com/myclabs/DeepCopy/issues", + "source": "https://github.com/myclabs/DeepCopy/tree/1.14.0" + }, + "funding": [ + { + "url": "https://github.com/mnapoli", + "type": "github" + } + ], + "time": "2026-08-11T10:17:44+00:00" + }, + { + "name": "nikic/php-parser", + "version": "v5.8.0", + "source": { + "type": "git", + "url": "https://github.com/nikic/PHP-Parser.git", + "reference": "044a6a392ff8ad0d61f14370a5fbbd0a0107152f" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/nikic/PHP-Parser/zipball/044a6a392ff8ad0d61f14370a5fbbd0a0107152f", + "reference": "044a6a392ff8ad0d61f14370a5fbbd0a0107152f", + "shasum": "" + }, + "require": { + "ext-json": "*", + "ext-tokenizer": "*", + "php": ">=7.4" + }, + "require-dev": { + "ircmaxell/php-yacc": "^0.0.7", + "phpunit/phpunit": "^9.0" + }, + "bin": [ + "bin/php-parse" + ], + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "5.x-dev" + } + }, + "autoload": { + "psr-4": { + "PhpParser\\": "lib/PhpParser" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Nikita Popov" + } + ], + "description": "A PHP parser written in PHP", + "keywords": [ + "parser", + "php" + ], + "support": { + "issues": "https://github.com/nikic/PHP-Parser/issues", + "source": "https://github.com/nikic/PHP-Parser/tree/v5.8.0" + }, + "time": "2026-07-04T14:30:18+00:00" + }, + { + "name": "phar-io/manifest", + "version": "2.0.4", + "source": { + "type": "git", + "url": "https://github.com/phar-io/manifest.git", + "reference": "54750ef60c58e43759730615a392c31c80e23176" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/phar-io/manifest/zipball/54750ef60c58e43759730615a392c31c80e23176", + "reference": "54750ef60c58e43759730615a392c31c80e23176", + "shasum": "" + }, + "require": { + "ext-dom": "*", + "ext-libxml": "*", + "ext-phar": "*", + "ext-xmlwriter": "*", + "phar-io/version": "^3.0.1", + "php": "^7.2 || ^8.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "2.0.x-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Arne Blankerts", + "email": "arne@blankerts.de", + "role": "Developer" + }, + { + "name": "Sebastian Heuer", + "email": "sebastian@phpeople.de", + "role": "Developer" + }, + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "Developer" + } + ], + "description": "Component for reading phar.io manifest information from a PHP Archive (PHAR)", + "support": { + "issues": "https://github.com/phar-io/manifest/issues", + "source": "https://github.com/phar-io/manifest/tree/2.0.4" + }, + "funding": [ + { + "url": "https://github.com/theseer", + "type": "github" + } + ], + "time": "2024-03-03T12:33:53+00:00" + }, + { + "name": "phar-io/version", + "version": "3.2.1", + "source": { + "type": "git", + "url": "https://github.com/phar-io/version.git", + "reference": "4f7fd7836c6f332bb2933569e566a0d6c4cbed74" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/phar-io/version/zipball/4f7fd7836c6f332bb2933569e566a0d6c4cbed74", + "reference": "4f7fd7836c6f332bb2933569e566a0d6c4cbed74", + "shasum": "" + }, + "require": { + "php": "^7.2 || ^8.0" + }, + "type": "library", + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Arne Blankerts", + "email": "arne@blankerts.de", + "role": "Developer" + }, + { + "name": "Sebastian Heuer", + "email": "sebastian@phpeople.de", + "role": "Developer" + }, + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "Developer" + } + ], + "description": "Library for handling version information and constraints", + "support": { + "issues": "https://github.com/phar-io/version/issues", + "source": "https://github.com/phar-io/version/tree/3.2.1" + }, + "time": "2022-02-21T01:04:05+00:00" + }, + { + "name": "phpunit/php-code-coverage", + "version": "11.0.12", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/php-code-coverage.git", + "reference": "2c1ed04922802c15e1de5d7447b4856de949cf56" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/php-code-coverage/zipball/2c1ed04922802c15e1de5d7447b4856de949cf56", + "reference": "2c1ed04922802c15e1de5d7447b4856de949cf56", + "shasum": "" + }, + "require": { + "ext-dom": "*", + "ext-libxml": "*", + "ext-xmlwriter": "*", + "nikic/php-parser": "^5.7.0", + "php": ">=8.2", + "phpunit/php-file-iterator": "^5.1.0", + "phpunit/php-text-template": "^4.0.1", + "sebastian/code-unit-reverse-lookup": "^4.0.1", + "sebastian/complexity": "^4.0.1", + "sebastian/environment": "^7.2.1", + "sebastian/lines-of-code": "^3.0.1", + "sebastian/version": "^5.0.2", + "theseer/tokenizer": "^1.3.1" + }, + "require-dev": { + "phpunit/phpunit": "^11.5.46" + }, + "suggest": { + "ext-pcov": "PHP extension that provides line coverage", + "ext-xdebug": "PHP extension that provides line coverage as well as branch and path coverage" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "11.0.x-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "lead" + } + ], + "description": "Library that provides collection, processing, and rendering functionality for PHP code coverage information.", + "homepage": "https://github.com/sebastianbergmann/php-code-coverage", + "keywords": [ + "coverage", + "testing", + "xunit" + ], + "support": { + "issues": "https://github.com/sebastianbergmann/php-code-coverage/issues", + "security": "https://github.com/sebastianbergmann/php-code-coverage/security/policy", + "source": "https://github.com/sebastianbergmann/php-code-coverage/tree/11.0.12" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + }, + { + "url": "https://liberapay.com/sebastianbergmann", + "type": "liberapay" + }, + { + "url": "https://thanks.dev/u/gh/sebastianbergmann", + "type": "thanks_dev" + }, + { + "url": "https://tidelift.com/funding/github/packagist/phpunit/php-code-coverage", + "type": "tidelift" + } + ], + "time": "2025-12-24T07:01:01+00:00" + }, + { + "name": "phpunit/php-file-iterator", + "version": "5.1.1", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/php-file-iterator.git", + "reference": "2f3a64888c814fc235386b7387dd5b5ed92ad903" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/php-file-iterator/zipball/2f3a64888c814fc235386b7387dd5b5ed92ad903", + "reference": "2f3a64888c814fc235386b7387dd5b5ed92ad903", + "shasum": "" + }, + "require": { + "php": ">=8.2" + }, + "require-dev": { + "phpunit/phpunit": "^11.3" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "5.1-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "lead" + } + ], + "description": "FilterIterator implementation that filters files based on a list of suffixes.", + "homepage": "https://github.com/sebastianbergmann/php-file-iterator/", + "keywords": [ + "filesystem", + "iterator" + ], + "support": { + "issues": "https://github.com/sebastianbergmann/php-file-iterator/issues", + "security": "https://github.com/sebastianbergmann/php-file-iterator/security/policy", + "source": "https://github.com/sebastianbergmann/php-file-iterator/tree/5.1.1" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + }, + { + "url": "https://liberapay.com/sebastianbergmann", + "type": "liberapay" + }, + { + "url": "https://thanks.dev/u/gh/sebastianbergmann", + "type": "thanks_dev" + }, + { + "url": "https://tidelift.com/funding/github/packagist/phpunit/php-file-iterator", + "type": "tidelift" + } + ], + "time": "2026-02-02T13:52:54+00:00" + }, + { + "name": "phpunit/php-invoker", + "version": "5.0.1", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/php-invoker.git", + "reference": "c1ca3814734c07492b3d4c5f794f4b0995333da2" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/php-invoker/zipball/c1ca3814734c07492b3d4c5f794f4b0995333da2", + "reference": "c1ca3814734c07492b3d4c5f794f4b0995333da2", + "shasum": "" + }, + "require": { + "php": ">=8.2" + }, + "require-dev": { + "ext-pcntl": "*", + "phpunit/phpunit": "^11.0" + }, + "suggest": { + "ext-pcntl": "*" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "5.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "lead" + } + ], + "description": "Invoke callables with a timeout", + "homepage": "https://github.com/sebastianbergmann/php-invoker/", + "keywords": [ + "process" + ], + "support": { + "issues": "https://github.com/sebastianbergmann/php-invoker/issues", + "security": "https://github.com/sebastianbergmann/php-invoker/security/policy", + "source": "https://github.com/sebastianbergmann/php-invoker/tree/5.0.1" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + } + ], + "time": "2024-07-03T05:07:44+00:00" + }, + { + "name": "phpunit/php-text-template", + "version": "4.0.1", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/php-text-template.git", + "reference": "3e0404dc6b300e6bf56415467ebcb3fe4f33e964" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/php-text-template/zipball/3e0404dc6b300e6bf56415467ebcb3fe4f33e964", + "reference": "3e0404dc6b300e6bf56415467ebcb3fe4f33e964", + "shasum": "" + }, + "require": { + "php": ">=8.2" + }, + "require-dev": { + "phpunit/phpunit": "^11.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "4.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "lead" + } + ], + "description": "Simple template engine.", + "homepage": "https://github.com/sebastianbergmann/php-text-template/", + "keywords": [ + "template" + ], + "support": { + "issues": "https://github.com/sebastianbergmann/php-text-template/issues", + "security": "https://github.com/sebastianbergmann/php-text-template/security/policy", + "source": "https://github.com/sebastianbergmann/php-text-template/tree/4.0.1" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + } + ], + "time": "2024-07-03T05:08:43+00:00" + }, + { + "name": "phpunit/php-timer", + "version": "7.0.1", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/php-timer.git", + "reference": "3b415def83fbcb41f991d9ebf16ae4ad8b7837b3" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/php-timer/zipball/3b415def83fbcb41f991d9ebf16ae4ad8b7837b3", + "reference": "3b415def83fbcb41f991d9ebf16ae4ad8b7837b3", + "shasum": "" + }, + "require": { + "php": ">=8.2" + }, + "require-dev": { + "phpunit/phpunit": "^11.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "7.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "lead" + } + ], + "description": "Utility class for timing", + "homepage": "https://github.com/sebastianbergmann/php-timer/", + "keywords": [ + "timer" + ], + "support": { + "issues": "https://github.com/sebastianbergmann/php-timer/issues", + "security": "https://github.com/sebastianbergmann/php-timer/security/policy", + "source": "https://github.com/sebastianbergmann/php-timer/tree/7.0.1" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + } + ], + "time": "2024-07-03T05:09:35+00:00" + }, + { + "name": "phpunit/phpunit", + "version": "11.5.56", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/phpunit.git", + "reference": "5f83edffa6967c3db468d48a695ec7bcb02e9256" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/phpunit/zipball/5f83edffa6967c3db468d48a695ec7bcb02e9256", + "reference": "5f83edffa6967c3db468d48a695ec7bcb02e9256", + "shasum": "" + }, + "require": { + "ext-dom": "*", + "ext-filter": "*", + "ext-json": "*", + "ext-libxml": "*", + "ext-mbstring": "*", + "ext-xmlwriter": "*", + "myclabs/deep-copy": "^1.13.4", + "phar-io/manifest": "^2.0.4", + "phar-io/version": "^3.2.1", + "php": ">=8.2", + "phpunit/php-code-coverage": "^11.0.12", + "phpunit/php-file-iterator": "^5.1.1", + "phpunit/php-invoker": "^5.0.1", + "phpunit/php-text-template": "^4.0.1", + "phpunit/php-timer": "^7.0.1", + "sebastian/cli-parser": "^3.0.2", + "sebastian/code-unit": "^3.0.3", + "sebastian/comparator": "^6.3.3", + "sebastian/diff": "^6.0.2", + "sebastian/environment": "^7.2.1", + "sebastian/exporter": "^6.3.2", + "sebastian/global-state": "^7.0.2", + "sebastian/object-enumerator": "^6.0.1", + "sebastian/recursion-context": "^6.0.3", + "sebastian/type": "^5.1.3", + "sebastian/version": "^5.0.2", + "staabm/side-effects-detector": "^1.0.5" + }, + "suggest": { + "ext-soap": "To be able to generate mocks based on WSDL files" + }, + "bin": [ + "phpunit" + ], + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "11.5-dev" + } + }, + "autoload": { + "files": [ + "src/Framework/Assert/Functions.php" + ], + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "lead" + } + ], + "description": "The PHP Unit Testing framework.", + "homepage": "https://phpunit.de/", + "keywords": [ + "phpunit", + "testing", + "xunit" + ], + "support": { + "issues": "https://github.com/sebastianbergmann/phpunit/issues", + "security": "https://github.com/sebastianbergmann/phpunit/security/policy", + "source": "https://github.com/sebastianbergmann/phpunit/tree/11.5.56" + }, + "funding": [ + { + "url": "https://phpunit.de/sponsoring.html", + "type": "other" + } + ], + "time": "2026-07-06T14:52:39+00:00" + }, + { + "name": "sebastian/cli-parser", + "version": "3.0.2", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/cli-parser.git", + "reference": "15c5dd40dc4f38794d383bb95465193f5e0ae180" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/cli-parser/zipball/15c5dd40dc4f38794d383bb95465193f5e0ae180", + "reference": "15c5dd40dc4f38794d383bb95465193f5e0ae180", + "shasum": "" + }, + "require": { + "php": ">=8.2" + }, + "require-dev": { + "phpunit/phpunit": "^11.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "3.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "lead" + } + ], + "description": "Library for parsing CLI options", + "homepage": "https://github.com/sebastianbergmann/cli-parser", + "support": { + "issues": "https://github.com/sebastianbergmann/cli-parser/issues", + "security": "https://github.com/sebastianbergmann/cli-parser/security/policy", + "source": "https://github.com/sebastianbergmann/cli-parser/tree/3.0.2" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + } + ], + "time": "2024-07-03T04:41:36+00:00" + }, + { + "name": "sebastian/code-unit", + "version": "3.0.3", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/code-unit.git", + "reference": "54391c61e4af8078e5b276ab082b6d3c54c9ad64" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/code-unit/zipball/54391c61e4af8078e5b276ab082b6d3c54c9ad64", + "reference": "54391c61e4af8078e5b276ab082b6d3c54c9ad64", + "shasum": "" + }, + "require": { + "php": ">=8.2" + }, + "require-dev": { + "phpunit/phpunit": "^11.5" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "3.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "lead" + } + ], + "description": "Collection of value objects that represent the PHP code units", + "homepage": "https://github.com/sebastianbergmann/code-unit", + "support": { + "issues": "https://github.com/sebastianbergmann/code-unit/issues", + "security": "https://github.com/sebastianbergmann/code-unit/security/policy", + "source": "https://github.com/sebastianbergmann/code-unit/tree/3.0.3" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + } + ], + "time": "2025-03-19T07:56:08+00:00" + }, + { + "name": "sebastian/code-unit-reverse-lookup", + "version": "4.0.1", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/code-unit-reverse-lookup.git", + "reference": "183a9b2632194febd219bb9246eee421dad8d45e" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/code-unit-reverse-lookup/zipball/183a9b2632194febd219bb9246eee421dad8d45e", + "reference": "183a9b2632194febd219bb9246eee421dad8d45e", + "shasum": "" + }, + "require": { + "php": ">=8.2" + }, + "require-dev": { + "phpunit/phpunit": "^11.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "4.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de" + } + ], + "description": "Looks up which function or method a line of code belongs to", + "homepage": "https://github.com/sebastianbergmann/code-unit-reverse-lookup/", + "support": { + "issues": "https://github.com/sebastianbergmann/code-unit-reverse-lookup/issues", + "security": "https://github.com/sebastianbergmann/code-unit-reverse-lookup/security/policy", + "source": "https://github.com/sebastianbergmann/code-unit-reverse-lookup/tree/4.0.1" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + } + ], + "time": "2024-07-03T04:45:54+00:00" + }, + { + "name": "sebastian/comparator", + "version": "6.3.3", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/comparator.git", + "reference": "2c95e1e86cb8dd41beb8d502057d1081ccc8eca9" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/comparator/zipball/2c95e1e86cb8dd41beb8d502057d1081ccc8eca9", + "reference": "2c95e1e86cb8dd41beb8d502057d1081ccc8eca9", + "shasum": "" + }, + "require": { + "ext-dom": "*", + "ext-mbstring": "*", + "php": ">=8.2", + "sebastian/diff": "^6.0", + "sebastian/exporter": "^6.0" + }, + "require-dev": { + "phpunit/phpunit": "^11.4" + }, + "suggest": { + "ext-bcmath": "For comparing BcMath\\Number objects" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "6.3-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de" + }, + { + "name": "Jeff Welch", + "email": "whatthejeff@gmail.com" + }, + { + "name": "Volker Dusch", + "email": "github@wallbash.com" + }, + { + "name": "Bernhard Schussek", + "email": "bschussek@2bepublished.at" + } + ], + "description": "Provides the functionality to compare PHP values for equality", + "homepage": "https://github.com/sebastianbergmann/comparator", + "keywords": [ + "comparator", + "compare", + "equality" + ], + "support": { + "issues": "https://github.com/sebastianbergmann/comparator/issues", + "security": "https://github.com/sebastianbergmann/comparator/security/policy", + "source": "https://github.com/sebastianbergmann/comparator/tree/6.3.3" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + }, + { + "url": "https://liberapay.com/sebastianbergmann", + "type": "liberapay" + }, + { + "url": "https://thanks.dev/u/gh/sebastianbergmann", + "type": "thanks_dev" + }, + { + "url": "https://tidelift.com/funding/github/packagist/sebastian/comparator", + "type": "tidelift" + } + ], + "time": "2026-01-24T09:26:40+00:00" + }, + { + "name": "sebastian/complexity", + "version": "4.0.1", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/complexity.git", + "reference": "ee41d384ab1906c68852636b6de493846e13e5a0" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/complexity/zipball/ee41d384ab1906c68852636b6de493846e13e5a0", + "reference": "ee41d384ab1906c68852636b6de493846e13e5a0", + "shasum": "" + }, + "require": { + "nikic/php-parser": "^5.0", + "php": ">=8.2" + }, + "require-dev": { + "phpunit/phpunit": "^11.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "4.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "lead" + } + ], + "description": "Library for calculating the complexity of PHP code units", + "homepage": "https://github.com/sebastianbergmann/complexity", + "support": { + "issues": "https://github.com/sebastianbergmann/complexity/issues", + "security": "https://github.com/sebastianbergmann/complexity/security/policy", + "source": "https://github.com/sebastianbergmann/complexity/tree/4.0.1" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + } + ], + "time": "2024-07-03T04:49:50+00:00" + }, + { + "name": "sebastian/diff", + "version": "6.0.2", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/diff.git", + "reference": "b4ccd857127db5d41a5b676f24b51371d76d8544" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/diff/zipball/b4ccd857127db5d41a5b676f24b51371d76d8544", + "reference": "b4ccd857127db5d41a5b676f24b51371d76d8544", + "shasum": "" + }, + "require": { + "php": ">=8.2" + }, + "require-dev": { + "phpunit/phpunit": "^11.0", + "symfony/process": "^4.2 || ^5" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "6.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de" + }, + { + "name": "Kore Nordmann", + "email": "mail@kore-nordmann.de" + } + ], + "description": "Diff implementation", + "homepage": "https://github.com/sebastianbergmann/diff", + "keywords": [ + "diff", + "udiff", + "unidiff", + "unified diff" + ], + "support": { + "issues": "https://github.com/sebastianbergmann/diff/issues", + "security": "https://github.com/sebastianbergmann/diff/security/policy", + "source": "https://github.com/sebastianbergmann/diff/tree/6.0.2" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + } + ], + "time": "2024-07-03T04:53:05+00:00" + }, + { + "name": "sebastian/environment", + "version": "7.2.1", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/environment.git", + "reference": "a5c75038693ad2e8d4b6c15ba2403532647830c4" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/environment/zipball/a5c75038693ad2e8d4b6c15ba2403532647830c4", + "reference": "a5c75038693ad2e8d4b6c15ba2403532647830c4", + "shasum": "" + }, + "require": { + "php": ">=8.2" + }, + "require-dev": { + "phpunit/phpunit": "^11.3" + }, + "suggest": { + "ext-posix": "*" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "7.2-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de" + } + ], + "description": "Provides functionality to handle HHVM/PHP environments", + "homepage": "https://github.com/sebastianbergmann/environment", + "keywords": [ + "Xdebug", + "environment", + "hhvm" + ], + "support": { + "issues": "https://github.com/sebastianbergmann/environment/issues", + "security": "https://github.com/sebastianbergmann/environment/security/policy", + "source": "https://github.com/sebastianbergmann/environment/tree/7.2.1" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + }, + { + "url": "https://liberapay.com/sebastianbergmann", + "type": "liberapay" + }, + { + "url": "https://thanks.dev/u/gh/sebastianbergmann", + "type": "thanks_dev" + }, + { + "url": "https://tidelift.com/funding/github/packagist/sebastian/environment", + "type": "tidelift" + } + ], + "time": "2025-05-21T11:55:47+00:00" + }, + { + "name": "sebastian/exporter", + "version": "6.3.2", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/exporter.git", + "reference": "70a298763b40b213ec087c51c739efcaa90bcd74" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/exporter/zipball/70a298763b40b213ec087c51c739efcaa90bcd74", + "reference": "70a298763b40b213ec087c51c739efcaa90bcd74", + "shasum": "" + }, + "require": { + "ext-mbstring": "*", + "php": ">=8.2", + "sebastian/recursion-context": "^6.0" + }, + "require-dev": { + "phpunit/phpunit": "^11.3" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "6.3-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de" + }, + { + "name": "Jeff Welch", + "email": "whatthejeff@gmail.com" + }, + { + "name": "Volker Dusch", + "email": "github@wallbash.com" + }, + { + "name": "Adam Harvey", + "email": "aharvey@php.net" + }, + { + "name": "Bernhard Schussek", + "email": "bschussek@gmail.com" + } + ], + "description": "Provides the functionality to export PHP variables for visualization", + "homepage": "https://www.github.com/sebastianbergmann/exporter", + "keywords": [ + "export", + "exporter" + ], + "support": { + "issues": "https://github.com/sebastianbergmann/exporter/issues", + "security": "https://github.com/sebastianbergmann/exporter/security/policy", + "source": "https://github.com/sebastianbergmann/exporter/tree/6.3.2" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + }, + { + "url": "https://liberapay.com/sebastianbergmann", + "type": "liberapay" + }, + { + "url": "https://thanks.dev/u/gh/sebastianbergmann", + "type": "thanks_dev" + }, + { + "url": "https://tidelift.com/funding/github/packagist/sebastian/exporter", + "type": "tidelift" + } + ], + "time": "2025-09-24T06:12:51+00:00" + }, + { + "name": "sebastian/global-state", + "version": "7.0.2", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/global-state.git", + "reference": "3be331570a721f9a4b5917f4209773de17f747d7" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/global-state/zipball/3be331570a721f9a4b5917f4209773de17f747d7", + "reference": "3be331570a721f9a4b5917f4209773de17f747d7", + "shasum": "" + }, + "require": { + "php": ">=8.2", + "sebastian/object-reflector": "^4.0", + "sebastian/recursion-context": "^6.0" + }, + "require-dev": { + "ext-dom": "*", + "phpunit/phpunit": "^11.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "7.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de" + } + ], + "description": "Snapshotting of global state", + "homepage": "https://www.github.com/sebastianbergmann/global-state", + "keywords": [ + "global state" + ], + "support": { + "issues": "https://github.com/sebastianbergmann/global-state/issues", + "security": "https://github.com/sebastianbergmann/global-state/security/policy", + "source": "https://github.com/sebastianbergmann/global-state/tree/7.0.2" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + } + ], + "time": "2024-07-03T04:57:36+00:00" + }, + { + "name": "sebastian/lines-of-code", + "version": "3.0.1", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/lines-of-code.git", + "reference": "d36ad0d782e5756913e42ad87cb2890f4ffe467a" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/lines-of-code/zipball/d36ad0d782e5756913e42ad87cb2890f4ffe467a", + "reference": "d36ad0d782e5756913e42ad87cb2890f4ffe467a", + "shasum": "" + }, + "require": { + "nikic/php-parser": "^5.0", + "php": ">=8.2" + }, + "require-dev": { + "phpunit/phpunit": "^11.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "3.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "lead" + } + ], + "description": "Library for counting the lines of code in PHP source code", + "homepage": "https://github.com/sebastianbergmann/lines-of-code", + "support": { + "issues": "https://github.com/sebastianbergmann/lines-of-code/issues", + "security": "https://github.com/sebastianbergmann/lines-of-code/security/policy", + "source": "https://github.com/sebastianbergmann/lines-of-code/tree/3.0.1" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + } + ], + "time": "2024-07-03T04:58:38+00:00" + }, + { + "name": "sebastian/object-enumerator", + "version": "6.0.1", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/object-enumerator.git", + "reference": "f5b498e631a74204185071eb41f33f38d64608aa" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/object-enumerator/zipball/f5b498e631a74204185071eb41f33f38d64608aa", + "reference": "f5b498e631a74204185071eb41f33f38d64608aa", + "shasum": "" + }, + "require": { + "php": ">=8.2", + "sebastian/object-reflector": "^4.0", + "sebastian/recursion-context": "^6.0" + }, + "require-dev": { + "phpunit/phpunit": "^11.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "6.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de" + } + ], + "description": "Traverses array structures and object graphs to enumerate all referenced objects", + "homepage": "https://github.com/sebastianbergmann/object-enumerator/", + "support": { + "issues": "https://github.com/sebastianbergmann/object-enumerator/issues", + "security": "https://github.com/sebastianbergmann/object-enumerator/security/policy", + "source": "https://github.com/sebastianbergmann/object-enumerator/tree/6.0.1" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + } + ], + "time": "2024-07-03T05:00:13+00:00" + }, + { + "name": "sebastian/object-reflector", + "version": "4.0.1", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/object-reflector.git", + "reference": "6e1a43b411b2ad34146dee7524cb13a068bb35f9" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/object-reflector/zipball/6e1a43b411b2ad34146dee7524cb13a068bb35f9", + "reference": "6e1a43b411b2ad34146dee7524cb13a068bb35f9", + "shasum": "" + }, + "require": { + "php": ">=8.2" + }, + "require-dev": { + "phpunit/phpunit": "^11.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "4.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de" + } + ], + "description": "Allows reflection of object attributes, including inherited and non-public ones", + "homepage": "https://github.com/sebastianbergmann/object-reflector/", + "support": { + "issues": "https://github.com/sebastianbergmann/object-reflector/issues", + "security": "https://github.com/sebastianbergmann/object-reflector/security/policy", + "source": "https://github.com/sebastianbergmann/object-reflector/tree/4.0.1" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + } + ], + "time": "2024-07-03T05:01:32+00:00" + }, + { + "name": "sebastian/recursion-context", + "version": "6.0.3", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/recursion-context.git", + "reference": "f6458abbf32a6c8174f8f26261475dc133b3d9dc" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/recursion-context/zipball/f6458abbf32a6c8174f8f26261475dc133b3d9dc", + "reference": "f6458abbf32a6c8174f8f26261475dc133b3d9dc", + "shasum": "" + }, + "require": { + "php": ">=8.2" + }, + "require-dev": { + "phpunit/phpunit": "^11.3" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "6.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de" + }, + { + "name": "Jeff Welch", + "email": "whatthejeff@gmail.com" + }, + { + "name": "Adam Harvey", + "email": "aharvey@php.net" + } + ], + "description": "Provides functionality to recursively process PHP variables", + "homepage": "https://github.com/sebastianbergmann/recursion-context", + "support": { + "issues": "https://github.com/sebastianbergmann/recursion-context/issues", + "security": "https://github.com/sebastianbergmann/recursion-context/security/policy", + "source": "https://github.com/sebastianbergmann/recursion-context/tree/6.0.3" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + }, + { + "url": "https://liberapay.com/sebastianbergmann", + "type": "liberapay" + }, + { + "url": "https://thanks.dev/u/gh/sebastianbergmann", + "type": "thanks_dev" + }, + { + "url": "https://tidelift.com/funding/github/packagist/sebastian/recursion-context", + "type": "tidelift" + } + ], + "time": "2025-08-13T04:42:22+00:00" + }, + { + "name": "sebastian/type", + "version": "5.1.3", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/type.git", + "reference": "f77d2d4e78738c98d9a68d2596fe5e8fa380f449" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/type/zipball/f77d2d4e78738c98d9a68d2596fe5e8fa380f449", + "reference": "f77d2d4e78738c98d9a68d2596fe5e8fa380f449", + "shasum": "" + }, + "require": { + "php": ">=8.2" + }, + "require-dev": { + "phpunit/phpunit": "^11.3" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "5.1-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "lead" + } + ], + "description": "Collection of value objects that represent the types of the PHP type system", + "homepage": "https://github.com/sebastianbergmann/type", + "support": { + "issues": "https://github.com/sebastianbergmann/type/issues", + "security": "https://github.com/sebastianbergmann/type/security/policy", + "source": "https://github.com/sebastianbergmann/type/tree/5.1.3" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + }, + { + "url": "https://liberapay.com/sebastianbergmann", + "type": "liberapay" + }, + { + "url": "https://thanks.dev/u/gh/sebastianbergmann", + "type": "thanks_dev" + }, + { + "url": "https://tidelift.com/funding/github/packagist/sebastian/type", + "type": "tidelift" + } + ], + "time": "2025-08-09T06:55:48+00:00" + }, + { + "name": "sebastian/version", + "version": "5.0.2", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/version.git", + "reference": "c687e3387b99f5b03b6caa64c74b63e2936ff874" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/version/zipball/c687e3387b99f5b03b6caa64c74b63e2936ff874", + "reference": "c687e3387b99f5b03b6caa64c74b63e2936ff874", + "shasum": "" + }, + "require": { + "php": ">=8.2" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "5.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "lead" + } + ], + "description": "Library that helps with managing the version number of Git-hosted PHP projects", + "homepage": "https://github.com/sebastianbergmann/version", + "support": { + "issues": "https://github.com/sebastianbergmann/version/issues", + "security": "https://github.com/sebastianbergmann/version/security/policy", + "source": "https://github.com/sebastianbergmann/version/tree/5.0.2" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + } + ], + "time": "2024-10-09T05:16:32+00:00" + }, + { + "name": "staabm/side-effects-detector", + "version": "1.0.5", + "source": { + "type": "git", + "url": "https://github.com/staabm/side-effects-detector.git", + "reference": "d8334211a140ce329c13726d4a715adbddd0a163" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/staabm/side-effects-detector/zipball/d8334211a140ce329c13726d4a715adbddd0a163", + "reference": "d8334211a140ce329c13726d4a715adbddd0a163", + "shasum": "" + }, + "require": { + "ext-tokenizer": "*", + "php": "^7.4 || ^8.0" + }, + "require-dev": { + "phpstan/extension-installer": "^1.4.3", + "phpstan/phpstan": "^1.12.6", + "phpunit/phpunit": "^9.6.21", + "symfony/var-dumper": "^5.4.43", + "tomasvotruba/type-coverage": "1.0.0", + "tomasvotruba/unused-public": "1.0.0" + }, + "type": "library", + "autoload": { + "classmap": [ + "lib/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "description": "A static analysis tool to detect side effects in PHP code", + "keywords": [ + "static analysis" + ], + "support": { + "issues": "https://github.com/staabm/side-effects-detector/issues", + "source": "https://github.com/staabm/side-effects-detector/tree/1.0.5" + }, + "funding": [ + { + "url": "https://github.com/staabm", + "type": "github" + } + ], + "time": "2024-10-20T05:08:20+00:00" + }, + { + "name": "theseer/tokenizer", + "version": "1.3.1", + "source": { + "type": "git", + "url": "https://github.com/theseer/tokenizer.git", + "reference": "b7489ce515e168639d17feec34b8847c326b0b3c" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/theseer/tokenizer/zipball/b7489ce515e168639d17feec34b8847c326b0b3c", + "reference": "b7489ce515e168639d17feec34b8847c326b0b3c", + "shasum": "" + }, + "require": { + "ext-dom": "*", + "ext-tokenizer": "*", + "ext-xmlwriter": "*", + "php": "^7.2 || ^8.0" + }, + "type": "library", + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Arne Blankerts", + "email": "arne@blankerts.de", + "role": "Developer" + } + ], + "description": "A small library for converting tokenized PHP source code into XML and potentially other formats", + "support": { + "issues": "https://github.com/theseer/tokenizer/issues", + "source": "https://github.com/theseer/tokenizer/tree/1.3.1" + }, + "funding": [ + { + "url": "https://github.com/theseer", + "type": "github" + } + ], + "time": "2025-11-17T20:03:58+00:00" + } + ], + "aliases": [], + "minimum-stability": "stable", + "stability-flags": {}, + "prefer-stable": false, + "prefer-lowest": false, + "platform": { + "php": ">=8.3", + "ext-curl": "*", + "ext-json": "*" + }, + "platform-dev": {}, + "plugin-api-version": "2.9.0" +} diff --git a/config.example.php b/config.example.php new file mode 100644 index 0000000..f5aa056 --- /dev/null +++ b/config.example.php @@ -0,0 +1,38 @@ + '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', +]; diff --git a/docs/superpowers/plans/2026-09-11-weerscherm.md b/docs/superpowers/plans/2026-09-11-weerscherm.md index 5c588ab..72d4f1c 100644 --- a/docs/superpowers/plans/2026-09-11-weerscherm.md +++ b/docs/superpowers/plans/2026-09-11-weerscherm.md @@ -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 diff --git a/package.json b/package.json new file mode 100644 index 0000000..4a02087 --- /dev/null +++ b/package.json @@ -0,0 +1,8 @@ +{ + "name": "weerscherm", + "private": true, + "type": "module", + "scripts": { + "test": "node --test \"tests/js/**/*.test.js\"" + } +} diff --git a/phpunit.xml b/phpunit.xml new file mode 100644 index 0000000..e80f8cf --- /dev/null +++ b/phpunit.xml @@ -0,0 +1,18 @@ + + + + + tests + + + + + src + + + diff --git a/public/.htaccess b/public/.htaccess new file mode 100644 index 0000000..5029711 --- /dev/null +++ b/public/.htaccess @@ -0,0 +1,11 @@ +# Alleen relevant als de productieserver AllowOverride toestaat. De vhost-config heeft voorrang. + + Header set X-Robots-Tag "noindex, nofollow" + + Header set Cache-Control "no-cache" + + + + AddType application/manifest+json .webmanifest + +Options -Indexes diff --git a/public/api/data.php b/public/api/data.php new file mode 100644 index 0000000..d9a5214 --- /dev/null +++ b/public/api/data.php @@ -0,0 +1,53 @@ + ['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']]); +} diff --git a/public/app.js b/public/app.js new file mode 100644 index 0000000..034a30a --- /dev/null +++ b/public/app.js @@ -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 `
+

${esc(r.name)}

+

${off ? '–' : f1(r.temp) + '°'}

+

↓ ${off ? '–' : f1(r.min) + '°'}↑ ${off ? '–' : f1(r.max) + '°'}

+
`; + }); + $('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) => `
+ ${f1(b.mm)}${b.label} +
`).join(''); +} + +function renderForecast(days) { + $('forecast').innerHTML = days.map((d) => `
+

${dayAbbr(d.date, state.tz)}

+
${icon(wmoIcon(d.weather_code))}
+

${fInt(d.temp_max)}°${fInt(d.temp_min)}°

+

${fmm(d.precip_mm)}${d.precip_prob === null ? '–' : d.precip_prob + '%'}

+
`).join(''); +} + +function renderBanner() { + const banner = $('banner'); + const errors = state.data ? state.data.errors : []; + if (errors.includes('netatmo_auth')) { + banner.innerHTML = 'Netatmo-koppeling verlopenVoer bin/netatmo-init.php opnieuw uit met een nieuwe refresh token.'; + 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 serverEr wordt elke 5 minuten opnieuw geprobeerd.'; + } + } 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(); diff --git a/public/format.js b/public/format.js new file mode 100644 index 0000000..2d2780f --- /dev/null +++ b/public/format.js @@ -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()); +} diff --git a/public/icons.js b/public/icons.js new file mode 100644 index 0000000..256fedb --- /dev/null +++ b/public/icons.js @@ -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 = ''; +const SMALL_SUN = ''; +const CLOUD_LOW = ''; +const CLOUD_HIGH = ''; +const CLOUD_MID = ''; + +const ICONS = { + sun: SUN, + partly: SMALL_SUN + CLOUD_LOW, + cloud: CLOUD_MID, + fog: CLOUD_HIGH + '', + drizzle: CLOUD_HIGH + '', + rain: CLOUD_HIGH + '', + showers: '' + + '' + + '', + snow: CLOUD_HIGH + '', + thunder: CLOUD_HIGH + '', +}; + +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 ``; +} diff --git a/public/icons/icon-180.png b/public/icons/icon-180.png new file mode 100644 index 0000000..b7a8996 Binary files /dev/null and b/public/icons/icon-180.png differ diff --git a/public/icons/icon-192.png b/public/icons/icon-192.png new file mode 100644 index 0000000..b17fb96 Binary files /dev/null and b/public/icons/icon-192.png differ diff --git a/public/icons/icon-512.png b/public/icons/icon-512.png new file mode 100644 index 0000000..e2025c9 Binary files /dev/null and b/public/icons/icon-512.png differ diff --git a/public/icons/icon.svg b/public/icons/icon.svg new file mode 100644 index 0000000..aadb695 --- /dev/null +++ b/public/icons/icon.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/public/index.html b/public/index.html new file mode 100644 index 0000000..1f2c674 --- /dev/null +++ b/public/index.html @@ -0,0 +1,63 @@ + + + + + +Weerscherm + + + + + + + + + + +
+
+
+   + + + meting – + –:– + +
+ +
+

Buiten

+

–

+
+
+
+ –laagst om – + –hoogst om – +
+
+
+ +
+ +
+
+

Regen

+
+
+

– mm

+

vandaag

+

––

+
+
+
+
+
+
+ + +
+
+
Draai de iPad een kwartslag
+ + + diff --git a/public/manifest.webmanifest b/public/manifest.webmanifest new file mode 100644 index 0000000..ec27a07 --- /dev/null +++ b/public/manifest.webmanifest @@ -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" } + ] +} diff --git a/public/robots.txt b/public/robots.txt new file mode 100644 index 0000000..1f53798 --- /dev/null +++ b/public/robots.txt @@ -0,0 +1,2 @@ +User-agent: * +Disallow: / diff --git a/public/styles.css b/public/styles.css new file mode 100644 index 0000000..7b3cca8 --- /dev/null +++ b/public/styles.css @@ -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; } +} diff --git a/public/sw.js b/public/sw.js new file mode 100644 index 0000000..952e016 --- /dev/null +++ b/public/sw.js @@ -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; +} diff --git a/src/Cache.php b/src/Cache.php new file mode 100644 index 0000000..a3af485 --- /dev/null +++ b/src/Cache.php @@ -0,0 +1,53 @@ +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'; + } +} diff --git a/src/Config.php b/src/Config.php new file mode 100644 index 0000000..8b554cc --- /dev/null +++ b/src/Config.php @@ -0,0 +1,52 @@ + '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 $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; + } +} diff --git a/src/DashboardService.php b/src/DashboardService.php new file mode 100644 index 0000000..5ec842f --- /dev/null +++ b/src/DashboardService.php @@ -0,0 +1,160 @@ + */ + private array $errors = []; + + /** + * @param array{stations: int, measure: int, forecast: int} $ttl + * @param array $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 */ + 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, 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, 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); + } +} diff --git a/src/FixtureSource.php b/src/FixtureSource.php new file mode 100644 index 0000000..29cd13f --- /dev/null +++ b/src/FixtureSource.php @@ -0,0 +1,74 @@ +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], + ]; + } +} diff --git a/src/ForecastMapper.php b/src/ForecastMapper.php new file mode 100644 index 0000000..a63273a --- /dev/null +++ b/src/ForecastMapper.php @@ -0,0 +1,45 @@ + $daily + * @return list + */ + 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; + } +} diff --git a/src/Http.php b/src/Http.php new file mode 100644 index 0000000..1be3561 --- /dev/null +++ b/src/Http.php @@ -0,0 +1,56 @@ +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 $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); + } +} diff --git a/src/HttpClient.php b/src/HttpClient.php new file mode 100644 index 0000000..d0ac700 --- /dev/null +++ b/src/HttpClient.php @@ -0,0 +1,17 @@ + $query + * @param list $headers + */ + public function get(string $url, array $query = [], array $headers = []): HttpResponse; + + /** @param array $fields */ + public function postForm(string $url, array $fields): HttpResponse; +} diff --git a/src/HttpResponse.php b/src/HttpResponse.php new file mode 100644 index 0000000..d47ae9a --- /dev/null +++ b/src/HttpResponse.php @@ -0,0 +1,26 @@ + */ + 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; + } +} diff --git a/src/LiveSource.php b/src/LiveSource.php new file mode 100644 index 0000000..2a56a4f --- /dev/null +++ b/src/LiveSource.php @@ -0,0 +1,41 @@ +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); + } +} diff --git a/src/NetatmoAuthException.php b/src/NetatmoAuthException.php new file mode 100644 index 0000000..d61744a --- /dev/null +++ b/src/NetatmoAuthException.php @@ -0,0 +1,10 @@ +now = $now ?? static fn(): int => time(); + } + + /** @return array 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 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 $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; + } +} diff --git a/src/OpenMeteoClient.php b/src/OpenMeteoClient.php new file mode 100644 index 0000000..3452b32 --- /dev/null +++ b/src/OpenMeteoClient.php @@ -0,0 +1,35 @@ + 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']; + } +} diff --git a/src/RainCalculator.php b/src/RainCalculator.php new file mode 100644 index 0000000..a0a1ddf --- /dev/null +++ b/src/RainCalculator.php @@ -0,0 +1,65 @@ +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 $body + * @return array{today: ?float, previous_days: list, 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), + ]; + } +} diff --git a/src/StationMapper.php b/src/StationMapper.php new file mode 100644 index 0000000..ff9a0b0 --- /dev/null +++ b/src/StationMapper.php @@ -0,0 +1,149 @@ + null, 'trend' => null, 'min' => null, 'min_at' => null, 'max' => null, 'max_at' => null]; + + /** + * @param array $body + * @param array $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, + * 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> $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> $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> $modules + * @param array $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; + } +} diff --git a/src/TokenStore.php b/src/TokenStore.php new file mode 100644 index 0000000..6e200e6 --- /dev/null +++ b/src/TokenStore.php @@ -0,0 +1,92 @@ +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); + } + } +} diff --git a/src/UpstreamException.php b/src/UpstreamException.php new file mode 100644 index 0000000..6913ee2 --- /dev/null +++ b/src/UpstreamException.php @@ -0,0 +1,10 @@ + de "body" van Netatmo getstationsdata */ + public function stationsData(): array; + + /** @return array de "body" van Netatmo getmeasure (timestamp => [mm]) */ + public function measure(string $deviceId, string $moduleId, int $begin, int $end): array; + + /** @return array het "daily"-blok van Open-Meteo */ + public function forecast(float $lat, float $lon, string $timezone): array; +} diff --git a/src/autoload.php b/src/autoload.php new file mode 100644 index 0000000..3177a76 --- /dev/null +++ b/src/autoload.php @@ -0,0 +1,24 @@ +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'); + } +} diff --git a/tests/ConfigTest.php b/tests/ConfigTest.php new file mode 100644 index 0000000..63d838c --- /dev/null +++ b/tests/ConfigTest.php @@ -0,0 +1,57 @@ +file = tempnam(sys_get_temp_dir(), 'cfg'); + } + + protected function tearDown(): void + { + @unlink($this->file); + } + + public function testDefaultsApplyWhenKeysAreMissing(): void + { + file_put_contents($this->file, '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, ' "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, 'file)->get('nope', 'x')); + } + + public function testFileMustReturnArray(): void + { + file_put_contents($this->file, 'expectException(\RuntimeException::class); + Config::load($this->file); + } +} diff --git a/tests/DashboardServiceTest.php b/tests/DashboardServiceTest.php new file mode 100644 index 0000000..1e2e058 --- /dev/null +++ b/tests/DashboardServiceTest.php @@ -0,0 +1,161 @@ +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'); + } +} diff --git a/tests/FakeHttp.php b/tests/FakeHttp.php new file mode 100644 index 0000000..caa34a4 --- /dev/null +++ b/tests/FakeHttp.php @@ -0,0 +1,49 @@ + */ + private array $queue = []; + + /** @var list */ + 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 */ + 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); + } +} diff --git a/tests/FakeHttpDecorator.php b/tests/FakeHttpDecorator.php new file mode 100644 index 0000000..738abe3 --- /dev/null +++ b/tests/FakeHttpDecorator.php @@ -0,0 +1,27 @@ +inner->get($url, $query, $headers); + } + + public function postForm(string $url, array $fields): HttpResponse + { + return $this->inner->postForm($url, $fields); + } +} diff --git a/tests/FakeSource.php b/tests/FakeSource.php new file mode 100644 index 0000000..d496992 --- /dev/null +++ b/tests/FakeSource.php @@ -0,0 +1,57 @@ + */ + public array $responses = []; + + /** @var array */ + public array $calls = ['stations' => 0, 'measure' => 0, 'forecast' => 0]; + + /** @var 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; + } +} diff --git a/tests/ForecastMapperTest.php b/tests/ForecastMapperTest.php new file mode 100644 index 0000000..1a768e0 --- /dev/null +++ b/tests/ForecastMapperTest.php @@ -0,0 +1,57 @@ + */ + 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([])); + } +} diff --git a/tests/NetatmoClientTest.php b/tests/NetatmoClientTest.php new file mode 100644 index 0000000..52985f4 --- /dev/null +++ b/tests/NetatmoClientTest.php @@ -0,0 +1,201 @@ +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']); + } +} diff --git a/tests/OpenMeteoClientTest.php b/tests/OpenMeteoClientTest.php new file mode 100644 index 0000000..7b9c6d0 --- /dev/null +++ b/tests/OpenMeteoClientTest.php @@ -0,0 +1,44 @@ +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'); + } +} diff --git a/tests/RainCalculatorTest.php b/tests/RainCalculatorTest.php new file mode 100644 index 0000000..10f48da --- /dev/null +++ b/tests/RainCalculatorTest.php @@ -0,0 +1,106 @@ +tz = new \DateTimeZone('Europe/Berlin'); + } + + /** @return array */ + 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()); + } +} diff --git a/tests/StationMapperTest.php b/tests/StationMapperTest.php new file mode 100644 index 0000000..ffa9db8 --- /dev/null +++ b/tests/StationMapperTest.php @@ -0,0 +1,112 @@ + */ + 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']); + } +} diff --git a/tests/TokenStoreTest.php b/tests/TokenStoreTest.php new file mode 100644 index 0000000..5077742 --- /dev/null +++ b/tests/TokenStoreTest.php @@ -0,0 +1,87 @@ +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( + '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'); + } +} diff --git a/tests/bootstrap.php b/tests/bootstrap.php new file mode 100644 index 0000000..976b8ba --- /dev/null +++ b/tests/bootstrap.php @@ -0,0 +1,7 @@ + { + 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'); +}); diff --git a/tests/js/sw.test.js b/tests/js/sw.test.js new file mode 100644 index 0000000..89d4b28 --- /dev/null +++ b/tests/js/sw.test.js @@ -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); +});