From bfc96acb5ab939a2fc78f4084d9691ef92448ab5 Mon Sep 17 00:00:00 2001 From: Theo Weijmer Date: Sun, 13 Sep 2026 12:10:40 +0200 Subject: [PATCH] feat: temperatuurverloop van vandaag ook bij de binnenmodules Naast het getal in elke modulekaart staat nu hetzelfde soort lijntje als bij buiten (200x40). Zonder labels: daar is in zo'n kaart geen ruimte voor, de schaal staat al op de regel eronder en het venster is hetzelfde "vandaag" als bij het grafiekje van buiten ernaast. Per module een eigen getmeasure, gecachet onder temperature- met een gedeelde TTL; fetch() heeft daarvoor een losse ttlKey gekregen, want anders zou de TTL-lookup per module mislukken en zou er helemaal niet gecachet worden. Het basisstation meet zelf temperatuur en wordt zonder module_id opgevraagd, dus die parameter is optioneel geworden. In de payload als indoor[].history; mislukt er een, dan blijft die lijst leeg en verdwijnt alleen dat lijntje, met de foutcode in errors. Een stale-vlag per kamer zou het contract nodeloos zwaar maken. De hoogte is overal 40px, ook bij een vastgezette module: gelijke hoogte houdt de kamers onderling vergelijkbaar, en een hoger lijntje zou de toch al krappe speling onder de kolom opeten. De grafiekjes voegen geen hoogte toe aan de kaarten. FakeSource houdt nu alle aanroepen bij in plaats van alleen de laatste, zodat een test de volgorde van de vier temperatuur-calls kan nalopen. Servicewerker naar v11. Co-Authored-By: Claude Opus 5 --- docs/weerscherm-spec.md | 8 +++++++ public/app.js | 7 +++++- public/styles.css | 6 ++++- public/sw.js | 2 +- src/DashboardService.php | 43 ++++++++++++++++++++++++++++++---- src/FixtureSource.php | 14 ++++++++--- src/LiveSource.php | 2 +- src/NetatmoClient.php | 13 +++++++--- src/WeatherSource.php | 7 ++++-- tests/DashboardServiceTest.php | 36 ++++++++++++++++++++++++++-- tests/FakeSource.php | 6 ++++- 11 files changed, 124 insertions(+), 20 deletions(-) diff --git a/docs/weerscherm-spec.md b/docs/weerscherm-spec.md index d352a7a..7e90cb5 100644 --- a/docs/weerscherm-spec.md +++ b/docs/weerscherm-spec.md @@ -267,6 +267,14 @@ De iconen `sun`, `partly`, `rain` en `showers` staan in de mockup. Maak `cloud`, die loopt uur voor uur door het venster en raadpleegt telkens de lokale klok, zodat de zomertijdwissel vanzelf goed gaat._ - **Binnen (rechts):** 3 rijen met naam, temperatuur (1 decimaal) en "↓ min° ↑ max°". Een onbereikbare module toont "–" in `--muted`. + _Toegevoegd 2026-09-13: rechts van elk temperatuurgetal staat het verloop van vandaag als klein lijntje + (200x40, zonder labels: de schaal staat op de regel eronder en het venster is hetzelfde "vandaag" als bij + buiten). Data per module uit een eigen `getmeasure`, apart gecachet onder `temperature-` maar met + één gedeelde TTL; het basisstation meet zelf en wordt zonder `module_id` opgevraagd. In de payload als + `indoor[].history`. Mislukt er één, dan blijft die lijst leeg, verdwijnt alleen dat lijntje en staat de + foutcode in `errors`; een stale-vlag per kamer zou het contract nodeloos zwaar maken. De hoogte is overal + gelijk, ook bij een vastgezette module, zodat de kamers vergelijkbaar blijven._ + _Toegevoegd 2026-09-13: een module is tikbaar. Een tik zet die module bovenaan de lijst (de overige twee behouden hun onderlinge volgorde) en vergroot alleen het temperatuurgetal van 50 naar 92px. Nogmaals tikken op de bovenste (vastgezette) module maakt hem weer los. De keuze blijft staan bij de 5-minuten-verversing en diff --git a/public/app.js b/public/app.js index 87679b0..b65076d 100644 --- a/public/app.js +++ b/public/app.js @@ -100,9 +100,14 @@ function renderRooms(rooms) { const co2 = co2Level(off ? null : r.co2); const pill = `${co2.level === 'unknown' ? '–' : r.co2 + ' ppm'}`; const pinned = r.id === state.pinnedRoomId; + const points = r.history || []; + // Zonder twee punten valt er niets te tekenen; dan blijft de plek naast het getal gewoon leeg. + const spark = points.length < 2 ? '' : ``; return `

${esc(r.name)}${pill}

-

${off ? '–' : f1(r.temp) + '°'}

+

${off ? '–' : f1(r.temp) + '°'}

${spark}

↓ ${off ? '–' : f1(r.min) + '°'}↑ ${off ? '–' : f1(r.max) + '°'}RV ${off ? '–' : fPct(r.humidity)}

`; }); diff --git a/public/styles.css b/public/styles.css index 3409307..f3834a6 100644 --- a/public/styles.css +++ b/public/styles.css @@ -119,7 +119,11 @@ html, body { .pill.medium { background: var(--warm); } .pill.bad { background: var(--bad); } .pill.unknown { background: var(--line); color: var(--muted); } -.a-rtemp { font-size: 50px; font-weight: 300; line-height: 1.05; transition: font-size .25s ease; } +.a-rrow { display: flex; align-items: center; gap: 16px; } +.a-rtemp { font-size: 50px; font-weight: 300; line-height: 1.05; transition: font-size .25s ease; flex: none; } +/* Overal even hoog: zo zijn de kamers onderling vergelijkbaar, en groeit een vastgezette kaart niet + zo ver door dat de speling onder de kolom opraakt. */ +.a-rspark { flex: 1; min-width: 0; height: 40px; overflow: visible; } .a-room.off .a-rtemp { color: var(--muted); } .a-room.pinned .a-rtemp { font-size: 92px; } .a-rmm { font-size: 17px; display: flex; gap: 18px; } diff --git a/public/sw.js b/public/sw.js index dc4c1f4..ee387a5 100644 --- a/public/sw.js +++ b/public/sw.js @@ -1,6 +1,6 @@ /* Service worker: app-shell cache-first, API network-first met timeout en fallback op de laatste response. */ -const VERSION = 'weerscherm-v10'; +const VERSION = 'weerscherm-v11'; const SHELL = [ './', './index.html', diff --git a/src/DashboardService.php b/src/DashboardService.php index b021b5b..56ec90d 100644 --- a/src/DashboardService.php +++ b/src/DashboardService.php @@ -70,7 +70,7 @@ final class DashboardService 'humidity' => $station['outdoor']['humidity'], ], 'outdoor_history' => $outdoorHistory, - 'indoor' => $station['indoor'], + 'indoor' => $this->indoorWithHistory($station['indoor'], $station['device_id'], $now), 'rain' => $rain, 'pressure' => $pressure, 'forecast' => $forecast['days'], @@ -128,11 +128,22 @@ final class DashboardService if ($station['device_id'] === null || $moduleId === null) { return ['points' => [], 'stale' => false]; } + return $this->temperatureHistory($station['device_id'], $moduleId, $now); + } + + /** + * Temperatuurverloop van vandaag voor één module, apart gecachet per module. + * + * @return array{points: list, stale: bool} + */ + private function temperatureHistory(string $deviceId, ?string $moduleId, \DateTimeImmutable $now): array + { $begin = $now->setTime(0, 0, 0)->getTimestamp(); $measure = $this->fetch( - 'temperature', - fn() => $this->source->temperatureHistory($station['device_id'], $moduleId, $begin, $now->getTimestamp()), + 'temperature-' . ($moduleId ?? $deviceId), + fn() => $this->source->temperatureHistory($deviceId, $moduleId, $begin, $now->getTimestamp()), self::ERR_NETATMO, + 'temperature', ); return [ 'points' => $measure['data'] === null ? [] : MeasureSeries::map($measure['data'], $now->getTimezone(), 'temp'), @@ -140,6 +151,27 @@ final class DashboardService ]; } + /** + * Zet het temperatuurverloop van vandaag bij elke binnenmodule. Mislukt er één, dan blijft die lijst + * leeg (het grafiekje verdwijnt) en staat de foutcode al in errors; een eigen stale-vlag per kamer + * zou het contract nodeloos zwaar maken. + * + * @param list> $rooms + * @return list> + */ + private function indoorWithHistory(array $rooms, ?string $deviceId, \DateTimeImmutable $now): array + { + if ($deviceId === null) { + return $rooms; + } + foreach ($rooms as &$room) { + $moduleId = $room['id'] === $deviceId ? null : $room['id']; + $room['history'] = $this->temperatureHistory($deviceId, $moduleId, $now)['points']; + } + unset($room); + return $rooms; + } + /** @return array{days: list, stale: bool} */ private function forecast(array $station, \DateTimeZone $tz): array { @@ -175,12 +207,13 @@ final class DashboardService * Cache-first per bron. Bij een fout: stale cache (ongeacht leeftijd) en een foutcode, anders null. * * @param callable(): array $fetcher + * @param ?string $ttlKey losse TTL-sleutel wanneer de cachesleutel per module verschilt * @return array{data: ?array, stale: bool} */ - private function fetch(string $key, callable $fetcher, string $errorCode): array + private function fetch(string $key, callable $fetcher, string $errorCode, ?string $ttlKey = null): array { $now = ($this->now ?? new \DateTimeImmutable('now'))->getTimestamp(); - $ttl = $this->ttl[$key] ?? 0; + $ttl = $this->ttl[$ttlKey ?? $key] ?? 0; if ($ttl > 0 && $this->cache->isFresh($key, $ttl, $now)) { return ['data' => $this->cache->read($key)['data'], 'stale' => false]; } diff --git a/src/FixtureSource.php b/src/FixtureSource.php index 9fa5bdf..2320769 100644 --- a/src/FixtureSource.php +++ b/src/FixtureSource.php @@ -69,9 +69,17 @@ final class FixtureSource implements WeatherSource return $body; } - public function temperatureHistory(string $deviceId, string $moduleId, int $begin, int $end): array + 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. + // Dagcurve tussen de min en max die stationsData() voor deze module meldt: koudst rond 06:00, + // warmst rond 15:00. Binnen beweegt minder en later dan buiten, zodat de grafiekjes verschillen. + [$min, $max, $shift] = match ($moduleId) { + '02:00:00:00:00:01' => [9.2, 19.8, 10], // buiten + '03:00:00:00:00:02' => [18.1, 19.6, 13], // slaapkamer + '03:00:00:00:00:03' => [19.0, 21.0, 14], // kantoor + default => [20.4, 22.1, 12], // basisstation + }; + $body = []; for ($ts = $begin - ($begin % 1800); $ts <= $end; $ts += 1800) { if ($ts < $begin) { @@ -79,7 +87,7 @@ final class FixtureSource implements WeatherSource } $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)); + $t = $min + ($max - $min) * (0.5 + 0.5 * sin((($hour - $shift) / 24) * 2 * M_PI)); $body[(string) $ts] = [round($t, 1)]; } return $body; diff --git a/src/LiveSource.php b/src/LiveSource.php index 9699f1d..1e0bd5d 100644 --- a/src/LiveSource.php +++ b/src/LiveSource.php @@ -39,7 +39,7 @@ final class LiveSource implements WeatherSource return $this->netatmo->getPressureHistory($deviceId, $begin, $end); } - public function temperatureHistory(string $deviceId, string $moduleId, int $begin, int $end): array + public function temperatureHistory(string $deviceId, ?string $moduleId, int $begin, int $end): array { return $this->netatmo->getTemperatureHistory($deviceId, $moduleId, $begin, $end); } diff --git a/src/NetatmoClient.php b/src/NetatmoClient.php index 74b0993..eaf3a7e 100644 --- a/src/NetatmoClient.php +++ b/src/NetatmoClient.php @@ -63,10 +63,17 @@ final class NetatmoClient return $this->measureSeries('Pressure', ['device_id' => $deviceId], $begin, $end); } - /** @return array timestamp => [°C], per 30 minuten, van de opgegeven module */ - public function getTemperatureHistory(string $deviceId, string $moduleId, int $begin, int $end): array + /** + * @param ?string $moduleId null meet het basisstation zelf; Netatmo verwacht dan geen module_id + * @return array timestamp => [°C], per 30 minuten + */ + public function getTemperatureHistory(string $deviceId, ?string $moduleId, int $begin, int $end): array { - return $this->measureSeries('Temperature', ['device_id' => $deviceId, 'module_id' => $moduleId], $begin, $end); + $target = ['device_id' => $deviceId]; + if ($moduleId !== null) { + $target['module_id'] = $moduleId; + } + return $this->measureSeries('Temperature', $target, $begin, $end); } /** diff --git a/src/WeatherSource.php b/src/WeatherSource.php index 22c8590..928196c 100644 --- a/src/WeatherSource.php +++ b/src/WeatherSource.php @@ -16,8 +16,11 @@ interface WeatherSource /** @return array de "body" van Netatmo getmeasure (timestamp => [hPa]) voor het basisstation */ public function pressureHistory(string $deviceId, int $begin, int $end): array; - /** @return array de "body" van Netatmo getmeasure (timestamp => [°C]) voor de buitenmodule */ - public function temperatureHistory(string $deviceId, string $moduleId, int $begin, int $end): array; + /** + * @param ?string $moduleId null meet het basisstation zelf + * @return array de "body" van Netatmo getmeasure (timestamp => [°C]) + */ + public function temperatureHistory(string $deviceId, ?string $moduleId, int $begin, int $end): array; /** @return array{daily: array, hourly: array} de "daily"- en "hourly"-blokken van Open-Meteo */ public function forecast(float $lat, float $lon, string $timezone): array; diff --git a/tests/DashboardServiceTest.php b/tests/DashboardServiceTest.php index 88f4c16..4aec53c 100644 --- a/tests/DashboardServiceTest.php +++ b/tests/DashboardServiceTest.php @@ -87,7 +87,7 @@ final class DashboardServiceTest extends TestCase { $p = $this->service()->build(); - [$deviceId, $moduleId, $begin, $end] = $this->source->lastArgs['temperature']; + [$deviceId, $moduleId, $begin, $end] = $this->source->allArgs['temperature'][0]; 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); @@ -98,6 +98,37 @@ final class DashboardServiceTest extends TestCase self::assertFalse($p['outdoor_history']['stale']); } + public function testEveryIndoorModuleGetsItsOwnHistory(): void + { + $p = $this->service()->build(); + + foreach ($p['indoor'] as $room) { + self::assertNotEmpty($room['history'], "kamer {$room['name']} heeft een verloop"); + self::assertSame(['at', 'temp'], array_keys($room['history'][0])); + } + + // Het basisstation meet zelf; dat vraag je op zonder module_id. + $modules = array_map(fn(array $args) => $args[1], $this->source->allArgs['temperature']); + self::assertSame( + ['02:00:00:00:00:01', null, '03:00:00:00:00:02', '03:00:00:00:00:03'], + $modules, + 'buiten, dan het basisstation zonder module_id, dan de twee losse modules', + ); + } + + public function testIndoorHistoryFailureOnlyEmptiesThatList(): void + { + $this->source->responses['temperature'] = new UpstreamException('500'); + + $p = $this->service()->build(); + + foreach ($p['indoor'] as $room) { + self::assertSame([], $room['history']); + } + self::assertSame(21.3, $p['indoor'][0]['temp'], 'de actuele waarden komen uit stationsdata en blijven staan'); + self::assertSame(['netatmo_unavailable'], $p['errors']); + } + public function testOutdoorHistoryFailureLeavesTheRestIntact(): void { $this->source->responses['temperature'] = new UpstreamException('500'); @@ -231,10 +262,11 @@ 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, 'temperature' => 1, 'forecast' => 1], $this->source->calls); + self::assertSame(['stations' => 1, 'measure' => 1, 'pressure' => 1, 'temperature' => 4, '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'); + self::assertSame(4, $this->source->calls['temperature'], 'verloop per module nog binnen 15 min'); self::assertSame(1, $this->source->calls['measure'], 'measure nog binnen 15 min'); } } diff --git a/tests/FakeSource.php b/tests/FakeSource.php index 7da26da..e48d307 100644 --- a/tests/FakeSource.php +++ b/tests/FakeSource.php @@ -18,6 +18,9 @@ final class FakeSource implements WeatherSource /** @var array */ public array $lastArgs = []; + /** @var array> alle aanroepen per soort, in volgorde */ + public array $allArgs = []; + public static function fromFixtures(): self { $s = new self(); @@ -49,7 +52,7 @@ final class FakeSource implements WeatherSource return $this->answer('pressure', func_get_args()); } - public function temperatureHistory(string $deviceId, string $moduleId, int $begin, int $end): array + public function temperatureHistory(string $deviceId, ?string $moduleId, int $begin, int $end): array { return $this->answer('temperature', func_get_args()); } @@ -63,6 +66,7 @@ final class FakeSource implements WeatherSource { $this->calls[$key]++; $this->lastArgs[$key] = $args; + $this->allArgs[$key][] = $args; $r = $this->responses[$key] ?? new \RuntimeException("geen response voor $key"); if ($r instanceof \Throwable) { throw $r;