diff --git a/config.example.php b/config.example.php
index f5aa056..c117d55 100644
--- a/config.example.php
+++ b/config.example.php
@@ -30,6 +30,7 @@ return [
'cache_ttl' => [
'stations' => 300,
'measure' => 900,
+ 'pressure' => 900,
'forecast' => 3600,
],
diff --git a/public/app.js b/public/app.js
index 034a30a..5dca169 100644
--- a/public/app.js
+++ b/public/app.js
@@ -1,6 +1,7 @@
import {
f1, fInt, fmm, markerPos, barHeights, wmoIcon, trendLabel,
formatDate, formatTime, dayAbbr, monthName, isOlderThan, msUntil,
+ co2Level, fPct, fHpa, seriesRange, sparklinePath,
} from './format.js';
import { icon } from './icons.js';
@@ -57,6 +58,7 @@ function renderMeasurement(now) {
function renderOutdoor(o) {
setText('trend', trendLabel(o.trend));
+ setText('out-hum', o.humidity === null || o.humidity === undefined ? '' : `RV ${fPct(o.humidity)}`);
setText('temp', `${f1(o.temp)}°`);
$('mark').style.left = `${markerPos(o.temp, o.min, o.max).toFixed(1)}%`;
setText('min', `${f1(o.min)}°`);
@@ -68,10 +70,12 @@ function renderOutdoor(o) {
function renderRooms(rooms) {
const html = rooms.map((r) => {
const off = !r.reachable || r.temp === null;
+ const co2 = co2Level(off ? null : r.co2);
+ const pill = `${co2.level === 'unknown' ? '–' : r.co2 + ' ppm'}`;
return `
-
${esc(r.name)}
+
${esc(r.name)}${pill}
${off ? '–' : f1(r.temp) + '°'}
-
↓ ${off ? '–' : f1(r.min) + '°'}↑ ${off ? '–' : f1(r.max) + '°'}
+
↓ ${off ? '–' : f1(r.min) + '°'}↑ ${off ? '–' : f1(r.max) + '°'}RV ${off ? '–' : fPct(r.humidity)}
`;
});
$('rooms').innerHTML = html.join('');
@@ -90,6 +94,15 @@ function renderRain(rain, now) {
`).join('');
}
+function renderPressure(p) {
+ setText('press', fHpa(p.current));
+ setText('press-trend', trendLabel(p.trend));
+ const values = p.history.map((h) => h.hpa);
+ const range = seriesRange(values);
+ setText('press-range', range ? `${Math.round(range.min)} – ${Math.round(range.max)} hPa in 24 u` : '');
+ $('press-path').setAttribute('d', sparklinePath(values, 560, 56));
+}
+
function renderForecast(days) {
$('forecast').innerHTML = days.map((d) => `
${dayAbbr(d.date, state.tz)}
@@ -131,6 +144,7 @@ function render() {
renderOutdoor(d.outdoor);
renderRooms(d.indoor);
renderRain(d.rain, now);
+ renderPressure(d.pressure || { current: null, trend: null, history: [] });
renderForecast(d.forecast);
}
renderBanner();
diff --git a/public/format.js b/public/format.js
index 2d2780f..28fbc1f 100644
--- a/public/format.js
+++ b/public/format.js
@@ -122,3 +122,49 @@ export function msUntil(now, tz, hour, minute = 0) {
if (diff <= 0) diff += 86400;
return Math.max(1000, diff * 1000 - now.getMilliseconds());
}
+
+/** CO₂-niveau volgens de Netatmo-drempels: tot 1000 goed, tot 1500 matig, daarboven slecht. */
+export function co2Level(ppm) {
+ if (!isNum(ppm)) return { level: 'unknown', label: DASH };
+ if (ppm <= 1000) return { level: 'good', label: 'goed' };
+ if (ppm <= 1500) return { level: 'medium', label: 'matig' };
+ return { level: 'bad', label: 'slecht' };
+}
+
+/** 48 -> "48%"; null -> "–" */
+export function fPct(v) {
+ return isNum(v) ? `${Math.round(v)}%` : DASH;
+}
+
+/** 1014.2 -> "1014 hPa"; null -> "–" */
+export function fHpa(v) {
+ return isNum(v) ? `${Math.round(v)} hPa` : DASH;
+}
+
+/** Laagste en hoogste waarde van een reeks (null-waarden genegeerd), of null zonder geldige waarden. */
+export function seriesRange(values) {
+ const nums = values.filter(isNum);
+ if (nums.length === 0) return null;
+ return { min: Math.min(...nums), max: Math.max(...nums) };
+}
+
+/**
+ * SVG-pad voor een sparkline: punten gelijkmatig over de breedte, waarden geschaald op min..max
+ * met `pad` px marge. Een vlakke reeks wordt een lijn door het midden. Minder dan 2 punten -> "".
+ */
+export function sparklinePath(values, w, h, pad = 3) {
+ const range = seriesRange(values);
+ if (!range || values.length < 2) return '';
+ const span = range.max - range.min;
+ const innerW = w - 2 * pad;
+ const innerH = h - 2 * pad;
+ const step = innerW / (values.length - 1);
+ const parts = [];
+ values.forEach((v, i) => {
+ if (!isNum(v)) return;
+ const x = pad + i * step;
+ const y = span === 0 ? h / 2 : pad + innerH - ((v - range.min) / span) * innerH;
+ parts.push(`${parts.length === 0 ? 'M' : 'L'}${x.toFixed(1)},${y.toFixed(1)}`);
+ });
+ return parts.join(' ');
+}
diff --git a/public/index.html b/public/index.html
index 1f2c674..150fb52 100644
--- a/public/index.html
+++ b/public/index.html
@@ -26,7 +26,7 @@
- Buiten
+ Buiten
–
@@ -35,6 +35,13 @@
–hoogst om –
+
diff --git a/public/styles.css b/public/styles.css
index 7b3cca8..647135f 100644
--- a/public/styles.css
+++ b/public/styles.css
@@ -9,6 +9,8 @@
--cold: #3A75A3;
--rain: #2E6A99;
--sun: #D39A2C;
+ --good: #4E8F5A;
+ --bad: #B5433B;
--iconfill: #E8EDF1;
--w: 1133px;
--h: 744px;
@@ -78,21 +80,38 @@ 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 24px -8px; }
+.a-temp { font-size: 200px; font-weight: 200; letter-spacing: -.045em; line-height: .95; margin: 4px 0 18px -8px; }
+.a-hum { font-size: 17px; font-weight: 400; }
+.a-hum:not(:empty)::before { content: "·"; margin-right: 14px; }
.a-range { width: 560px; }
.a-track { position: relative; height: 12px; border-radius: 6px; background: linear-gradient(90deg, var(--cold), var(--warm)); }
.a-mark { position: absolute; top: 50%; width: 28px; height: 28px; border-radius: 50%; background: var(--bg); border: 5px solid var(--ink); transform: translate(-50%, -50%); transition: left .6s ease; }
.a-ends { display: flex; justify-content: space-between; margin-top: 14px; font-size: 17px; color: var(--muted); }
.a-ends b { font-size: 24px; font-weight: 600; margin-right: 6px; }
+/* Luchtdruk, 24 uur */
+.a-pressure { width: 560px; margin-top: 18px; }
+.a-plabel { font-size: 17px; color: var(--muted); display: flex; gap: 12px; align-items: baseline; margin-bottom: 6px; }
+.a-plabel b { font-size: 20px; font-weight: 600; color: var(--ink); }
+.a-prange { margin-left: auto; font-size: 14px; }
+.a-spark { display: block; width: 560px; height: 56px; overflow: visible; }
+.a-spark-base { stroke: var(--line); stroke-width: 1; }
+.a-spark-line { fill: none; stroke: var(--rain); stroke-width: 2.4; stroke-linecap: round; stroke-linejoin: round; vector-effect: non-scaling-stroke; }
+
/* Binnen */
.a-inside { align-self: center; }
.a-room { padding: 12px 0; border-top: 1px solid var(--line); }
.a-room:first-child { border-top: 0; }
-.a-rname { font-size: 19px; color: var(--muted); }
+.a-rname { font-size: 19px; color: var(--muted); display: flex; justify-content: space-between; align-items: center; }
+.pill { display: inline-block; font-size: 13px; font-weight: 600; line-height: 1; padding: 5px 10px; border-radius: 999px; color: #fff; background: var(--muted); letter-spacing: .01em; white-space: nowrap; }
+.pill.good { background: var(--good); }
+.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; }
.a-room.off .a-rtemp { color: var(--muted); }
.a-rmm { font-size: 17px; display: flex; gap: 18px; }
+.a-rmm .rv { color: var(--muted); }
/* Onderste strook */
.a-bottom { grid-column: 1 / -1; border-top: 1px solid var(--line); padding-top: 22px; display: grid; grid-template-columns: 390px 1fr; column-gap: 36px; }
diff --git a/public/sw.js b/public/sw.js
index 952e016..51328f5 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-v1';
+const VERSION = 'weerscherm-v2';
const SHELL = [
'./',
'./index.html',
diff --git a/src/Config.php b/src/Config.php
index 8b554cc..79b61e2 100644
--- a/src/Config.php
+++ b/src/Config.php
@@ -18,6 +18,7 @@ final class Config
'cache_ttl' => [
'stations' => 300,
'measure' => 900,
+ 'pressure' => 900,
'forecast' => 3600,
],
'storage_path' => null,
diff --git a/src/DashboardService.php b/src/DashboardService.php
index 5ec842f..271194a 100644
--- a/src/DashboardService.php
+++ b/src/DashboardService.php
@@ -18,7 +18,7 @@ final class DashboardService
private array $errors = [];
/**
- * @param array{stations: int, measure: int, forecast: int} $ttl
+ * @param array{stations: int, measure: int, pressure: int, forecast: int} $ttl
* @param array $indoorModules
*/
public function __construct(
@@ -47,6 +47,7 @@ final class DashboardService
$now = $nowUtc->setTimezone($tz);
$rain = $this->rain($station, $now);
+ $pressure = $this->pressure($station, $now);
$forecast = $this->forecast($station, $tz);
return [
@@ -63,9 +64,11 @@ final class DashboardService
'min_at' => $this->iso($station['outdoor']['min_at'], $tz),
'max' => $station['outdoor']['max'],
'max_at' => $this->iso($station['outdoor']['max_at'], $tz),
+ 'humidity' => $station['outdoor']['humidity'],
],
'indoor' => $station['indoor'],
'rain' => $rain,
+ 'pressure' => $pressure,
'forecast' => $forecast['days'],
'forecast_stale' => $forecast['stale'],
'errors' => $this->errors,
@@ -91,6 +94,25 @@ final class DashboardService
return RainCalculator::compute($measure['data'], $now, $station['sum_rain_24']) + ['stale' => $measure['stale']];
}
+ /** @return array{current: ?float, trend: ?string, history: list, stale: bool} */
+ private function pressure(array $station, \DateTimeImmutable $now): array
+ {
+ $base = ['current' => $station['pressure']['current'], 'trend' => $station['pressure']['trend']];
+ if ($station['device_id'] === null) {
+ return $base + ['history' => [], 'stale' => false];
+ }
+ $begin = $now->getTimestamp() - PressureHistory::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()),
+ 'stale' => $measure['stale'],
+ ];
+ }
+
/** @return array{days: list, stale: bool} */
private function forecast(array $station, \DateTimeZone $tz): array
{
diff --git a/src/FixtureSource.php b/src/FixtureSource.php
index 29cd13f..6ee8f5e 100644
--- a/src/FixtureSource.php
+++ b/src/FixtureSource.php
@@ -27,16 +27,17 @@ final class FixtureSource implements WeatherSource
return ['devices' => [[
'_id' => '70:ee:50:00:00:01', 'type' => 'NAMain', 'station_name' => 'Thuis', 'module_name' => 'Woonkamer', 'reachable' => true,
'place' => ['timezone' => $this->timezone, 'location' => [6.55, 51.82]],
- 'dashboard_data' => ['time_utc' => $at('-2 minutes'), 'Temperature' => 21.3, 'min_temp' => 20.4, 'max_temp' => 22.1,
- 'date_min_temp' => $today('05:50'), 'date_max_temp' => $today('13:40'), 'temp_trend' => 'stable'],
+ 'dashboard_data' => ['time_utc' => $at('-2 minutes'), 'Temperature' => 21.3, 'CO2' => 612, 'Humidity' => 48,
+ 'Pressure' => 1014.2, 'AbsolutePressure' => 1011.8, 'pressure_trend' => 'up', 'Noise' => 38,
+ 'min_temp' => 20.4, 'max_temp' => 22.1, 'date_min_temp' => $today('05:50'), 'date_max_temp' => $today('13:40'), 'temp_trend' => 'stable'],
'modules' => [
['_id' => '02:00:00:00:00:01', 'type' => 'NAModule1', 'module_name' => 'Buiten', 'reachable' => true,
- 'dashboard_data' => ['time_utc' => $at('-3 minutes'), 'Temperature' => 16.4, 'min_temp' => 9.2, 'max_temp' => 19.8,
+ 'dashboard_data' => ['time_utc' => $at('-3 minutes'), 'Temperature' => 16.4, 'Humidity' => 71, 'min_temp' => 9.2, 'max_temp' => 19.8,
'date_min_temp' => $today('06:10'), 'date_max_temp' => $today('13:05'), 'temp_trend' => 'down']],
['_id' => '03:00:00:00:00:02', 'type' => 'NAModule4', 'module_name' => 'Slaapkamer', 'reachable' => true,
- 'dashboard_data' => ['time_utc' => $at('-4 minutes'), 'Temperature' => 18.9, 'min_temp' => 18.1, 'max_temp' => 19.6, 'temp_trend' => 'up']],
+ 'dashboard_data' => ['time_utc' => $at('-4 minutes'), 'Temperature' => 18.9, 'CO2' => 1180, 'Humidity' => 52, 'min_temp' => 18.1, 'max_temp' => 19.6, 'temp_trend' => 'up']],
['_id' => '03:00:00:00:00:03', 'type' => 'NAModule4', 'module_name' => 'Kantoor', 'reachable' => true,
- 'dashboard_data' => ['time_utc' => $at('-4 minutes'), 'Temperature' => 20.2, 'min_temp' => 19.0, 'max_temp' => 21.0, 'temp_trend' => 'stable']],
+ 'dashboard_data' => ['time_utc' => $at('-4 minutes'), 'Temperature' => 20.2, 'CO2' => 1640, 'Humidity' => 44, 'min_temp' => 19.0, 'max_temp' => 21.0, 'temp_trend' => 'stable']],
['_id' => '05:00:00:00:00:04', 'type' => 'NAModule3', 'module_name' => 'Regenmeter', 'reachable' => true,
'dashboard_data' => ['time_utc' => $at('-3 minutes'), 'Rain' => 0, 'sum_rain_1' => 0, 'sum_rain_24' => 2.4]],
],
@@ -56,6 +57,16 @@ final class FixtureSource implements WeatherSource
return $body;
}
+ public function pressureHistory(string $deviceId, int $begin, int $end): array
+ {
+ $body = [];
+ for ($ts = $begin - ($begin % 1800); $ts <= $end; $ts += 1800) {
+ $i = ($end - $ts) / 1800;
+ $body[(string) $ts] = [round(1012 + 3 * sin($i / 8) + ($i % 5) * 0.1, 1)];
+ }
+ return $body;
+ }
+
public function forecast(float $lat, float $lon, string $timezone): array
{
$dates = [];
diff --git a/src/LiveSource.php b/src/LiveSource.php
index 2a56a4f..b2e3acb 100644
--- a/src/LiveSource.php
+++ b/src/LiveSource.php
@@ -34,6 +34,11 @@ final class LiveSource implements WeatherSource
return $this->netatmo->getMeasure($deviceId, $moduleId, $begin, $end);
}
+ public function pressureHistory(string $deviceId, int $begin, int $end): array
+ {
+ return $this->netatmo->getPressureHistory($deviceId, $begin, $end);
+ }
+
public function forecast(float $lat, float $lon, string $timezone): array
{
return $this->openMeteo->dailyForecast($lat, $lon, $timezone);
diff --git a/src/NetatmoClient.php b/src/NetatmoClient.php
index d66bb32..ae4b0db 100644
--- a/src/NetatmoClient.php
+++ b/src/NetatmoClient.php
@@ -57,6 +57,24 @@ final class NetatmoClient
return $json['body'];
}
+ /** @return array 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,
+ 'scale' => '30min',
+ 'type' => 'Pressure',
+ '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');
+ }
+ return $json['body'];
+ }
+
/** @param array $query */
private function apiGet(string $path, array $query): array
{
diff --git a/src/PressureHistory.php b/src/PressureHistory.php
new file mode 100644
index 0000000..ec4d622
--- /dev/null
+++ b/src/PressureHistory.php
@@ -0,0 +1,33 @@
+ $body
+ * @return list
+ */
+ 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;
+ }
+}
diff --git a/src/StationMapper.php b/src/StationMapper.php
index ff9a0b0..d66f40a 100644
--- a/src/StationMapper.php
+++ b/src/StationMapper.php
@@ -10,14 +10,16 @@ namespace Weerscherm;
*/
final class StationMapper
{
- private const EMPTY_OUTDOOR = ['temp' => null, 'trend' => null, 'min' => null, 'min_at' => null, 'max' => null, 'max_at' => null];
+ private const EMPTY_OUTDOOR = ['temp' => null, 'trend' => null, 'min' => null, 'min_at' => null, 'max' => null, 'max_at' => null, 'humidity' => null];
+ private const EMPTY_PRESSURE = ['current' => null, 'trend' => null];
/**
* @param array $body
* @param array $indoorModules _id => label, in de gewenste volgorde
* @return array{timezone: ?string, lat: ?float, lon: ?float, device_id: ?string, measured_at: ?int,
- * outdoor: array{temp: ?float, trend: ?string, min: ?float, min_at: ?int, max: ?float, max_at: ?int},
- * indoor: list,
+ * outdoor: array{temp: ?float, trend: ?string, min: ?float, min_at: ?int, max: ?float, max_at: ?int, humidity: ?float},
+ * indoor: list,
+ * pressure: array{current: ?float, trend: ?string},
* rain_module_id: ?string, sum_rain_24: ?float}
*/
public static function map(array $body, ?string $deviceId, array $indoorModules): array
@@ -26,7 +28,8 @@ final class StationMapper
if ($device === null) {
return [
'timezone' => null, 'lat' => null, 'lon' => null, 'device_id' => null, 'measured_at' => null,
- 'outdoor' => self::EMPTY_OUTDOOR, 'indoor' => [], 'rain_module_id' => null, 'sum_rain_24' => null,
+ 'outdoor' => self::EMPTY_OUTDOOR, 'indoor' => [], 'pressure' => self::EMPTY_PRESSURE,
+ 'rain_module_id' => null, 'sum_rain_24' => null,
];
}
@@ -43,6 +46,10 @@ final class StationMapper
'measured_at' => self::intOrNull(self::dashboard($device)['time_utc'] ?? self::dashboard($outdoor)['time_utc'] ?? null),
'outdoor' => self::mapOutdoor($outdoor),
'indoor' => self::mapIndoor($device, $modules, $indoorModules),
+ 'pressure' => [
+ 'current' => self::floatOrNull(self::dashboard($device)['Pressure'] ?? null),
+ 'trend' => self::stringOrNull(self::dashboard($device)['pressure_trend'] ?? null),
+ ],
'rain_module_id' => $rain === null ? null : self::stringOrNull($rain['_id'] ?? null),
'sum_rain_24' => self::floatOrNull(self::dashboard($rain)['sum_rain_24'] ?? null),
];
@@ -92,6 +99,7 @@ final class StationMapper
'min_at' => self::intOrNull($d['date_min_temp'] ?? null),
'max' => self::floatOrNull($d['max_temp'] ?? null),
'max_at' => self::intOrNull($d['date_max_temp'] ?? null),
+ 'humidity' => self::floatOrNull($d['Humidity'] ?? null),
];
}
@@ -118,6 +126,8 @@ final class StationMapper
'temp' => self::floatOrNull($d['Temperature'] ?? null),
'min' => self::floatOrNull($d['min_temp'] ?? null),
'max' => self::floatOrNull($d['max_temp'] ?? null),
+ 'humidity' => self::floatOrNull($d['Humidity'] ?? null),
+ 'co2' => self::intOrNull($d['CO2'] ?? null),
'reachable' => $d !== [],
];
}
diff --git a/src/WeatherSource.php b/src/WeatherSource.php
index 4c83cfb..e2991c2 100644
--- a/src/WeatherSource.php
+++ b/src/WeatherSource.php
@@ -13,6 +13,9 @@ interface WeatherSource
/** @return array de "body" van Netatmo getmeasure (timestamp => [mm]) */
public function measure(string $deviceId, string $moduleId, int $begin, int $end): array;
+ /** @return array de "body" van Netatmo getmeasure (timestamp => [hPa]) voor het basisstation */
+ public function pressureHistory(string $deviceId, int $begin, int $end): array;
+
/** @return array het "daily"-blok van Open-Meteo */
public function forecast(float $lat, float $lon, string $timezone): array;
}
diff --git a/tests/ConfigTest.php b/tests/ConfigTest.php
index 63d838c..6ea3a3e 100644
--- a/tests/ConfigTest.php
+++ b/tests/ConfigTest.php
@@ -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, 'forecast' => 3600], $config->get('cache_ttl'));
+ self::assertSame(['stations' => 300, 'measure' => 900, 'pressure' => 900, 'forecast' => 3600], $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, 'forecast' => 3600], $config->get('cache_ttl'));
+ self::assertSame(['stations' => 60, 'measure' => 900, 'pressure' => 900, 'forecast' => 3600], $config->get('cache_ttl'));
}
public function testUnknownKeyReturnsGivenDefault(): void
diff --git a/tests/DashboardServiceTest.php b/tests/DashboardServiceTest.php
index 1e2e058..80d1a24 100644
--- a/tests/DashboardServiceTest.php
+++ b/tests/DashboardServiceTest.php
@@ -32,7 +32,7 @@ final class DashboardServiceTest extends TestCase
return new DashboardService(
$this->source,
new Cache($this->dir),
- ['stations' => 300, 'measure' => 900, 'forecast' => 3600],
+ ['stations' => 300, 'measure' => 900, 'pressure' => 900, 'forecast' => 3600],
'Europe/Amsterdam',
$lat,
$lon,
@@ -46,12 +46,19 @@ final class DashboardServiceTest extends TestCase
{
$p = $this->service()->build();
- self::assertSame(['generated_at', 'timezone', 'station', 'outdoor', 'indoor', 'rain', 'forecast', 'forecast_stale', 'errors'], array_keys($p));
+ self::assertSame(['generated_at', 'timezone', 'station', 'outdoor', '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']);
- self::assertSame(['temp' => 16.4, 'trend' => 'down', 'min' => 9.2, 'min_at' => '2026-09-11T06:10:00+02:00', 'max' => 19.8, 'max_at' => '2026-09-11T13:05:00+02:00'], $p['outdoor']);
+ self::assertSame(['temp' => 16.4, 'trend' => 'down', 'min' => 9.2, 'min_at' => '2026-09-11T06:10:00+02:00', 'max' => 19.8, 'max_at' => '2026-09-11T13:05:00+02:00', 'humidity' => 71.0], $p['outdoor']);
self::assertCount(3, $p['indoor']);
+ self::assertSame(612, $p['indoor'][0]['co2']);
+ self::assertSame(48.0, $p['indoor'][0]['humidity']);
+ self::assertSame(1014.2, $p['pressure']['current']);
+ self::assertSame('up', $p['pressure']['trend']);
+ self::assertCount(49, $p['pressure']['history']);
+ self::assertSame('2026-09-10T14:30:00+02:00', $p['pressure']['history'][0]['at']);
+ self::assertFalse($p['pressure']['stale']);
self::assertSame(2.4, $p['rain']['today']);
self::assertSame(['2026-09-08', '2026-09-09', '2026-09-10'], array_column($p['rain']['previous_days'], 'date'));
self::assertEqualsWithDelta(38.5, $p['rain']['month_total'], 0.0001);
@@ -73,6 +80,29 @@ final class DashboardServiceTest extends TestCase
self::assertSame((new \DateTimeImmutable('2026-09-11 12:32:05 UTC'))->getTimestamp(), $end);
}
+ public function testPressureHistoryIsRequestedForLast24Hours(): void
+ {
+ $this->service()->build();
+
+ [$deviceId, $begin, $end] = $this->source->lastArgs['pressure'];
+ $now = (new \DateTimeImmutable('2026-09-11 12:32:05 UTC'))->getTimestamp();
+ self::assertSame('70:ee:50:00:00:01', $deviceId);
+ self::assertSame($now - 86400, $begin);
+ self::assertSame($now, $end);
+ }
+
+ public function testPressureFailureKeepsCurrentValueAndMarksStale(): void
+ {
+ $this->source->responses['pressure'] = new UpstreamException('500');
+
+ $p = $this->service()->build();
+
+ self::assertSame(1014.2, $p['pressure']['current']);
+ self::assertSame([], $p['pressure']['history']);
+ self::assertTrue($p['pressure']['stale']);
+ self::assertSame(['netatmo_unavailable'], $p['errors']);
+ }
+
public function testForecastUsesStationLocationUnlessConfigured(): void
{
$this->service()->build();
@@ -96,6 +126,8 @@ final class DashboardServiceTest extends TestCase
self::assertSame([], $p['indoor']);
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(['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']);
}
@@ -152,7 +184,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, 'forecast' => 1], $this->source->calls);
+ self::assertSame(['stations' => 1, 'measure' => 1, 'pressure' => 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');
diff --git a/tests/FakeSource.php b/tests/FakeSource.php
index d496992..151f4a7 100644
--- a/tests/FakeSource.php
+++ b/tests/FakeSource.php
@@ -13,7 +13,7 @@ final class FakeSource implements WeatherSource
public array $responses = [];
/** @var array */
- public array $calls = ['stations' => 0, 'measure' => 0, 'forecast' => 0];
+ public array $calls = ['stations' => 0, 'measure' => 0, 'pressure' => 0, 'forecast' => 0];
/** @var array */
public array $lastArgs = [];
@@ -24,6 +24,7 @@ final class FakeSource implements WeatherSource
$s->responses = [
'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'],
'forecast' => json_decode((string) file_get_contents(__DIR__ . '/fixtures/forecast.json'), true)['daily'],
];
return $s;
@@ -39,6 +40,11 @@ final class FakeSource implements WeatherSource
return $this->answer('measure', func_get_args());
}
+ public function pressureHistory(string $deviceId, int $begin, int $end): array
+ {
+ return $this->answer('pressure', func_get_args());
+ }
+
public function forecast(float $lat, float $lon, string $timezone): array
{
return $this->answer('forecast', func_get_args());
diff --git a/tests/NetatmoClientTest.php b/tests/NetatmoClientTest.php
index 52985f4..8191320 100644
--- a/tests/NetatmoClientTest.php
+++ b/tests/NetatmoClientTest.php
@@ -198,4 +198,18 @@ final class NetatmoClientTest extends TestCase
'date_begin' => 1000, 'date_end' => 2000, 'optimize' => 'false', 'real_time' => 'false',
], $this->http->requests[0]['params']);
}
+
+ public function testGetPressureHistoryQuery(): void
+ {
+ $this->store->write(['access_token' => 'A1', 'refresh_token' => 'R1', 'expires_at' => self::NOW + 3600]);
+ $this->http->queue(200, json_decode((string) file_get_contents(__DIR__ . '/fixtures/getmeasure-pressure.json'), true));
+
+ $body = $this->client()->getPressureHistory('dev', 1000, 2000);
+
+ self::assertCount(49, $body);
+ self::assertSame([
+ 'device_id' => 'dev', 'scale' => '30min', 'type' => 'Pressure',
+ 'date_begin' => 1000, 'date_end' => 2000, 'optimize' => 'false', 'real_time' => 'false',
+ ], $this->http->requests[0]['params']);
+ }
}
diff --git a/tests/PressureHistoryTest.php b/tests/PressureHistoryTest.php
new file mode 100644
index 0000000..031dfa6
--- /dev/null
+++ b/tests/PressureHistoryTest.php
@@ -0,0 +1,32 @@
+ [1000.0], '1789129800' => ['x'], '1789133400' => [1013.5]], new \DateTimeZone('UTC'));
+
+ self::assertSame([['at' => '2026-09-11T13:30:00+00:00', 'hpa' => 1013.5]], $points);
+ }
+}
diff --git a/tests/StationMapperTest.php b/tests/StationMapperTest.php
index ffa9db8..6829ac8 100644
--- a/tests/StationMapperTest.php
+++ b/tests/StationMapperTest.php
@@ -38,6 +38,7 @@ final class StationMapperTest extends TestCase
'min_at' => 1789099800,
'max' => 19.8,
'max_at' => 1789124700,
+ 'humidity' => 71.0,
], $m['outdoor']);
}
@@ -47,8 +48,8 @@ final class StationMapperTest extends TestCase
self::assertCount(3, $m['indoor']);
self::assertSame(['Woonkamer', 'Slaapkamer', 'Kantoor'], array_column($m['indoor'], 'name'));
- self::assertSame(['id' => '70:ee:50:00:00:01', 'name' => 'Woonkamer', 'temp' => 21.3, 'min' => 20.4, 'max' => 22.1, 'reachable' => true], $m['indoor'][0]);
- self::assertSame(['id' => '03:00:00:00:00:03', 'name' => 'Kantoor', 'temp' => null, 'min' => null, 'max' => null, 'reachable' => false], $m['indoor'][2]);
+ self::assertSame(['id' => '70:ee:50:00:00:01', 'name' => 'Woonkamer', 'temp' => 21.3, 'min' => 20.4, 'max' => 22.1, 'humidity' => 48.0, 'co2' => 612, 'reachable' => true], $m['indoor'][0]);
+ self::assertSame(['id' => '03:00:00:00:00:03', 'name' => 'Kantoor', 'temp' => null, 'min' => null, 'max' => null, 'humidity' => null, 'co2' => null, 'reachable' => false], $m['indoor'][2]);
}
public function testIndoorConfigDeterminesOrderAndLabels(): void
@@ -69,6 +70,24 @@ final class StationMapperTest extends TestCase
self::assertSame(2.4, $m['sum_rain_24']);
}
+ public function testPressureFromBaseStation(): void
+ {
+ $m = StationMapper::map($this->body(), null, []);
+
+ self::assertSame(['current' => 1014.2, 'trend' => 'up'], $m['pressure']);
+ }
+
+ public function testPressureNullWhenBaseStationUnreachable(): void
+ {
+ $body = $this->body();
+ $body['devices'][0]['reachable'] = false;
+ unset($body['devices'][0]['dashboard_data']);
+ $m = StationMapper::map($body, null, []);
+
+ self::assertSame(['current' => null, 'trend' => null], $m['pressure']);
+ self::assertSame(1789129780, $m['measured_at'], 'valt terug op time_utc van de buitenmodule');
+ }
+
public function testWithoutRainModule(): void
{
$body = $this->body();
@@ -89,7 +108,7 @@ final class StationMapperTest extends TestCase
unset($body['devices'][0]['modules'][0]['dashboard_data']);
$m = StationMapper::map($body, null, []);
- self::assertSame(['temp' => null, 'trend' => null, 'min' => null, 'min_at' => null, 'max' => null, 'max_at' => null], $m['outdoor']);
+ self::assertSame(['temp' => null, 'trend' => null, 'min' => null, 'min_at' => null, 'max' => null, 'max_at' => null, 'humidity' => null], $m['outdoor']);
}
public function testUnknownDeviceIdFallsBackToFirstStation(): void
@@ -108,5 +127,6 @@ final class StationMapperTest extends TestCase
self::assertSame([], $m['indoor']);
self::assertNull($m['outdoor']['temp']);
self::assertNull($m['rain_module_id']);
+ self::assertSame(['current' => null, 'trend' => null], $m['pressure']);
}
}
diff --git a/tests/fixtures/getmeasure-pressure.json b/tests/fixtures/getmeasure-pressure.json
new file mode 100644
index 0000000..4ec085f
--- /dev/null
+++ b/tests/fixtures/getmeasure-pressure.json
@@ -0,0 +1,152 @@
+{
+ "body": {
+ "1789043400": [
+ 1011.5
+ ],
+ "1789045200": [
+ 1011
+ ],
+ "1789047000": [
+ 1010.6
+ ],
+ "1789048800": [
+ 1010.2
+ ],
+ "1789050600": [
+ 1010.3
+ ],
+ "1789052400": [
+ 1009.9
+ ],
+ "1789054200": [
+ 1009.6
+ ],
+ "1789056000": [
+ 1009.4
+ ],
+ "1789057800": [
+ 1009.1
+ ],
+ "1789059600": [
+ 1009.4
+ ],
+ "1789061400": [
+ 1009.3
+ ],
+ "1789063200": [
+ 1009.2
+ ],
+ "1789065000": [
+ 1009.2
+ ],
+ "1789066800": [
+ 1009.2
+ ],
+ "1789068600": [
+ 1009.7
+ ],
+ "1789070400": [
+ 1009.8
+ ],
+ "1789072200": [
+ 1009.9
+ ],
+ "1789074000": [
+ 1010.1
+ ],
+ "1789075800": [
+ 1010.3
+ ],
+ "1789077600": [
+ 1011
+ ],
+ "1789079400": [
+ 1011.2
+ ],
+ "1789081200": [
+ 1011.5
+ ],
+ "1789083000": [
+ 1011.8
+ ],
+ "1789084800": [
+ 1012
+ ],
+ "1789086600": [
+ 1012.8
+ ],
+ "1789088400": [
+ 1013.1
+ ],
+ "1789090200": [
+ 1013.3
+ ],
+ "1789092000": [
+ 1013.6
+ ],
+ "1789093800": [
+ 1013.8
+ ],
+ "1789095600": [
+ 1014.5
+ ],
+ "1789097400": [
+ 1014.6
+ ],
+ "1789099200": [
+ 1014.8
+ ],
+ "1789101000": [
+ 1014.8
+ ],
+ "1789102800": [
+ 1014.9
+ ],
+ "1789104600": [
+ 1015.4
+ ],
+ "1789106400": [
+ 1015.3
+ ],
+ "1789108200": [
+ 1015.2
+ ],
+ "1789110000": [
+ 1015
+ ],
+ "1789111800": [
+ 1014.8
+ ],
+ "1789113600": [
+ 1015.1
+ ],
+ "1789115400": [
+ 1014.8
+ ],
+ "1789117200": [
+ 1014.5
+ ],
+ "1789119000": [
+ 1014.1
+ ],
+ "1789120800": [
+ 1013.8
+ ],
+ "1789122600": [
+ 1013.8
+ ],
+ "1789124400": [
+ 1013.4
+ ],
+ "1789126200": [
+ 1012.9
+ ],
+ "1789128000": [
+ 1012.5
+ ],
+ "1789129800": [
+ 1012
+ ]
+ },
+ "status": "ok"
+}
diff --git a/tests/js/format.test.js b/tests/js/format.test.js
index 4addfe7..6228163 100644
--- a/tests/js/format.test.js
+++ b/tests/js/format.test.js
@@ -118,3 +118,36 @@ test('msUntil computes the wait until the next local time', () => {
assert.equal(msUntil(now, TZ, 15), (28 * 60 - 5) * 1000 - 500);
assert.equal(msUntil(new Date('2026-09-11T22:00:00.000Z'), TZ, 0), 86400000, 'precies op het moment zelf: pas morgen weer');
});
+
+import { co2Level, fPct, fHpa, seriesRange, sparklinePath } from '../../public/format.js';
+
+test('co2Level uses the Netatmo thresholds', () => {
+ assert.deepEqual(co2Level(612), { level: 'good', label: 'goed' });
+ assert.deepEqual(co2Level(1000), { level: 'good', label: 'goed' });
+ assert.deepEqual(co2Level(1001), { level: 'medium', label: 'matig' });
+ assert.deepEqual(co2Level(1500), { level: 'medium', label: 'matig' });
+ assert.deepEqual(co2Level(1501), { level: 'bad', label: 'slecht' });
+ assert.deepEqual(co2Level(null), { level: 'unknown', label: '–' });
+});
+
+test('fPct and fHpa', () => {
+ assert.equal(fPct(48), '48%');
+ assert.equal(fPct(47.6), '48%');
+ assert.equal(fPct(null), '–');
+ assert.equal(fHpa(1014.2), '1014 hPa');
+ assert.equal(fHpa(null), '–');
+});
+
+test('seriesRange ignores nulls', () => {
+ assert.deepEqual(seriesRange([1010, null, 1015.5, 1008]), { min: 1008, max: 1015.5 });
+ assert.equal(seriesRange([null, undefined]), null);
+});
+
+test('sparklinePath scales points into the box', () => {
+ assert.equal(sparklinePath([1000, 1010, 1005], 100, 50, 0), 'M0.0,50.0 L50.0,0.0 L100.0,25.0');
+ assert.equal(sparklinePath([1000, 1010], 100, 50, 5), 'M5.0,45.0 L95.0,5.0');
+ assert.equal(sparklinePath([1010, 1010, 1010], 100, 50, 0), 'M0.0,25.0 L50.0,25.0 L100.0,25.0', 'vlakke reeks in het midden');
+ assert.equal(sparklinePath([1000, null, 1010], 100, 50, 0), 'M0.0,50.0 L100.0,0.0', 'null-punt overgeslagen');
+ assert.equal(sparklinePath([1000], 100, 50), '');
+ assert.equal(sparklinePath([], 100, 50), '');
+});