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:
2026-09-13 11:50:40 +02:00
co-authored by Claude Opus 5
parent 5058bb850d
commit 107978b265
20 changed files with 318 additions and 55 deletions
+1
View File
@@ -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,
], ],
+7
View File
@@ -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,
+22
View File
@@ -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: [] });
+11 -1
View File
@@ -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>
<p class="a-temp num" id="temp">–</p> <div class="a-temprow">
<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
View File
@@ -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
View File
@@ -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',
+1
View File
@@ -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,
], ],
+29 -2
View File
@@ -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
View File
@@ -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];
+5
View File
@@ -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);
+38
View File
@@ -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
View File
@@ -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'];
} }
-33
View File
@@ -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;
}
}
+3 -2
View File
@@ -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),
]; ];
+3
View File
@@ -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;
} }
+2 -2
View File
@@ -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
+31 -3
View File
@@ -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');
+7 -1
View File
@@ -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
View File
@@ -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"
}