Compare commits
2
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
107978b265 | ||
|
|
5058bb850d |
@@ -31,6 +31,7 @@ return [
|
||||
'stations' => 300,
|
||||
'measure' => 900,
|
||||
'pressure' => 900,
|
||||
'temperature' => 900,
|
||||
'forecast' => 3600,
|
||||
'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".
|
||||
- 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".
|
||||
- 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.
|
||||
_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,
|
||||
|
||||
@@ -72,6 +72,27 @@ function renderOutdoor(o) {
|
||||
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) {
|
||||
const ordered = pinRoom(rooms, state.pinnedRoomId);
|
||||
const html = ordered.map((r) => {
|
||||
@@ -271,6 +292,7 @@ function render() {
|
||||
if (d) {
|
||||
renderMeasurement(now);
|
||||
renderOutdoor(d.outdoor);
|
||||
renderTodayChart(d.outdoor_history);
|
||||
renderRooms(d.indoor);
|
||||
renderRain(d.rain, now);
|
||||
renderPressure(d.pressure || { current: null, trend: null, history: [] });
|
||||
|
||||
+11
-1
@@ -27,7 +27,17 @@
|
||||
|
||||
<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-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-track"><span class="a-mark" id="mark" style="left:50%"></span></div>
|
||||
<div class="a-ends num">
|
||||
|
||||
+8
-1
@@ -80,7 +80,14 @@ html, body {
|
||||
.a-hero { align-self: center; }
|
||||
.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-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:not(:empty)::before { content: "·"; margin-right: 14px; }
|
||||
.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. */
|
||||
|
||||
const VERSION = 'weerscherm-v9';
|
||||
const VERSION = 'weerscherm-v10';
|
||||
const SHELL = [
|
||||
'./',
|
||||
'./index.html',
|
||||
|
||||
@@ -19,6 +19,7 @@ final class Config
|
||||
'stations' => 300,
|
||||
'measure' => 900,
|
||||
'pressure' => 900,
|
||||
'temperature' => 900,
|
||||
'forecast' => 3600,
|
||||
'radar' => 240,
|
||||
],
|
||||
|
||||
@@ -10,6 +10,8 @@ namespace Weerscherm;
|
||||
*/
|
||||
final class DashboardService
|
||||
{
|
||||
private const PRESSURE_HOURS = 24;
|
||||
|
||||
private const ERR_NETATMO_AUTH = 'netatmo_auth';
|
||||
private const ERR_NETATMO = 'netatmo_unavailable';
|
||||
private const ERR_FORECAST = 'forecast_unavailable';
|
||||
@@ -48,6 +50,7 @@ final class DashboardService
|
||||
|
||||
$rain = $this->rain($station, $now);
|
||||
$pressure = $this->pressure($station, $now);
|
||||
$outdoorHistory = $this->outdoorHistory($station, $now);
|
||||
$forecast = $this->forecast($station, $tz);
|
||||
|
||||
return [
|
||||
@@ -66,6 +69,7 @@ final class DashboardService
|
||||
'max_at' => $this->iso($station['outdoor']['max_at'], $tz),
|
||||
'humidity' => $station['outdoor']['humidity'],
|
||||
],
|
||||
'outdoor_history' => $outdoorHistory,
|
||||
'indoor' => $station['indoor'],
|
||||
'rain' => $rain,
|
||||
'pressure' => $pressure,
|
||||
@@ -101,14 +105,37 @@ final class DashboardService
|
||||
if ($station['device_id'] === null) {
|
||||
return $base + ['history' => [], 'stale' => false];
|
||||
}
|
||||
$begin = $now->getTimestamp() - PressureHistory::HOURS * 3600;
|
||||
$begin = $now->getTimestamp() - self::PRESSURE_HOURS * 3600;
|
||||
$measure = $this->fetch(
|
||||
'pressure',
|
||||
fn() => $this->source->pressureHistory($station['device_id'], $begin, $now->getTimestamp()),
|
||||
self::ERR_NETATMO,
|
||||
);
|
||||
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'],
|
||||
];
|
||||
}
|
||||
|
||||
+19
-1
@@ -61,12 +61,30 @@ final class FixtureSource implements WeatherSource
|
||||
{
|
||||
$body = [];
|
||||
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)];
|
||||
}
|
||||
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
|
||||
{
|
||||
$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);
|
||||
}
|
||||
|
||||
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
|
||||
{
|
||||
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 */
|
||||
public function getPressureHistory(string $deviceId, int $begin, int $end): array
|
||||
{
|
||||
$json = $this->apiGet('getmeasure', [
|
||||
'device_id' => $deviceId,
|
||||
return $this->measureSeries('Pressure', ['device_id' => $deviceId], $begin, $end);
|
||||
}
|
||||
|
||||
/** @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',
|
||||
'type' => 'Pressure',
|
||||
'type' => $type,
|
||||
'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');
|
||||
throw new UpstreamException("getmeasure ($type): geen body in antwoord");
|
||||
}
|
||||
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},
|
||||
* 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},
|
||||
* 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
|
||||
{
|
||||
@@ -29,7 +29,7 @@ final class StationMapper
|
||||
return [
|
||||
'timezone' => null, 'lat' => null, 'lon' => null, 'device_id' => null, 'measured_at' => null,
|
||||
'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),
|
||||
'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),
|
||||
'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 */
|
||||
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 */
|
||||
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('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::assertNull($config->get('netatmo_device_id'));
|
||||
}
|
||||
@@ -39,7 +39,7 @@ final class ConfigTest extends TestCase
|
||||
$config = Config::load($this->file);
|
||||
|
||||
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
|
||||
|
||||
@@ -32,7 +32,7 @@ final class DashboardServiceTest extends TestCase
|
||||
return new DashboardService(
|
||||
$this->source,
|
||||
new Cache($this->dir),
|
||||
['stations' => 300, 'measure' => 900, 'pressure' => 900, 'forecast' => 3600],
|
||||
['stations' => 300, 'measure' => 900, 'pressure' => 900, 'temperature' => 900, 'forecast' => 3600],
|
||||
'Europe/Amsterdam',
|
||||
$lat,
|
||||
$lon,
|
||||
@@ -46,7 +46,7 @@ final class DashboardServiceTest extends TestCase
|
||||
{
|
||||
$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('Europe/Berlin', $p['timezone']);
|
||||
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);
|
||||
}
|
||||
|
||||
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
|
||||
{
|
||||
$this->service()->build();
|
||||
@@ -130,6 +156,8 @@ final class DashboardServiceTest extends TestCase
|
||||
self::assertNull($p['rain']['today']);
|
||||
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['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('Europe/Amsterdam', $p['timezone'], 'fallback-tijdzone uit config');
|
||||
self::assertSame(['netatmo_unavailable', 'forecast_unavailable'], $p['errors']);
|
||||
@@ -203,7 +231,7 @@ final class DashboardServiceTest extends TestCase
|
||||
$this->service()->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();
|
||||
self::assertSame(2, $this->source->calls['stations'], 'stations na 5 min opnieuw');
|
||||
|
||||
@@ -13,7 +13,7 @@ final class FakeSource implements WeatherSource
|
||||
public array $responses = [];
|
||||
|
||||
/** @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> */
|
||||
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'],
|
||||
'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'],
|
||||
'temperature' => json_decode((string) file_get_contents(__DIR__ . '/fixtures/getmeasure-temperature.json'), true)['body'],
|
||||
'forecast' => array_intersect_key(
|
||||
json_decode((string) file_get_contents(__DIR__ . '/fixtures/forecast.json'), true),
|
||||
['daily' => null, 'hourly' => null],
|
||||
@@ -48,6 +49,11 @@ final class FakeSource implements WeatherSource
|
||||
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
|
||||
{
|
||||
return $this->answer('forecast', func_get_args());
|
||||
|
||||
@@ -5,16 +5,16 @@ declare(strict_types=1);
|
||||
namespace Weerscherm\Tests;
|
||||
|
||||
use PHPUnit\Framework\TestCase;
|
||||
use Weerscherm\PressureHistory;
|
||||
use Weerscherm\MeasureSeries;
|
||||
|
||||
final class PressureHistoryTest extends TestCase
|
||||
final class MeasureSeriesTest extends TestCase
|
||||
{
|
||||
public function testMapsAndSortsPoints(): void
|
||||
{
|
||||
$body = json_decode((string) file_get_contents(__DIR__ . '/fixtures/getmeasure-pressure.json'), true)['body'];
|
||||
$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::assertSame('2026-09-10T14:30:00+02:00', $points[0]['at']);
|
||||
@@ -25,8 +25,19 @@ final class PressureHistoryTest extends TestCase
|
||||
|
||||
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);
|
||||
}
|
||||
|
||||
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