*/ private array $errors = []; /** * @param array{stations: int, measure: int, pressure: 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); $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, 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, 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, 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, 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); } }