154 lines
5.7 KiB
PHP
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;
|
|
}
|
|
}
|