Compare commits
5
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
768cc7151f | ||
|
|
dba1875683 | ||
|
|
66bce8e8d0 | ||
|
|
7fd49cc391 | ||
|
|
246756438b |
@@ -3,3 +3,4 @@ config.php
|
||||
/vendor/
|
||||
/node_modules/
|
||||
.DS_Store
|
||||
.phpunit.result.cache
|
||||
|
||||
@@ -1 +0,0 @@
|
||||
{"version":2,"defects":{"Weerscherm\\Tests\\DashboardServiceTest::testForecastUsesStationLocationUnlessConfigured":7,"Weerscherm\\Tests\\DashboardServiceTest::testMeasureFailureFallsBackToSumRain24":7,"Weerscherm\\Tests\\NetatmoClientTest::testRejectedTokenAlreadyReplacedByOtherProcessIsReusedWithoutRefresh":8},"times":{"Weerscherm\\Tests\\ConfigTest::testDefaultsApplyWhenKeysAreMissing":0,"Weerscherm\\Tests\\ConfigTest::testFileValuesOverrideDefaults":0,"Weerscherm\\Tests\\ConfigTest::testUnknownKeyReturnsGivenDefault":0,"Weerscherm\\Tests\\ConfigTest::testFileMustReturnArray":0,"Weerscherm\\Tests\\StationMapperTest::testStationMetadata":0,"Weerscherm\\Tests\\StationMapperTest::testOutdoor":0,"Weerscherm\\Tests\\StationMapperTest::testIndoorDefaultOrderAndUnreachableModule":0,"Weerscherm\\Tests\\StationMapperTest::testIndoorConfigDeterminesOrderAndLabels":0,"Weerscherm\\Tests\\StationMapperTest::testRainModule":0,"Weerscherm\\Tests\\StationMapperTest::testWithoutRainModule":0,"Weerscherm\\Tests\\StationMapperTest::testUnreachableOutdoorModuleGivesNulls":0,"Weerscherm\\Tests\\StationMapperTest::testUnknownDeviceIdFallsBackToFirstStation":0,"Weerscherm\\Tests\\StationMapperTest::testEmptyDevicesGivesNullsWithoutCrashing":0,"Weerscherm\\Tests\\RainCalculatorTest::testNormalWeek":0,"Weerscherm\\Tests\\RainCalculatorTest::testMissingPreviousDayIsNullAndExcludedFromTotal":0,"Weerscherm\\Tests\\RainCalculatorTest::testMissingTodayFallsBackToSumRain24":0,"Weerscherm\\Tests\\RainCalculatorTest::testMonthBoundaryKeepsPreviousDaysButNotInMonthTotal":0,"Weerscherm\\Tests\\RainCalculatorTest::testDaylightSavingTransitionMapsTimestampsToLocalDates":0,"Weerscherm\\Tests\\RainCalculatorTest::testEmptyBodyGivesNulls":0,"Weerscherm\\Tests\\RainCalculatorTest::testDateBeginIsMinOfFirstOfMonthAndThreeDaysAgo":0,"Weerscherm\\Tests\\CacheTest::testWriteThenReadRoundTrip":0.003,"Weerscherm\\Tests\\CacheTest::testMissingKeyReadsNull":0.004,"Weerscherm\\Tests\\CacheTest::testCorruptFileReadsNull":0,"Weerscherm\\Tests\\CacheTest::testFreshness":0.001,"Weerscherm\\Tests\\CacheTest::testKeyIsSanitised":0,"Weerscherm\\Tests\\CacheTest::testWriteCreatesDirectory":0,"Weerscherm\\Tests\\ForecastMapperTest::testSkipsTodayAndReturnsFiveDays":0,"Weerscherm\\Tests\\ForecastMapperTest::testNullValuesStayNull":0,"Weerscherm\\Tests\\ForecastMapperTest::testShorterResponseGivesFewerDays":0,"Weerscherm\\Tests\\ForecastMapperTest::testMissingTimeArrayGivesEmptyList":0,"Weerscherm\\Tests\\DashboardServiceTest::testHappyPathMatchesContract":0.002,"Weerscherm\\Tests\\DashboardServiceTest::testMeasureIsRequestedForRainModuleFromMidnightOfDateBegin":0.001,"Weerscherm\\Tests\\DashboardServiceTest::testForecastUsesStationLocationUnlessConfigured":0.001,"Weerscherm\\Tests\\DashboardServiceTest::testStationsFailureWithoutCacheGivesNullsAndError":0,"Weerscherm\\Tests\\DashboardServiceTest::testStationsFailureWithCacheServesStaleData":0.001,"Weerscherm\\Tests\\DashboardServiceTest::testAuthFailureGivesNetatmoAuthError":0.001,"Weerscherm\\Tests\\DashboardServiceTest::testMeasureFailureFallsBackToSumRain24":0,"Weerscherm\\Tests\\DashboardServiceTest::testForecastFailureLeavesRestIntact":0.001,"Weerscherm\\Tests\\DashboardServiceTest::testSecondBuildWithinTtlUsesCache":0.001,"Weerscherm\\Tests\\TokenStoreTest::testWriteReadRoundTripAndMode":0,"Weerscherm\\Tests\\TokenStoreTest::testMissingOrInvalidFileReadsNull":0,"Weerscherm\\Tests\\TokenStoreTest::testWithLockReturnsValueAndReleasesAfterException":0,"Weerscherm\\Tests\\TokenStoreTest::testConcurrentProcessesAreSerialised":0.438,"Weerscherm\\Tests\\NetatmoClientTest::testValidTokenIsUsedWithoutRefresh":0.001,"Weerscherm\\Tests\\NetatmoClientTest::testExpiringTokenIsRefreshedAndRotatedRefreshTokenStored":0.001,"Weerscherm\\Tests\\NetatmoClientTest::testFreshlyInitialisedStoreWithoutAccessTokenRefreshesFirst":0.001,"Weerscherm\\Tests\\NetatmoClientTest::testUnauthorizedResponseTriggersOneRefreshAndRetry":0.001,"Weerscherm\\Tests\\NetatmoClientTest::testSecondUnauthorizedResponseIsAuthError":0.001,"Weerscherm\\Tests\\NetatmoClientTest::testInvalidGrantIsAuthError":0,"Weerscherm\\Tests\\NetatmoClientTest::testMissingTokenFileIsAuthError":0,"Weerscherm\\Tests\\NetatmoClientTest::testServerErrorIsUpstreamError":0,"Weerscherm\\Tests\\NetatmoClientTest::testRefreshServerErrorIsUpstreamNotAuth":0.001,"Weerscherm\\Tests\\NetatmoClientTest::testConcurrentClientsShareOneRefresh":0.001,"Weerscherm\\Tests\\NetatmoClientTest::testRejectedTokenAlreadyReplacedByOtherProcessIsReusedWithoutRefresh":0.001,"Weerscherm\\Tests\\NetatmoClientTest::testGetMeasureQuery":0,"Weerscherm\\Tests\\OpenMeteoClientTest::testRequestsSixDaysWithAllDailyVariables":0,"Weerscherm\\Tests\\OpenMeteoClientTest::testErrorStatusIsUpstreamException":0,"Weerscherm\\Tests\\OpenMeteoClientTest::testMissingDailyIsUpstreamException":0}}
|
||||
@@ -92,25 +92,41 @@ De bestanden staan in `orbstack/divers/weerscherm/` (Dockerfile, compose) en `or
|
||||
|
||||
## Deploy (productie)
|
||||
|
||||
Productie-URL: `https://homedisplay.bahne-drei.de/`
|
||||
Productie-URL: `https://homedisplay.bahne-drei.de/` op `pensare-server`, beheerd met Ansible
|
||||
(`infra-ansible-pensare`, app `weerscherm` in `inventories/production/host_vars/pensare-server.yml`).
|
||||
|
||||
- Git op de self-hosted Gitea, squash-and-merge naar `main`, deploy via DeployPad.
|
||||
- Document root is `public/`. `src/`, `storage/`, `config.php` en `bin/` zijn dan niet via HTTP bereikbaar.
|
||||
- `storage/` moet deploys overleven: maak er in DeployPad een gedeelde map van (shared folder) of zet
|
||||
`storage_path` in `config.php` naar een map buiten de release-map, bijvoorbeeld `/var/www/weerscherm-storage`.
|
||||
De map moet schrijfbaar zijn voor de webserver-gebruiker (`www-data`).
|
||||
- `config.php` is per omgeving en staat niet in git; zet hem ook in de gedeelde map en symlink, of maak hem
|
||||
eenmalig aan in de release-root.
|
||||
- `composer install` is op de server niet nodig: `src/autoload.php` laadt de klassen zelf als `vendor/` ontbreekt.
|
||||
- HTTPS is vereist voor de service worker en "Zet op beginscherm".
|
||||
- Na de eerste deploy: `php bin/netatmo-init.php '<refresh_token>'` op de server, en controleer
|
||||
`https://homedisplay.bahne-drei.de/api/data.php` op `"errors": []`.
|
||||
- De app staat bewust zonder login. Kies een niet-voor-de-hand-liggend (sub)domein; `robots.txt` en
|
||||
`X-Robots-Tag: noindex` houden zoekmachines buiten de deur.
|
||||
- Code komt uit de publieke Gitea-repo `https://git.pensare.nl/theo/weerscherm.git` (branch `main`);
|
||||
de role cloont of pullt als Linux-user `weerscherm`.
|
||||
- `config.php` wordt gerenderd uit `roles/webapp/templates/weerscherm.config.php.j2` met de Netatmo-gegevens
|
||||
uit de vault (`vault_weerscherm_netatmo_client_id`, `vault_weerscherm_netatmo_client_secret`).
|
||||
- `storage_path` is `/var/www/weerscherm-storage` (www-data, 0770), buiten de release-map, zodat de token
|
||||
en de cache deploys overleven.
|
||||
- Document root is `public/`; `src/`, `bin/` en `config.php` zijn niet via HTTP bereikbaar.
|
||||
- `composer install` is op de server niet nodig: `src/autoload.php` laadt de klassen zelf.
|
||||
- SSL via certbot in dezelfde playbook-run.
|
||||
|
||||
Nieuwe versie van de frontend? Verhoog `VERSION` in `public/sw.js`; de app herlaadt zichzelf bij `controllerchange`
|
||||
Nieuwe versie uitrollen:
|
||||
|
||||
```bash
|
||||
git push origin main
|
||||
cd /Volumes/development/sites/pensare/infra-ansible-pensare
|
||||
ansible-playbook playbooks/deploy-app.yml -e app=weerscherm
|
||||
```
|
||||
|
||||
Verhoog bij frontend-wijzigingen `VERSION` in `public/sw.js`; de app herlaadt zichzelf bij `controllerchange`
|
||||
en in elk geval dagelijks rond 04:00.
|
||||
|
||||
Token opnieuw koppelen (melding "Netatmo-koppeling verlopen"):
|
||||
|
||||
```bash
|
||||
ssh weerscherm@65.108.40.86 'cd /var/www/weerscherm && sudo -u www-data php8.3 bin/netatmo-init.php "<refresh_token>"'
|
||||
```
|
||||
|
||||
Lukt `sudo -u www-data` niet als `weerscherm`, doe het dan als `deploy` met `sudo`. Controleer daarna
|
||||
`https://homedisplay.bahne-drei.de/api/data.php` op `"errors": []`.
|
||||
|
||||
- De app staat bewust zonder login. `robots.txt` en `X-Robots-Tag: noindex` houden zoekmachines buiten de deur.
|
||||
|
||||
## iPad-instellingen
|
||||
|
||||
1. Open de URL in Safari, tik op Delen → **Zet op beginscherm**. Start de app daarna altijd vanaf het beginscherm
|
||||
|
||||
@@ -30,6 +30,7 @@ return [
|
||||
'cache_ttl' => [
|
||||
'stations' => 300,
|
||||
'measure' => 900,
|
||||
'pressure' => 900,
|
||||
'forecast' => 3600,
|
||||
],
|
||||
|
||||
|
||||
+16
-2
@@ -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 = `<span class="pill ${co2.level} num" title="${co2.label}">${co2.level === 'unknown' ? '–' : r.co2 + ' ppm'}</span>`;
|
||||
return `<div class="a-room${off ? ' off' : ''}">
|
||||
<p class="a-rname">${esc(r.name)}</p>
|
||||
<p class="a-rname"><span>${esc(r.name)}</span>${pill}</p>
|
||||
<p class="a-rtemp num">${off ? '–' : f1(r.temp) + '°'}</p>
|
||||
<p class="a-rmm num"><span class="cold">↓ ${off ? '–' : f1(r.min) + '°'}</span><span class="warm">↑ ${off ? '–' : f1(r.max) + '°'}</span></p>
|
||||
<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>`;
|
||||
});
|
||||
$('rooms').innerHTML = html.join('');
|
||||
@@ -90,6 +94,15 @@ function renderRain(rain, now) {
|
||||
</div>`).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) => `<div class="a-day">
|
||||
<p class="a-dname">${dayAbbr(d.date, state.tz)}</p>
|
||||
@@ -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();
|
||||
|
||||
@@ -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(' ');
|
||||
}
|
||||
|
||||
+8
-1
@@ -26,7 +26,7 @@
|
||||
</header>
|
||||
|
||||
<section class="a-hero">
|
||||
<p class="a-label">Buiten <span class="a-trend" id="trend"></span></p>
|
||||
<p class="a-label">Buiten <span class="a-trend" id="trend"></span><span class="a-hum" id="out-hum"></span></p>
|
||||
<p class="a-temp num" id="temp">–</p>
|
||||
<div class="a-range">
|
||||
<div class="a-track"><span class="a-mark" id="mark" style="left:50%"></span></div>
|
||||
@@ -35,6 +35,13 @@
|
||||
<span><b class="warm" id="max">–</b><span id="max-at">hoogst om –</span></span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="a-pressure">
|
||||
<p class="a-plabel">Luchtdruk <b class="num" id="press">–</b><span class="a-trend" id="press-trend"></span><span class="a-prange num" id="press-range"></span></p>
|
||||
<svg class="a-spark" viewBox="0 0 560 56" preserveAspectRatio="none" aria-hidden="true">
|
||||
<line class="a-spark-base" x1="0" y1="55.5" x2="560" y2="55.5"/>
|
||||
<path class="a-spark-line" id="press-path" d=""/>
|
||||
</svg>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="a-inside" id="rooms"></section>
|
||||
|
||||
+21
-2
@@ -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; }
|
||||
|
||||
+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-v1';
|
||||
const VERSION = 'weerscherm-v2';
|
||||
const SHELL = [
|
||||
'./',
|
||||
'./index.html',
|
||||
|
||||
@@ -18,6 +18,7 @@ final class Config
|
||||
'cache_ttl' => [
|
||||
'stations' => 300,
|
||||
'measure' => 900,
|
||||
'pressure' => 900,
|
||||
'forecast' => 3600,
|
||||
],
|
||||
'storage_path' => null,
|
||||
|
||||
@@ -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<string, string> $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<array{at: string, hpa: float}>, 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<array>, stale: bool} */
|
||||
private function forecast(array $station, \DateTimeZone $tz): array
|
||||
{
|
||||
|
||||
+16
-5
@@ -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 = [];
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -57,6 +57,24 @@ final class NetatmoClient
|
||||
return $json['body'];
|
||||
}
|
||||
|
||||
/** @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,
|
||||
'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<string, string|int|float> $query */
|
||||
private function apiGet(string $path, array $query): array
|
||||
{
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
<?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;
|
||||
}
|
||||
}
|
||||
+14
-4
@@ -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<string, mixed> $body
|
||||
* @param array<string, string> $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<array{id: string, name: string, temp: ?float, min: ?float, max: ?float, reachable: bool}>,
|
||||
* 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}
|
||||
*/
|
||||
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 !== [],
|
||||
];
|
||||
}
|
||||
|
||||
@@ -13,6 +13,9 @@ interface WeatherSource
|
||||
/** @return array<string|int, mixed> de "body" van Netatmo getmeasure (timestamp => [mm]) */
|
||||
public function measure(string $deviceId, string $moduleId, int $begin, int $end): array;
|
||||
|
||||
/** @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, mixed> het "daily"-blok 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, '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
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -13,7 +13,7 @@ final class FakeSource implements WeatherSource
|
||||
public array $responses = [];
|
||||
|
||||
/** @var array<string, int> */
|
||||
public array $calls = ['stations' => 0, 'measure' => 0, 'forecast' => 0];
|
||||
public array $calls = ['stations' => 0, 'measure' => 0, 'pressure' => 0, 'forecast' => 0];
|
||||
|
||||
/** @var array<string, 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());
|
||||
|
||||
@@ -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']);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Weerscherm\Tests;
|
||||
|
||||
use PHPUnit\Framework\TestCase;
|
||||
use Weerscherm\PressureHistory;
|
||||
|
||||
final class PressureHistoryTest 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'));
|
||||
|
||||
self::assertCount(49, $points);
|
||||
self::assertSame('2026-09-10T14:30:00+02:00', $points[0]['at']);
|
||||
self::assertSame('2026-09-11T14:30:00+02:00', $points[48]['at']);
|
||||
self::assertIsFloat($points[0]['hpa']);
|
||||
self::assertSame(['at', 'hpa'], array_keys($points[0]));
|
||||
}
|
||||
|
||||
public function testSkipsGarbage(): void
|
||||
{
|
||||
$points = PressureHistory::map(['abc' => [1000.0], '1789129800' => ['x'], '1789133400' => [1013.5]], new \DateTimeZone('UTC'));
|
||||
|
||||
self::assertSame([['at' => '2026-09-11T13:30:00+00:00', 'hpa' => 1013.5]], $points);
|
||||
}
|
||||
}
|
||||
@@ -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']);
|
||||
}
|
||||
}
|
||||
|
||||
+152
@@ -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"
|
||||
}
|
||||
@@ -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), '');
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user