feat: NetatmoClient with token rotation, OpenMeteoClient, LiveSource, netatmo-init
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
+17
-5
@@ -4,26 +4,38 @@ declare(strict_types=1);
|
||||
|
||||
namespace Weerscherm;
|
||||
|
||||
/** Echte bronnen: Netatmo (stationsdata, regenmetingen) en Open-Meteo (verwachting). Wordt in fase 3/4 ingevuld. */
|
||||
/** Echte bronnen: Netatmo (stationsdata, regenmetingen) en Open-Meteo (verwachting). */
|
||||
final class LiveSource implements WeatherSource
|
||||
{
|
||||
public function __construct(private readonly NetatmoClient $netatmo, private readonly OpenMeteoClient $openMeteo)
|
||||
{
|
||||
}
|
||||
|
||||
public static function fromConfig(Config $config): self
|
||||
{
|
||||
throw new \RuntimeException('LiveSource is nog niet beschikbaar; gebruik data_source = fixture');
|
||||
$clientId = (string) $config->get('netatmo_client_id');
|
||||
$clientSecret = (string) $config->get('netatmo_client_secret');
|
||||
if ($clientId === '' || $clientSecret === '') {
|
||||
throw new \RuntimeException('netatmo_client_id en netatmo_client_secret ontbreken in config.php');
|
||||
}
|
||||
$http = new Http();
|
||||
$store = new TokenStore(rtrim((string) $config->get('storage_path'), '/') . '/netatmo-token.json');
|
||||
|
||||
return new self(new NetatmoClient($http, $store, $clientId, $clientSecret), new OpenMeteoClient($http));
|
||||
}
|
||||
|
||||
public function stationsData(): array
|
||||
{
|
||||
throw new UpstreamException('niet geïmplementeerd');
|
||||
return $this->netatmo->getStationsData();
|
||||
}
|
||||
|
||||
public function measure(string $deviceId, string $moduleId, int $begin, int $end): array
|
||||
{
|
||||
throw new UpstreamException('niet geïmplementeerd');
|
||||
return $this->netatmo->getMeasure($deviceId, $moduleId, $begin, $end);
|
||||
}
|
||||
|
||||
public function forecast(float $lat, float $lon, string $timezone): array
|
||||
{
|
||||
throw new UpstreamException('niet geïmplementeerd');
|
||||
return $this->openMeteo->dailyForecast($lat, $lon, $timezone);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,135 @@
|
||||
<?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'];
|
||||
}
|
||||
|
||||
/** @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;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Weerscherm;
|
||||
|
||||
/** Open-Meteo dagverwachting. Gratis voor niet-commercieel gebruik, geen API-key. */
|
||||
final class OpenMeteoClient
|
||||
{
|
||||
private const URL = 'https://api.open-meteo.com/v1/forecast';
|
||||
|
||||
public function __construct(private readonly HttpClient $http)
|
||||
{
|
||||
}
|
||||
|
||||
/** @return array<string, mixed> het "daily"-blok */
|
||||
public function dailyForecast(float $lat, float $lon, string $timezone): array
|
||||
{
|
||||
$res = $this->http->get(self::URL, [
|
||||
'latitude' => $lat,
|
||||
'longitude' => $lon,
|
||||
'daily' => 'weather_code,temperature_2m_max,temperature_2m_min,precipitation_sum,precipitation_probability_max',
|
||||
'timezone' => $timezone,
|
||||
'forecast_days' => 6,
|
||||
]);
|
||||
if ($res->status !== 200) {
|
||||
throw new UpstreamException("Open-Meteo: HTTP {$res->status}");
|
||||
}
|
||||
$json = $res->json();
|
||||
if (!is_array($json['daily'] ?? null)) {
|
||||
throw new UpstreamException('Open-Meteo: geen daily-blok in antwoord');
|
||||
}
|
||||
return $json['daily'];
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user