Files
weerscherm/src/NetatmoClient.php
T

154 lines
5.7 KiB
PHP

<?php
declare(strict_types=1);
namespace Weerscherm;
/**
* Netatmo Weather API met OAuth2 refresh-token-flow.
* De refresh token roteert: elk refresh-antwoord bevat een nieuwe, die direct wordt opgeslagen.
* Verversen gebeurt onder een lock (TokenStore::withLock) zodat parallelle requests niet dubbel verversen.
*/
final class NetatmoClient
{
private const TOKEN_URL = 'https://api.netatmo.com/oauth2/token';
private const API_URL = 'https://api.netatmo.com/api/';
private const REFRESH_MARGIN = 300; // seconden vóór verloop waarop proactief ververst wordt
/** @var \Closure(): int */
private readonly \Closure $now;
public function __construct(
private readonly HttpClient $http,
private readonly TokenStore $store,
private readonly string $clientId,
private readonly string $clientSecret,
?\Closure $now = null,
) {
$this->now = $now ?? static fn(): int => time();
}
/** @return array<string, mixed> 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<string|int, mixed> 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<string|int, mixed> timestamp => [hPa], per 30 minuten, van het basisstation */
public function getPressureHistory(string $deviceId, int $begin, int $end): array
{
$json = $this->apiGet('getmeasure', [
'device_id' => $deviceId,
'scale' => '30min',
'type' => 'Pressure',
'date_begin' => $begin,
'date_end' => $end,
'optimize' => 'false',
'real_time' => 'false',
]);
if (!is_array($json['body'] ?? null)) {
throw new UpstreamException('getmeasure (Pressure): geen body in antwoord');
}
return $json['body'];
}
/** @param array<string, string|int|float> $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;
}
}