From 216b6616fca95838395ea8fca35101b4dd559e84 Mon Sep 17 00:00:00 2001 From: Theo Weijmer Date: Fri, 11 Sep 2026 14:39:03 +0200 Subject: [PATCH] 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; + } +}