feat: klein grafiekje van het temperatuurverloop van vandaag
In de lege ruimte rechts van de grote buitentemperatuur staat nu het
verloop van vandaag: onderschrift "Vandaag", de lijn, en tijdlabels op
hele uren met "nu" rechts. Geen y-as, want de laagste en hoogste waarde
van vandaag staan al onder de balk en dat is precies de schaal.
Het getal en het grafiekje staan in een flexrij, zodat het grafiekje de
resterende ruimte pakt en meebeweegt als het getal smaller of breder
wordt. Vlak na middernacht zijn er nog geen twee meetpunten; dan blijft
het blokje verborgen in plaats van een leeg kader te tonen.
Data uit een extra getmeasure (scale=30min, type=Temperature, op de
buitenmodule) vanaf middernacht lokale tijd, cache 15 minuten, in de
payload als outdoor_history: {points, stale} zodat een storing daar de
rest niet raakt. PressureHistory is generiek gemaakt tot MeasureSeries
met de naam van het waardeveld als parameter, in plaats van er een bijna
identieke tweede klasse naast te zetten. StationMapper geeft nu ook het
id van de buitenmodule door.
Onderweg gevonden: FixtureSource deed een modulo op een float, wat een
PHP-deprecation vóór de JSON in de response zette. Dat verklaart de
kapotte JSON die eerder in de browserconsole langskwam. Opgelost met
intdiv. Servicewerker naar v10.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -31,6 +31,7 @@ return [
|
|||||||
'stations' => 300,
|
'stations' => 300,
|
||||||
'measure' => 900,
|
'measure' => 900,
|
||||||
'pressure' => 900,
|
'pressure' => 900,
|
||||||
|
'temperature' => 900,
|
||||||
'forecast' => 3600,
|
'forecast' => 3600,
|
||||||
'radar' => 240,
|
'radar' => 240,
|
||||||
],
|
],
|
||||||
|
|||||||
@@ -251,6 +251,13 @@ De iconen `sun`, `partly`, `rain` en `showers` staan in de mockup. Maak `cloud`,
|
|||||||
- De temperatuur staat groot, met 1 decimaal ("16,4°"). Het label heeft een trend-aanduiding: `up` → "↗ stijgend", `down` → "↘ dalend", `stable` → "→ stabiel".
|
- De temperatuur staat groot, met 1 decimaal ("16,4°"). Het label heeft een trend-aanduiding: `up` → "↗ stijgend", `down` → "↘ dalend", `stable` → "→ stabiel".
|
||||||
- Daaronder de gradiëntbalk van `--cold` naar `--warm`. De marker staat op `(temp − min) / (max − min)`, begrensd op 0–100%. Bij `max == min` staat hij op 50%.
|
- Daaronder de gradiëntbalk van `--cold` naar `--warm`. De marker staat op `(temp − min) / (max − min)`, begrensd op 0–100%. Bij `max == min` staat hij op 50%.
|
||||||
- Onder de balk: "9,2° laagst om 06:10" en "19,8° hoogst om 13:05".
|
- Onder de balk: "9,2° laagst om 06:10" en "19,8° hoogst om 13:05".
|
||||||
|
- Rechts van het grote getal een klein grafiekje van het temperatuurverloop van vandaag.
|
||||||
|
_Toegevoegd 2026-09-13: onderschrift "Vandaag", de lijn, en tijdlabels op hele uren met "nu" rechts
|
||||||
|
(zelfde `hourTicks` als de luchtdruk). Geen y-as: de laagste en hoogste waarde van vandaag staan al
|
||||||
|
onder de balk en dat is precies de schaal van de lijn. Data uit een extra `getmeasure`
|
||||||
|
(`scale=30min`, `type=Temperature`, buitenmodule) vanaf middernacht lokale tijd, in de payload als
|
||||||
|
`outdoor_history: {points, stale}`. Vlak na middernacht zijn er nog geen twee punten; dan blijft het
|
||||||
|
blokje verborgen in plaats van een leeg kader te tonen._
|
||||||
- Daaronder de luchtdruk: "Luchtdruk 1014 hPa ↗ stijgend" met een grafiek van de afgelopen 24 uur.
|
- Daaronder de luchtdruk: "Luchtdruk 1014 hPa ↗ stijgend" met een grafiek van de afgelopen 24 uur.
|
||||||
_Assen toegevoegd 2026-09-13: links de hoogste en laagste waarde van het venster in hele hPa, onder de
|
_Assen toegevoegd 2026-09-13: links de hoogste en laagste waarde van het venster in hele hPa, onder de
|
||||||
lijn tijdlabels op hele uren die op de klok zijn uitgelijnd (elke 6 uur vanaf middernacht, dus 00:00,
|
lijn tijdlabels op hele uren die op de klok zijn uitgelijnd (elke 6 uur vanaf middernacht, dus 00:00,
|
||||||
|
|||||||
@@ -72,6 +72,27 @@ function renderOutdoor(o) {
|
|||||||
setText('max-at', `hoogst om ${formatTime(o.max_at, state.tz)}`);
|
setText('max-at', `hoogst om ${formatTime(o.max_at, state.tz)}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Verloop van vandaag naast het grote getal. Met minder dan twee punten valt er niets te tekenen. */
|
||||||
|
function renderTodayChart(history) {
|
||||||
|
const block = $('today-chart');
|
||||||
|
const points = history?.points || [];
|
||||||
|
if (points.length < 2) {
|
||||||
|
block.hidden = true;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
block.hidden = false;
|
||||||
|
$('today-path').setAttribute('d', sparklinePath(points.map((p) => p.temp), 200, 96, 4));
|
||||||
|
|
||||||
|
const ticks = hourTicks(points[0].at, points[points.length - 1].at, state.tz);
|
||||||
|
$('today-hours').innerHTML = ticks
|
||||||
|
.map((t) => {
|
||||||
|
const style = t.percent < 6 ? 'left:0' : `left:${t.percent.toFixed(1)}%;transform:translateX(-50%)`;
|
||||||
|
return `<span style="${style}">${formatTime(t.at, state.tz)}</span>`;
|
||||||
|
})
|
||||||
|
.concat(['<span style="right:0">nu</span>'])
|
||||||
|
.join('');
|
||||||
|
}
|
||||||
|
|
||||||
function renderRooms(rooms) {
|
function renderRooms(rooms) {
|
||||||
const ordered = pinRoom(rooms, state.pinnedRoomId);
|
const ordered = pinRoom(rooms, state.pinnedRoomId);
|
||||||
const html = ordered.map((r) => {
|
const html = ordered.map((r) => {
|
||||||
@@ -271,6 +292,7 @@ function render() {
|
|||||||
if (d) {
|
if (d) {
|
||||||
renderMeasurement(now);
|
renderMeasurement(now);
|
||||||
renderOutdoor(d.outdoor);
|
renderOutdoor(d.outdoor);
|
||||||
|
renderTodayChart(d.outdoor_history);
|
||||||
renderRooms(d.indoor);
|
renderRooms(d.indoor);
|
||||||
renderRain(d.rain, now);
|
renderRain(d.rain, now);
|
||||||
renderPressure(d.pressure || { current: null, trend: null, history: [] });
|
renderPressure(d.pressure || { current: null, trend: null, history: [] });
|
||||||
|
|||||||
@@ -27,7 +27,17 @@
|
|||||||
|
|
||||||
<section class="a-hero">
|
<section class="a-hero">
|
||||||
<p class="a-label">Buiten <span class="a-trend" id="trend"></span><span class="a-hum" id="out-hum"></span></p>
|
<p class="a-label">Buiten <span class="a-trend" id="trend"></span><span class="a-hum" id="out-hum"></span></p>
|
||||||
|
<div class="a-temprow">
|
||||||
<p class="a-temp num" id="temp">–</p>
|
<p class="a-temp num" id="temp">–</p>
|
||||||
|
<div class="a-today" id="today-chart" hidden>
|
||||||
|
<p class="a-tcap">Vandaag</p>
|
||||||
|
<svg class="a-tspark" viewBox="0 0 200 96" preserveAspectRatio="none" aria-hidden="true">
|
||||||
|
<line class="a-spark-base" x1="0" y1="95.5" x2="200" y2="95.5"/>
|
||||||
|
<path class="a-spark-line" id="today-path" d=""/>
|
||||||
|
</svg>
|
||||||
|
<div class="a-px num" id="today-hours"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
<div class="a-range">
|
<div class="a-range">
|
||||||
<div class="a-track"><span class="a-mark" id="mark" style="left:50%"></span></div>
|
<div class="a-track"><span class="a-mark" id="mark" style="left:50%"></span></div>
|
||||||
<div class="a-ends num">
|
<div class="a-ends num">
|
||||||
|
|||||||
+8
-1
@@ -80,7 +80,14 @@ html, body {
|
|||||||
.a-hero { align-self: center; }
|
.a-hero { align-self: center; }
|
||||||
.a-label { font-size: 22px; color: var(--muted); font-weight: 500; display: flex; gap: 14px; align-items: baseline; }
|
.a-label { font-size: 22px; color: var(--muted); font-weight: 500; display: flex; gap: 14px; align-items: baseline; }
|
||||||
.a-trend { font-size: 17px; font-weight: 400; }
|
.a-trend { font-size: 17px; font-weight: 400; }
|
||||||
.a-temp { font-size: 200px; font-weight: 200; letter-spacing: -.045em; line-height: .95; margin: 4px 0 10px -8px; }
|
.a-temprow { display: flex; align-items: center; gap: 28px; margin: 4px 0 10px 0; }
|
||||||
|
.a-temp { font-size: 200px; font-weight: 200; letter-spacing: -.045em; line-height: .95; margin-left: -8px; }
|
||||||
|
|
||||||
|
/* Temperatuurverloop van vandaag, in de ruimte rechts van het grote getal */
|
||||||
|
.a-today { flex: 1; min-width: 0; }
|
||||||
|
.a-today[hidden] { display: none; }
|
||||||
|
.a-tcap { font-size: 15px; color: var(--muted); margin-bottom: 4px; }
|
||||||
|
.a-tspark { display: block; width: 100%; height: 96px; overflow: visible; }
|
||||||
.a-hum { font-size: 17px; font-weight: 400; }
|
.a-hum { font-size: 17px; font-weight: 400; }
|
||||||
.a-hum:not(:empty)::before { content: "·"; margin-right: 14px; }
|
.a-hum:not(:empty)::before { content: "·"; margin-right: 14px; }
|
||||||
.a-range { width: 560px; }
|
.a-range { width: 560px; }
|
||||||
|
|||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
/* Service worker: app-shell cache-first, API network-first met timeout en fallback op de laatste response. */
|
/* Service worker: app-shell cache-first, API network-first met timeout en fallback op de laatste response. */
|
||||||
|
|
||||||
const VERSION = 'weerscherm-v9';
|
const VERSION = 'weerscherm-v10';
|
||||||
const SHELL = [
|
const SHELL = [
|
||||||
'./',
|
'./',
|
||||||
'./index.html',
|
'./index.html',
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ final class Config
|
|||||||
'stations' => 300,
|
'stations' => 300,
|
||||||
'measure' => 900,
|
'measure' => 900,
|
||||||
'pressure' => 900,
|
'pressure' => 900,
|
||||||
|
'temperature' => 900,
|
||||||
'forecast' => 3600,
|
'forecast' => 3600,
|
||||||
'radar' => 240,
|
'radar' => 240,
|
||||||
],
|
],
|
||||||
|
|||||||
@@ -10,6 +10,8 @@ namespace Weerscherm;
|
|||||||
*/
|
*/
|
||||||
final class DashboardService
|
final class DashboardService
|
||||||
{
|
{
|
||||||
|
private const PRESSURE_HOURS = 24;
|
||||||
|
|
||||||
private const ERR_NETATMO_AUTH = 'netatmo_auth';
|
private const ERR_NETATMO_AUTH = 'netatmo_auth';
|
||||||
private const ERR_NETATMO = 'netatmo_unavailable';
|
private const ERR_NETATMO = 'netatmo_unavailable';
|
||||||
private const ERR_FORECAST = 'forecast_unavailable';
|
private const ERR_FORECAST = 'forecast_unavailable';
|
||||||
@@ -48,6 +50,7 @@ final class DashboardService
|
|||||||
|
|
||||||
$rain = $this->rain($station, $now);
|
$rain = $this->rain($station, $now);
|
||||||
$pressure = $this->pressure($station, $now);
|
$pressure = $this->pressure($station, $now);
|
||||||
|
$outdoorHistory = $this->outdoorHistory($station, $now);
|
||||||
$forecast = $this->forecast($station, $tz);
|
$forecast = $this->forecast($station, $tz);
|
||||||
|
|
||||||
return [
|
return [
|
||||||
@@ -66,6 +69,7 @@ final class DashboardService
|
|||||||
'max_at' => $this->iso($station['outdoor']['max_at'], $tz),
|
'max_at' => $this->iso($station['outdoor']['max_at'], $tz),
|
||||||
'humidity' => $station['outdoor']['humidity'],
|
'humidity' => $station['outdoor']['humidity'],
|
||||||
],
|
],
|
||||||
|
'outdoor_history' => $outdoorHistory,
|
||||||
'indoor' => $station['indoor'],
|
'indoor' => $station['indoor'],
|
||||||
'rain' => $rain,
|
'rain' => $rain,
|
||||||
'pressure' => $pressure,
|
'pressure' => $pressure,
|
||||||
@@ -101,14 +105,37 @@ final class DashboardService
|
|||||||
if ($station['device_id'] === null) {
|
if ($station['device_id'] === null) {
|
||||||
return $base + ['history' => [], 'stale' => false];
|
return $base + ['history' => [], 'stale' => false];
|
||||||
}
|
}
|
||||||
$begin = $now->getTimestamp() - PressureHistory::HOURS * 3600;
|
$begin = $now->getTimestamp() - self::PRESSURE_HOURS * 3600;
|
||||||
$measure = $this->fetch(
|
$measure = $this->fetch(
|
||||||
'pressure',
|
'pressure',
|
||||||
fn() => $this->source->pressureHistory($station['device_id'], $begin, $now->getTimestamp()),
|
fn() => $this->source->pressureHistory($station['device_id'], $begin, $now->getTimestamp()),
|
||||||
self::ERR_NETATMO,
|
self::ERR_NETATMO,
|
||||||
);
|
);
|
||||||
return $base + [
|
return $base + [
|
||||||
'history' => $measure['data'] === null ? [] : PressureHistory::map($measure['data'], $now->getTimezone()),
|
'history' => $measure['data'] === null ? [] : MeasureSeries::map($measure['data'], $now->getTimezone(), 'hpa'),
|
||||||
|
'stale' => $measure['stale'],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Temperatuurverloop van vandaag: vanaf middernacht lokale tijd tot nu.
|
||||||
|
*
|
||||||
|
* @return array{points: list<array{at: string, temp: float}>, stale: bool}
|
||||||
|
*/
|
||||||
|
private function outdoorHistory(array $station, \DateTimeImmutable $now): array
|
||||||
|
{
|
||||||
|
$moduleId = $station['outdoor_module_id'];
|
||||||
|
if ($station['device_id'] === null || $moduleId === null) {
|
||||||
|
return ['points' => [], 'stale' => false];
|
||||||
|
}
|
||||||
|
$begin = $now->setTime(0, 0, 0)->getTimestamp();
|
||||||
|
$measure = $this->fetch(
|
||||||
|
'temperature',
|
||||||
|
fn() => $this->source->temperatureHistory($station['device_id'], $moduleId, $begin, $now->getTimestamp()),
|
||||||
|
self::ERR_NETATMO,
|
||||||
|
);
|
||||||
|
return [
|
||||||
|
'points' => $measure['data'] === null ? [] : MeasureSeries::map($measure['data'], $now->getTimezone(), 'temp'),
|
||||||
'stale' => $measure['stale'],
|
'stale' => $measure['stale'],
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|||||||
+19
-1
@@ -61,12 +61,30 @@ final class FixtureSource implements WeatherSource
|
|||||||
{
|
{
|
||||||
$body = [];
|
$body = [];
|
||||||
for ($ts = $begin - ($begin % 1800); $ts <= $end; $ts += 1800) {
|
for ($ts = $begin - ($begin % 1800); $ts <= $end; $ts += 1800) {
|
||||||
$i = ($end - $ts) / 1800;
|
// Hele halfuren sinds het einde; intdiv houdt $i een int, zodat de modulo hieronder geen
|
||||||
|
// deprecation geeft (en de waarschuwing niet vóór de JSON in de response belandt).
|
||||||
|
$i = intdiv($end - $ts, 1800);
|
||||||
$body[(string) $ts] = [round(1012 + 3 * sin($i / 8) + ($i % 5) * 0.1, 1)];
|
$body[(string) $ts] = [round(1012 + 3 * sin($i / 8) + ($i % 5) * 0.1, 1)];
|
||||||
}
|
}
|
||||||
return $body;
|
return $body;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function temperatureHistory(string $deviceId, string $moduleId, int $begin, int $end): array
|
||||||
|
{
|
||||||
|
// Dagcurve tussen de min en max uit stationsData(): koudst rond 06:00, warmst rond 15:00.
|
||||||
|
$body = [];
|
||||||
|
for ($ts = $begin - ($begin % 1800); $ts <= $end; $ts += 1800) {
|
||||||
|
if ($ts < $begin) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
$at = (new \DateTimeImmutable('@' . $ts))->setTimezone($this->now->getTimezone());
|
||||||
|
$hour = (int) $at->format('G') + (int) $at->format('i') / 60;
|
||||||
|
$t = 9.2 + (19.8 - 9.2) * (0.5 + 0.5 * sin((($hour - 10) / 24) * 2 * M_PI));
|
||||||
|
$body[(string) $ts] = [round($t, 1)];
|
||||||
|
}
|
||||||
|
return $body;
|
||||||
|
}
|
||||||
|
|
||||||
public function forecast(float $lat, float $lon, string $timezone): array
|
public function forecast(float $lat, float $lon, string $timezone): array
|
||||||
{
|
{
|
||||||
$tmin = [11.0, 10.1, 11.3, 12.0, 9.4, 8.1];
|
$tmin = [11.0, 10.1, 11.3, 12.0, 9.4, 8.1];
|
||||||
|
|||||||
@@ -39,6 +39,11 @@ final class LiveSource implements WeatherSource
|
|||||||
return $this->netatmo->getPressureHistory($deviceId, $begin, $end);
|
return $this->netatmo->getPressureHistory($deviceId, $begin, $end);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function temperatureHistory(string $deviceId, string $moduleId, int $begin, int $end): array
|
||||||
|
{
|
||||||
|
return $this->netatmo->getTemperatureHistory($deviceId, $moduleId, $begin, $end);
|
||||||
|
}
|
||||||
|
|
||||||
public function forecast(float $lat, float $lon, string $timezone): array
|
public function forecast(float $lat, float $lon, string $timezone): array
|
||||||
{
|
{
|
||||||
return $this->openMeteo->forecast($lat, $lon, $timezone);
|
return $this->openMeteo->forecast($lat, $lon, $timezone);
|
||||||
|
|||||||
@@ -0,0 +1,38 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace Weerscherm;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Zet een getmeasure-body (één waarde per tijdstip, bv. scale=30min met type=Pressure of Temperature)
|
||||||
|
* om naar een chronologisch gesorteerde reeks punten. De naam van het waardeveld verschilt per meting,
|
||||||
|
* zodat de payload "hpa" of "temp" kan heten.
|
||||||
|
*/
|
||||||
|
final class MeasureSeries
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* @param array<string|int, mixed> $body
|
||||||
|
* @return list<array{at: string, ...}> elk punt heeft "at" en het veld $valueKey
|
||||||
|
*/
|
||||||
|
public static function map(array $body, \DateTimeZone $tz, string $valueKey): array
|
||||||
|
{
|
||||||
|
$points = [];
|
||||||
|
foreach ($body as $timestamp => $values) {
|
||||||
|
if (!is_numeric($timestamp) || !is_array($values) || !isset($values[0]) || !is_numeric($values[0])) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
$points[(int) $timestamp] = (float) $values[0];
|
||||||
|
}
|
||||||
|
ksort($points);
|
||||||
|
|
||||||
|
$out = [];
|
||||||
|
foreach ($points as $ts => $value) {
|
||||||
|
$out[] = [
|
||||||
|
'at' => (new \DateTimeImmutable('@' . $ts))->setTimezone($tz)->format(\DATE_ATOM),
|
||||||
|
$valueKey => $value,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
return $out;
|
||||||
|
}
|
||||||
|
}
|
||||||
+20
-4
@@ -60,17 +60,33 @@ final class NetatmoClient
|
|||||||
/** @return array<string|int, mixed> timestamp => [hPa], per 30 minuten, van het basisstation */
|
/** @return array<string|int, mixed> timestamp => [hPa], per 30 minuten, van het basisstation */
|
||||||
public function getPressureHistory(string $deviceId, int $begin, int $end): array
|
public function getPressureHistory(string $deviceId, int $begin, int $end): array
|
||||||
{
|
{
|
||||||
$json = $this->apiGet('getmeasure', [
|
return $this->measureSeries('Pressure', ['device_id' => $deviceId], $begin, $end);
|
||||||
'device_id' => $deviceId,
|
}
|
||||||
|
|
||||||
|
/** @return array<string|int, mixed> timestamp => [°C], per 30 minuten, van de opgegeven module */
|
||||||
|
public function getTemperatureHistory(string $deviceId, string $moduleId, int $begin, int $end): array
|
||||||
|
{
|
||||||
|
return $this->measureSeries('Temperature', ['device_id' => $deviceId, 'module_id' => $moduleId], $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',
|
'scale' => '30min',
|
||||||
'type' => 'Pressure',
|
'type' => $type,
|
||||||
'date_begin' => $begin,
|
'date_begin' => $begin,
|
||||||
'date_end' => $end,
|
'date_end' => $end,
|
||||||
'optimize' => 'false',
|
'optimize' => 'false',
|
||||||
'real_time' => 'false',
|
'real_time' => 'false',
|
||||||
]);
|
]);
|
||||||
if (!is_array($json['body'] ?? null)) {
|
if (!is_array($json['body'] ?? null)) {
|
||||||
throw new UpstreamException('getmeasure (Pressure): geen body in antwoord');
|
throw new UpstreamException("getmeasure ($type): geen body in antwoord");
|
||||||
}
|
}
|
||||||
return $json['body'];
|
return $json['body'];
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,33 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
declare(strict_types=1);
|
|
||||||
|
|
||||||
namespace Weerscherm;
|
|
||||||
|
|
||||||
/** Zet een getmeasure-body (scale=30min, type=Pressure) om naar een gesorteerde reeks punten. */
|
|
||||||
final class PressureHistory
|
|
||||||
{
|
|
||||||
public const HOURS = 24;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @param array<string|int, mixed> $body
|
|
||||||
* @return list<array{at: string, hpa: float}>
|
|
||||||
*/
|
|
||||||
public static function map(array $body, \DateTimeZone $tz): array
|
|
||||||
{
|
|
||||||
$points = [];
|
|
||||||
foreach ($body as $timestamp => $values) {
|
|
||||||
if (!is_numeric($timestamp) || !is_array($values) || !isset($values[0]) || !is_numeric($values[0])) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
$points[(int) $timestamp] = (float) $values[0];
|
|
||||||
}
|
|
||||||
ksort($points);
|
|
||||||
|
|
||||||
$out = [];
|
|
||||||
foreach ($points as $ts => $hpa) {
|
|
||||||
$out[] = ['at' => (new \DateTimeImmutable('@' . $ts))->setTimezone($tz)->format(\DATE_ATOM), 'hpa' => $hpa];
|
|
||||||
}
|
|
||||||
return $out;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -20,7 +20,7 @@ final class StationMapper
|
|||||||
* outdoor: array{temp: ?float, trend: ?string, min: ?float, min_at: ?int, max: ?float, max_at: ?int, humidity: ?float},
|
* outdoor: array{temp: ?float, trend: ?string, min: ?float, min_at: ?int, max: ?float, max_at: ?int, humidity: ?float},
|
||||||
* indoor: list<array{id: string, name: string, temp: ?float, min: ?float, max: ?float, humidity: ?float, co2: ?int, reachable: bool}>,
|
* indoor: list<array{id: string, name: string, temp: ?float, min: ?float, max: ?float, humidity: ?float, co2: ?int, reachable: bool}>,
|
||||||
* pressure: array{current: ?float, trend: ?string},
|
* pressure: array{current: ?float, trend: ?string},
|
||||||
* rain_module_id: ?string, sum_rain_24: ?float}
|
* outdoor_module_id: ?string, rain_module_id: ?string, sum_rain_24: ?float}
|
||||||
*/
|
*/
|
||||||
public static function map(array $body, ?string $deviceId, array $indoorModules): array
|
public static function map(array $body, ?string $deviceId, array $indoorModules): array
|
||||||
{
|
{
|
||||||
@@ -29,7 +29,7 @@ final class StationMapper
|
|||||||
return [
|
return [
|
||||||
'timezone' => null, 'lat' => null, 'lon' => null, 'device_id' => null, 'measured_at' => null,
|
'timezone' => null, 'lat' => null, 'lon' => null, 'device_id' => null, 'measured_at' => null,
|
||||||
'outdoor' => self::EMPTY_OUTDOOR, 'indoor' => [], 'pressure' => self::EMPTY_PRESSURE,
|
'outdoor' => self::EMPTY_OUTDOOR, 'indoor' => [], 'pressure' => self::EMPTY_PRESSURE,
|
||||||
'rain_module_id' => null, 'sum_rain_24' => null,
|
'outdoor_module_id' => null, 'rain_module_id' => null, 'sum_rain_24' => null,
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -50,6 +50,7 @@ final class StationMapper
|
|||||||
'current' => self::floatOrNull(self::dashboard($device)['Pressure'] ?? null),
|
'current' => self::floatOrNull(self::dashboard($device)['Pressure'] ?? null),
|
||||||
'trend' => self::stringOrNull(self::dashboard($device)['pressure_trend'] ?? null),
|
'trend' => self::stringOrNull(self::dashboard($device)['pressure_trend'] ?? null),
|
||||||
],
|
],
|
||||||
|
'outdoor_module_id' => $outdoor === null ? null : self::stringOrNull($outdoor['_id'] ?? null),
|
||||||
'rain_module_id' => $rain === null ? null : self::stringOrNull($rain['_id'] ?? null),
|
'rain_module_id' => $rain === null ? null : self::stringOrNull($rain['_id'] ?? null),
|
||||||
'sum_rain_24' => self::floatOrNull(self::dashboard($rain)['sum_rain_24'] ?? null),
|
'sum_rain_24' => self::floatOrNull(self::dashboard($rain)['sum_rain_24'] ?? null),
|
||||||
];
|
];
|
||||||
|
|||||||
@@ -16,6 +16,9 @@ interface WeatherSource
|
|||||||
/** @return array<string|int, mixed> de "body" van Netatmo getmeasure (timestamp => [hPa]) voor het basisstation */
|
/** @return array<string|int, mixed> de "body" van Netatmo getmeasure (timestamp => [hPa]) voor het basisstation */
|
||||||
public function pressureHistory(string $deviceId, int $begin, int $end): array;
|
public function pressureHistory(string $deviceId, int $begin, int $end): array;
|
||||||
|
|
||||||
|
/** @return array<string|int, mixed> de "body" van Netatmo getmeasure (timestamp => [°C]) voor de buitenmodule */
|
||||||
|
public function temperatureHistory(string $deviceId, string $moduleId, int $begin, int $end): array;
|
||||||
|
|
||||||
/** @return array{daily: array<string, mixed>, hourly: array<string, mixed>} de "daily"- en "hourly"-blokken van Open-Meteo */
|
/** @return array{daily: array<string, mixed>, hourly: array<string, mixed>} de "daily"- en "hourly"-blokken van Open-Meteo */
|
||||||
public function forecast(float $lat, float $lon, string $timezone): array;
|
public function forecast(float $lat, float $lon, string $timezone): array;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -28,7 +28,7 @@ final class ConfigTest extends TestCase
|
|||||||
|
|
||||||
self::assertSame('Europe/Berlin', $config->get('timezone'));
|
self::assertSame('Europe/Berlin', $config->get('timezone'));
|
||||||
self::assertSame('fixture', $config->get('data_source'));
|
self::assertSame('fixture', $config->get('data_source'));
|
||||||
self::assertSame(['stations' => 300, 'measure' => 900, 'pressure' => 900, 'forecast' => 3600, 'radar' => 240], $config->get('cache_ttl'));
|
self::assertSame(['stations' => 300, 'measure' => 900, 'pressure' => 900, 'temperature' => 900, 'forecast' => 3600, 'radar' => 240], $config->get('cache_ttl'));
|
||||||
self::assertSame([], $config->get('indoor_modules'));
|
self::assertSame([], $config->get('indoor_modules'));
|
||||||
self::assertNull($config->get('netatmo_device_id'));
|
self::assertNull($config->get('netatmo_device_id'));
|
||||||
}
|
}
|
||||||
@@ -39,7 +39,7 @@ final class ConfigTest extends TestCase
|
|||||||
$config = Config::load($this->file);
|
$config = Config::load($this->file);
|
||||||
|
|
||||||
self::assertSame('Europe/Amsterdam', $config->get('timezone'));
|
self::assertSame('Europe/Amsterdam', $config->get('timezone'));
|
||||||
self::assertSame(['stations' => 60, 'measure' => 900, 'pressure' => 900, 'forecast' => 3600, 'radar' => 240], $config->get('cache_ttl'));
|
self::assertSame(['stations' => 60, 'measure' => 900, 'pressure' => 900, 'temperature' => 900, 'forecast' => 3600, 'radar' => 240], $config->get('cache_ttl'));
|
||||||
}
|
}
|
||||||
|
|
||||||
public function testUnknownKeyReturnsGivenDefault(): void
|
public function testUnknownKeyReturnsGivenDefault(): void
|
||||||
|
|||||||
@@ -32,7 +32,7 @@ final class DashboardServiceTest extends TestCase
|
|||||||
return new DashboardService(
|
return new DashboardService(
|
||||||
$this->source,
|
$this->source,
|
||||||
new Cache($this->dir),
|
new Cache($this->dir),
|
||||||
['stations' => 300, 'measure' => 900, 'pressure' => 900, 'forecast' => 3600],
|
['stations' => 300, 'measure' => 900, 'pressure' => 900, 'temperature' => 900, 'forecast' => 3600],
|
||||||
'Europe/Amsterdam',
|
'Europe/Amsterdam',
|
||||||
$lat,
|
$lat,
|
||||||
$lon,
|
$lon,
|
||||||
@@ -46,7 +46,7 @@ final class DashboardServiceTest extends TestCase
|
|||||||
{
|
{
|
||||||
$p = $this->service()->build();
|
$p = $this->service()->build();
|
||||||
|
|
||||||
self::assertSame(['generated_at', 'timezone', 'station', 'outdoor', 'indoor', 'rain', 'pressure', 'forecast', 'forecast_stale', 'errors'], array_keys($p));
|
self::assertSame(['generated_at', 'timezone', 'station', 'outdoor', 'outdoor_history', 'indoor', 'rain', 'pressure', 'forecast', 'forecast_stale', 'errors'], array_keys($p));
|
||||||
self::assertSame('2026-09-11T14:32:05+02:00', $p['generated_at']);
|
self::assertSame('2026-09-11T14:32:05+02:00', $p['generated_at']);
|
||||||
self::assertSame('Europe/Berlin', $p['timezone']);
|
self::assertSame('Europe/Berlin', $p['timezone']);
|
||||||
self::assertSame(['measured_at' => '2026-09-11T14:30:12+02:00', 'stale' => false], $p['station']);
|
self::assertSame(['measured_at' => '2026-09-11T14:30:12+02:00', 'stale' => false], $p['station']);
|
||||||
@@ -83,6 +83,32 @@ final class DashboardServiceTest extends TestCase
|
|||||||
self::assertSame((new \DateTimeImmutable('2026-09-11 12:32:05 UTC'))->getTimestamp(), $end);
|
self::assertSame((new \DateTimeImmutable('2026-09-11 12:32:05 UTC'))->getTimestamp(), $end);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function testOutdoorHistoryCoversTodayFromLocalMidnight(): void
|
||||||
|
{
|
||||||
|
$p = $this->service()->build();
|
||||||
|
|
||||||
|
[$deviceId, $moduleId, $begin, $end] = $this->source->lastArgs['temperature'];
|
||||||
|
self::assertSame('70:ee:50:00:00:01', $deviceId);
|
||||||
|
self::assertSame('02:00:00:00:00:01', $moduleId, 'de buitenmodule, niet het basisstation');
|
||||||
|
self::assertSame((new \DateTimeImmutable('2026-09-11 00:00', new \DateTimeZone('Europe/Berlin')))->getTimestamp(), $begin);
|
||||||
|
self::assertSame((new \DateTimeImmutable('2026-09-11 12:32:05 UTC'))->getTimestamp(), $end);
|
||||||
|
|
||||||
|
self::assertNotEmpty($p['outdoor_history']['points']);
|
||||||
|
self::assertSame(['at', 'temp'], array_keys($p['outdoor_history']['points'][0]));
|
||||||
|
self::assertFalse($p['outdoor_history']['stale']);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testOutdoorHistoryFailureLeavesTheRestIntact(): void
|
||||||
|
{
|
||||||
|
$this->source->responses['temperature'] = new UpstreamException('500');
|
||||||
|
|
||||||
|
$p = $this->service()->build();
|
||||||
|
|
||||||
|
self::assertSame(['points' => [], 'stale' => true], $p['outdoor_history']);
|
||||||
|
self::assertSame(16.4, $p['outdoor']['temp'], 'de actuele waarden komen uit stationsdata en blijven staan');
|
||||||
|
self::assertSame(['netatmo_unavailable'], $p['errors']);
|
||||||
|
}
|
||||||
|
|
||||||
public function testPressureHistoryIsRequestedForLast24Hours(): void
|
public function testPressureHistoryIsRequestedForLast24Hours(): void
|
||||||
{
|
{
|
||||||
$this->service()->build();
|
$this->service()->build();
|
||||||
@@ -130,6 +156,8 @@ final class DashboardServiceTest extends TestCase
|
|||||||
self::assertNull($p['rain']['today']);
|
self::assertNull($p['rain']['today']);
|
||||||
self::assertSame(0, $this->source->calls['measure'], 'zonder regenmeter-id geen getmeasure-call');
|
self::assertSame(0, $this->source->calls['measure'], 'zonder regenmeter-id geen getmeasure-call');
|
||||||
self::assertSame(0, $this->source->calls['pressure'], 'zonder device-id geen luchtdruk-call');
|
self::assertSame(0, $this->source->calls['pressure'], 'zonder device-id geen luchtdruk-call');
|
||||||
|
self::assertSame(0, $this->source->calls['temperature'], 'zonder buitenmodule geen temperatuur-call');
|
||||||
|
self::assertSame(['points' => [], 'stale' => false], $p['outdoor_history']);
|
||||||
self::assertSame(['current' => null, 'trend' => null, 'history' => [], 'stale' => false], $p['pressure']);
|
self::assertSame(['current' => null, 'trend' => null, 'history' => [], 'stale' => false], $p['pressure']);
|
||||||
self::assertSame('Europe/Amsterdam', $p['timezone'], 'fallback-tijdzone uit config');
|
self::assertSame('Europe/Amsterdam', $p['timezone'], 'fallback-tijdzone uit config');
|
||||||
self::assertSame(['netatmo_unavailable', 'forecast_unavailable'], $p['errors']);
|
self::assertSame(['netatmo_unavailable', 'forecast_unavailable'], $p['errors']);
|
||||||
@@ -203,7 +231,7 @@ final class DashboardServiceTest extends TestCase
|
|||||||
$this->service()->build();
|
$this->service()->build();
|
||||||
$this->service('2026-09-11 12:36:05 UTC')->build();
|
$this->service('2026-09-11 12:36:05 UTC')->build();
|
||||||
|
|
||||||
self::assertSame(['stations' => 1, 'measure' => 1, 'pressure' => 1, 'forecast' => 1], $this->source->calls);
|
self::assertSame(['stations' => 1, 'measure' => 1, 'pressure' => 1, 'temperature' => 1, 'forecast' => 1], $this->source->calls);
|
||||||
|
|
||||||
$this->service('2026-09-11 12:38:05 UTC')->build();
|
$this->service('2026-09-11 12:38:05 UTC')->build();
|
||||||
self::assertSame(2, $this->source->calls['stations'], 'stations na 5 min opnieuw');
|
self::assertSame(2, $this->source->calls['stations'], 'stations na 5 min opnieuw');
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ final class FakeSource implements WeatherSource
|
|||||||
public array $responses = [];
|
public array $responses = [];
|
||||||
|
|
||||||
/** @var array<string, int> */
|
/** @var array<string, int> */
|
||||||
public array $calls = ['stations' => 0, 'measure' => 0, 'pressure' => 0, 'forecast' => 0];
|
public array $calls = ['stations' => 0, 'measure' => 0, 'pressure' => 0, 'temperature' => 0, 'forecast' => 0];
|
||||||
|
|
||||||
/** @var array<string, array> */
|
/** @var array<string, array> */
|
||||||
public array $lastArgs = [];
|
public array $lastArgs = [];
|
||||||
@@ -25,6 +25,7 @@ final class FakeSource implements WeatherSource
|
|||||||
'stations' => json_decode((string) file_get_contents(__DIR__ . '/fixtures/getstationsdata.json'), true)['body'],
|
'stations' => json_decode((string) file_get_contents(__DIR__ . '/fixtures/getstationsdata.json'), true)['body'],
|
||||||
'measure' => json_decode((string) file_get_contents(__DIR__ . '/fixtures/getmeasure.json'), true)['body'],
|
'measure' => json_decode((string) file_get_contents(__DIR__ . '/fixtures/getmeasure.json'), true)['body'],
|
||||||
'pressure' => json_decode((string) file_get_contents(__DIR__ . '/fixtures/getmeasure-pressure.json'), true)['body'],
|
'pressure' => json_decode((string) file_get_contents(__DIR__ . '/fixtures/getmeasure-pressure.json'), true)['body'],
|
||||||
|
'temperature' => json_decode((string) file_get_contents(__DIR__ . '/fixtures/getmeasure-temperature.json'), true)['body'],
|
||||||
'forecast' => array_intersect_key(
|
'forecast' => array_intersect_key(
|
||||||
json_decode((string) file_get_contents(__DIR__ . '/fixtures/forecast.json'), true),
|
json_decode((string) file_get_contents(__DIR__ . '/fixtures/forecast.json'), true),
|
||||||
['daily' => null, 'hourly' => null],
|
['daily' => null, 'hourly' => null],
|
||||||
@@ -48,6 +49,11 @@ final class FakeSource implements WeatherSource
|
|||||||
return $this->answer('pressure', func_get_args());
|
return $this->answer('pressure', func_get_args());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function temperatureHistory(string $deviceId, string $moduleId, int $begin, int $end): array
|
||||||
|
{
|
||||||
|
return $this->answer('temperature', func_get_args());
|
||||||
|
}
|
||||||
|
|
||||||
public function forecast(float $lat, float $lon, string $timezone): array
|
public function forecast(float $lat, float $lon, string $timezone): array
|
||||||
{
|
{
|
||||||
return $this->answer('forecast', func_get_args());
|
return $this->answer('forecast', func_get_args());
|
||||||
|
|||||||
@@ -5,16 +5,16 @@ declare(strict_types=1);
|
|||||||
namespace Weerscherm\Tests;
|
namespace Weerscherm\Tests;
|
||||||
|
|
||||||
use PHPUnit\Framework\TestCase;
|
use PHPUnit\Framework\TestCase;
|
||||||
use Weerscherm\PressureHistory;
|
use Weerscherm\MeasureSeries;
|
||||||
|
|
||||||
final class PressureHistoryTest extends TestCase
|
final class MeasureSeriesTest extends TestCase
|
||||||
{
|
{
|
||||||
public function testMapsAndSortsPoints(): void
|
public function testMapsAndSortsPoints(): void
|
||||||
{
|
{
|
||||||
$body = json_decode((string) file_get_contents(__DIR__ . '/fixtures/getmeasure-pressure.json'), true)['body'];
|
$body = json_decode((string) file_get_contents(__DIR__ . '/fixtures/getmeasure-pressure.json'), true)['body'];
|
||||||
$shuffled = array_reverse($body, true);
|
$shuffled = array_reverse($body, true);
|
||||||
|
|
||||||
$points = PressureHistory::map($shuffled, new \DateTimeZone('Europe/Berlin'));
|
$points = MeasureSeries::map($shuffled, new \DateTimeZone('Europe/Berlin'), 'hpa');
|
||||||
|
|
||||||
self::assertCount(49, $points);
|
self::assertCount(49, $points);
|
||||||
self::assertSame('2026-09-10T14:30:00+02:00', $points[0]['at']);
|
self::assertSame('2026-09-10T14:30:00+02:00', $points[0]['at']);
|
||||||
@@ -25,8 +25,19 @@ final class PressureHistoryTest extends TestCase
|
|||||||
|
|
||||||
public function testSkipsGarbage(): void
|
public function testSkipsGarbage(): void
|
||||||
{
|
{
|
||||||
$points = PressureHistory::map(['abc' => [1000.0], '1789129800' => ['x'], '1789133400' => [1013.5]], new \DateTimeZone('UTC'));
|
$points = MeasureSeries::map(['abc' => [1000.0], '1789129800' => ['x'], '1789133400' => [1013.5]], new \DateTimeZone('UTC'), 'hpa');
|
||||||
|
|
||||||
self::assertSame([['at' => '2026-09-11T13:30:00+00:00', 'hpa' => 1013.5]], $points);
|
self::assertSame([['at' => '2026-09-11T13:30:00+00:00', 'hpa' => 1013.5]], $points);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function testTheValueFieldIsNamedPerMeasurement(): void
|
||||||
|
{
|
||||||
|
$body = ['1789129800' => [16.4], '1789131600' => [16.9]];
|
||||||
|
|
||||||
|
$temps = MeasureSeries::map($body, new \DateTimeZone('Europe/Berlin'), 'temp');
|
||||||
|
|
||||||
|
self::assertSame(['at', 'temp'], array_keys($temps[0]));
|
||||||
|
self::assertSame(16.4, $temps[0]['temp']);
|
||||||
|
self::assertSame(16.9, $temps[1]['temp']);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
+95
@@ -0,0 +1,95 @@
|
|||||||
|
{
|
||||||
|
"body": {
|
||||||
|
"1789077600": [
|
||||||
|
11.9
|
||||||
|
],
|
||||||
|
"1789079400": [
|
||||||
|
11.9
|
||||||
|
],
|
||||||
|
"1789081200": [
|
||||||
|
10.8
|
||||||
|
],
|
||||||
|
"1789083000": [
|
||||||
|
10.8
|
||||||
|
],
|
||||||
|
"1789084800": [
|
||||||
|
9.9
|
||||||
|
],
|
||||||
|
"1789086600": [
|
||||||
|
9.9
|
||||||
|
],
|
||||||
|
"1789088400": [
|
||||||
|
9.4
|
||||||
|
],
|
||||||
|
"1789090200": [
|
||||||
|
9.4
|
||||||
|
],
|
||||||
|
"1789092000": [
|
||||||
|
9.2
|
||||||
|
],
|
||||||
|
"1789093800": [
|
||||||
|
9.2
|
||||||
|
],
|
||||||
|
"1789095600": [
|
||||||
|
9.4
|
||||||
|
],
|
||||||
|
"1789097400": [
|
||||||
|
9.4
|
||||||
|
],
|
||||||
|
"1789099200": [
|
||||||
|
9.9
|
||||||
|
],
|
||||||
|
"1789101000": [
|
||||||
|
9.9
|
||||||
|
],
|
||||||
|
"1789102800": [
|
||||||
|
10.8
|
||||||
|
],
|
||||||
|
"1789104600": [
|
||||||
|
10.8
|
||||||
|
],
|
||||||
|
"1789106400": [
|
||||||
|
11.9
|
||||||
|
],
|
||||||
|
"1789108200": [
|
||||||
|
11.9
|
||||||
|
],
|
||||||
|
"1789110000": [
|
||||||
|
13.1
|
||||||
|
],
|
||||||
|
"1789111800": [
|
||||||
|
13.1
|
||||||
|
],
|
||||||
|
"1789113600": [
|
||||||
|
14.5
|
||||||
|
],
|
||||||
|
"1789115400": [
|
||||||
|
14.5
|
||||||
|
],
|
||||||
|
"1789117200": [
|
||||||
|
15.9
|
||||||
|
],
|
||||||
|
"1789119000": [
|
||||||
|
15.9
|
||||||
|
],
|
||||||
|
"1789120800": [
|
||||||
|
17.2
|
||||||
|
],
|
||||||
|
"1789122600": [
|
||||||
|
17.2
|
||||||
|
],
|
||||||
|
"1789124400": [
|
||||||
|
18.2
|
||||||
|
],
|
||||||
|
"1789126200": [
|
||||||
|
18.2
|
||||||
|
],
|
||||||
|
"1789128000": [
|
||||||
|
19.1
|
||||||
|
],
|
||||||
|
"1789129800": [
|
||||||
|
19.1
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"status": "ok"
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user