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 }