Files
HoneyOS/weather.go
T
marcandClaude Opus 5 4e38ccdec1 feat(wetter): Wetter auf dem Schreibtisch und in der Leiste
Zwei Schalter, unabhaengig voneinander: Schreibtisch, Leiste, beides oder
keins. Die Stadt wird einmal gesucht und mit ihren Koordinaten gespeichert,
sonst suchte jede Abfrage den Ort erneut.

Geholt wird serverseitig und zehn Minuten zwischengespeichert. Der Grund ist
nicht Sparsamkeit: Die Leiste steht auf jeder Seite jeder App, und fragte sie
selbst an, ginge bei jedem Seitenaufruf eine Anfrage samt IP an einen Fremden —
und die CSP jeder App muesste ihn erlauben.

Kein Ort eingestellt oder Dienst nicht erreichbar: 204, und das Symbol bleibt
weg. Eine Zahl von gestern waere schlechter als keine.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-25 13:12:09 +02:00

211 lines
5.8 KiB
Go

package main
import (
"context"
"encoding/json"
"fmt"
"net/http"
"net/url"
"strconv"
"strings"
"sync"
"time"
)
// Wetter kommt vom Server, nicht aus dem Browser.
//
// Die Leiste läuft auf jeder Seite jeder App. Fragte sie selbst beim
// Wetterdienst an, ginge bei jedem Seitenaufruf aus jeder App eine Anfrage samt
// IP dorthin — und die CSP jeder App müsste den fremden Ursprung erlauben. Holt
// HoneyOS es, sieht der Dienst einen Server, und die Apps bleiben unberührt.
//
// Open-Meteo braucht keinen Schlüssel und keine Anmeldung. Das ist der Grund für
// die Wahl: ein Zugangsschlüssel in der .env wäre eine Sache mehr, die jemand
// einrichten muss, bevor irgendetwas zu sehen ist.
const (
wetterFrisch = 10 * time.Minute // so lange gilt eine Antwort
wetterTimeout = 6 * time.Second // ein hängender Dienst darf nichts aufhalten
)
type Wetter struct {
Ort string `json:"ort"`
Grad float64 `json:"grad"`
Code int `json:"code"`
Text string `json:"text"`
Glyph string `json:"glyph"`
Stand string `json:"stand"`
}
type Ort struct {
Name string `json:"name"`
Land string `json:"land"`
Lat float64 `json:"lat"`
Lon float64 `json:"lon"`
}
type wetterDienst struct {
mu sync.Mutex
cache map[string]wetterEintrag
client *http.Client
// Adressen als Feld, damit der Test einen eigenen Server unterschieben kann.
wetterURL string
sucheURL string
}
type wetterEintrag struct {
w Wetter
bis time.Time
}
func neuerWetterdienst() *wetterDienst {
return &wetterDienst{
cache: map[string]wetterEintrag{},
client: &http.Client{Timeout: wetterTimeout},
wetterURL: "https://api.open-meteo.com/v1/forecast",
sucheURL: "https://geocoding-api.open-meteo.com/v1/search",
}
}
// wmo übersetzt den Wettercode in ein Pixelsymbol und einen kurzen Text.
// Die Skala ist die der Weltorganisation für Meteorologie; Open-Meteo liefert
// sie so. Zusammengefasst auf sechs Symbole — feiner lässt sich in zwölf mal
// zwölf Pixeln ohnehin nichts unterscheiden.
func wmo(code int) (glyph, text string) {
switch {
case code == 0:
return "sonne", "klar"
case code <= 2:
return "heiter", "heiter"
case code == 3:
return "wolke", "bedeckt"
case code == 45 || code == 48:
return "nebel", "nebel"
case code >= 51 && code <= 67, code >= 80 && code <= 82:
return "regen", "regen"
case code >= 71 && code <= 77, code == 85 || code == 86:
return "schnee", "schnee"
case code >= 95:
return "gewitter", "gewitter"
}
return "wolke", "wolken"
}
// Jetzt liefert das Wetter für einen Ort — aus dem Zwischenspeicher, solange er
// frisch ist. Das Wetter ändert sich langsamer, als Seiten neu geladen werden;
// ohne diesen Speicher stellte jeder Seitenaufruf in jeder App eine Anfrage
// nach draußen.
func (d *wetterDienst) Jetzt(ctx context.Context, ort string, lat, lon float64) (Wetter, error) {
schlüssel := fmt.Sprintf("%.3f,%.3f", lat, lon)
d.mu.Lock()
if e, ok := d.cache[schlüssel]; ok && time.Now().Before(e.bis) {
d.mu.Unlock()
e.w.Ort = ort // der Name gehört den Einstellungen, nicht dem Dienst
return e.w, nil
}
d.mu.Unlock()
u, _ := url.Parse(d.wetterURL)
q := u.Query()
q.Set("latitude", strconv.FormatFloat(lat, 'f', 4, 64))
q.Set("longitude", strconv.FormatFloat(lon, 'f', 4, 64))
q.Set("current", "temperature_2m,weather_code")
q.Set("timezone", "auto")
u.RawQuery = q.Encode()
req, err := http.NewRequestWithContext(ctx, http.MethodGet, u.String(), nil)
if err != nil {
return Wetter{}, err
}
res, err := d.client.Do(req)
if err != nil {
return Wetter{}, err
}
defer res.Body.Close()
if res.StatusCode != http.StatusOK {
return Wetter{}, fmt.Errorf("wetterdienst antwortet %d", res.StatusCode)
}
var antwort struct {
Current struct {
Temp float64 `json:"temperature_2m"`
Code int `json:"weather_code"`
} `json:"current"`
}
if err := json.NewDecoder(res.Body).Decode(&antwort); err != nil {
return Wetter{}, err
}
glyph, text := wmo(antwort.Current.Code)
w := Wetter{
Ort: ort,
Grad: antwort.Current.Temp,
Code: antwort.Current.Code,
Text: text,
Glyph: glyph,
Stand: time.Now().Format(time.RFC3339),
}
d.mu.Lock()
d.cache[schlüssel] = wetterEintrag{w: w, bis: time.Now().Add(wetterFrisch)}
d.mu.Unlock()
return w, nil
}
// Suche findet Orte zu einem Namen. Nur für die Einstellungen: Der gefundene
// Ort wird mit seinen Koordinaten gespeichert, damit die spätere Abfrage nicht
// jedes Mal erneut sucht.
func (d *wetterDienst) Suche(ctx context.Context, name string) ([]Ort, error) {
name = strings.TrimSpace(name)
if name == "" {
return nil, fmt.Errorf("kein Suchbegriff")
}
u, _ := url.Parse(d.sucheURL)
q := u.Query()
q.Set("name", name)
q.Set("count", "6")
q.Set("language", "de")
q.Set("format", "json")
u.RawQuery = q.Encode()
req, err := http.NewRequestWithContext(ctx, http.MethodGet, u.String(), nil)
if err != nil {
return nil, err
}
res, err := d.client.Do(req)
if err != nil {
return nil, err
}
defer res.Body.Close()
if res.StatusCode != http.StatusOK {
return nil, fmt.Errorf("ortssuche antwortet %d", res.StatusCode)
}
var antwort struct {
Results []struct {
Name string `json:"name"`
Country string `json:"country"`
Admin1 string `json:"admin1"`
Lat float64 `json:"latitude"`
Lon float64 `json:"longitude"`
} `json:"results"`
}
if err := json.NewDecoder(res.Body).Decode(&antwort); err != nil {
return nil, err
}
orte := make([]Ort, 0, len(antwort.Results))
for _, r := range antwort.Results {
land := r.Country
// „Köln, Nordrhein-Westfalen" trennt zwei Treffer besser als
// „Köln, Deutschland" — davon gibt es mehrere.
if r.Admin1 != "" && r.Admin1 != r.Name {
land = r.Admin1 + ", " + r.Country
}
orte = append(orte, Ort{Name: r.Name, Land: land, Lat: r.Lat, Lon: r.Lon})
}
return orte, nil
}