Naast het getal in elke modulekaart staat nu hetzelfde soort lijntje als bij buiten (200x40). Zonder labels: daar is in zo'n kaart geen ruimte voor, de schaal staat al op de regel eronder en het venster is hetzelfde "vandaag" als bij het grafiekje van buiten ernaast. Per module een eigen getmeasure, gecachet onder temperature-<module> met een gedeelde TTL; fetch() heeft daarvoor een losse ttlKey gekregen, want anders zou de TTL-lookup per module mislukken en zou er helemaal niet gecachet worden. Het basisstation meet zelf temperatuur en wordt zonder module_id opgevraagd, dus die parameter is optioneel geworden. In de payload als indoor[].history; mislukt er een, dan blijft die lijst leeg en verdwijnt alleen dat lijntje, met de foutcode in errors. Een stale-vlag per kamer zou het contract nodeloos zwaar maken. De hoogte is overal 40px, ook bij een vastgezette module: gelijke hoogte houdt de kamers onderling vergelijkbaar, en een hoger lijntje zou de toch al krappe speling onder de kolom opeten. De grafiekjes voegen geen hoogte toe aan de kaarten. FakeSource houdt nu alle aanroepen bij in plaats van alleen de laatste, zodat een test de volgorde van de vier temperatuur-calls kan nalopen. Servicewerker naar v11. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
177 lines
6.6 KiB
PHP
177 lines
6.6 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
|
|
{
|
|
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<string|int, mixed> 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<string, string> $target
|
|
* @return array<string|int, mixed>
|
|
*/
|
|
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<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;
|
|
}
|
|
}
|