Compare commits
11
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a3bbf5639d | ||
|
|
ca2fc76552 | ||
|
|
75111a5571 | ||
|
|
bfc96acb5a | ||
|
|
4c4881fd11 | ||
|
|
107978b265 | ||
|
|
5058bb850d | ||
|
|
5d61b6ec90 | ||
|
|
e2db301aef | ||
|
|
c0715b7cc9 | ||
|
|
c95d198341 |
@@ -31,6 +31,7 @@ return [
|
|||||||
'stations' => 300,
|
'stations' => 300,
|
||||||
'measure' => 900,
|
'measure' => 900,
|
||||||
'pressure' => 900,
|
'pressure' => 900,
|
||||||
|
'temperature' => 900,
|
||||||
'forecast' => 3600,
|
'forecast' => 3600,
|
||||||
'radar' => 240,
|
'radar' => 240,
|
||||||
],
|
],
|
||||||
|
|||||||
+28
-1
@@ -251,7 +251,34 @@ De iconen `sun`, `partly`, `rain` en `showers` staan in de mockup. Maak `cloud`,
|
|||||||
- De temperatuur staat groot, met 1 decimaal ("16,4°"). Het label heeft een trend-aanduiding: `up` → "↗ stijgend", `down` → "↘ dalend", `stable` → "→ stabiel".
|
- De temperatuur staat groot, met 1 decimaal ("16,4°"). Het label heeft een trend-aanduiding: `up` → "↗ stijgend", `down` → "↘ dalend", `stable` → "→ stabiel".
|
||||||
- Daaronder de gradiëntbalk van `--cold` naar `--warm`. De marker staat op `(temp − min) / (max − min)`, begrensd op 0–100%. Bij `max == min` staat hij op 50%.
|
- Daaronder de gradiëntbalk van `--cold` naar `--warm`. De marker staat op `(temp − min) / (max − min)`, begrensd op 0–100%. Bij `max == min` staat hij op 50%.
|
||||||
- Onder de balk: "9,2° laagst om 06:10" en "19,8° hoogst om 13:05".
|
- Onder de balk: "9,2° laagst om 06:10" en "19,8° hoogst om 13:05".
|
||||||
- **Binnen (rechts):** 3 rijen met naam, temperatuur (1 decimaal) en "↓ min° ↑ max°". Een onbereikbare module toont "–" in `--muted`.
|
- Rechts van het grote getal een klein grafiekje van het temperatuurverloop van vandaag.
|
||||||
|
_Toegevoegd 2026-09-13: onderschrift "Vandaag", de lijn, en tijdlabels op hele uren met "nu" rechts
|
||||||
|
(zelfde `hourTicks` als de luchtdruk). Geen y-as: de laagste en hoogste waarde van vandaag staan al
|
||||||
|
onder de balk en dat is precies de schaal van de lijn. Data uit een extra `getmeasure`
|
||||||
|
(`scale=30min`, `type=Temperature`, buitenmodule) vanaf middernacht lokale tijd, in de payload als
|
||||||
|
`outdoor_history: {points, stale}`. Vlak na middernacht zijn er nog geen twee punten; dan blijft het
|
||||||
|
blokje verborgen in plaats van een leeg kader te tonen._
|
||||||
|
- Daaronder de luchtdruk: "Luchtdruk 1014 hPa ↗ stijgend" met een grafiek van de afgelopen 24 uur.
|
||||||
|
_Assen toegevoegd 2026-09-13: links de hoogste en laagste waarde van het venster in hele hPa, onder de
|
||||||
|
lijn tijdlabels op hele uren die op de klok zijn uitgelijnd (elke 6 uur vanaf middernacht, dus 00:00,
|
||||||
|
06:00, 12:00, 18:00) op hun echte tijdspositie, met "nu" op de rechterrand, plus drie lichte verticale
|
||||||
|
hulplijnen. Een markering te dicht bij het einde valt weg omdat "nu" daar staat. De losse tekst
|
||||||
|
"1009 – 1015 hPa in 24 u" in de kopregel is daarmee vervallen. Zie `hourTicks()` in `public/format.js`;
|
||||||
|
die loopt uur voor uur door het venster en raadpleegt telkens de lokale klok, zodat de zomertijdwissel
|
||||||
|
vanzelf goed gaat._
|
||||||
|
- **Binnen (rechts):** een rij per module met naam, temperatuur (1 decimaal) en "↓ min° ↑ max°". Een onbereikbare module toont "–" in `--muted`.
|
||||||
|
_Gewijzigd 2026-09-14: het aantal modules ligt niet vast op 3. De kolom heeft een vaste hoogte van 412px
|
||||||
|
(744 − 2×30 padding − 40 kopregel − 232 onderstrook) en `fitRooms()` verkleint de inhoud met een schaal
|
||||||
|
zodra die niet past. Zonder dat duwde een vierde module de onderste strook 39px van het scherm af. Bij
|
||||||
|
drie modules verandert er niets (schaal 1); vier geeft ongeveer 0,86, en met een vastgezette module 0,78._
|
||||||
|
_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
|
_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
|
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
|
op de bovenste (vastgezette) module maakt hem weer los. De keuze blijft staan bij de 5-minuten-verversing en
|
||||||
|
|||||||
+59
-3
@@ -2,7 +2,7 @@ import {
|
|||||||
f1, fInt, fmm, markerPos, barHeights, wmoIcon, trendLabel,
|
f1, fInt, fmm, markerPos, barHeights, wmoIcon, trendLabel,
|
||||||
formatDate, formatTime, dayAbbr, monthName, isOlderThan, msUntil,
|
formatDate, formatTime, dayAbbr, monthName, isOlderThan, msUntil,
|
||||||
co2Level, fPct, fHpa, seriesRange, sparklinePath,
|
co2Level, fPct, fHpa, seriesRange, sparklinePath,
|
||||||
windArrowDeg, fKmh, formatHour, formatFullDate, pinRoom,
|
windArrowDeg, fKmh, formatHour, formatFullDate, pinRoom, hourTicks, scaleToFit,
|
||||||
} from './format.js';
|
} from './format.js';
|
||||||
import { icon, windArrow } from './icons.js';
|
import { icon, windArrow } from './icons.js';
|
||||||
|
|
||||||
@@ -17,6 +17,7 @@ const FALLBACK_TZ = 'Europe/Berlin';
|
|||||||
const OVERLAY_MS = 20000; // hoe lang een overlay blijft staan zonder tik
|
const OVERLAY_MS = 20000; // hoe lang een overlay blijft staan zonder tik
|
||||||
const RADAR_BUCKET_MS = 240000; // hergebruikvenster voor het radarbeeld, gelijk aan de servercache
|
const RADAR_BUCKET_MS = 240000; // hergebruikvenster voor het radarbeeld, gelijk aan de servercache
|
||||||
const HOURLY_WIDTH = 1045; // = stage-breedte 1133 min 2x 44px padding, zelfde marge als de rest van het scherm
|
const HOURLY_WIDTH = 1045; // = stage-breedte 1133 min 2x 44px padding, zelfde marge als de rest van het scherm
|
||||||
|
const INSIDE_H = 412; // hoogte van de middelste rij: 744 - 2x30 padding - 40 kopregel - 232 onderstrook
|
||||||
|
|
||||||
const $ = (id) => document.getElementById(id);
|
const $ = (id) => document.getElementById(id);
|
||||||
const stage = $('stage');
|
const stage = $('stage');
|
||||||
@@ -72,6 +73,27 @@ function renderOutdoor(o) {
|
|||||||
setText('max-at', `hoogst om ${formatTime(o.max_at, state.tz)}`);
|
setText('max-at', `hoogst om ${formatTime(o.max_at, state.tz)}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Verloop van vandaag naast het grote getal. Met minder dan twee punten valt er niets te tekenen. */
|
||||||
|
function renderTodayChart(history) {
|
||||||
|
const block = $('today-chart');
|
||||||
|
const points = history?.points || [];
|
||||||
|
if (points.length < 2) {
|
||||||
|
block.hidden = true;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
block.hidden = false;
|
||||||
|
$('today-path').setAttribute('d', sparklinePath(points.map((p) => p.temp), 200, 96, 4));
|
||||||
|
|
||||||
|
const ticks = hourTicks(points[0].at, points[points.length - 1].at, state.tz);
|
||||||
|
$('today-hours').innerHTML = ticks
|
||||||
|
.map((t) => {
|
||||||
|
const style = t.percent < 6 ? 'left:0' : `left:${t.percent.toFixed(1)}%;transform:translateX(-50%)`;
|
||||||
|
return `<span style="${style}">${formatTime(t.at, state.tz)}</span>`;
|
||||||
|
})
|
||||||
|
.concat(['<span style="right:0">nu</span>'])
|
||||||
|
.join('');
|
||||||
|
}
|
||||||
|
|
||||||
function renderRooms(rooms) {
|
function renderRooms(rooms) {
|
||||||
const ordered = pinRoom(rooms, state.pinnedRoomId);
|
const ordered = pinRoom(rooms, state.pinnedRoomId);
|
||||||
const html = ordered.map((r) => {
|
const html = ordered.map((r) => {
|
||||||
@@ -79,13 +101,31 @@ function renderRooms(rooms) {
|
|||||||
const co2 = co2Level(off ? null : r.co2);
|
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 pill = `<span class="pill ${co2.level} num" title="${co2.label}">${co2.level === 'unknown' ? '–' : r.co2 + ' ppm'}</span>`;
|
||||||
const pinned = r.id === state.pinnedRoomId;
|
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)}">
|
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-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>
|
<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>`;
|
</div>`;
|
||||||
});
|
});
|
||||||
$('rooms').innerHTML = html.join('');
|
$('rooms').innerHTML = html.join('');
|
||||||
|
fitRooms();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Houdt de modulekolom binnen zijn vaste hoogte. Bij drie modules past alles op ware grootte; komt er een
|
||||||
|
* module bij (of groeit er een doordat je hem vastzet), dan wordt het geheel net genoeg verkleind. Zonder
|
||||||
|
* dit duwt een vierde kaart de onderste strook van het scherm af.
|
||||||
|
*/
|
||||||
|
function fitRooms() {
|
||||||
|
const el = $('rooms');
|
||||||
|
el.style.transform = 'none';
|
||||||
|
const scale = scaleToFit(el.scrollHeight, INSIDE_H);
|
||||||
|
el.style.transform = scale < 1 ? `scale(${scale.toFixed(4)})` : '';
|
||||||
}
|
}
|
||||||
|
|
||||||
function renderRain(rain, now) {
|
function renderRain(rain, now) {
|
||||||
@@ -104,10 +144,25 @@ function renderRain(rain, now) {
|
|||||||
function renderPressure(p) {
|
function renderPressure(p) {
|
||||||
setText('press', fHpa(p.current));
|
setText('press', fHpa(p.current));
|
||||||
setText('press-trend', trendLabel(p.trend));
|
setText('press-trend', trendLabel(p.trend));
|
||||||
|
|
||||||
const values = p.history.map((h) => h.hpa);
|
const values = p.history.map((h) => h.hpa);
|
||||||
const range = seriesRange(values);
|
const range = seriesRange(values);
|
||||||
setText('press-range', range ? `${Math.round(range.min)} – ${Math.round(range.max)} hPa in 24 u` : '');
|
setText('press-max', range ? String(Math.round(range.max)) : '–');
|
||||||
|
setText('press-min', range ? String(Math.round(range.min)) : '–');
|
||||||
$('press-path').setAttribute('d', sparklinePath(values, 560, 56));
|
$('press-path').setAttribute('d', sparklinePath(values, 560, 56));
|
||||||
|
|
||||||
|
// Hele uren op hun echte tijdspositie, plus "nu" op de rechterrand.
|
||||||
|
const first = p.history[0];
|
||||||
|
const last = p.history[p.history.length - 1];
|
||||||
|
const ticks = first && last ? hourTicks(first.at, last.at, state.tz) : [];
|
||||||
|
$('press-hours').innerHTML = ticks
|
||||||
|
.map((t) => {
|
||||||
|
// Een markering vlak op de linkerrand niet centreren, anders steekt het label buiten de grafiek.
|
||||||
|
const style = t.percent < 3 ? 'left:0' : `left:${t.percent.toFixed(1)}%;transform:translateX(-50%)`;
|
||||||
|
return `<span style="${style}">${formatTime(t.at, state.tz)}</span>`;
|
||||||
|
})
|
||||||
|
.concat(last ? ['<span style="right:0">nu</span>'] : [])
|
||||||
|
.join('');
|
||||||
}
|
}
|
||||||
|
|
||||||
function renderForecast(days) {
|
function renderForecast(days) {
|
||||||
@@ -256,6 +311,7 @@ function render() {
|
|||||||
if (d) {
|
if (d) {
|
||||||
renderMeasurement(now);
|
renderMeasurement(now);
|
||||||
renderOutdoor(d.outdoor);
|
renderOutdoor(d.outdoor);
|
||||||
|
renderTodayChart(d.outdoor_history);
|
||||||
renderRooms(d.indoor);
|
renderRooms(d.indoor);
|
||||||
renderRain(d.rain, now);
|
renderRain(d.rain, now);
|
||||||
renderPressure(d.pressure || { current: null, trend: null, history: [] });
|
renderPressure(d.pressure || { current: null, trend: null, history: [] });
|
||||||
|
|||||||
@@ -219,3 +219,36 @@ export function pinRoom(rooms, pinnedId) {
|
|||||||
copy.unshift(pinned);
|
copy.unshift(pinned);
|
||||||
return copy;
|
return copy;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Markeringen op hele uren binnen een tijdvenster, uitgelijnd op de klok: elke `stepHours` uur
|
||||||
|
* gerekend vanaf middernacht in de gegeven tijdzone, dus 00:00, 06:00, 12:00, 18:00 bij een stap van 6.
|
||||||
|
* Per markering het tijdstip en de positie in procenten van het venster.
|
||||||
|
*
|
||||||
|
* Er wordt uur voor uur door het venster gelopen en telkens de lokale klok geraadpleegd, zodat de
|
||||||
|
* zomertijdwissel vanzelf goed gaat: een dag van 23 of 25 uur levert gewoon de juiste hele uren op.
|
||||||
|
* Markeringen binnen `minGapPercent` van het einde vallen weg; daar staat het label "nu".
|
||||||
|
*/
|
||||||
|
export function hourTicks(startIso, endIso, tz, stepHours = 6, minGapPercent = 9) {
|
||||||
|
const t0 = new Date(startIso).getTime();
|
||||||
|
const t1 = new Date(endIso).getTime();
|
||||||
|
if (Number.isNaN(t0) || Number.isNaN(t1) || t1 <= t0) return [];
|
||||||
|
|
||||||
|
const hourMs = 3600000;
|
||||||
|
const span = t1 - t0;
|
||||||
|
const ticks = [];
|
||||||
|
for (let t = Math.ceil(t0 / hourMs) * hourMs; t <= t1; t += hourMs) {
|
||||||
|
const at = new Date(t).toISOString();
|
||||||
|
if (Number(formatHour(at, tz)) % stepHours !== 0) continue;
|
||||||
|
const percent = ((t - t0) / span) * 100;
|
||||||
|
if (percent > 100 - minGapPercent) break;
|
||||||
|
ticks.push({ at, percent });
|
||||||
|
}
|
||||||
|
return ticks;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Schaal om `natural` px binnen `available` px te passen; nooit vergroten, nooit negatief. */
|
||||||
|
export function scaleToFit(natural, available) {
|
||||||
|
if (!(natural > 0) || !(available > 0)) return 1;
|
||||||
|
return Math.min(1, available / natural);
|
||||||
|
}
|
||||||
|
|||||||
+25
-7
@@ -27,7 +27,17 @@
|
|||||||
|
|
||||||
<section class="a-hero">
|
<section class="a-hero">
|
||||||
<p class="a-label">Buiten <span class="a-trend" id="trend"></span><span class="a-hum" id="out-hum"></span></p>
|
<p class="a-label">Buiten <span class="a-trend" id="trend"></span><span class="a-hum" id="out-hum"></span></p>
|
||||||
<p class="a-temp num" id="temp">–</p>
|
<div class="a-temprow">
|
||||||
|
<p class="a-temp num" id="temp">–</p>
|
||||||
|
<div class="a-today" id="today-chart" hidden>
|
||||||
|
<p class="a-tcap">Vandaag</p>
|
||||||
|
<svg class="a-tspark" viewBox="0 0 200 96" preserveAspectRatio="none" aria-hidden="true">
|
||||||
|
<line class="a-spark-base" x1="0" y1="95.5" x2="200" y2="95.5"/>
|
||||||
|
<path class="a-spark-line" id="today-path" d=""/>
|
||||||
|
</svg>
|
||||||
|
<div class="a-px num" id="today-hours"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
<div class="a-range">
|
<div class="a-range">
|
||||||
<div class="a-track"><span class="a-mark" id="mark" style="left:50%"></span></div>
|
<div class="a-track"><span class="a-mark" id="mark" style="left:50%"></span></div>
|
||||||
<div class="a-ends num">
|
<div class="a-ends num">
|
||||||
@@ -36,15 +46,23 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="a-pressure">
|
<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>
|
<p class="a-plabel">Luchtdruk <b class="num" id="press">–</b><span class="a-trend" id="press-trend"></span></p>
|
||||||
<svg class="a-spark" viewBox="0 0 560 56" preserveAspectRatio="none" aria-hidden="true">
|
<div class="a-pgrid">
|
||||||
<line class="a-spark-base" x1="0" y1="55.5" x2="560" y2="55.5"/>
|
<div class="a-py num"><span id="press-max">–</span><span id="press-min">–</span></div>
|
||||||
<path class="a-spark-line" id="press-path" d=""/>
|
<svg class="a-spark" viewBox="0 0 560 56" preserveAspectRatio="none" aria-hidden="true">
|
||||||
</svg>
|
<line class="a-spark-grid" x1="140" y1="0" x2="140" y2="56"/>
|
||||||
|
<line class="a-spark-grid" x1="280" y1="0" x2="280" y2="56"/>
|
||||||
|
<line class="a-spark-grid" x1="420" y1="0" x2="420" y2="56"/>
|
||||||
|
<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></div>
|
||||||
|
<div class="a-px num" id="press-hours"></div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section class="a-inside" id="rooms"></section>
|
<section class="a-inside"><div id="rooms"></div></section>
|
||||||
|
|
||||||
<section class="a-bottom">
|
<section class="a-bottom">
|
||||||
<div id="rain-block">
|
<div id="rain-block">
|
||||||
|
|||||||
+25
-8
@@ -80,7 +80,14 @@ html, body {
|
|||||||
.a-hero { align-self: center; }
|
.a-hero { align-self: center; }
|
||||||
.a-label { font-size: 22px; color: var(--muted); font-weight: 500; display: flex; gap: 14px; align-items: baseline; }
|
.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-trend { font-size: 17px; font-weight: 400; }
|
||||||
.a-temp { font-size: 200px; font-weight: 200; letter-spacing: -.045em; line-height: .95; margin: 4px 0 18px -8px; }
|
.a-temprow { display: flex; align-items: center; gap: 28px; margin: 4px 0 10px 0; }
|
||||||
|
.a-temp { font-size: 200px; font-weight: 200; letter-spacing: -.045em; line-height: .95; margin-left: -8px; }
|
||||||
|
|
||||||
|
/* Temperatuurverloop van vandaag, in de ruimte rechts van het grote getal */
|
||||||
|
.a-today { flex: 1; min-width: 0; }
|
||||||
|
.a-today[hidden] { display: none; }
|
||||||
|
.a-tcap { font-size: 15px; color: var(--muted); margin-bottom: 4px; }
|
||||||
|
.a-tspark { display: block; width: 100%; height: 96px; overflow: visible; }
|
||||||
.a-hum { font-size: 17px; font-weight: 400; }
|
.a-hum { font-size: 17px; font-weight: 400; }
|
||||||
.a-hum:not(:empty)::before { content: "·"; margin-right: 14px; }
|
.a-hum:not(:empty)::before { content: "·"; margin-right: 14px; }
|
||||||
.a-range { width: 560px; }
|
.a-range { width: 560px; }
|
||||||
@@ -89,17 +96,23 @@ html, body {
|
|||||||
.a-ends { display: flex; justify-content: space-between; margin-top: 14px; font-size: 17px; color: var(--muted); }
|
.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; }
|
.a-ends b { font-size: 24px; font-weight: 600; margin-right: 6px; }
|
||||||
|
|
||||||
/* Luchtdruk, 24 uur */
|
/* Luchtdruk, 24 uur. Grafiek met een y-as (hoogste/laagste waarde) en tijdlabels op de x-as. */
|
||||||
.a-pressure { width: 560px; margin-top: 18px; }
|
.a-pressure { width: 560px; margin-top: 14px; }
|
||||||
.a-plabel { font-size: 17px; color: var(--muted); display: flex; gap: 12px; align-items: baseline; margin-bottom: 6px; }
|
.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-plabel b { font-size: 20px; font-weight: 600; color: var(--ink); }
|
||||||
.a-prange { margin-left: auto; font-size: 14px; }
|
.a-pgrid { display: grid; grid-template-columns: 44px 1fr; column-gap: 8px; row-gap: 3px; }
|
||||||
.a-spark { display: block; width: 560px; height: 56px; overflow: visible; }
|
.a-py { display: flex; flex-direction: column; justify-content: space-between; text-align: right; font-size: 13px; color: var(--muted); line-height: 1; padding: 1px 0; }
|
||||||
|
.a-px { position: relative; height: 17px; font-size: 13px; color: var(--muted); }
|
||||||
|
.a-px span { position: absolute; top: 0; white-space: nowrap; }
|
||||||
|
.a-spark { display: block; width: 100%; height: 56px; overflow: visible; }
|
||||||
.a-spark-base { stroke: var(--line); stroke-width: 1; }
|
.a-spark-base { stroke: var(--line); stroke-width: 1; }
|
||||||
|
.a-spark-grid { stroke: var(--line); stroke-width: 1; vector-effect: non-scaling-stroke; opacity: .6; }
|
||||||
.a-spark-line { fill: none; stroke: var(--rain); stroke-width: 2.4; stroke-linecap: round; stroke-linejoin: round; vector-effect: non-scaling-stroke; }
|
.a-spark-line { fill: none; stroke: var(--rain); stroke-width: 2.4; stroke-linecap: round; stroke-linejoin: round; vector-effect: non-scaling-stroke; }
|
||||||
|
|
||||||
/* Binnen */
|
/* Binnen. Vaste hoogte met overflow verborgen: hoeveel modules er ook zijn, de kolom duwt nooit de
|
||||||
.a-inside { align-self: center; }
|
onderste strook van het scherm af. Past de inhoud niet, dan verkleint fitRooms() hem met een schaal. */
|
||||||
|
.a-inside { align-self: center; height: 412px; display: flex; align-items: center; overflow: hidden; }
|
||||||
|
#rooms { width: 100%; transform-origin: center center; }
|
||||||
.a-room { padding: 12px 0; border-top: 1px solid var(--line); }
|
.a-room { padding: 12px 0; border-top: 1px solid var(--line); }
|
||||||
.a-room:first-child { border-top: 0; }
|
.a-room:first-child { border-top: 0; }
|
||||||
.a-rname { font-size: 19px; color: var(--muted); display: flex; justify-content: space-between; align-items: center; }
|
.a-rname { font-size: 19px; color: var(--muted); display: flex; justify-content: space-between; align-items: center; }
|
||||||
@@ -108,7 +121,11 @@ html, body {
|
|||||||
.pill.medium { background: var(--warm); }
|
.pill.medium { background: var(--warm); }
|
||||||
.pill.bad { background: var(--bad); }
|
.pill.bad { background: var(--bad); }
|
||||||
.pill.unknown { background: var(--line); color: var(--muted); }
|
.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.off .a-rtemp { color: var(--muted); }
|
||||||
.a-room.pinned .a-rtemp { font-size: 92px; }
|
.a-room.pinned .a-rtemp { font-size: 92px; }
|
||||||
.a-rmm { font-size: 17px; display: flex; gap: 18px; }
|
.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. */
|
/* Service worker: app-shell cache-first, API network-first met timeout en fallback op de laatste response. */
|
||||||
|
|
||||||
const VERSION = 'weerscherm-v7';
|
const VERSION = 'weerscherm-v12';
|
||||||
const SHELL = [
|
const SHELL = [
|
||||||
'./',
|
'./',
|
||||||
'./index.html',
|
'./index.html',
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ final class Config
|
|||||||
'stations' => 300,
|
'stations' => 300,
|
||||||
'measure' => 900,
|
'measure' => 900,
|
||||||
'pressure' => 900,
|
'pressure' => 900,
|
||||||
|
'temperature' => 900,
|
||||||
'forecast' => 3600,
|
'forecast' => 3600,
|
||||||
'radar' => 240,
|
'radar' => 240,
|
||||||
],
|
],
|
||||||
|
|||||||
@@ -10,6 +10,8 @@ namespace Weerscherm;
|
|||||||
*/
|
*/
|
||||||
final class DashboardService
|
final class DashboardService
|
||||||
{
|
{
|
||||||
|
private const PRESSURE_HOURS = 24;
|
||||||
|
|
||||||
private const ERR_NETATMO_AUTH = 'netatmo_auth';
|
private const ERR_NETATMO_AUTH = 'netatmo_auth';
|
||||||
private const ERR_NETATMO = 'netatmo_unavailable';
|
private const ERR_NETATMO = 'netatmo_unavailable';
|
||||||
private const ERR_FORECAST = 'forecast_unavailable';
|
private const ERR_FORECAST = 'forecast_unavailable';
|
||||||
@@ -48,6 +50,7 @@ final class DashboardService
|
|||||||
|
|
||||||
$rain = $this->rain($station, $now);
|
$rain = $this->rain($station, $now);
|
||||||
$pressure = $this->pressure($station, $now);
|
$pressure = $this->pressure($station, $now);
|
||||||
|
$outdoorHistory = $this->outdoorHistory($station, $now);
|
||||||
$forecast = $this->forecast($station, $tz);
|
$forecast = $this->forecast($station, $tz);
|
||||||
|
|
||||||
return [
|
return [
|
||||||
@@ -66,7 +69,8 @@ final class DashboardService
|
|||||||
'max_at' => $this->iso($station['outdoor']['max_at'], $tz),
|
'max_at' => $this->iso($station['outdoor']['max_at'], $tz),
|
||||||
'humidity' => $station['outdoor']['humidity'],
|
'humidity' => $station['outdoor']['humidity'],
|
||||||
],
|
],
|
||||||
'indoor' => $station['indoor'],
|
'outdoor_history' => $outdoorHistory,
|
||||||
|
'indoor' => $this->indoorWithHistory($station['indoor'], $station['device_id'], $now),
|
||||||
'rain' => $rain,
|
'rain' => $rain,
|
||||||
'pressure' => $pressure,
|
'pressure' => $pressure,
|
||||||
'forecast' => $forecast['days'],
|
'forecast' => $forecast['days'],
|
||||||
@@ -101,18 +105,73 @@ final class DashboardService
|
|||||||
if ($station['device_id'] === null) {
|
if ($station['device_id'] === null) {
|
||||||
return $base + ['history' => [], 'stale' => false];
|
return $base + ['history' => [], 'stale' => false];
|
||||||
}
|
}
|
||||||
$begin = $now->getTimestamp() - PressureHistory::HOURS * 3600;
|
$begin = $now->getTimestamp() - self::PRESSURE_HOURS * 3600;
|
||||||
$measure = $this->fetch(
|
$measure = $this->fetch(
|
||||||
'pressure',
|
'pressure',
|
||||||
fn() => $this->source->pressureHistory($station['device_id'], $begin, $now->getTimestamp()),
|
fn() => $this->source->pressureHistory($station['device_id'], $begin, $now->getTimestamp()),
|
||||||
self::ERR_NETATMO,
|
self::ERR_NETATMO,
|
||||||
);
|
);
|
||||||
return $base + [
|
return $base + [
|
||||||
'history' => $measure['data'] === null ? [] : PressureHistory::map($measure['data'], $now->getTimezone()),
|
'history' => $measure['data'] === null ? [] : MeasureSeries::map($measure['data'], $now->getTimezone(), 'hpa'),
|
||||||
'stale' => $measure['stale'],
|
'stale' => $measure['stale'],
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Temperatuurverloop van vandaag: vanaf middernacht lokale tijd tot nu.
|
||||||
|
*
|
||||||
|
* @return array{points: list<array{at: string, temp: float}>, stale: bool}
|
||||||
|
*/
|
||||||
|
private function outdoorHistory(array $station, \DateTimeImmutable $now): array
|
||||||
|
{
|
||||||
|
$moduleId = $station['outdoor_module_id'];
|
||||||
|
if ($station['device_id'] === null || $moduleId === null) {
|
||||||
|
return ['points' => [], 'stale' => false];
|
||||||
|
}
|
||||||
|
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-' . ($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'),
|
||||||
|
'stale' => $measure['stale'],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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} */
|
/** @return array{days: list<array>, stale: bool} */
|
||||||
private function forecast(array $station, \DateTimeZone $tz): array
|
private function forecast(array $station, \DateTimeZone $tz): array
|
||||||
{
|
{
|
||||||
@@ -148,12 +207,13 @@ final class DashboardService
|
|||||||
* Cache-first per bron. Bij een fout: stale cache (ongeacht leeftijd) en een foutcode, anders null.
|
* Cache-first per bron. Bij een fout: stale cache (ongeacht leeftijd) en een foutcode, anders null.
|
||||||
*
|
*
|
||||||
* @param callable(): array $fetcher
|
* @param callable(): array $fetcher
|
||||||
|
* @param ?string $ttlKey losse TTL-sleutel wanneer de cachesleutel per module verschilt
|
||||||
* @return array{data: ?array, stale: bool}
|
* @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();
|
$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)) {
|
if ($ttl > 0 && $this->cache->isFresh($key, $ttl, $now)) {
|
||||||
return ['data' => $this->cache->read($key)['data'], 'stale' => false];
|
return ['data' => $this->cache->read($key)['data'], 'stale' => false];
|
||||||
}
|
}
|
||||||
|
|||||||
+30
-1
@@ -38,6 +38,8 @@ final class FixtureSource implements WeatherSource
|
|||||||
'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']],
|
'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,
|
['_id' => '03:00:00:00:00:03', 'type' => 'NAModule4', 'module_name' => 'Kantoor', 'reachable' => true,
|
||||||
'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']],
|
'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' => '03:00:00:00:00:05', 'type' => 'NAModule4', 'module_name' => 'Zolder', 'reachable' => true,
|
||||||
|
'dashboard_data' => ['time_utc' => $at('-5 minutes'), 'Temperature' => 17.4, 'CO2' => 780, 'Humidity' => 55, 'min_temp' => 16.2, 'max_temp' => 18.0, 'temp_trend' => 'up']],
|
||||||
['_id' => '05:00:00:00:00:04', 'type' => 'NAModule3', 'module_name' => 'Regenmeter', 'reachable' => true,
|
['_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]],
|
'dashboard_data' => ['time_utc' => $at('-3 minutes'), 'Rain' => 0, 'sum_rain_1' => 0, 'sum_rain_24' => 2.4]],
|
||||||
],
|
],
|
||||||
@@ -61,12 +63,39 @@ final class FixtureSource implements WeatherSource
|
|||||||
{
|
{
|
||||||
$body = [];
|
$body = [];
|
||||||
for ($ts = $begin - ($begin % 1800); $ts <= $end; $ts += 1800) {
|
for ($ts = $begin - ($begin % 1800); $ts <= $end; $ts += 1800) {
|
||||||
$i = ($end - $ts) / 1800;
|
// Hele halfuren sinds het einde; intdiv houdt $i een int, zodat de modulo hieronder geen
|
||||||
|
// deprecation geeft (en de waarschuwing niet vóór de JSON in de response belandt).
|
||||||
|
$i = intdiv($end - $ts, 1800);
|
||||||
$body[(string) $ts] = [round(1012 + 3 * sin($i / 8) + ($i % 5) * 0.1, 1)];
|
$body[(string) $ts] = [round(1012 + 3 * sin($i / 8) + ($i % 5) * 0.1, 1)];
|
||||||
}
|
}
|
||||||
return $body;
|
return $body;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function temperatureHistory(string $deviceId, ?string $moduleId, int $begin, int $end): array
|
||||||
|
{
|
||||||
|
// 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
|
||||||
|
'03:00:00:00:00:05' => [16.2, 18.0, 15], // zolder
|
||||||
|
default => [20.4, 22.1, 12], // basisstation
|
||||||
|
};
|
||||||
|
|
||||||
|
$body = [];
|
||||||
|
for ($ts = $begin - ($begin % 1800); $ts <= $end; $ts += 1800) {
|
||||||
|
if ($ts < $begin) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
$at = (new \DateTimeImmutable('@' . $ts))->setTimezone($this->now->getTimezone());
|
||||||
|
$hour = (int) $at->format('G') + (int) $at->format('i') / 60;
|
||||||
|
$t = $min + ($max - $min) * (0.5 + 0.5 * sin((($hour - $shift) / 24) * 2 * M_PI));
|
||||||
|
$body[(string) $ts] = [round($t, 1)];
|
||||||
|
}
|
||||||
|
return $body;
|
||||||
|
}
|
||||||
|
|
||||||
public function forecast(float $lat, float $lon, string $timezone): array
|
public function forecast(float $lat, float $lon, string $timezone): array
|
||||||
{
|
{
|
||||||
$tmin = [11.0, 10.1, 11.3, 12.0, 9.4, 8.1];
|
$tmin = [11.0, 10.1, 11.3, 12.0, 9.4, 8.1];
|
||||||
|
|||||||
@@ -39,6 +39,11 @@ final class LiveSource implements WeatherSource
|
|||||||
return $this->netatmo->getPressureHistory($deviceId, $begin, $end);
|
return $this->netatmo->getPressureHistory($deviceId, $begin, $end);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function temperatureHistory(string $deviceId, ?string $moduleId, int $begin, int $end): array
|
||||||
|
{
|
||||||
|
return $this->netatmo->getTemperatureHistory($deviceId, $moduleId, $begin, $end);
|
||||||
|
}
|
||||||
|
|
||||||
public function forecast(float $lat, float $lon, string $timezone): array
|
public function forecast(float $lat, float $lon, string $timezone): array
|
||||||
{
|
{
|
||||||
return $this->openMeteo->forecast($lat, $lon, $timezone);
|
return $this->openMeteo->forecast($lat, $lon, $timezone);
|
||||||
|
|||||||
@@ -0,0 +1,38 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace Weerscherm;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Zet een getmeasure-body (één waarde per tijdstip, bv. scale=30min met type=Pressure of Temperature)
|
||||||
|
* om naar een chronologisch gesorteerde reeks punten. De naam van het waardeveld verschilt per meting,
|
||||||
|
* zodat de payload "hpa" of "temp" kan heten.
|
||||||
|
*/
|
||||||
|
final class MeasureSeries
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* @param array<string|int, mixed> $body
|
||||||
|
* @return list<array{at: string, ...}> elk punt heeft "at" en het veld $valueKey
|
||||||
|
*/
|
||||||
|
public static function map(array $body, \DateTimeZone $tz, string $valueKey): array
|
||||||
|
{
|
||||||
|
$points = [];
|
||||||
|
foreach ($body as $timestamp => $values) {
|
||||||
|
if (!is_numeric($timestamp) || !is_array($values) || !isset($values[0]) || !is_numeric($values[0])) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
$points[(int) $timestamp] = (float) $values[0];
|
||||||
|
}
|
||||||
|
ksort($points);
|
||||||
|
|
||||||
|
$out = [];
|
||||||
|
foreach ($points as $ts => $value) {
|
||||||
|
$out[] = [
|
||||||
|
'at' => (new \DateTimeImmutable('@' . $ts))->setTimezone($tz)->format(\DATE_ATOM),
|
||||||
|
$valueKey => $value,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
return $out;
|
||||||
|
}
|
||||||
|
}
|
||||||
+27
-4
@@ -60,17 +60,40 @@ final class NetatmoClient
|
|||||||
/** @return array<string|int, mixed> timestamp => [hPa], per 30 minuten, van het basisstation */
|
/** @return array<string|int, mixed> timestamp => [hPa], per 30 minuten, van het basisstation */
|
||||||
public function getPressureHistory(string $deviceId, int $begin, int $end): array
|
public function getPressureHistory(string $deviceId, int $begin, int $end): array
|
||||||
{
|
{
|
||||||
$json = $this->apiGet('getmeasure', [
|
return $this->measureSeries('Pressure', ['device_id' => $deviceId], $begin, $end);
|
||||||
'device_id' => $deviceId,
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @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
|
||||||
|
{
|
||||||
|
$target = ['device_id' => $deviceId];
|
||||||
|
if ($moduleId !== null) {
|
||||||
|
$target['module_id'] = $moduleId;
|
||||||
|
}
|
||||||
|
return $this->measureSeries('Temperature', $target, $begin, $end);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Eén meetreeks per 30 minuten ophalen. $target bevat device_id en eventueel module_id.
|
||||||
|
*
|
||||||
|
* @param array<string, string> $target
|
||||||
|
* @return array<string|int, mixed>
|
||||||
|
*/
|
||||||
|
private function measureSeries(string $type, array $target, int $begin, int $end): array
|
||||||
|
{
|
||||||
|
$json = $this->apiGet('getmeasure', $target + [
|
||||||
'scale' => '30min',
|
'scale' => '30min',
|
||||||
'type' => 'Pressure',
|
'type' => $type,
|
||||||
'date_begin' => $begin,
|
'date_begin' => $begin,
|
||||||
'date_end' => $end,
|
'date_end' => $end,
|
||||||
'optimize' => 'false',
|
'optimize' => 'false',
|
||||||
'real_time' => 'false',
|
'real_time' => 'false',
|
||||||
]);
|
]);
|
||||||
if (!is_array($json['body'] ?? null)) {
|
if (!is_array($json['body'] ?? null)) {
|
||||||
throw new UpstreamException('getmeasure (Pressure): geen body in antwoord');
|
throw new UpstreamException("getmeasure ($type): geen body in antwoord");
|
||||||
}
|
}
|
||||||
return $json['body'];
|
return $json['body'];
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,33 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
declare(strict_types=1);
|
|
||||||
|
|
||||||
namespace Weerscherm;
|
|
||||||
|
|
||||||
/** Zet een getmeasure-body (scale=30min, type=Pressure) om naar een gesorteerde reeks punten. */
|
|
||||||
final class PressureHistory
|
|
||||||
{
|
|
||||||
public const HOURS = 24;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @param array<string|int, mixed> $body
|
|
||||||
* @return list<array{at: string, hpa: float}>
|
|
||||||
*/
|
|
||||||
public static function map(array $body, \DateTimeZone $tz): array
|
|
||||||
{
|
|
||||||
$points = [];
|
|
||||||
foreach ($body as $timestamp => $values) {
|
|
||||||
if (!is_numeric($timestamp) || !is_array($values) || !isset($values[0]) || !is_numeric($values[0])) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
$points[(int) $timestamp] = (float) $values[0];
|
|
||||||
}
|
|
||||||
ksort($points);
|
|
||||||
|
|
||||||
$out = [];
|
|
||||||
foreach ($points as $ts => $hpa) {
|
|
||||||
$out[] = ['at' => (new \DateTimeImmutable('@' . $ts))->setTimezone($tz)->format(\DATE_ATOM), 'hpa' => $hpa];
|
|
||||||
}
|
|
||||||
return $out;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -20,7 +20,7 @@ final class StationMapper
|
|||||||
* outdoor: array{temp: ?float, trend: ?string, min: ?float, min_at: ?int, max: ?float, max_at: ?int, humidity: ?float},
|
* 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}>,
|
* 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},
|
* pressure: array{current: ?float, trend: ?string},
|
||||||
* rain_module_id: ?string, sum_rain_24: ?float}
|
* outdoor_module_id: ?string, rain_module_id: ?string, sum_rain_24: ?float}
|
||||||
*/
|
*/
|
||||||
public static function map(array $body, ?string $deviceId, array $indoorModules): array
|
public static function map(array $body, ?string $deviceId, array $indoorModules): array
|
||||||
{
|
{
|
||||||
@@ -29,7 +29,7 @@ final class StationMapper
|
|||||||
return [
|
return [
|
||||||
'timezone' => null, 'lat' => null, 'lon' => null, 'device_id' => null, 'measured_at' => null,
|
'timezone' => null, 'lat' => null, 'lon' => null, 'device_id' => null, 'measured_at' => null,
|
||||||
'outdoor' => self::EMPTY_OUTDOOR, 'indoor' => [], 'pressure' => self::EMPTY_PRESSURE,
|
'outdoor' => self::EMPTY_OUTDOOR, 'indoor' => [], 'pressure' => self::EMPTY_PRESSURE,
|
||||||
'rain_module_id' => null, 'sum_rain_24' => null,
|
'outdoor_module_id' => null, 'rain_module_id' => null, 'sum_rain_24' => null,
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -50,6 +50,7 @@ final class StationMapper
|
|||||||
'current' => self::floatOrNull(self::dashboard($device)['Pressure'] ?? null),
|
'current' => self::floatOrNull(self::dashboard($device)['Pressure'] ?? null),
|
||||||
'trend' => self::stringOrNull(self::dashboard($device)['pressure_trend'] ?? null),
|
'trend' => self::stringOrNull(self::dashboard($device)['pressure_trend'] ?? null),
|
||||||
],
|
],
|
||||||
|
'outdoor_module_id' => $outdoor === null ? null : self::stringOrNull($outdoor['_id'] ?? null),
|
||||||
'rain_module_id' => $rain === null ? null : self::stringOrNull($rain['_id'] ?? null),
|
'rain_module_id' => $rain === null ? null : self::stringOrNull($rain['_id'] ?? null),
|
||||||
'sum_rain_24' => self::floatOrNull(self::dashboard($rain)['sum_rain_24'] ?? null),
|
'sum_rain_24' => self::floatOrNull(self::dashboard($rain)['sum_rain_24'] ?? null),
|
||||||
];
|
];
|
||||||
|
|||||||
@@ -16,6 +16,12 @@ interface WeatherSource
|
|||||||
/** @return array<string|int, mixed> de "body" van Netatmo getmeasure (timestamp => [hPa]) voor het basisstation */
|
/** @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;
|
public function pressureHistory(string $deviceId, 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 */
|
/** @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;
|
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('Europe/Berlin', $config->get('timezone'));
|
||||||
self::assertSame('fixture', $config->get('data_source'));
|
self::assertSame('fixture', $config->get('data_source'));
|
||||||
self::assertSame(['stations' => 300, 'measure' => 900, 'pressure' => 900, 'forecast' => 3600, 'radar' => 240], $config->get('cache_ttl'));
|
self::assertSame(['stations' => 300, 'measure' => 900, 'pressure' => 900, 'temperature' => 900, 'forecast' => 3600, 'radar' => 240], $config->get('cache_ttl'));
|
||||||
self::assertSame([], $config->get('indoor_modules'));
|
self::assertSame([], $config->get('indoor_modules'));
|
||||||
self::assertNull($config->get('netatmo_device_id'));
|
self::assertNull($config->get('netatmo_device_id'));
|
||||||
}
|
}
|
||||||
@@ -39,7 +39,7 @@ final class ConfigTest extends TestCase
|
|||||||
$config = Config::load($this->file);
|
$config = Config::load($this->file);
|
||||||
|
|
||||||
self::assertSame('Europe/Amsterdam', $config->get('timezone'));
|
self::assertSame('Europe/Amsterdam', $config->get('timezone'));
|
||||||
self::assertSame(['stations' => 60, 'measure' => 900, 'pressure' => 900, 'forecast' => 3600, 'radar' => 240], $config->get('cache_ttl'));
|
self::assertSame(['stations' => 60, 'measure' => 900, 'pressure' => 900, 'temperature' => 900, 'forecast' => 3600, 'radar' => 240], $config->get('cache_ttl'));
|
||||||
}
|
}
|
||||||
|
|
||||||
public function testUnknownKeyReturnsGivenDefault(): void
|
public function testUnknownKeyReturnsGivenDefault(): void
|
||||||
|
|||||||
@@ -32,7 +32,7 @@ final class DashboardServiceTest extends TestCase
|
|||||||
return new DashboardService(
|
return new DashboardService(
|
||||||
$this->source,
|
$this->source,
|
||||||
new Cache($this->dir),
|
new Cache($this->dir),
|
||||||
['stations' => 300, 'measure' => 900, 'pressure' => 900, 'forecast' => 3600],
|
['stations' => 300, 'measure' => 900, 'pressure' => 900, 'temperature' => 900, 'forecast' => 3600],
|
||||||
'Europe/Amsterdam',
|
'Europe/Amsterdam',
|
||||||
$lat,
|
$lat,
|
||||||
$lon,
|
$lon,
|
||||||
@@ -46,7 +46,7 @@ final class DashboardServiceTest extends TestCase
|
|||||||
{
|
{
|
||||||
$p = $this->service()->build();
|
$p = $this->service()->build();
|
||||||
|
|
||||||
self::assertSame(['generated_at', 'timezone', 'station', 'outdoor', 'indoor', 'rain', 'pressure', 'forecast', 'forecast_stale', 'errors'], array_keys($p));
|
self::assertSame(['generated_at', 'timezone', 'station', 'outdoor', 'outdoor_history', 'indoor', 'rain', 'pressure', 'forecast', 'forecast_stale', 'errors'], array_keys($p));
|
||||||
self::assertSame('2026-09-11T14:32:05+02:00', $p['generated_at']);
|
self::assertSame('2026-09-11T14:32:05+02:00', $p['generated_at']);
|
||||||
self::assertSame('Europe/Berlin', $p['timezone']);
|
self::assertSame('Europe/Berlin', $p['timezone']);
|
||||||
self::assertSame(['measured_at' => '2026-09-11T14:30:12+02:00', 'stale' => false], $p['station']);
|
self::assertSame(['measured_at' => '2026-09-11T14:30:12+02:00', 'stale' => false], $p['station']);
|
||||||
@@ -83,6 +83,63 @@ final class DashboardServiceTest extends TestCase
|
|||||||
self::assertSame((new \DateTimeImmutable('2026-09-11 12:32:05 UTC'))->getTimestamp(), $end);
|
self::assertSame((new \DateTimeImmutable('2026-09-11 12:32:05 UTC'))->getTimestamp(), $end);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function testOutdoorHistoryCoversTodayFromLocalMidnight(): void
|
||||||
|
{
|
||||||
|
$p = $this->service()->build();
|
||||||
|
|
||||||
|
[$deviceId, $moduleId, $begin, $end] = $this->source->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);
|
||||||
|
self::assertSame((new \DateTimeImmutable('2026-09-11 12:32:05 UTC'))->getTimestamp(), $end);
|
||||||
|
|
||||||
|
self::assertNotEmpty($p['outdoor_history']['points']);
|
||||||
|
self::assertSame(['at', 'temp'], array_keys($p['outdoor_history']['points'][0]));
|
||||||
|
self::assertFalse($p['outdoor_history']['stale']);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function 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');
|
||||||
|
|
||||||
|
$p = $this->service()->build();
|
||||||
|
|
||||||
|
self::assertSame(['points' => [], 'stale' => true], $p['outdoor_history']);
|
||||||
|
self::assertSame(16.4, $p['outdoor']['temp'], 'de actuele waarden komen uit stationsdata en blijven staan');
|
||||||
|
self::assertSame(['netatmo_unavailable'], $p['errors']);
|
||||||
|
}
|
||||||
|
|
||||||
public function testPressureHistoryIsRequestedForLast24Hours(): void
|
public function testPressureHistoryIsRequestedForLast24Hours(): void
|
||||||
{
|
{
|
||||||
$this->service()->build();
|
$this->service()->build();
|
||||||
@@ -130,6 +187,8 @@ final class DashboardServiceTest extends TestCase
|
|||||||
self::assertNull($p['rain']['today']);
|
self::assertNull($p['rain']['today']);
|
||||||
self::assertSame(0, $this->source->calls['measure'], 'zonder regenmeter-id geen getmeasure-call');
|
self::assertSame(0, $this->source->calls['measure'], 'zonder regenmeter-id geen getmeasure-call');
|
||||||
self::assertSame(0, $this->source->calls['pressure'], 'zonder device-id geen luchtdruk-call');
|
self::assertSame(0, $this->source->calls['pressure'], 'zonder device-id geen luchtdruk-call');
|
||||||
|
self::assertSame(0, $this->source->calls['temperature'], 'zonder buitenmodule geen temperatuur-call');
|
||||||
|
self::assertSame(['points' => [], 'stale' => false], $p['outdoor_history']);
|
||||||
self::assertSame(['current' => null, 'trend' => null, 'history' => [], 'stale' => false], $p['pressure']);
|
self::assertSame(['current' => null, 'trend' => null, 'history' => [], 'stale' => false], $p['pressure']);
|
||||||
self::assertSame('Europe/Amsterdam', $p['timezone'], 'fallback-tijdzone uit config');
|
self::assertSame('Europe/Amsterdam', $p['timezone'], 'fallback-tijdzone uit config');
|
||||||
self::assertSame(['netatmo_unavailable', 'forecast_unavailable'], $p['errors']);
|
self::assertSame(['netatmo_unavailable', 'forecast_unavailable'], $p['errors']);
|
||||||
@@ -203,10 +262,11 @@ final class DashboardServiceTest extends TestCase
|
|||||||
$this->service()->build();
|
$this->service()->build();
|
||||||
$this->service('2026-09-11 12:36:05 UTC')->build();
|
$this->service('2026-09-11 12:36:05 UTC')->build();
|
||||||
|
|
||||||
self::assertSame(['stations' => 1, 'measure' => 1, 'pressure' => 1, 'forecast' => 1], $this->source->calls);
|
self::assertSame(['stations' => 1, 'measure' => 1, 'pressure' => 1, 'temperature' => 4, 'forecast' => 1], $this->source->calls);
|
||||||
|
|
||||||
$this->service('2026-09-11 12:38:05 UTC')->build();
|
$this->service('2026-09-11 12:38:05 UTC')->build();
|
||||||
self::assertSame(2, $this->source->calls['stations'], 'stations na 5 min opnieuw');
|
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');
|
self::assertSame(1, $this->source->calls['measure'], 'measure nog binnen 15 min');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+11
-1
@@ -13,11 +13,14 @@ final class FakeSource implements WeatherSource
|
|||||||
public array $responses = [];
|
public array $responses = [];
|
||||||
|
|
||||||
/** @var array<string, int> */
|
/** @var array<string, int> */
|
||||||
public array $calls = ['stations' => 0, 'measure' => 0, 'pressure' => 0, 'forecast' => 0];
|
public array $calls = ['stations' => 0, 'measure' => 0, 'pressure' => 0, 'temperature' => 0, 'forecast' => 0];
|
||||||
|
|
||||||
/** @var array<string, array> */
|
/** @var array<string, array> */
|
||||||
public array $lastArgs = [];
|
public array $lastArgs = [];
|
||||||
|
|
||||||
|
/** @var array<string, list<array>> alle aanroepen per soort, in volgorde */
|
||||||
|
public array $allArgs = [];
|
||||||
|
|
||||||
public static function fromFixtures(): self
|
public static function fromFixtures(): self
|
||||||
{
|
{
|
||||||
$s = new self();
|
$s = new self();
|
||||||
@@ -25,6 +28,7 @@ final class FakeSource implements WeatherSource
|
|||||||
'stations' => json_decode((string) file_get_contents(__DIR__ . '/fixtures/getstationsdata.json'), true)['body'],
|
'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'],
|
'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'],
|
'pressure' => json_decode((string) file_get_contents(__DIR__ . '/fixtures/getmeasure-pressure.json'), true)['body'],
|
||||||
|
'temperature' => json_decode((string) file_get_contents(__DIR__ . '/fixtures/getmeasure-temperature.json'), true)['body'],
|
||||||
'forecast' => array_intersect_key(
|
'forecast' => array_intersect_key(
|
||||||
json_decode((string) file_get_contents(__DIR__ . '/fixtures/forecast.json'), true),
|
json_decode((string) file_get_contents(__DIR__ . '/fixtures/forecast.json'), true),
|
||||||
['daily' => null, 'hourly' => null],
|
['daily' => null, 'hourly' => null],
|
||||||
@@ -48,6 +52,11 @@ final class FakeSource implements WeatherSource
|
|||||||
return $this->answer('pressure', func_get_args());
|
return $this->answer('pressure', func_get_args());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function temperatureHistory(string $deviceId, ?string $moduleId, int $begin, int $end): array
|
||||||
|
{
|
||||||
|
return $this->answer('temperature', func_get_args());
|
||||||
|
}
|
||||||
|
|
||||||
public function forecast(float $lat, float $lon, string $timezone): array
|
public function forecast(float $lat, float $lon, string $timezone): array
|
||||||
{
|
{
|
||||||
return $this->answer('forecast', func_get_args());
|
return $this->answer('forecast', func_get_args());
|
||||||
@@ -57,6 +66,7 @@ final class FakeSource implements WeatherSource
|
|||||||
{
|
{
|
||||||
$this->calls[$key]++;
|
$this->calls[$key]++;
|
||||||
$this->lastArgs[$key] = $args;
|
$this->lastArgs[$key] = $args;
|
||||||
|
$this->allArgs[$key][] = $args;
|
||||||
$r = $this->responses[$key] ?? new \RuntimeException("geen response voor $key");
|
$r = $this->responses[$key] ?? new \RuntimeException("geen response voor $key");
|
||||||
if ($r instanceof \Throwable) {
|
if ($r instanceof \Throwable) {
|
||||||
throw $r;
|
throw $r;
|
||||||
|
|||||||
@@ -5,16 +5,16 @@ declare(strict_types=1);
|
|||||||
namespace Weerscherm\Tests;
|
namespace Weerscherm\Tests;
|
||||||
|
|
||||||
use PHPUnit\Framework\TestCase;
|
use PHPUnit\Framework\TestCase;
|
||||||
use Weerscherm\PressureHistory;
|
use Weerscherm\MeasureSeries;
|
||||||
|
|
||||||
final class PressureHistoryTest extends TestCase
|
final class MeasureSeriesTest extends TestCase
|
||||||
{
|
{
|
||||||
public function testMapsAndSortsPoints(): void
|
public function testMapsAndSortsPoints(): void
|
||||||
{
|
{
|
||||||
$body = json_decode((string) file_get_contents(__DIR__ . '/fixtures/getmeasure-pressure.json'), true)['body'];
|
$body = json_decode((string) file_get_contents(__DIR__ . '/fixtures/getmeasure-pressure.json'), true)['body'];
|
||||||
$shuffled = array_reverse($body, true);
|
$shuffled = array_reverse($body, true);
|
||||||
|
|
||||||
$points = PressureHistory::map($shuffled, new \DateTimeZone('Europe/Berlin'));
|
$points = MeasureSeries::map($shuffled, new \DateTimeZone('Europe/Berlin'), 'hpa');
|
||||||
|
|
||||||
self::assertCount(49, $points);
|
self::assertCount(49, $points);
|
||||||
self::assertSame('2026-09-10T14:30:00+02:00', $points[0]['at']);
|
self::assertSame('2026-09-10T14:30:00+02:00', $points[0]['at']);
|
||||||
@@ -25,8 +25,19 @@ final class PressureHistoryTest extends TestCase
|
|||||||
|
|
||||||
public function testSkipsGarbage(): void
|
public function testSkipsGarbage(): void
|
||||||
{
|
{
|
||||||
$points = PressureHistory::map(['abc' => [1000.0], '1789129800' => ['x'], '1789133400' => [1013.5]], new \DateTimeZone('UTC'));
|
$points = MeasureSeries::map(['abc' => [1000.0], '1789129800' => ['x'], '1789133400' => [1013.5]], new \DateTimeZone('UTC'), 'hpa');
|
||||||
|
|
||||||
self::assertSame([['at' => '2026-09-11T13:30:00+00:00', 'hpa' => 1013.5]], $points);
|
self::assertSame([['at' => '2026-09-11T13:30:00+00:00', 'hpa' => 1013.5]], $points);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function testTheValueFieldIsNamedPerMeasurement(): void
|
||||||
|
{
|
||||||
|
$body = ['1789129800' => [16.4], '1789131600' => [16.9]];
|
||||||
|
|
||||||
|
$temps = MeasureSeries::map($body, new \DateTimeZone('Europe/Berlin'), 'temp');
|
||||||
|
|
||||||
|
self::assertSame(['at', 'temp'], array_keys($temps[0]));
|
||||||
|
self::assertSame(16.4, $temps[0]['temp']);
|
||||||
|
self::assertSame(16.9, $temps[1]['temp']);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
+95
@@ -0,0 +1,95 @@
|
|||||||
|
{
|
||||||
|
"body": {
|
||||||
|
"1789077600": [
|
||||||
|
11.9
|
||||||
|
],
|
||||||
|
"1789079400": [
|
||||||
|
11.9
|
||||||
|
],
|
||||||
|
"1789081200": [
|
||||||
|
10.8
|
||||||
|
],
|
||||||
|
"1789083000": [
|
||||||
|
10.8
|
||||||
|
],
|
||||||
|
"1789084800": [
|
||||||
|
9.9
|
||||||
|
],
|
||||||
|
"1789086600": [
|
||||||
|
9.9
|
||||||
|
],
|
||||||
|
"1789088400": [
|
||||||
|
9.4
|
||||||
|
],
|
||||||
|
"1789090200": [
|
||||||
|
9.4
|
||||||
|
],
|
||||||
|
"1789092000": [
|
||||||
|
9.2
|
||||||
|
],
|
||||||
|
"1789093800": [
|
||||||
|
9.2
|
||||||
|
],
|
||||||
|
"1789095600": [
|
||||||
|
9.4
|
||||||
|
],
|
||||||
|
"1789097400": [
|
||||||
|
9.4
|
||||||
|
],
|
||||||
|
"1789099200": [
|
||||||
|
9.9
|
||||||
|
],
|
||||||
|
"1789101000": [
|
||||||
|
9.9
|
||||||
|
],
|
||||||
|
"1789102800": [
|
||||||
|
10.8
|
||||||
|
],
|
||||||
|
"1789104600": [
|
||||||
|
10.8
|
||||||
|
],
|
||||||
|
"1789106400": [
|
||||||
|
11.9
|
||||||
|
],
|
||||||
|
"1789108200": [
|
||||||
|
11.9
|
||||||
|
],
|
||||||
|
"1789110000": [
|
||||||
|
13.1
|
||||||
|
],
|
||||||
|
"1789111800": [
|
||||||
|
13.1
|
||||||
|
],
|
||||||
|
"1789113600": [
|
||||||
|
14.5
|
||||||
|
],
|
||||||
|
"1789115400": [
|
||||||
|
14.5
|
||||||
|
],
|
||||||
|
"1789117200": [
|
||||||
|
15.9
|
||||||
|
],
|
||||||
|
"1789119000": [
|
||||||
|
15.9
|
||||||
|
],
|
||||||
|
"1789120800": [
|
||||||
|
17.2
|
||||||
|
],
|
||||||
|
"1789122600": [
|
||||||
|
17.2
|
||||||
|
],
|
||||||
|
"1789124400": [
|
||||||
|
18.2
|
||||||
|
],
|
||||||
|
"1789126200": [
|
||||||
|
18.2
|
||||||
|
],
|
||||||
|
"1789128000": [
|
||||||
|
19.1
|
||||||
|
],
|
||||||
|
"1789129800": [
|
||||||
|
19.1
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"status": "ok"
|
||||||
|
}
|
||||||
+50
-1
@@ -121,7 +121,7 @@ test('msUntil computes the wait until the next local time', () => {
|
|||||||
|
|
||||||
import {
|
import {
|
||||||
co2Level, fPct, fHpa, seriesRange, sparklinePath, sparklinePoints,
|
co2Level, fPct, fHpa, seriesRange, sparklinePath, sparklinePoints,
|
||||||
windArrowDeg, fKmh, formatHour, formatFullDate, pinRoom,
|
windArrowDeg, fKmh, formatHour, formatFullDate, pinRoom, hourTicks, scaleToFit,
|
||||||
} from '../../public/format.js';
|
} from '../../public/format.js';
|
||||||
|
|
||||||
test('co2Level uses the Netatmo thresholds', () => {
|
test('co2Level uses the Netatmo thresholds', () => {
|
||||||
@@ -208,3 +208,52 @@ test('pinRoom does not mutate the input array', () => {
|
|||||||
pinRoom(rooms, 'c');
|
pinRoom(rooms, 'c');
|
||||||
assert.deepEqual(rooms, original);
|
assert.deepEqual(rooms, original);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
||||||
|
test('hourTicks puts marks on clock-aligned whole hours inside the window', () => {
|
||||||
|
const ticks = hourTicks('2026-09-12T11:41:00+02:00', '2026-09-13T11:41:00+02:00', TZ);
|
||||||
|
|
||||||
|
assert.deepEqual(ticks.map((t) => formatTime(t.at, TZ)), ['12:00', '18:00', '00:00', '06:00']);
|
||||||
|
assert.deepEqual(ticks.map((t) => Number(t.percent.toFixed(2))), [1.32, 26.32, 51.32, 76.32]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('hourTicks drops a mark that would collide with the "nu" label', () => {
|
||||||
|
// Venster eindigt om 12:05, dus de markering van 12:00 zit op 99,7% en valt weg.
|
||||||
|
const ticks = hourTicks('2026-09-12T12:05:00+02:00', '2026-09-13T12:05:00+02:00', TZ);
|
||||||
|
|
||||||
|
assert.deepEqual(ticks.map((t) => formatTime(t.at, TZ)), ['18:00', '00:00', '06:00']);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('hourTicks stays on local whole hours across the daylight saving change', () => {
|
||||||
|
// 25 oktober 2026: de klok gaat om 03:00 terug naar 02:00, dus dat venster duurt 25 uur.
|
||||||
|
// Zonder de lokale klok te raadplegen zou 00:00 hier op 01:00 uitkomen.
|
||||||
|
const ticks = hourTicks('2026-10-24T12:00:00+02:00', '2026-10-25T12:00:00+01:00', TZ);
|
||||||
|
|
||||||
|
assert.deepEqual(ticks.map((t) => formatTime(t.at, TZ)), ['12:00', '18:00', '00:00', '06:00']);
|
||||||
|
assert.equal(ticks[0].percent, 0, 'een venster dat precies op een heel uur begint krijgt daar een markering');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('hourTicks honours a different step', () => {
|
||||||
|
const ticks = hourTicks('2026-09-13T09:30:00+02:00', '2026-09-13T21:30:00+02:00', TZ, 3);
|
||||||
|
|
||||||
|
assert.deepEqual(ticks.map((t) => formatTime(t.at, TZ)), ['12:00', '15:00', '18:00']);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('hourTicks gives nothing for an empty or invalid window', () => {
|
||||||
|
assert.deepEqual(hourTicks('2026-09-13T10:00:00+02:00', '2026-09-13T10:00:00+02:00', TZ), []);
|
||||||
|
assert.deepEqual(hourTicks('kapot', '2026-09-13T10:00:00+02:00', TZ), []);
|
||||||
|
assert.deepEqual(hourTicks(undefined, undefined, TZ), []);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('scaleToFit shrinks to fit but never enlarges', () => {
|
||||||
|
assert.equal(scaleToFit(481, 412), 412 / 481);
|
||||||
|
assert.equal(scaleToFit(361, 412), 1, 'past al: niet vergroten');
|
||||||
|
assert.equal(scaleToFit(412, 412), 1);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('scaleToFit is safe with nonsense input', () => {
|
||||||
|
assert.equal(scaleToFit(0, 412), 1);
|
||||||
|
assert.equal(scaleToFit(-10, 412), 1);
|
||||||
|
assert.equal(scaleToFit(481, 0), 1);
|
||||||
|
assert.equal(scaleToFit(undefined, 412), 1);
|
||||||
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user