feat: DashboardService with per-source cache and stale fallback, FixtureSource

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
2026-09-11 14:39:03 +02:00
co-authored by Claude Fable 5.1
parent 89e1e544c5
commit 216b6616fc
6 changed files with 471 additions and 1 deletions
+160
View File
@@ -0,0 +1,160 @@
<?php
declare(strict_types=1);
namespace Weerscherm;
/**
* Bouwt de JSON-payload voor /api/data.php: haalt per bron data op (via cache of de WeatherSource),
* valt bij een storing terug op de laatst gecachte data en markeert die als stale.
*/
final class DashboardService
{
private const ERR_NETATMO_AUTH = 'netatmo_auth';
private const ERR_NETATMO = 'netatmo_unavailable';
private const ERR_FORECAST = 'forecast_unavailable';
/** @var list<string> */
private array $errors = [];
/**
* @param array{stations: int, measure: int, forecast: int} $ttl
* @param array<string, string> $indoorModules
*/
public function __construct(
private readonly WeatherSource $source,
private readonly Cache $cache,
private readonly array $ttl,
private readonly string $timezoneFallback,
private readonly ?float $forecastLat,
private readonly ?float $forecastLon,
private readonly array $indoorModules,
private readonly ?string $deviceId,
private readonly ?\DateTimeImmutable $now = null,
) {
}
/** @return array<string, mixed> */
public function build(): array
{
$this->errors = [];
$nowUtc = $this->now ?? new \DateTimeImmutable('now');
$stations = $this->fetch('stations', fn() => $this->source->stationsData(), self::ERR_NETATMO);
$station = StationMapper::map($stations['data'] ?? [], $this->deviceId, $this->indoorModules);
$tz = $this->timezone($station['timezone']);
$now = $nowUtc->setTimezone($tz);
$rain = $this->rain($station, $now);
$forecast = $this->forecast($station, $tz);
return [
'generated_at' => $now->format(\DATE_ATOM),
'timezone' => $tz->getName(),
'station' => [
'measured_at' => $this->iso($station['measured_at'], $tz),
'stale' => $stations['stale'],
],
'outdoor' => [
'temp' => $station['outdoor']['temp'],
'trend' => $station['outdoor']['trend'],
'min' => $station['outdoor']['min'],
'min_at' => $this->iso($station['outdoor']['min_at'], $tz),
'max' => $station['outdoor']['max'],
'max_at' => $this->iso($station['outdoor']['max_at'], $tz),
],
'indoor' => $station['indoor'],
'rain' => $rain,
'forecast' => $forecast['days'],
'forecast_stale' => $forecast['stale'],
'errors' => $this->errors,
];
}
/** @return array{today: ?float, previous_days: list<array{date: string, mm: ?float}>, month_total: ?float, stale: bool} */
private function rain(array $station, \DateTimeImmutable $now): array
{
$empty = RainCalculator::compute([], $now, null) + ['stale' => false];
if ($station['device_id'] === null || $station['rain_module_id'] === null) {
return $empty;
}
$begin = RainCalculator::dateBegin($now)->getTimestamp();
$measure = $this->fetch(
'measure',
fn() => $this->source->measure($station['device_id'], $station['rain_module_id'], $begin, $now->getTimestamp()),
self::ERR_NETATMO,
);
if ($measure['data'] === null) {
return ['today' => $station['sum_rain_24'], 'stale' => true] + $empty;
}
return RainCalculator::compute($measure['data'], $now, $station['sum_rain_24']) + ['stale' => $measure['stale']];
}
/** @return array{days: list<array>, stale: bool} */
private function forecast(array $station, \DateTimeZone $tz): array
{
$lat = $this->forecastLat ?? $station['lat'];
$lon = $this->forecastLon ?? $station['lon'];
if ($lat === null || $lon === null) {
$this->addError(self::ERR_FORECAST);
return ['days' => [], 'stale' => true];
}
$daily = $this->fetch('forecast', fn() => $this->source->forecast($lat, $lon, $tz->getName()), self::ERR_FORECAST);
return [
'days' => $daily['data'] === null ? [] : ForecastMapper::map($daily['data']),
'stale' => $daily['stale'],
];
}
/**
* Cache-first per bron. Bij een fout: stale cache (ongeacht leeftijd) en een foutcode, anders null.
*
* @param callable(): array $fetcher
* @return array{data: ?array, stale: bool}
*/
private function fetch(string $key, callable $fetcher, string $errorCode): array
{
$now = ($this->now ?? new \DateTimeImmutable('now'))->getTimestamp();
$ttl = $this->ttl[$key] ?? 0;
if ($ttl > 0 && $this->cache->isFresh($key, $ttl, $now)) {
return ['data' => $this->cache->read($key)['data'], 'stale' => false];
}
try {
$data = $fetcher();
if ($ttl > 0) {
$this->cache->write($key, $data, $now);
}
return ['data' => $data, 'stale' => false];
} catch (NetatmoAuthException) {
$this->addError(self::ERR_NETATMO_AUTH);
} catch (\Throwable $e) {
error_log("weerscherm: $key: " . $e->getMessage());
$this->addError($errorCode);
}
$cached = $this->cache->read($key);
return ['data' => $cached['data'] ?? null, 'stale' => true];
}
private function addError(string $code): void
{
if (!in_array($code, $this->errors, true)) {
$this->errors[] = $code;
}
}
private function timezone(?string $name): \DateTimeZone
{
foreach ([$name, $this->timezoneFallback, 'UTC'] as $candidate) {
if ($candidate !== null && in_array($candidate, \DateTimeZone::listIdentifiers(), true)) {
return new \DateTimeZone($candidate);
}
}
return new \DateTimeZone('UTC');
}
private function iso(?int $timestamp, \DateTimeZone $tz): ?string
{
return $timestamp === null ? null : (new \DateTimeImmutable('@' . $timestamp))->setTimezone($tz)->format(\DATE_ATOM);
}
}