Compare commits
2
Commits
4c4881fd11
...
75111a5571
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
75111a5571 | ||
|
|
bfc96acb5a |
@@ -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-<module>` 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
|
||||
|
||||
+6
-1
@@ -100,9 +100,14 @@ function renderRooms(rooms) {
|
||||
const co2 = co2Level(off ? null : r.co2);
|
||||
const pill = `<span class="pill ${co2.level} num" title="${co2.label}">${co2.level === 'unknown' ? '–' : r.co2 + ' ppm'}</span>`;
|
||||
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 ? '' : `<svg class="a-rspark" viewBox="0 0 200 40" preserveAspectRatio="none" aria-hidden="true">
|
||||
<path class="a-spark-line" d="${sparklinePath(points.map((p) => p.temp), 200, 40, 3)}"/>
|
||||
</svg>`;
|
||||
return `<div class="a-room${off ? ' off' : ''}${pinned ? ' pinned' : ''}" data-id="${esc(r.id)}">
|
||||
<p class="a-rname"><span>${esc(r.name)}</span>${pill}</p>
|
||||
<p class="a-rtemp num">${off ? '–' : f1(r.temp) + '°'}</p>
|
||||
<div class="a-rrow"><p class="a-rtemp num">${off ? '–' : f1(r.temp) + '°'}</p>${spark}</div>
|
||||
<p class="a-rmm num"><span class="cold">↓ ${off ? '–' : f1(r.min) + '°'}</span><span class="warm">↑ ${off ? '–' : f1(r.max) + '°'}</span><span class="rv">RV ${off ? '–' : fPct(r.humidity)}</span></p>
|
||||
</div>`;
|
||||
});
|
||||
|
||||
+5
-1
@@ -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; }
|
||||
|
||||
+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-v10';
|
||||
const VERSION = 'weerscherm-v11';
|
||||
const SHELL = [
|
||||
'./',
|
||||
'./index.html',
|
||||
|
||||
@@ -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<array{at: string, temp: float}>, 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<array<string, mixed>> $rooms
|
||||
* @return list<array<string, mixed>>
|
||||
*/
|
||||
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<array>, 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];
|
||||
}
|
||||
|
||||
+11
-3
@@ -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;
|
||||
|
||||
+1
-1
@@ -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);
|
||||
}
|
||||
|
||||
+10
-3
@@ -63,10 +63,17 @@ final class NetatmoClient
|
||||
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
|
||||
/**
|
||||
* @param ?string $moduleId null meet het basisstation zelf; Netatmo verwacht dan geen module_id
|
||||
* @return array<string|int, mixed> timestamp => [°C], per 30 minuten
|
||||
*/
|
||||
public function getTemperatureHistory(string $deviceId, ?string $moduleId, int $begin, int $end): array
|
||||
{
|
||||
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);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -16,8 +16,11 @@ 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;
|
||||
/**
|
||||
* @param ?string $moduleId null meet het basisstation zelf
|
||||
* @return array<string|int, mixed> de "body" van Netatmo getmeasure (timestamp => [°C])
|
||||
*/
|
||||
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;
|
||||
|
||||
@@ -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');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -18,6 +18,9 @@ final class FakeSource implements WeatherSource
|
||||
/** @var array<string, array> */
|
||||
public array $lastArgs = [];
|
||||
|
||||
/** @var array<string, list<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;
|
||||
|
||||
Reference in New Issue
Block a user