In de lege ruimte rechts van de grote buitentemperatuur staat nu het
verloop van vandaag: onderschrift "Vandaag", de lijn, en tijdlabels op
hele uren met "nu" rechts. Geen y-as, want de laagste en hoogste waarde
van vandaag staan al onder de balk en dat is precies de schaal.
Het getal en het grafiekje staan in een flexrij, zodat het grafiekje de
resterende ruimte pakt en meebeweegt als het getal smaller of breder
wordt. Vlak na middernacht zijn er nog geen twee meetpunten; dan blijft
het blokje verborgen in plaats van een leeg kader te tonen.
Data uit een extra getmeasure (scale=30min, type=Temperature, op de
buitenmodule) vanaf middernacht lokale tijd, cache 15 minuten, in de
payload als outdoor_history: {points, stale} zodat een storing daar de
rest niet raakt. PressureHistory is generiek gemaakt tot MeasureSeries
met de naam van het waardeveld als parameter, in plaats van er een bijna
identieke tweede klasse naast te zetten. StationMapper geeft nu ook het
id van de buitenmodule door.
Onderweg gevonden: FixtureSource deed een modulo op een float, wat een
PHP-deprecation vóór de JSON in de response zette. Dat verklaart de
kapotte JSON die eerder in de browserconsole langskwam. Opgelost met
intdiv. Servicewerker naar v10.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
225 lines
8.9 KiB
PHP
225 lines
8.9 KiB
PHP
<?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 PRESSURE_HOURS = 24;
|
|
|
|
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, pressure: 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);
|
|
$pressure = $this->pressure($station, $now);
|
|
$outdoorHistory = $this->outdoorHistory($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),
|
|
'humidity' => $station['outdoor']['humidity'],
|
|
],
|
|
'outdoor_history' => $outdoorHistory,
|
|
'indoor' => $station['indoor'],
|
|
'rain' => $rain,
|
|
'pressure' => $pressure,
|
|
'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{current: ?float, trend: ?string, history: list<array{at: string, hpa: float}>, stale: bool} */
|
|
private function pressure(array $station, \DateTimeImmutable $now): array
|
|
{
|
|
$base = ['current' => $station['pressure']['current'], 'trend' => $station['pressure']['trend']];
|
|
if ($station['device_id'] === null) {
|
|
return $base + ['history' => [], 'stale' => false];
|
|
}
|
|
$begin = $now->getTimestamp() - self::PRESSURE_HOURS * 3600;
|
|
$measure = $this->fetch(
|
|
'pressure',
|
|
fn() => $this->source->pressureHistory($station['device_id'], $begin, $now->getTimestamp()),
|
|
self::ERR_NETATMO,
|
|
);
|
|
return $base + [
|
|
'history' => $measure['data'] === null ? [] : MeasureSeries::map($measure['data'], $now->getTimezone(), 'hpa'),
|
|
'stale' => $measure['stale'],
|
|
];
|
|
}
|
|
|
|
/**
|
|
* Temperatuurverloop van vandaag: vanaf middernacht lokale tijd tot nu.
|
|
*
|
|
* @return array{points: list<array{at: string, temp: float}>, stale: bool}
|
|
*/
|
|
private function outdoorHistory(array $station, \DateTimeImmutable $now): array
|
|
{
|
|
$moduleId = $station['outdoor_module_id'];
|
|
if ($station['device_id'] === null || $moduleId === null) {
|
|
return ['points' => [], 'stale' => false];
|
|
}
|
|
$begin = $now->setTime(0, 0, 0)->getTimestamp();
|
|
$measure = $this->fetch(
|
|
'temperature',
|
|
fn() => $this->source->temperatureHistory($station['device_id'], $moduleId, $begin, $now->getTimestamp()),
|
|
self::ERR_NETATMO,
|
|
);
|
|
return [
|
|
'points' => $measure['data'] === null ? [] : MeasureSeries::map($measure['data'], $now->getTimezone(), 'temp'),
|
|
'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];
|
|
}
|
|
$result = $this->fetch('forecast', fn() => $this->source->forecast($lat, $lon, $tz->getName()), self::ERR_FORECAST);
|
|
$daily = is_array($result['data']['daily'] ?? null) ? $result['data']['daily'] : null;
|
|
if ($daily === null) {
|
|
// Data ontbreekt (echte storing, al gemeld door fetch()), of de cache staat nog in een ouder
|
|
// formaat na een deploy dat de opgeslagen vorm wijzigde. In beide gevallen liever zichtbaar
|
|
// niets tonen dan stil een lege verwachting zonder foutmelding.
|
|
if ($result['data'] !== null) {
|
|
$this->addError(self::ERR_FORECAST);
|
|
}
|
|
return ['days' => [], 'stale' => true];
|
|
}
|
|
|
|
$days = ForecastMapper::map($daily);
|
|
$hourlyByDate = HourlyForecastMapper::mapByDate($result['data']['hourly'] ?? [], $tz);
|
|
foreach ($days as &$day) {
|
|
$day['hourly'] = $hourlyByDate[$day['date']] ?? [];
|
|
}
|
|
unset($day);
|
|
|
|
return ['days' => $days, 'stale' => $result['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);
|
|
}
|
|
}
|