364 lines
18 KiB
Markdown
364 lines
18 KiB
Markdown
# Weerscherm: bouwspecificatie v1
|
||
|
||
Opdracht voor Claude Code: bouw een PWA die fullscreen draait op een iPad mini (liggend) in de woonkamer. Het scherm toont actuele gegevens van een Netatmo-weerstation en een weersverwachting voor 5 dagen.
|
||
|
||
**Ontwerp A** in `docs/design/weerscherm-layouts.html` is leidend voor de layout. Dat bestand is een statische mockup met voorbeelddata. De CSS van ontwerp A (klasse `.da` en alle `.a-*`-klassen) is getest op 1133 × 744 punten en dient als uitgangspunt. De tabbladen B en C zijn niet in scope.
|
||
|
||
Werkwijze: lees eerst dit document en de mockup. Stel daarna een plan voor per fase (zie §12) en bouw pas na akkoord.
|
||
|
||
---
|
||
|
||
## 0. Vooraf in te vullen (Theo)
|
||
|
||
| Wat | Waarde |
|
||
|---|---|
|
||
| iPad mini-generatie en iPadOS-versie | … |
|
||
| Productie-hostname en pad | … |
|
||
| Netatmo `client_id` / `client_secret` | in `config.php`, nooit in git |
|
||
| Volgorde en labels van de 3 binnenruimtes | … (standaard: `module_name` uit Netatmo) |
|
||
| Locatie voor de verwachting | standaard de coördinaten van het Netatmo-station |
|
||
|
||
---
|
||
|
||
## 1. Scope
|
||
|
||
**In v1:**
|
||
- Buitentemperatuur, met de laagste en hoogste temperatuur van vandaag (inclusief tijdstippen) en de trend.
|
||
- De 3 binnensensoren, elk met huidige, laagste en hoogste temperatuur van vandaag.
|
||
- Regen: vandaag, de afgelopen 3 dagen per dag, en het totaal van de huidige maand.
|
||
- Verwachting voor de komende 5 dagen (vanaf morgen): max/min-temperatuur, neerslag in mm, neerslagkans en een weericoon.
|
||
- Installeerbaar als PWA op het beginscherm, blijft werken met de laatst bekende data als de verbinding wegvalt.
|
||
|
||
**Niet in v1:** nachtmodus (ontwerp C), andere meetwaarden (CO₂, luchtvochtigheid, wind), meerdere stations, een instellingenscherm, pushmeldingen.
|
||
|
||
---
|
||
|
||
## 2. Architectuur
|
||
|
||
```
|
||
iPad (PWA) ──GET /api/data.php──▶ PHP-backend ──▶ Netatmo API
|
||
│ └──▶ Open-Meteo API
|
||
└── storage/ (tokens + cache)
|
||
```
|
||
|
||
De backend is nodig om drie redenen:
|
||
- Het Netatmo `client_secret` en de roterende refresh token mogen de browser nooit bereiken.
|
||
- Een cache houdt het aantal API-calls laag.
|
||
- De frontend krijgt één JSON-bestand waarin alle waarden al zijn omgerekend.
|
||
|
||
**Stack:**
|
||
- PHP 8.3 zonder framework en zonder runtime-dependencies. Alleen `ext-curl` en `ext-json`; PHPUnit als dev-dependency.
|
||
- Frontend in vanilla HTML/CSS/JS, zonder build-stap, externe fonts of CDN. Alles wordt lokaal geserveerd.
|
||
- Uitgangspunt: simpel en onderhoudbaar, ook voor iemand anders die het later overneemt.
|
||
|
||
**Mappenstructuur (voorstel):**
|
||
|
||
```
|
||
weerscherm/
|
||
├── public/ # document root
|
||
│ ├── index.html
|
||
│ ├── styles.css
|
||
│ ├── app.js
|
||
│ ├── icons.js # weericonen (inline SVG-strings)
|
||
│ ├── sw.js
|
||
│ ├── manifest.webmanifest
|
||
│ ├── icons/ # app-iconen 180, 192, 512 px
|
||
│ └── api/data.php
|
||
├── src/
|
||
│ ├── Config.php
|
||
│ ├── Http.php # dunne curl-wrapper met timeouts
|
||
│ ├── TokenStore.php # atomisch lezen/schrijven + flock
|
||
│ ├── NetatmoClient.php
|
||
│ ├── OpenMeteoClient.php
|
||
│ ├── Cache.php
|
||
│ └── DashboardService.php # bouwt de JSON-payload
|
||
├── bin/netatmo-init.php # eenmalig refresh token opslaan
|
||
├── storage/ # NIET in git, niet in docroot, schrijfbaar voor www-data
|
||
├── tests/ # PHPUnit + JSON-fixtures
|
||
├── docs/design/weerscherm-layouts.html
|
||
├── config.example.php # config.php staat in .gitignore
|
||
└── README.md
|
||
```
|
||
|
||
---
|
||
|
||
## 3. Netatmo
|
||
|
||
### 3.1 App en tokens
|
||
|
||
1. Maak een app aan op dev.netatmo.com. Dat levert een `client_id` en `client_secret` op.
|
||
2. Genereer met de token generator op de app-pagina een token met scope `read_station`.
|
||
3. Sla de token op met `php bin/netatmo-init.php <refresh_token>`. Dat schrijft naar `storage/netatmo-token.json`.
|
||
|
||
**Tokenbeheer:**
|
||
- Een access token is ongeveer 3 uur geldig. Ververs hem via `POST https://api.netatmo.com/oauth2/token` met `grant_type=refresh_token`.
|
||
- **De refresh token roteert.** Het antwoord bevat een nieuwe refresh token. Sla die direct op en gebruik vanaf dan altijd de nieuwste.
|
||
- Schrijf atomisch (tijdelijk bestand + `rename`) en onder een `flock`. Anders kunnen twee gelijktijdige requests dubbel verversen en elkaars token ongeldig maken.
|
||
- Ververs proactief als de access token binnen 5 minuten verloopt, en eenmalig opnieuw bij een 401/403.
|
||
- Geeft Netatmo `invalid_grant` terug, dan retourneert de API de fout `netatmo_auth`. De frontend toont dan "Netatmo-koppeling verlopen: voer bin/netatmo-init.php opnieuw uit".
|
||
|
||
### 3.2 Stationsdata
|
||
|
||
`GET https://api.netatmo.com/api/getstationsdata` met `Authorization: Bearer <token>`. Kies het station via `device_id` uit de config, of standaard het eerste station.
|
||
|
||
| Nodig | Bron in response |
|
||
|---|---|
|
||
| Tijdzone, locatie | `body.devices[i].place.timezone`, `place.location` (= `[lon, lat]`) |
|
||
| Buiten | module met `type = NAModule1` → `dashboard_data.Temperature`, `min_temp`, `max_temp`, `date_min_temp`, `date_max_temp` (unix), `temp_trend` (`up`/`down`/`stable`), `time_utc` |
|
||
| Binnen (3×) | het basisstation zelf (`type = NAMain`, eigen `dashboard_data`) plus modules met `type = NAModule4`; naam uit `module_name`. Volgorde en labels zijn te overschrijven in de config op basis van `_id` |
|
||
| Regenmeter | module met `type = NAModule3`: de `_id` is nodig voor §3.3; `dashboard_data.sum_rain_24` dient alleen als fallback |
|
||
|
||
Is een module onbereikbaar (`reachable: false` of geen `dashboard_data`), zet dan alle waarden van die module op `null`. De backend mag hier niet op crashen.
|
||
|
||
### 3.3 Regen per dag
|
||
|
||
Eén call levert vandaag, de vorige 3 dagen en het maandtotaal op:
|
||
|
||
```
|
||
GET https://api.netatmo.com/api/getmeasure
|
||
device_id = <_id van NAMain>
|
||
module_id = <_id van NAModule3>
|
||
scale = 1day
|
||
type = sum_rain
|
||
date_begin = 00:00 lokale tijd van min(1e van de maand, vandaag − 3 dagen)
|
||
date_end = nu
|
||
optimize = false
|
||
real_time = false
|
||
```
|
||
|
||
**Verwerking:**
|
||
- Koppel elke timestamp aan een **lokale datum in de tijdzone van het station**. Bij `real_time=false` liggen de timestamps van dagwaarden niet op middernacht, dus ga daar niet van uit.
|
||
- `today`: de waarde voor de datum van vandaag. Ontbreekt die, gebruik dan `sum_rain_24`.
|
||
- `previous_days`: de 3 datums vóór vandaag, oudste eerst. Een ontbrekende dag wordt `null` en de frontend toont dan "–".
|
||
- `month_total`: de som van alle dagen in de huidige kalendermaand, inclusief vandaag. Op de 1e t/m 3e van de maand vallen de vorige dagen in de vorige maand. Die tellen wél mee in `previous_days`, maar niet in het maandtotaal.
|
||
|
||
### 3.4 Cache en frequentie
|
||
|
||
Het station stuurt ongeveer elke 10 minuten nieuwe metingen. Cache `getstationsdata` 5 minuten en `getmeasure` 15 minuten. Dat blijft ruim binnen de rate limits van Netatmo.
|
||
|
||
---
|
||
|
||
## 4. Open-Meteo (verwachting)
|
||
|
||
Gratis voor niet-commercieel gebruik, geen API-key nodig.
|
||
|
||
```
|
||
GET https://api.open-meteo.com/v1/forecast
|
||
latitude, longitude = config, anders stationslocatie
|
||
daily = weather_code,temperature_2m_max,temperature_2m_min,precipitation_sum,precipitation_probability_max
|
||
timezone = Europe/Berlin
|
||
forecast_days = 6
|
||
```
|
||
|
||
Gebruik index 1 t/m 5 (index 0 is vandaag) en cache 60 minuten.
|
||
|
||
**WMO-code → icoon:**
|
||
|
||
| Code | Betekenis | Icoon |
|
||
|---|---|---|
|
||
| 0 | onbewolkt | `sun` |
|
||
| 1, 2 | licht / half bewolkt | `partly` |
|
||
| 3 | bewolkt | `cloud` |
|
||
| 45, 48 | mist | `fog` |
|
||
| 51–57 | motregen | `drizzle` |
|
||
| 61–67 | regen | `rain` |
|
||
| 80–82 | regenbuien | `showers` |
|
||
| 71–77, 85, 86 | sneeuw | `snow` |
|
||
| 95–99 | onweer | `thunder` |
|
||
|
||
De iconen `sun`, `partly`, `rain` en `showers` staan in de mockup. Maak `cloud`, `fog`, `drizzle`, `snow` en `thunder` in dezelfde stijl:
|
||
- `viewBox 0 0 48 48`, stroke 2.4, ronde uiteinden.
|
||
- Zon-delen krijgen de klasse `i-sun`, wolk-vullingen `i-cloud` en druppels of vlokken `i-drop`.
|
||
|
||
---
|
||
|
||
## 5. API-contract: `GET /api/data.php`
|
||
|
||
```json
|
||
{
|
||
"generated_at": "2026-09-11T14:32:05+02:00",
|
||
"timezone": "Europe/Berlin",
|
||
"station": { "measured_at": "2026-09-11T14:30:12+02:00", "stale": false },
|
||
"outdoor": {
|
||
"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"
|
||
},
|
||
"indoor": [
|
||
{ "id": "70:ee:50:…", "name": "Woonkamer", "temp": 21.3, "min": 20.4, "max": 22.1, "reachable": true }
|
||
],
|
||
"rain": {
|
||
"today": 2.4,
|
||
"previous_days": [
|
||
{ "date": "2026-09-08", "mm": 1.2 },
|
||
{ "date": "2026-09-09", "mm": 6.8 },
|
||
{ "date": "2026-09-10", "mm": 0.0 }
|
||
],
|
||
"month_total": 38.5,
|
||
"stale": false
|
||
},
|
||
"forecast": [
|
||
{ "date": "2026-09-12", "weather_code": 2, "temp_max": 18.3, "temp_min": 10.1,
|
||
"precip_mm": 0.4, "precip_prob": 20 }
|
||
],
|
||
"forecast_stale": false,
|
||
"errors": []
|
||
}
|
||
```
|
||
|
||
**Regels:**
|
||
- Geef ruwe getallen door. De frontend rondt af en formatteert.
|
||
- Tijden zijn ISO 8601 met offset, in de tijdzone van het station. Onbekende waarden zijn `null`.
|
||
- Bij een storing bij één bron: serveer de laatst gecachte data voor die bron, zet `stale: true` en voeg een code toe aan `errors` (`netatmo_auth`, `netatmo_unavailable`, `forecast_unavailable`). De HTTP-status blijft 200 zolang er iets te tonen is.
|
||
- Stuur `Cache-Control: no-store` mee.
|
||
- Met `DATA_SOURCE=fixture` in de config levert de API vaste voorbeelddata. Dan kan de frontend gebouwd worden zonder Netatmo-koppeling.
|
||
|
||
---
|
||
|
||
## 6. Frontend
|
||
|
||
### 6.1 Canvas en schaal
|
||
|
||
- Ontwerp op een vast canvas van **1133 × 744**, het formaat van de iPad mini 6 in liggende stand.
|
||
- Schaal het canvas met `transform: scale()` op `min(innerWidth/1133, innerHeight/744)` en centreer het. Vul de ruimte eromheen op met de achtergrondkleur. Zo klopt de layout ook op een mini 5 (1024 × 768).
|
||
- Alleen liggend. In portretstand verschijnt de melding "Draai de iPad een kwartslag".
|
||
- Gebruik `viewport-fit=cover` en houd rekening met `env(safe-area-inset-*)`.
|
||
|
||
### 6.2 Design tokens (uit de mockup)
|
||
|
||
| Token | Waarde | Gebruik |
|
||
|---|---|---|
|
||
| `--bg` | `#E8EDF1` | achtergrond, ook de vulling van wolk-iconen |
|
||
| `--ink` | `#1B2A35` | hoofdtekst |
|
||
| `--muted` | `#5F707C` | labels, secundaire tekst |
|
||
| `--line` | `#C6D0D8` | scheidingslijnen |
|
||
| `--cold` | `#3A75A3` | minimumtemperaturen |
|
||
| `--warm` | `#B8761C` | maximumtemperaturen |
|
||
| `--rain` | `#2E6A99` | regen |
|
||
| `--sun` | `#D39A2C` | zon in iconen |
|
||
|
||
- Font: `-apple-system, system-ui, sans-serif` (SF Pro op de iPad), met overal `font-variant-numeric: tabular-nums`.
|
||
- Grid: padding `30px 44px`, kolommen `1fr 330px`, rijen `40px 1fr 232px`, `column-gap: 56px`.
|
||
- Onderste zone: kolommen `390px 1fr`, `column-gap: 36px`. Deze waarden zijn in de mockup al gecorrigeerd.
|
||
|
||
### 6.3 Zones en formattering
|
||
|
||
**Algemene notatie:** `nl-NL`, decimale komma, tijden in de tijdzone van het station via `Intl.DateTimeFormat`.
|
||
|
||
- **Kopregel:** datum ("Vrijdag 11 september") links. Rechts "meting HH:MM" (uit `station.measured_at`) en de klok (apparaattijd, per minuut bijgewerkt).
|
||
- **Buiten:**
|
||
- 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%.
|
||
- 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`.
|
||
- **Regen (linksonder):**
|
||
- "Vandaag" groot, in mm met 1 decimaal.
|
||
- Daarnaast 4 staafjes: de 3 vorige dagen (labels "Di", "Wo", "Do") en vandaag, uitgelicht. De hoogte is `mm / max(4 waarden) × 70px`, met een minimum van 2px. Zijn alle waarden 0, dan zijn alle staafjes 2px.
|
||
- Daaronder "September 38,5 mm".
|
||
- **Verwachting (rechtsonder):** 5 kolommen met dagafkorting ("Za"), icoon (58px), "max° min°" als hele getallen (min in `--muted`) en "x,x mm" plus neerslagkans in %. Geen neerslag toont "0 mm".
|
||
|
||
### 6.4 Verversen
|
||
|
||
- Haal `/api/data.php` op bij het laden, daarna elke 5 minuten, en direct bij `visibilitychange` naar zichtbaar.
|
||
- Om 00:00 lokale tijd: opnieuw ophalen en de datum bijwerken.
|
||
- Dagelijks rond 04:00 een volledige `location.reload()`. Dat voorkomt geheugenlekken en pakt nieuwe deploys op.
|
||
- Optioneel: vraag een `navigator.wakeLock` aan als die beschikbaar is. De primaire oplossing is Automatisch vergrendelen op "Nooit" in de iPad-instellingen.
|
||
|
||
### 6.5 Verouderde data en fouten
|
||
|
||
- Is `station.measured_at` ouder dan 30 minuten, dan wordt "meting HH:MM" `--warm` met de toevoeging "verouderd".
|
||
- Is de API niet bereikbaar, laat dan de laatste data staan en toon klein in de kopregel "geen verbinding sinds HH:MM".
|
||
- Bij `netatmo_auth`: toon een duidelijke melding over de hele bovenzone met de herstelinstructie uit §3.1.
|
||
- Nooit een leeg of wit scherm. Bij de allereerste load zonder data: toon "Gegevens laden…" en daarna een concrete foutmelding.
|
||
|
||
### 6.6 Browserdoel
|
||
|
||
Hangt af van de iPadOS-versie in §0:
|
||
- **iPadOS 15 of nieuwer:** moderne JS/CSS is prima.
|
||
- **Ouder:** schrijf ES2017 (geen optional chaining of `??`). In CSS geen `gap` op flexbox (gebruik margins), geen `inset` en geen `aspect-ratio`. Grid-`gap` mag wel.
|
||
|
||
---
|
||
|
||
## 7. PWA
|
||
|
||
- **`manifest.webmanifest`:**
|
||
- `name: "Weerscherm"`, `short_name: "Weer"`, `display: "standalone"`, `orientation: "landscape"`.
|
||
- `start_url` en `scope` relatief (`"./"`).
|
||
- `background_color` en `theme_color`: `#E8EDF1`.
|
||
- Iconen van 192 en 512 px.
|
||
- **iOS-meta:** `apple-mobile-web-app-capable`, `apple-mobile-web-app-status-bar-style` (`default`), een `apple-touch-icon` van 180 px, `apple-mobile-web-app-title`.
|
||
- **Service worker:**
|
||
- De app-shell (html, css, js, iconen) is cache-first met een geversioneerde cachenaam.
|
||
- `/api/data.php` is network-first met een timeout van 8 s. Val terug op de laatst gecachte response en markeer die in de UI als verouderd.
|
||
- Gebruik `skipWaiting` + `clients.claim`. De pagina herlaadt zichzelf bij `controllerchange`.
|
||
- **App-icoon:** een eenvoudig zon/wolk-icoon in de stijl van de weericonen, op `--bg`.
|
||
|
||
---
|
||
|
||
## 8. Configuratie en beveiliging
|
||
|
||
`config.example.php` staat in git, `config.php` niet. Sleutels:
|
||
- `netatmo_client_id`, `netatmo_client_secret`
|
||
- `netatmo_device_id` (optioneel)
|
||
- `timezone` (standaard `Europe/Berlin`)
|
||
- `forecast_lat` / `forecast_lon` (optioneel)
|
||
- `indoor_modules`: optioneel, een lijst van `_id` → label in de gewenste volgorde
|
||
- `data_source` (`live` | `fixture`)
|
||
- `cache_ttl` per bron
|
||
|
||
**Beveiliging:**
|
||
- `storage/` staat buiten de document root en wordt nooit geserveerd. Tokenbestanden krijgen `chmod 600`.
|
||
- Er komen geen secrets of tokens in responses, logs of de frontend.
|
||
- Stuur `X-Robots-Tag: noindex` mee en zet een `robots.txt` die alles blokkeert.
|
||
- Standaard geen login. Dat is proportioneel voor weerdata, en HTTP basic auth werkt slecht in iOS-standalone-PWA's. Plaats de app wel op een niet-voor-de-hand-liggend (sub)domein of pad.
|
||
|
||
---
|
||
|
||
## 9. Lokaal ontwikkelen en deployen
|
||
|
||
- **Lokaal:** via de bestaande OrbStack shared-Apache-setup met een PHP 8.3-container, onder `weerscherm.test`. Begin met `data_source = fixture`.
|
||
- **Visuele check:** een browservenster van exact 1133 × 744 naast `docs/design/weerscherm-layouts.html` (tab A).
|
||
- **Deploy:** Git op de self-hosted Gitea (squash-and-merge), deploy via DeployPad. `storage/` moet deploys overleven: zet hem buiten de release-map of maak er een gedeelde map van.
|
||
- **README:** installatie, Netatmo-koppeling (§3.1), config, deploy, en de iPad-instellingen:
|
||
- toevoegen aan beginscherm
|
||
- Automatisch vergrendelen → Nooit
|
||
- Begeleide toegang
|
||
|
||
---
|
||
|
||
## 10. Tests
|
||
|
||
**PHPUnit, met JSON-fixtures van echte responses** (geanonimiseerde MAC-adressen):
|
||
- Mapping van `getstationsdata` naar de payload, inclusief een onbereikbare module en een ontbrekende regenmeter.
|
||
- Regenberekening: datum-mapping in de stationstijdzone, de maandgrens (1e–3e van de maand), een ontbrekende dag, en de dag van de zomertijdwissel.
|
||
- Token-refresh: de nieuwe refresh token wordt opgeslagen, een gelijktijdige refresh verloopt goed dankzij de lock, en `invalid_grant` geeft de fout `netatmo_auth`.
|
||
- Stale-fallback: bij een upstream-fout wordt de gecachte data geserveerd met `stale: true`.
|
||
|
||
**Frontend:** houd formatteer- en rekenfuncties puur en los testbaar. Denk aan de decimale komma, de markerpositie, de staafhoogtes en de WMO-mapping. Vitest mag als dev-dependency, maar zonder build-stap voor productie.
|
||
|
||
---
|
||
|
||
## 11. Acceptatiecriteria
|
||
|
||
- [ ] Op de iPad mini, gestart vanaf het beginscherm, draait de app fullscreen zonder Safari-balken en klopt de layout visueel met ontwerp A.
|
||
- [ ] Alle waarden komen overeen met de Netatmo-app: buiten, 3× binnen, min/max met tijdstippen, regen vandaag en de afgelopen 3 dagen, en het maandtotaal.
|
||
- [ ] De verwachting toont 5 dagen vanaf morgen met het juiste icoon per WMO-code.
|
||
- [ ] De data ververst zichzelf. Na middernacht staat de nieuwe datum er binnen 5 minuten.
|
||
- [ ] Wifi uit: de laatste data blijft staan, met een melding "geen verbinding sinds". Wifi aan: herstelt vanzelf.
|
||
- [ ] De Netatmo-token ververst meerdere dagen achter elkaar zonder handmatig ingrijpen.
|
||
- [ ] Geen secrets in git, in de browser of in responses. `storage/` is niet via HTTP bereikbaar.
|
||
- [ ] De PHPUnit-tests uit §10 slagen.
|
||
|
||
---
|
||
|
||
## 12. Werkvolgorde
|
||
|
||
1. **Backend met fixtures:** mappenstructuur, config, `DashboardService` op fixtures, `/api/data.php`, tests voor de mapping en de regenberekening.
|
||
2. **Frontend:** ontwerp A op basis van de fixture-API, met schaal, formattering, verversen en de foutstatussen.
|
||
3. **Netatmo live:** `bin/netatmo-init.php`, TokenStore met lock, `getstationsdata` en `getmeasure`, cache, stale-fallback, tokentests.
|
||
4. **Open-Meteo live:** client, cache, WMO-mapping en de ontbrekende iconen.
|
||
5. **PWA:** manifest, iOS-meta, service worker, app-iconen.
|
||
6. **Deploy en README:** productieconfig, DeployPad, een test op de iPad aan de hand van de acceptatiecriteria.
|
||
|
||
Sluit elke fase af met iets dat te testen is, en meld afwijkingen van deze spec voordat je ze doorvoert.
|