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']; } /** @return array timestamp => [hPa], per 30 minuten, van het basisstation */ public function getPressureHistory(string $deviceId, int $begin, int $end): array { return $this->measureSeries('Pressure', ['device_id' => $deviceId], $begin, $end); } /** * @param ?string $moduleId null meet het basisstation zelf; Netatmo verwacht dan geen module_id * @return array timestamp => [°C], per 30 minuten */ public function getTemperatureHistory(string $deviceId, ?string $moduleId, int $begin, int $end): array { $target = ['device_id' => $deviceId]; if ($moduleId !== null) { $target['module_id'] = $moduleId; } return $this->measureSeries('Temperature', $target, $begin, $end); } /** * Eén meetreeks per 30 minuten ophalen. $target bevat device_id en eventueel module_id. * * @param array $target * @return array */ private function measureSeries(string $type, array $target, int $begin, int $end): array { $json = $this->apiGet('getmeasure', $target + [ 'scale' => '30min', 'type' => $type, 'date_begin' => $begin, 'date_end' => $end, 'optimize' => 'false', 'real_time' => 'false', ]); if (!is_array($json['body'] ?? null)) { throw new UpstreamException("getmeasure ($type): 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; } }