From b38b7a3ac4a9ff3b90a7ea30b37e6b8a19f3bf3f Mon Sep 17 00:00:00 2001 From: Theo Weijmer Date: Fri, 11 Sep 2026 14:34:39 +0200 Subject: [PATCH 01/14] feat: project skeleton with Config and exceptions Co-Authored-By: Claude Fable 5.1 --- .phpunit.result.cache | 1 + composer.json | 26 + composer.lock | 1787 +++++++++++++++++ config.example.php | 38 + .../plans/2026-09-11-weerscherm.md | 2 +- phpunit.xml | 18 + src/Config.php | 52 + src/NetatmoAuthException.php | 10 + src/UpstreamException.php | 10 + tests/ConfigTest.php | 57 + tests/bootstrap.php | 7 + 11 files changed, 2007 insertions(+), 1 deletion(-) create mode 100644 .phpunit.result.cache create mode 100644 composer.json create mode 100644 composer.lock create mode 100644 config.example.php create mode 100644 phpunit.xml create mode 100644 src/Config.php create mode 100644 src/NetatmoAuthException.php create mode 100644 src/UpstreamException.php create mode 100644 tests/ConfigTest.php create mode 100644 tests/bootstrap.php diff --git a/.phpunit.result.cache b/.phpunit.result.cache new file mode 100644 index 0000000..77a9cb4 --- /dev/null +++ b/.phpunit.result.cache @@ -0,0 +1 @@ +{"version":2,"defects":[],"times":{"Weerscherm\\Tests\\ConfigTest::testDefaultsApplyWhenKeysAreMissing":0.001,"Weerscherm\\Tests\\ConfigTest::testFileValuesOverrideDefaults":0,"Weerscherm\\Tests\\ConfigTest::testUnknownKeyReturnsGivenDefault":0,"Weerscherm\\Tests\\ConfigTest::testFileMustReturnArray":0}} \ No newline at end of file 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/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/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/NetatmoAuthException.php b/src/NetatmoAuthException.php new file mode 100644 index 0000000..d61744a --- /dev/null +++ b/src/NetatmoAuthException.php @@ -0,0 +1,10 @@ +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/bootstrap.php b/tests/bootstrap.php new file mode 100644 index 0000000..976b8ba --- /dev/null +++ b/tests/bootstrap.php @@ -0,0 +1,7 @@ + Date: Fri, 11 Sep 2026 14:36:00 +0200 Subject: [PATCH 02/14] feat: StationMapper maps getstationsdata to outdoor/indoor/rain Co-Authored-By: Claude Fable 5.1 --- .phpunit.result.cache | 2 +- src/StationMapper.php | 149 ++++++++++++++++++++++++++++ tests/StationMapperTest.php | 112 +++++++++++++++++++++ tests/fixtures/getstationsdata.json | 116 ++++++++++++++++++++++ 4 files changed, 378 insertions(+), 1 deletion(-) create mode 100644 src/StationMapper.php create mode 100644 tests/StationMapperTest.php create mode 100644 tests/fixtures/getstationsdata.json diff --git a/.phpunit.result.cache b/.phpunit.result.cache index 77a9cb4..0db9609 100644 --- a/.phpunit.result.cache +++ b/.phpunit.result.cache @@ -1 +1 @@ -{"version":2,"defects":[],"times":{"Weerscherm\\Tests\\ConfigTest::testDefaultsApplyWhenKeysAreMissing":0.001,"Weerscherm\\Tests\\ConfigTest::testFileValuesOverrideDefaults":0,"Weerscherm\\Tests\\ConfigTest::testUnknownKeyReturnsGivenDefault":0,"Weerscherm\\Tests\\ConfigTest::testFileMustReturnArray":0}} \ No newline at end of file +{"version":2,"defects":[],"times":{"Weerscherm\\Tests\\ConfigTest::testDefaultsApplyWhenKeysAreMissing":0.004,"Weerscherm\\Tests\\ConfigTest::testFileValuesOverrideDefaults":0,"Weerscherm\\Tests\\ConfigTest::testUnknownKeyReturnsGivenDefault":0,"Weerscherm\\Tests\\ConfigTest::testFileMustReturnArray":0.001,"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}} \ No newline at end of file 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/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/fixtures/getstationsdata.json b/tests/fixtures/getstationsdata.json new file mode 100644 index 0000000..c963888 --- /dev/null +++ b/tests/fixtures/getstationsdata.json @@ -0,0 +1,116 @@ +{ + "body": { + "devices": [ + { + "_id": "70:ee:50:00:00:01", + "type": "NAMain", + "station_name": "Thuis", + "module_name": "Woonkamer", + "reachable": true, + "firmware": 204, + "wifi_status": 50, + "co2_calibrating": false, + "place": { + "altitude": 20, + "city": "Voorbeeldstad", + "country": "DE", + "timezone": "Europe/Berlin", + "location": [ + 6.55, + 51.82 + ] + }, + "dashboard_data": { + "time_utc": 1789129812, + "Temperature": 21.3, + "CO2": 612, + "Humidity": 48, + "Noise": 38, + "Pressure": 1014.2, + "AbsolutePressure": 1011.8, + "min_temp": 20.4, + "max_temp": 22.1, + "date_max_temp": 1789126800, + "date_min_temp": 1789098600, + "temp_trend": "stable", + "pressure_trend": "up" + }, + "modules": [ + { + "_id": "02:00:00:00:00:01", + "type": "NAModule1", + "module_name": "Buiten", + "reachable": true, + "battery_percent": 71, + "rf_status": 70, + "dashboard_data": { + "time_utc": 1789129780, + "Temperature": 16.4, + "Humidity": 71, + "min_temp": 9.2, + "max_temp": 19.8, + "date_min_temp": 1789099800, + "date_max_temp": 1789124700, + "temp_trend": "down" + } + }, + { + "_id": "03:00:00:00:00:02", + "type": "NAModule4", + "module_name": "Slaapkamer", + "reachable": true, + "battery_percent": 64, + "rf_status": 66, + "dashboard_data": { + "time_utc": 1789129680, + "Temperature": 18.9, + "CO2": 540, + "Humidity": 52, + "min_temp": 18.1, + "max_temp": 19.6, + "date_min_temp": 1789101000, + "date_max_temp": 1789120800, + "temp_trend": "up" + } + }, + { + "_id": "03:00:00:00:00:03", + "type": "NAModule4", + "module_name": "Kantoor", + "reachable": false, + "battery_percent": 5, + "rf_status": 90 + }, + { + "_id": "05:00:00:00:00:04", + "type": "NAModule3", + "module_name": "Regenmeter", + "reachable": true, + "battery_percent": 80, + "rf_status": 68, + "dashboard_data": { + "time_utc": 1789129780, + "Rain": 0, + "sum_rain_1": 0, + "sum_rain_24": 2.4 + } + } + ] + } + ], + "user": { + "mail": "x@example.org", + "administrative": { + "lang": "nl-NL", + "reg_locale": "nl-NL", + "unit": 0, + "windunit": 0, + "pressureunit": 0, + "feel_like_algo": 0 + } + } + }, + "status": "ok", + "time_exec": 0.05, + "time_server": 1789129925 +} From d657fd069f9131e6040d6c7aa2e619d7f5fc199f Mon Sep 17 00:00:00 2001 From: Theo Weijmer Date: Fri, 11 Sep 2026 14:37:06 +0200 Subject: [PATCH 03/14] feat: RainCalculator with timezone-aware day mapping and month total Co-Authored-By: Claude Fable 5.1 --- .phpunit.result.cache | 2 +- src/RainCalculator.php | 65 ++++++++++++++++ tests/RainCalculatorTest.php | 106 +++++++++++++++++++++++++++ tests/fixtures/getmeasure-dst.json | 20 +++++ tests/fixtures/getmeasure-month.json | 20 +++++ tests/fixtures/getmeasure.json | 40 ++++++++++ 6 files changed, 252 insertions(+), 1 deletion(-) create mode 100644 src/RainCalculator.php create mode 100644 tests/RainCalculatorTest.php create mode 100644 tests/fixtures/getmeasure-dst.json create mode 100644 tests/fixtures/getmeasure-month.json create mode 100644 tests/fixtures/getmeasure.json diff --git a/.phpunit.result.cache b/.phpunit.result.cache index 0db9609..fa3bd28 100644 --- a/.phpunit.result.cache +++ b/.phpunit.result.cache @@ -1 +1 @@ -{"version":2,"defects":[],"times":{"Weerscherm\\Tests\\ConfigTest::testDefaultsApplyWhenKeysAreMissing":0.004,"Weerscherm\\Tests\\ConfigTest::testFileValuesOverrideDefaults":0,"Weerscherm\\Tests\\ConfigTest::testUnknownKeyReturnsGivenDefault":0,"Weerscherm\\Tests\\ConfigTest::testFileMustReturnArray":0.001,"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}} \ No newline at end of file +{"version":2,"defects":[],"times":{"Weerscherm\\Tests\\ConfigTest::testDefaultsApplyWhenKeysAreMissing":0.003,"Weerscherm\\Tests\\ConfigTest::testFileValuesOverrideDefaults":0,"Weerscherm\\Tests\\ConfigTest::testUnknownKeyReturnsGivenDefault":0,"Weerscherm\\Tests\\ConfigTest::testFileMustReturnArray":0.001,"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.001,"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}} \ No newline at end of file diff --git a/src/RainCalculator.php b/src/RainCalculator.php new file mode 100644 index 0000000..3846f52 --- /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, + ]; + } +} 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/fixtures/getmeasure-dst.json b/tests/fixtures/getmeasure-dst.json new file mode 100644 index 0000000..c7d85e1 --- /dev/null +++ b/tests/fixtures/getmeasure-dst.json @@ -0,0 +1,20 @@ +{ + "body": { + "1792621800": [ + 1 + ], + "1792708200": [ + 2 + ], + "1792794600": [ + 3 + ], + "1792884600": [ + 4 + ], + "1792971000": [ + 9 + ] + }, + "status": "ok" +} diff --git a/tests/fixtures/getmeasure-month.json b/tests/fixtures/getmeasure-month.json new file mode 100644 index 0000000..48994d6 --- /dev/null +++ b/tests/fixtures/getmeasure-month.json @@ -0,0 +1,20 @@ +{ + "body": { + "1790558220": [ + 1 + ], + "1790644620": [ + 2 + ], + "1790731020": [ + 3 + ], + "1790817420": [ + 4 + ], + "1790903820": [ + 5 + ] + }, + "status": "ok" +} diff --git a/tests/fixtures/getmeasure.json b/tests/fixtures/getmeasure.json new file mode 100644 index 0000000..41e14b9 --- /dev/null +++ b/tests/fixtures/getmeasure.json @@ -0,0 +1,40 @@ +{ + "body": { + "1788225420": [ + 4.3 + ], + "1788311820": [ + 0 + ], + "1788398220": [ + 3.2 + ], + "1788484620": [ + 5.1 + ], + "1788571020": [ + 0 + ], + "1788657420": [ + 0 + ], + "1788743820": [ + 15.5 + ], + "1788830220": [ + 1.2 + ], + "1788916620": [ + 6.8 + ], + "1789003020": [ + 0 + ], + "1789089420": [ + 2.4 + ] + }, + "status": "ok", + "time_exec": 0.02, + "time_server": 1789129925 +} From 89e1e544c50d49f4e12afe588340d8f2583dbad4 Mon Sep 17 00:00:00 2001 From: Theo Weijmer Date: Fri, 11 Sep 2026 14:38:30 +0200 Subject: [PATCH 04/14] feat: ForecastMapper and file Cache Co-Authored-By: Claude Fable 5.1 --- .phpunit.result.cache | 2 +- src/Cache.php | 53 ++++++++++++++++++++++++++ src/ForecastMapper.php | 45 ++++++++++++++++++++++ tests/CacheTest.php | 72 ++++++++++++++++++++++++++++++++++++ tests/ForecastMapperTest.php | 57 ++++++++++++++++++++++++++++ tests/fixtures/forecast.json | 25 +++++++++++++ 6 files changed, 253 insertions(+), 1 deletion(-) create mode 100644 src/Cache.php create mode 100644 src/ForecastMapper.php create mode 100644 tests/CacheTest.php create mode 100644 tests/ForecastMapperTest.php create mode 100644 tests/fixtures/forecast.json diff --git a/.phpunit.result.cache b/.phpunit.result.cache index fa3bd28..03ac22b 100644 --- a/.phpunit.result.cache +++ b/.phpunit.result.cache @@ -1 +1 @@ -{"version":2,"defects":[],"times":{"Weerscherm\\Tests\\ConfigTest::testDefaultsApplyWhenKeysAreMissing":0.003,"Weerscherm\\Tests\\ConfigTest::testFileValuesOverrideDefaults":0,"Weerscherm\\Tests\\ConfigTest::testUnknownKeyReturnsGivenDefault":0,"Weerscherm\\Tests\\ConfigTest::testFileMustReturnArray":0.001,"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.001,"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}} \ No newline at end of file +{"version":2,"defects":[],"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}} \ No newline at end of file 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/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/tests/CacheTest.php b/tests/CacheTest.php new file mode 100644 index 0000000..4d211d9 --- /dev/null +++ b/tests/CacheTest.php @@ -0,0 +1,72 @@ +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/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/fixtures/forecast.json b/tests/fixtures/forecast.json new file mode 100644 index 0000000..1b5d86f --- /dev/null +++ b/tests/fixtures/forecast.json @@ -0,0 +1,25 @@ +{ + "latitude": 51.82, + "longitude": 6.56, + "generationtime_ms": 0.2, + "utc_offset_seconds": 7200, + "timezone": "Europe/Berlin", + "timezone_abbreviation": "CEST", + "elevation": 20.0, + "daily_units": { + "time": "iso8601", + "weather_code": "wmo code", + "temperature_2m_max": "°C", + "temperature_2m_min": "°C", + "precipitation_sum": "mm", + "precipitation_probability_max": "%" + }, + "daily": { + "time": ["2026-09-11", "2026-09-12", "2026-09-13", "2026-09-14", "2026-09-15", "2026-09-16"], + "weather_code": [3, 2, 0, 61, 80, 2], + "temperature_2m_max": [17.9, 18.3, 20.1, 17.4, 15.2, 16.0], + "temperature_2m_min": [11.0, 10.1, 11.3, 12.0, 9.4, 8.1], + "precipitation_sum": [0.0, 0.4, 0.0, 8.2, 3.1, 0.2], + "precipitation_probability_max": [10, 20, 5, 80, 60, 15] + } +} From 216b6616fca95838395ea8fca35101b4dd559e84 Mon Sep 17 00:00:00 2001 From: Theo Weijmer Date: Fri, 11 Sep 2026 14:39:03 +0200 Subject: [PATCH 05/14] feat: DashboardService with per-source cache and stale fallback, FixtureSource Co-Authored-By: Claude Fable 5.1 --- .phpunit.result.cache | 2 +- src/DashboardService.php | 160 ++++++++++++++++++++++++++++++++ src/FixtureSource.php | 74 +++++++++++++++ src/WeatherSource.php | 18 ++++ tests/DashboardServiceTest.php | 161 +++++++++++++++++++++++++++++++++ tests/FakeSource.php | 57 ++++++++++++ 6 files changed, 471 insertions(+), 1 deletion(-) create mode 100644 src/DashboardService.php create mode 100644 src/FixtureSource.php create mode 100644 src/WeatherSource.php create mode 100644 tests/DashboardServiceTest.php create mode 100644 tests/FakeSource.php diff --git a/.phpunit.result.cache b/.phpunit.result.cache index 03ac22b..e5a434c 100644 --- a/.phpunit.result.cache +++ b/.phpunit.result.cache @@ -1 +1 @@ -{"version":2,"defects":[],"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}} \ No newline at end of file +{"version":2,"defects":{"Weerscherm\\Tests\\DashboardServiceTest::testForecastUsesStationLocationUnlessConfigured":7,"Weerscherm\\Tests\\DashboardServiceTest::testMeasureFailureFallsBackToSumRain24":7},"times":{"Weerscherm\\Tests\\ConfigTest::testDefaultsApplyWhenKeysAreMissing":0,"Weerscherm\\Tests\\ConfigTest::testFileValuesOverrideDefaults":0,"Weerscherm\\Tests\\ConfigTest::testUnknownKeyReturnsGivenDefault":0,"Weerscherm\\Tests\\ConfigTest::testFileMustReturnArray":0,"Weerscherm\\Tests\\StationMapperTest::testStationMetadata":0,"Weerscherm\\Tests\\StationMapperTest::testOutdoor":0,"Weerscherm\\Tests\\StationMapperTest::testIndoorDefaultOrderAndUnreachableModule":0,"Weerscherm\\Tests\\StationMapperTest::testIndoorConfigDeterminesOrderAndLabels":0,"Weerscherm\\Tests\\StationMapperTest::testRainModule":0,"Weerscherm\\Tests\\StationMapperTest::testWithoutRainModule":0,"Weerscherm\\Tests\\StationMapperTest::testUnreachableOutdoorModuleGivesNulls":0,"Weerscherm\\Tests\\StationMapperTest::testUnknownDeviceIdFallsBackToFirstStation":0,"Weerscherm\\Tests\\StationMapperTest::testEmptyDevicesGivesNullsWithoutCrashing":0,"Weerscherm\\Tests\\RainCalculatorTest::testNormalWeek":0,"Weerscherm\\Tests\\RainCalculatorTest::testMissingPreviousDayIsNullAndExcludedFromTotal":0,"Weerscherm\\Tests\\RainCalculatorTest::testMissingTodayFallsBackToSumRain24":0,"Weerscherm\\Tests\\RainCalculatorTest::testMonthBoundaryKeepsPreviousDaysButNotInMonthTotal":0,"Weerscherm\\Tests\\RainCalculatorTest::testDaylightSavingTransitionMapsTimestampsToLocalDates":0,"Weerscherm\\Tests\\RainCalculatorTest::testEmptyBodyGivesNulls":0,"Weerscherm\\Tests\\RainCalculatorTest::testDateBeginIsMinOfFirstOfMonthAndThreeDaysAgo":0,"Weerscherm\\Tests\\CacheTest::testWriteThenReadRoundTrip":0.002,"Weerscherm\\Tests\\CacheTest::testMissingKeyReadsNull":0.002,"Weerscherm\\Tests\\CacheTest::testCorruptFileReadsNull":0,"Weerscherm\\Tests\\CacheTest::testFreshness":0,"Weerscherm\\Tests\\CacheTest::testKeyIsSanitised":0,"Weerscherm\\Tests\\CacheTest::testWriteCreatesDirectory":0,"Weerscherm\\Tests\\ForecastMapperTest::testSkipsTodayAndReturnsFiveDays":0,"Weerscherm\\Tests\\ForecastMapperTest::testNullValuesStayNull":0,"Weerscherm\\Tests\\ForecastMapperTest::testShorterResponseGivesFewerDays":0,"Weerscherm\\Tests\\ForecastMapperTest::testMissingTimeArrayGivesEmptyList":0,"Weerscherm\\Tests\\DashboardServiceTest::testHappyPathMatchesContract":0.001,"Weerscherm\\Tests\\DashboardServiceTest::testMeasureIsRequestedForRainModuleFromMidnightOfDateBegin":0.001,"Weerscherm\\Tests\\DashboardServiceTest::testForecastUsesStationLocationUnlessConfigured":0.001,"Weerscherm\\Tests\\DashboardServiceTest::testStationsFailureWithoutCacheGivesNullsAndError":0,"Weerscherm\\Tests\\DashboardServiceTest::testStationsFailureWithCacheServesStaleData":0.001,"Weerscherm\\Tests\\DashboardServiceTest::testAuthFailureGivesNetatmoAuthError":0,"Weerscherm\\Tests\\DashboardServiceTest::testMeasureFailureFallsBackToSumRain24":0,"Weerscherm\\Tests\\DashboardServiceTest::testForecastFailureLeavesRestIntact":0.001,"Weerscherm\\Tests\\DashboardServiceTest::testSecondBuildWithinTtlUsesCache":0.001}} \ No newline at end of file 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/WeatherSource.php b/src/WeatherSource.php new file mode 100644 index 0000000..4c83cfb --- /dev/null +++ b/src/WeatherSource.php @@ -0,0 +1,18 @@ + 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/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/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; + } +} From dfc28ee6c31aaa9bd8979ddb914b5bf2e50bcbff Mon Sep 17 00:00:00 2001 From: Theo Weijmer Date: Fri, 11 Sep 2026 14:40:15 +0200 Subject: [PATCH 06/14] feat: /api/data.php with fixture source, robots.txt Co-Authored-By: Claude Fable 5.1 --- public/api/data.php | 53 +++++++++++++++++++++++++++++++++++++++++++++ public/robots.txt | 2 ++ src/LiveSource.php | 29 +++++++++++++++++++++++++ 3 files changed, 84 insertions(+) create mode 100644 public/api/data.php create mode 100644 public/robots.txt create mode 100644 src/LiveSource.php diff --git a/public/api/data.php b/public/api/data.php new file mode 100644 index 0000000..aa2cb7a --- /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/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/src/LiveSource.php b/src/LiveSource.php new file mode 100644 index 0000000..a086e7c --- /dev/null +++ b/src/LiveSource.php @@ -0,0 +1,29 @@ + Date: Fri, 11 Sep 2026 14:42:06 +0200 Subject: [PATCH 07/14] feat: format.js pure helpers with node tests, weather icons Co-Authored-By: Claude Fable 5.1 --- package.json | 8 +++ public/format.js | 124 ++++++++++++++++++++++++++++++++++++++++ public/icons.js | 32 +++++++++++ tests/js/format.test.js | 120 ++++++++++++++++++++++++++++++++++++++ 4 files changed, 284 insertions(+) create mode 100644 package.json create mode 100644 public/format.js create mode 100644 public/icons.js create mode 100644 tests/js/format.test.js 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/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/tests/js/format.test.js b/tests/js/format.test.js new file mode 100644 index 0000000..4addfe7 --- /dev/null +++ b/tests/js/format.test.js @@ -0,0 +1,120 @@ +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { + f1, fInt, fmm, markerPos, barHeights, wmoIcon, trendLabel, + formatDate, formatTime, dayAbbr, monthName, isOlderThan, msUntil, +} from '../../public/format.js'; + +const TZ = 'Europe/Berlin'; + +test('f1 uses a decimal comma and one decimal', () => { + assert.equal(f1(16.4), '16,4'); + assert.equal(f1(0), '0,0'); + assert.equal(f1(19.75), '19,8'); + assert.equal(f1(null), '–'); + assert.equal(f1(undefined), '–'); +}); + +test('fInt rounds to whole degrees', () => { + assert.equal(fInt(18.3), '18'); + assert.equal(fInt(9.5), '10'); + assert.equal(fInt(null), '–'); +}); + +test('fmm shows "0 mm" for zero', () => { + assert.equal(fmm(0), '0 mm'); + assert.equal(fmm(0.4), '0,4 mm'); + assert.equal(fmm(8.25), '8,3 mm'); + assert.equal(fmm(null), '–'); +}); + +test('markerPos interpolates and clamps', () => { + assert.ok(Math.abs(markerPos(16.4, 9.2, 19.8) - 67.92) < 0.01); + assert.equal(markerPos(5, 9.2, 19.8), 0); + assert.equal(markerPos(25, 9.2, 19.8), 100); + assert.equal(markerPos(10, 10, 10), 50); + assert.equal(markerPos(null, 1, 2), 50); +}); + +test('barHeights scales to the tallest bar with a 2px floor', () => { + const h = barHeights([1.2, 6.8, 0, 2.4]); + assert.ok(Math.abs(h[0] - 12.35) < 0.01); + assert.equal(h[1], 70); + assert.equal(h[2], 2); + assert.ok(Math.abs(h[3] - 24.7) < 0.01); + assert.deepEqual(barHeights([0, 0, 0, 0]), [2, 2, 2, 2]); + assert.deepEqual(barHeights([null, 0, undefined, 0]), [2, 2, 2, 2]); + assert.deepEqual(barHeights([null, 5]), [2, 70]); +}); + +test('wmoIcon maps every documented code', () => { + assert.equal(wmoIcon(0), 'sun'); + assert.equal(wmoIcon(1), 'partly'); + assert.equal(wmoIcon(2), 'partly'); + assert.equal(wmoIcon(3), 'cloud'); + assert.equal(wmoIcon(45), 'fog'); + assert.equal(wmoIcon(48), 'fog'); + assert.equal(wmoIcon(51), 'drizzle'); + assert.equal(wmoIcon(57), 'drizzle'); + assert.equal(wmoIcon(61), 'rain'); + assert.equal(wmoIcon(67), 'rain'); + assert.equal(wmoIcon(80), 'showers'); + assert.equal(wmoIcon(82), 'showers'); + assert.equal(wmoIcon(71), 'snow'); + assert.equal(wmoIcon(77), 'snow'); + assert.equal(wmoIcon(85), 'snow'); + assert.equal(wmoIcon(86), 'snow'); + assert.equal(wmoIcon(95), 'thunder'); + assert.equal(wmoIcon(99), 'thunder'); + assert.equal(wmoIcon(42), 'cloud'); + assert.equal(wmoIcon(null), 'cloud'); +}); + +test('trendLabel', () => { + assert.equal(trendLabel('up'), '↗ stijgend'); + assert.equal(trendLabel('down'), '↘ dalend'); + assert.equal(trendLabel('stable'), '→ stabiel'); + assert.equal(trendLabel(null), ''); +}); + +test('formatDate gives a capitalised Dutch date in the station timezone', () => { + assert.equal(formatDate(new Date('2026-09-11T12:32:05Z'), TZ), 'Vrijdag 11 september'); + // 23:30 UTC is al de volgende dag in Berlijn + assert.equal(formatDate(new Date('2026-09-11T23:30:00Z'), TZ), 'Zaterdag 12 september'); +}); + +test('formatTime uses HH:MM in the station timezone', () => { + assert.equal(formatTime('2026-09-11T06:10:00+02:00', TZ), '06:10'); + assert.equal(formatTime('2026-09-11T22:05:00Z', TZ), '00:05'); + assert.equal(formatTime(null, TZ), '–'); + assert.equal(formatTime('kapot', TZ), '–'); +}); + +test('dayAbbr gives two-letter Dutch weekday', () => { + assert.equal(dayAbbr('2026-09-12', TZ), 'Za'); + assert.equal(dayAbbr('2026-09-13', TZ), 'Zo'); + assert.equal(dayAbbr('2026-09-14', TZ), 'Ma'); + assert.equal(dayAbbr('2026-09-15', TZ), 'Di'); + assert.equal(dayAbbr('2026-09-16', TZ), 'Wo'); + assert.equal(dayAbbr('2026-09-17', TZ), 'Do'); + assert.equal(dayAbbr('2026-09-18', TZ), 'Vr'); +}); + +test('monthName', () => { + assert.equal(monthName(new Date('2026-09-11T12:00:00Z'), TZ), 'September'); +}); + +test('isOlderThan', () => { + const now = new Date('2026-09-11T12:32:00Z'); + assert.equal(isOlderThan('2026-09-11T12:10:00Z', now, 30), false); + assert.equal(isOlderThan('2026-09-11T11:50:00Z', now, 30), true); + assert.equal(isOlderThan(null, now, 30), true); +}); + +test('msUntil computes the wait until the next local time', () => { + const now = new Date('2026-09-11T12:32:05.500Z'); // 14:32:05 in Berlijn + assert.equal(msUntil(now, TZ, 0), ((24 - 14) * 3600 - 32 * 60 - 5) * 1000 - 500); + assert.equal(msUntil(now, TZ, 4), ((24 - 14 + 4) * 3600 - 32 * 60 - 5) * 1000 - 500); + assert.equal(msUntil(now, TZ, 15), (28 * 60 - 5) * 1000 - 500); + assert.equal(msUntil(new Date('2026-09-11T22:00:00.000Z'), TZ, 0), 86400000, 'precies op het moment zelf: pas morgen weer'); +}); From c65bc1d95711a4ff5ed54ec987b09cda9ad84e88 Mon Sep 17 00:00:00 2001 From: Theo Weijmer Date: Fri, 11 Sep 2026 14:43:37 +0200 Subject: [PATCH 08/14] feat: frontend design A with scaling, formatting, refresh and error states Co-Authored-By: Claude Fable 5.1 --- public/app.js | 238 ++++++++++++++++++++++++++++++++++++++++++++++ public/index.html | 63 ++++++++++++ public/styles.css | 158 ++++++++++++++++++++++++++++++ 3 files changed, 459 insertions(+) create mode 100644 public/app.js create mode 100644 public/index.html create mode 100644 public/styles.css diff --git a/public/app.js b/public/app.js new file mode 100644 index 0000000..a151310 --- /dev/null +++ b/public/app.js @@ -0,0 +1,238 @@ +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. + } +} + +/* ---------- start ---------- */ + +window.addEventListener('resize', fit); +window.addEventListener('orientationchange', () => setTimeout(fit, 50)); +document.addEventListener('visibilitychange', () => { + if (document.visibilityState === 'visible') { + fit(); + refresh(); + requestWakeLock(); + } +}); + +fit(); +render(); +refresh(); +setInterval(refresh, REFRESH_MS); +scheduleClock(); +scheduleMidnight(); +scheduleReload(); +requestWakeLock(); 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/styles.css b/public/styles.css new file mode 100644 index 0000000..d2745f7 --- /dev/null +++ b/public/styles.css @@ -0,0 +1,158 @@ +/* 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; + 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; } +} From dababbd1f233b2b4b87efc5a2931b3b535471911 Mon Sep 17 00:00:00 2001 From: Theo Weijmer Date: Fri, 11 Sep 2026 14:45:18 +0200 Subject: [PATCH 09/14] feat: Http curl wrapper and TokenStore with atomic write and flock Co-Authored-By: Claude Fable 5.1 --- .phpunit.result.cache | 2 +- src/Http.php | 56 ++++++++++++++++++++++++ src/HttpClient.php | 17 ++++++++ src/HttpResponse.php | 26 ++++++++++++ src/TokenStore.php | 92 ++++++++++++++++++++++++++++++++++++++++ tests/TokenStoreTest.php | 87 +++++++++++++++++++++++++++++++++++++ 6 files changed, 279 insertions(+), 1 deletion(-) create mode 100644 src/Http.php create mode 100644 src/HttpClient.php create mode 100644 src/HttpResponse.php create mode 100644 src/TokenStore.php create mode 100644 tests/TokenStoreTest.php diff --git a/.phpunit.result.cache b/.phpunit.result.cache index e5a434c..067d162 100644 --- a/.phpunit.result.cache +++ b/.phpunit.result.cache @@ -1 +1 @@ -{"version":2,"defects":{"Weerscherm\\Tests\\DashboardServiceTest::testForecastUsesStationLocationUnlessConfigured":7,"Weerscherm\\Tests\\DashboardServiceTest::testMeasureFailureFallsBackToSumRain24":7},"times":{"Weerscherm\\Tests\\ConfigTest::testDefaultsApplyWhenKeysAreMissing":0,"Weerscherm\\Tests\\ConfigTest::testFileValuesOverrideDefaults":0,"Weerscherm\\Tests\\ConfigTest::testUnknownKeyReturnsGivenDefault":0,"Weerscherm\\Tests\\ConfigTest::testFileMustReturnArray":0,"Weerscherm\\Tests\\StationMapperTest::testStationMetadata":0,"Weerscherm\\Tests\\StationMapperTest::testOutdoor":0,"Weerscherm\\Tests\\StationMapperTest::testIndoorDefaultOrderAndUnreachableModule":0,"Weerscherm\\Tests\\StationMapperTest::testIndoorConfigDeterminesOrderAndLabels":0,"Weerscherm\\Tests\\StationMapperTest::testRainModule":0,"Weerscherm\\Tests\\StationMapperTest::testWithoutRainModule":0,"Weerscherm\\Tests\\StationMapperTest::testUnreachableOutdoorModuleGivesNulls":0,"Weerscherm\\Tests\\StationMapperTest::testUnknownDeviceIdFallsBackToFirstStation":0,"Weerscherm\\Tests\\StationMapperTest::testEmptyDevicesGivesNullsWithoutCrashing":0,"Weerscherm\\Tests\\RainCalculatorTest::testNormalWeek":0,"Weerscherm\\Tests\\RainCalculatorTest::testMissingPreviousDayIsNullAndExcludedFromTotal":0,"Weerscherm\\Tests\\RainCalculatorTest::testMissingTodayFallsBackToSumRain24":0,"Weerscherm\\Tests\\RainCalculatorTest::testMonthBoundaryKeepsPreviousDaysButNotInMonthTotal":0,"Weerscherm\\Tests\\RainCalculatorTest::testDaylightSavingTransitionMapsTimestampsToLocalDates":0,"Weerscherm\\Tests\\RainCalculatorTest::testEmptyBodyGivesNulls":0,"Weerscherm\\Tests\\RainCalculatorTest::testDateBeginIsMinOfFirstOfMonthAndThreeDaysAgo":0,"Weerscherm\\Tests\\CacheTest::testWriteThenReadRoundTrip":0.002,"Weerscherm\\Tests\\CacheTest::testMissingKeyReadsNull":0.002,"Weerscherm\\Tests\\CacheTest::testCorruptFileReadsNull":0,"Weerscherm\\Tests\\CacheTest::testFreshness":0,"Weerscherm\\Tests\\CacheTest::testKeyIsSanitised":0,"Weerscherm\\Tests\\CacheTest::testWriteCreatesDirectory":0,"Weerscherm\\Tests\\ForecastMapperTest::testSkipsTodayAndReturnsFiveDays":0,"Weerscherm\\Tests\\ForecastMapperTest::testNullValuesStayNull":0,"Weerscherm\\Tests\\ForecastMapperTest::testShorterResponseGivesFewerDays":0,"Weerscherm\\Tests\\ForecastMapperTest::testMissingTimeArrayGivesEmptyList":0,"Weerscherm\\Tests\\DashboardServiceTest::testHappyPathMatchesContract":0.001,"Weerscherm\\Tests\\DashboardServiceTest::testMeasureIsRequestedForRainModuleFromMidnightOfDateBegin":0.001,"Weerscherm\\Tests\\DashboardServiceTest::testForecastUsesStationLocationUnlessConfigured":0.001,"Weerscherm\\Tests\\DashboardServiceTest::testStationsFailureWithoutCacheGivesNullsAndError":0,"Weerscherm\\Tests\\DashboardServiceTest::testStationsFailureWithCacheServesStaleData":0.001,"Weerscherm\\Tests\\DashboardServiceTest::testAuthFailureGivesNetatmoAuthError":0,"Weerscherm\\Tests\\DashboardServiceTest::testMeasureFailureFallsBackToSumRain24":0,"Weerscherm\\Tests\\DashboardServiceTest::testForecastFailureLeavesRestIntact":0.001,"Weerscherm\\Tests\\DashboardServiceTest::testSecondBuildWithinTtlUsesCache":0.001}} \ No newline at end of file +{"version":2,"defects":{"Weerscherm\\Tests\\DashboardServiceTest::testForecastUsesStationLocationUnlessConfigured":7,"Weerscherm\\Tests\\DashboardServiceTest::testMeasureFailureFallsBackToSumRain24":7},"times":{"Weerscherm\\Tests\\ConfigTest::testDefaultsApplyWhenKeysAreMissing":0,"Weerscherm\\Tests\\ConfigTest::testFileValuesOverrideDefaults":0,"Weerscherm\\Tests\\ConfigTest::testUnknownKeyReturnsGivenDefault":0,"Weerscherm\\Tests\\ConfigTest::testFileMustReturnArray":0.001,"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.005,"Weerscherm\\Tests\\CacheTest::testCorruptFileReadsNull":0,"Weerscherm\\Tests\\CacheTest::testFreshness":0.001,"Weerscherm\\Tests\\CacheTest::testKeyIsSanitised":0,"Weerscherm\\Tests\\CacheTest::testWriteCreatesDirectory":0.001,"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,"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.442}} \ No newline at end of file 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/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/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'); + } +} From 4250fa5abf569fa799008c5dd7db2b268922eed7 Mon Sep 17 00:00:00 2001 From: Theo Weijmer Date: Fri, 11 Sep 2026 14:45:54 +0200 Subject: [PATCH 10/14] feat: NetatmoClient with token rotation, OpenMeteoClient, LiveSource, netatmo-init Co-Authored-By: Claude Fable 5.1 --- .phpunit.result.cache | 2 +- bin/netatmo-init.php | 42 +++++++ src/LiveSource.php | 22 +++- src/NetatmoClient.php | 135 +++++++++++++++++++++++ src/OpenMeteoClient.php | 35 ++++++ tests/FakeHttp.php | 49 +++++++++ tests/FakeHttpDecorator.php | 27 +++++ tests/NetatmoClientTest.php | 201 ++++++++++++++++++++++++++++++++++ tests/OpenMeteoClientTest.php | 44 ++++++++ 9 files changed, 551 insertions(+), 6 deletions(-) create mode 100755 bin/netatmo-init.php create mode 100644 src/NetatmoClient.php create mode 100644 src/OpenMeteoClient.php create mode 100644 tests/FakeHttp.php create mode 100644 tests/FakeHttpDecorator.php create mode 100644 tests/NetatmoClientTest.php create mode 100644 tests/OpenMeteoClientTest.php diff --git a/.phpunit.result.cache b/.phpunit.result.cache index 067d162..db26766 100644 --- a/.phpunit.result.cache +++ b/.phpunit.result.cache @@ -1 +1 @@ -{"version":2,"defects":{"Weerscherm\\Tests\\DashboardServiceTest::testForecastUsesStationLocationUnlessConfigured":7,"Weerscherm\\Tests\\DashboardServiceTest::testMeasureFailureFallsBackToSumRain24":7},"times":{"Weerscherm\\Tests\\ConfigTest::testDefaultsApplyWhenKeysAreMissing":0,"Weerscherm\\Tests\\ConfigTest::testFileValuesOverrideDefaults":0,"Weerscherm\\Tests\\ConfigTest::testUnknownKeyReturnsGivenDefault":0,"Weerscherm\\Tests\\ConfigTest::testFileMustReturnArray":0.001,"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.005,"Weerscherm\\Tests\\CacheTest::testCorruptFileReadsNull":0,"Weerscherm\\Tests\\CacheTest::testFreshness":0.001,"Weerscherm\\Tests\\CacheTest::testKeyIsSanitised":0,"Weerscherm\\Tests\\CacheTest::testWriteCreatesDirectory":0.001,"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,"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.442}} \ No newline at end of file +{"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.001,"Weerscherm\\Tests\\CacheTest::testMissingKeyReadsNull":0.002,"Weerscherm\\Tests\\CacheTest::testCorruptFileReadsNull":0,"Weerscherm\\Tests\\CacheTest::testFreshness":0,"Weerscherm\\Tests\\CacheTest::testKeyIsSanitised":0,"Weerscherm\\Tests\\CacheTest::testWriteCreatesDirectory":0,"Weerscherm\\Tests\\ForecastMapperTest::testSkipsTodayAndReturnsFiveDays":0,"Weerscherm\\Tests\\ForecastMapperTest::testNullValuesStayNull":0,"Weerscherm\\Tests\\ForecastMapperTest::testShorterResponseGivesFewerDays":0,"Weerscherm\\Tests\\ForecastMapperTest::testMissingTimeArrayGivesEmptyList":0,"Weerscherm\\Tests\\DashboardServiceTest::testHappyPathMatchesContract":0.001,"Weerscherm\\Tests\\DashboardServiceTest::testMeasureIsRequestedForRainModuleFromMidnightOfDateBegin":0.001,"Weerscherm\\Tests\\DashboardServiceTest::testForecastUsesStationLocationUnlessConfigured":0.001,"Weerscherm\\Tests\\DashboardServiceTest::testStationsFailureWithoutCacheGivesNullsAndError":0,"Weerscherm\\Tests\\DashboardServiceTest::testStationsFailureWithCacheServesStaleData":0.001,"Weerscherm\\Tests\\DashboardServiceTest::testAuthFailureGivesNetatmoAuthError":0,"Weerscherm\\Tests\\DashboardServiceTest::testMeasureFailureFallsBackToSumRain24":0.001,"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.437,"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,"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/bin/netatmo-init.php b/bin/netatmo-init.php new file mode 100755 index 0000000..c591e71 --- /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__) . '/vendor/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/src/LiveSource.php b/src/LiveSource.php index a086e7c..2a56a4f 100644 --- a/src/LiveSource.php +++ b/src/LiveSource.php @@ -4,26 +4,38 @@ declare(strict_types=1); namespace Weerscherm; -/** Echte bronnen: Netatmo (stationsdata, regenmetingen) en Open-Meteo (verwachting). Wordt in fase 3/4 ingevuld. */ +/** Echte bronnen: Netatmo (stationsdata, regenmetingen) en Open-Meteo (verwachting). */ final class LiveSource implements WeatherSource { + public function __construct(private readonly NetatmoClient $netatmo, private readonly OpenMeteoClient $openMeteo) + { + } + public static function fromConfig(Config $config): self { - throw new \RuntimeException('LiveSource is nog niet beschikbaar; gebruik data_source = fixture'); + $clientId = (string) $config->get('netatmo_client_id'); + $clientSecret = (string) $config->get('netatmo_client_secret'); + if ($clientId === '' || $clientSecret === '') { + throw new \RuntimeException('netatmo_client_id en netatmo_client_secret ontbreken in config.php'); + } + $http = new Http(); + $store = new TokenStore(rtrim((string) $config->get('storage_path'), '/') . '/netatmo-token.json'); + + return new self(new NetatmoClient($http, $store, $clientId, $clientSecret), new OpenMeteoClient($http)); } public function stationsData(): array { - throw new UpstreamException('niet geïmplementeerd'); + return $this->netatmo->getStationsData(); } public function measure(string $deviceId, string $moduleId, int $begin, int $end): array { - throw new UpstreamException('niet geïmplementeerd'); + return $this->netatmo->getMeasure($deviceId, $moduleId, $begin, $end); } public function forecast(float $lat, float $lon, string $timezone): array { - throw new UpstreamException('niet geïmplementeerd'); + return $this->openMeteo->dailyForecast($lat, $lon, $timezone); } } diff --git a/src/NetatmoClient.php b/src/NetatmoClient.php new file mode 100644 index 0000000..d66bb32 --- /dev/null +++ b/src/NetatmoClient.php @@ -0,0 +1,135 @@ +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/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/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'); + } +} From efe6928953ff83c360c81513aa97611303ab4dba Mon Sep 17 00:00:00 2001 From: Theo Weijmer Date: Fri, 11 Sep 2026 14:46:08 +0200 Subject: [PATCH 11/14] fix: round rain month total to avoid float noise Co-Authored-By: Claude Fable 5.1 --- .phpunit.result.cache | 2 +- src/RainCalculator.php | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/.phpunit.result.cache b/.phpunit.result.cache index db26766..9785b34 100644 --- a/.phpunit.result.cache +++ b/.phpunit.result.cache @@ -1 +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.001,"Weerscherm\\Tests\\CacheTest::testMissingKeyReadsNull":0.002,"Weerscherm\\Tests\\CacheTest::testCorruptFileReadsNull":0,"Weerscherm\\Tests\\CacheTest::testFreshness":0,"Weerscherm\\Tests\\CacheTest::testKeyIsSanitised":0,"Weerscherm\\Tests\\CacheTest::testWriteCreatesDirectory":0,"Weerscherm\\Tests\\ForecastMapperTest::testSkipsTodayAndReturnsFiveDays":0,"Weerscherm\\Tests\\ForecastMapperTest::testNullValuesStayNull":0,"Weerscherm\\Tests\\ForecastMapperTest::testShorterResponseGivesFewerDays":0,"Weerscherm\\Tests\\ForecastMapperTest::testMissingTimeArrayGivesEmptyList":0,"Weerscherm\\Tests\\DashboardServiceTest::testHappyPathMatchesContract":0.001,"Weerscherm\\Tests\\DashboardServiceTest::testMeasureIsRequestedForRainModuleFromMidnightOfDateBegin":0.001,"Weerscherm\\Tests\\DashboardServiceTest::testForecastUsesStationLocationUnlessConfigured":0.001,"Weerscherm\\Tests\\DashboardServiceTest::testStationsFailureWithoutCacheGivesNullsAndError":0,"Weerscherm\\Tests\\DashboardServiceTest::testStationsFailureWithCacheServesStaleData":0.001,"Weerscherm\\Tests\\DashboardServiceTest::testAuthFailureGivesNetatmoAuthError":0,"Weerscherm\\Tests\\DashboardServiceTest::testMeasureFailureFallsBackToSumRain24":0.001,"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.437,"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,"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 +{"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.001,"Weerscherm\\Tests\\DashboardServiceTest::testMeasureIsRequestedForRainModuleFromMidnightOfDateBegin":0.001,"Weerscherm\\Tests\\DashboardServiceTest::testForecastUsesStationLocationUnlessConfigured":0.001,"Weerscherm\\Tests\\DashboardServiceTest::testStationsFailureWithoutCacheGivesNullsAndError":0,"Weerscherm\\Tests\\DashboardServiceTest::testStationsFailureWithCacheServesStaleData":0.001,"Weerscherm\\Tests\\DashboardServiceTest::testAuthFailureGivesNetatmoAuthError":0.001,"Weerscherm\\Tests\\DashboardServiceTest::testMeasureFailureFallsBackToSumRain24":0.001,"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.443,"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,"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/src/RainCalculator.php b/src/RainCalculator.php index 3846f52..a0a1ddf 100644 --- a/src/RainCalculator.php +++ b/src/RainCalculator.php @@ -59,7 +59,7 @@ final class RainCalculator return [ 'today' => $perDate[$today] ?? $sumRain24, 'previous_days' => $previous, - 'month_total' => $monthTotal, + 'month_total' => $monthTotal === null ? null : round($monthTotal, 2), ]; } } From e971163f71e71625f4004deb108d638b70e45a13 Mon Sep 17 00:00:00 2001 From: Theo Weijmer Date: Fri, 11 Sep 2026 14:48:11 +0200 Subject: [PATCH 12/14] feat: PWA manifest, app icons, service worker with tests Co-Authored-By: Claude Fable 5.1 --- public/app.js | 15 ++++++ public/icons/icon-180.png | Bin 0 -> 11521 bytes public/icons/icon-192.png | Bin 0 -> 12216 bytes public/icons/icon-512.png | Bin 0 -> 24786 bytes public/icons/icon.svg | 7 +++ public/manifest.webmanifest | 16 ++++++ public/sw.js | 82 ++++++++++++++++++++++++++++++ tests/js/sw.test.js | 98 ++++++++++++++++++++++++++++++++++++ 8 files changed, 218 insertions(+) create mode 100644 public/icons/icon-180.png create mode 100644 public/icons/icon-192.png create mode 100644 public/icons/icon-512.png create mode 100644 public/icons/icon.svg create mode 100644 public/manifest.webmanifest create mode 100644 public/sw.js create mode 100644 tests/js/sw.test.js diff --git a/public/app.js b/public/app.js index a151310..034a30a 100644 --- a/public/app.js +++ b/public/app.js @@ -216,6 +216,19 @@ async function requestWakeLock() { } } +/* ---------- 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); @@ -225,6 +238,7 @@ document.addEventListener('visibilitychange', () => { fit(); refresh(); requestWakeLock(); +registerServiceWorker(); } }); @@ -236,3 +250,4 @@ scheduleClock(); scheduleMidnight(); scheduleReload(); requestWakeLock(); +registerServiceWorker(); diff --git a/public/icons/icon-180.png b/public/icons/icon-180.png new file mode 100644 index 0000000000000000000000000000000000000000..b7a89963ef192200a896349930e92ce6b5911e15 GIT binary patch literal 11521 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 g4VVAlFm>lgi4_@~ literal 0 HcmV?d00001 diff --git a/public/icons/icon-192.png b/public/icons/icon-192.png new file mode 100644 index 0000000000000000000000000000000000000000..b17fb96142ced6712f6df5401b6c781650888eef GIT binary patch literal 12216 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 W^3gOVgTm_p02v8I@fuO1;Qs?(2W?#d literal 0 HcmV?d00001 diff --git a/public/icons/icon-512.png b/public/icons/icon-512.png new file mode 100644 index 0000000000000000000000000000000000000000..e2025c955f88a497f751b1881d9a752a2be17b3a GIT binary patch literal 24786 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 Q4GjG0Xy~g~sM?17AEfkS9RL6T literal 0 HcmV?d00001 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/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/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/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); +}); From dc8cbee8da96f967f932f40a7565ae6e7e98fe4a Mon Sep 17 00:00:00 2001 From: Theo Weijmer Date: Fri, 11 Sep 2026 14:49:39 +0200 Subject: [PATCH 13/14] docs: README, OrbStack notes, autoload fallback for deploys without composer Co-Authored-By: Claude Fable 5.1 --- .phpunit.result.cache | 2 +- README.md | 154 ++++++++++++++++++++++++++++++++++++++++++ bin/netatmo-init.php | 2 +- public/.htaccess | 11 +++ public/api/data.php | 2 +- src/autoload.php | 24 +++++++ 6 files changed, 192 insertions(+), 3 deletions(-) create mode 100644 README.md create mode 100644 public/.htaccess create mode 100644 src/autoload.php diff --git a/.phpunit.result.cache b/.phpunit.result.cache index 9785b34..db970fc 100644 --- a/.phpunit.result.cache +++ b/.phpunit.result.cache @@ -1 +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.001,"Weerscherm\\Tests\\DashboardServiceTest::testMeasureIsRequestedForRainModuleFromMidnightOfDateBegin":0.001,"Weerscherm\\Tests\\DashboardServiceTest::testForecastUsesStationLocationUnlessConfigured":0.001,"Weerscherm\\Tests\\DashboardServiceTest::testStationsFailureWithoutCacheGivesNullsAndError":0,"Weerscherm\\Tests\\DashboardServiceTest::testStationsFailureWithCacheServesStaleData":0.001,"Weerscherm\\Tests\\DashboardServiceTest::testAuthFailureGivesNetatmoAuthError":0.001,"Weerscherm\\Tests\\DashboardServiceTest::testMeasureFailureFallsBackToSumRain24":0.001,"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.443,"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,"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 +{"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 index c591e71..f4edf06 100755 --- a/bin/netatmo-init.php +++ b/bin/netatmo-init.php @@ -16,7 +16,7 @@ declare(strict_types=1); use Weerscherm\Config; use Weerscherm\TokenStore; -require dirname(__DIR__) . '/vendor/autoload.php'; +require dirname(__DIR__) . '/src/autoload.php'; $refresh = $argv[1] ?? ''; $access = $argv[2] ?? ''; 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 index aa2cb7a..d9a5214 100644 --- a/public/api/data.php +++ b/public/api/data.php @@ -14,7 +14,7 @@ header('Content-Type: application/json; charset=utf-8'); header('Cache-Control: no-store'); header('X-Robots-Tag: noindex'); -require $root . '/vendor/autoload.php'; +require $root . '/src/autoload.php'; $configFile = $root . '/config.php'; if (!is_file($configFile)) { 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 @@ + Date: Fri, 11 Sep 2026 14:50:38 +0200 Subject: [PATCH 14/14] fix: stack banner title and hint vertically Co-Authored-By: Claude Fable 5.1 --- public/styles.css | 1 + 1 file changed, 1 insertion(+) diff --git a/public/styles.css b/public/styles.css index d2745f7..7b3cca8 100644 --- a/public/styles.css +++ b/public/styles.css @@ -128,6 +128,7 @@ html, body { top: 86px; height: 396px; display: flex; + flex-direction: column; align-items: center; justify-content: center; text-align: center;