// Package weather fetches forecasts and place names from Open-Meteo // (open source, no API key), with MET Norway and Nominatim as fallbacks. // Coordinates are rounded to ~1 km before they leave the machine. package weather import ( "bytes" "context" "encoding/json" "errors" "fmt" "io" "math" "net/http" "net/url" "strconv" "strings" "sync" "time" ) // Hour is the weather for one hour of the forecast. type Hour struct { Time time.Time Temp float64 // °C Feels float64 // apparent temperature, °C PrecipProb int // %, or -1 when the source doesn't say Precip float64 // mm Code int // WMO weather code Wind float64 // km/h Gusts float64 // km/h WindDirection int // degrees, where the wind comes from IsDay bool } // Forecast is the current weather plus the next 24 hours. type Forecast struct { Now Hour Hours []Hour // starting at the current hour Sunrise time.Time Sunset time.Time Source string // who made the forecast, shown in the UI GustsEstimated bool // the source had no gusts; they're derived from wind speed } // Place is a geocoded location. type Place struct { Name string Lat float64 Lon float64 } // Client fetches forecasts from Open-Meteo, falling back to MET Norway when // Open-Meteo is down, and caches them for 15 minutes. type Client struct { ForecastURL string // default https://api.open-meteo.com/v1/forecast GeocodeURL string // default https://geocoding-api.open-meteo.com/v1/search MetNoURL string // default https://api.met.no/weatherapi/locationforecast/2.0/complete; "-" disables NominatimURL string // default https://nominatim.openstreetmap.org/search; "-" disables HTTP *http.Client now func() time.Time // for tests; nil means time.Now mu sync.Mutex cache map[string]cached } type cached struct { f Forecast at time.Time } const ( cacheFor = 15 * time.Minute staleFor = 3 * time.Hour // when every source is down, an older forecast beats none userAgent = "workout-suggester/0.1 (+https://git.b0b.be/bdeb/workout-suggester)" ) func (c *Client) http() *http.Client { if c.HTTP != nil { return c.HTTP } return http.DefaultClient } func or(v, fallback string) string { if v == "" { return fallback } return v } // Round keeps two decimals (~1 km), which is plenty for weather. func Round(v float64) float64 { return math.Round(v*100) / 100 } // getJSON fetches u, retrying once when the server is busy (429/5xx). func (c *Client) getJSON(ctx context.Context, u string, out any) error { var err error for attempt := range 2 { if attempt > 0 { select { case <-ctx.Done(): return ctx.Err() case <-time.After(time.Second): } } var retry bool if retry, err = c.getOnce(ctx, u, out); !retry { return err } } return err } func (c *Client) getOnce(ctx context.Context, u string, out any) (retry bool, err error) { req, err := http.NewRequestWithContext(ctx, http.MethodGet, u, nil) if err != nil { return false, err } req.Header.Set("User-Agent", userAgent) // MET Norway and Nominatim require one req.Header.Set("Accept", "application/json") resp, err := c.http().Do(req) if err != nil { return ctx.Err() == nil, err } defer resp.Body.Close() if resp.StatusCode >= 300 { msg, _ := io.ReadAll(io.LimitReader(resp.Body, 512)) busy := resp.StatusCode == http.StatusTooManyRequests || resp.StatusCode >= 500 return busy, fmt.Errorf("%s returned %s: %s", req.URL.Host, resp.Status, bytes.TrimSpace(msg)) } return false, json.NewDecoder(resp.Body).Decode(out) } // Geocode finds a town or city by name, via Open-Meteo or else Nominatim. func (c *Client) Geocode(ctx context.Context, name string) (Place, error) { q := url.Values{"name": {name}, "count": {"1"}, "language": {"en"}, "format": {"json"}} var out struct { Results []struct { Name string `json:"name"` Country string `json:"country"` Lat float64 `json:"latitude"` Lon float64 `json:"longitude"` } `json:"results"` } err := c.getJSON(ctx, or(c.GeocodeURL, "https://geocoding-api.open-meteo.com/v1/search")+"?"+q.Encode(), &out) if err == nil { if len(out.Results) == 0 { return Place{}, fmt.Errorf("couldn't find a place called %q", name) } r := out.Results[0] n := r.Name if r.Country != "" { n += ", " + r.Country } return Place{Name: n, Lat: r.Lat, Lon: r.Lon}, nil } if c.NominatimURL == "-" { return Place{}, err } p, err2 := c.nominatim(ctx, name) if err2 != nil { return Place{}, errors.Join(err, err2) } return p, nil } func (c *Client) nominatim(ctx context.Context, name string) (Place, error) { q := url.Values{"q": {name}, "format": {"jsonv2"}, "limit": {"1"}, "accept-language": {"en"}} var out []struct { Name string `json:"name"` Display string `json:"display_name"` Lat string `json:"lat"` Lon string `json:"lon"` } if err := c.getJSON(ctx, or(c.NominatimURL, "https://nominatim.openstreetmap.org/search")+"?"+q.Encode(), &out); err != nil { return Place{}, err } if len(out) == 0 { return Place{}, fmt.Errorf("couldn't find a place called %q", name) } lat, err1 := strconv.ParseFloat(out[0].Lat, 64) lon, err2 := strconv.ParseFloat(out[0].Lon, 64) if err1 != nil || err2 != nil { return Place{}, errors.New("nominatim: bad coordinates") } n := out[0].Name if parts := strings.Split(out[0].Display, ", "); len(parts) > 1 { n += ", " + parts[len(parts)-1] // country } return Place{Name: n, Lat: lat, Lon: lon}, nil } // Forecast returns current weather and the next 24 hours. func (c *Client) Forecast(ctx context.Context, lat, lon float64) (Forecast, error) { lat, lon = Round(lat), Round(lon) key := fmt.Sprintf("%.2f,%.2f", lat, lon) c.mu.Lock() hit, ok := c.cache[key] c.mu.Unlock() if ok && time.Since(hit.at) < cacheFor { return hit.f, nil } f, err := c.openMeteo(ctx, lat, lon) if err != nil && c.MetNoURL != "-" { var err2 error if f, err2 = c.metNo(ctx, lat, lon); err2 != nil { err = errors.Join(err, err2) } else { err = nil } } if err != nil { if ok && time.Since(hit.at) < staleFor { hit.f.Source += fmt.Sprintf(", from %s", hit.at.Format("15:04")) return hit.f, nil } return Forecast{}, err } c.mu.Lock() if c.cache == nil { c.cache = make(map[string]cached) } c.cache[key] = cached{f, time.Now()} c.mu.Unlock() return f, nil } func (c *Client) openMeteo(ctx context.Context, lat, lon float64) (Forecast, error) { vars := "temperature_2m,apparent_temperature,precipitation,weather_code,wind_speed_10m,wind_gusts_10m,wind_direction_10m,is_day" q := url.Values{ "latitude": {strconv.FormatFloat(lat, 'f', 2, 64)}, "longitude": {strconv.FormatFloat(lon, 'f', 2, 64)}, "current": {vars}, "hourly": {vars + ",precipitation_probability"}, "daily": {"sunrise,sunset"}, "forecast_hours": {"24"}, "forecast_days": {"2"}, "timezone": {"auto"}, } var raw apiResponse if err := c.getJSON(ctx, or(c.ForecastURL, "https://api.open-meteo.com/v1/forecast")+"?"+q.Encode(), &raw); err != nil { return Forecast{}, err } return raw.forecast() } type apiResponse struct { UTCOffset int `json:"utc_offset_seconds"` Current struct { Time string `json:"time"` Temp float64 `json:"temperature_2m"` Feels float64 `json:"apparent_temperature"` Precip float64 `json:"precipitation"` Code int `json:"weather_code"` Wind float64 `json:"wind_speed_10m"` Gusts float64 `json:"wind_gusts_10m"` Dir int `json:"wind_direction_10m"` IsDay int `json:"is_day"` } `json:"current"` Hourly struct { Time []string `json:"time"` Temp []float64 `json:"temperature_2m"` Feels []float64 `json:"apparent_temperature"` Precip []float64 `json:"precipitation"` PrecipProb []int `json:"precipitation_probability"` Code []int `json:"weather_code"` Wind []float64 `json:"wind_speed_10m"` Gusts []float64 `json:"wind_gusts_10m"` Dir []int `json:"wind_direction_10m"` IsDay []int `json:"is_day"` } `json:"hourly"` Daily struct { Sunrise []string `json:"sunrise"` Sunset []string `json:"sunset"` } `json:"daily"` } func (r apiResponse) forecast() (Forecast, error) { loc := time.FixedZone("local", r.UTCOffset) parse := func(s string) time.Time { t, _ := time.ParseInLocation("2006-01-02T15:04", s, loc) return t } h := r.Hourly n := len(h.Time) if n == 0 || len(h.Temp) != n || len(h.Feels) != n || len(h.Precip) != n || len(h.PrecipProb) != n || len(h.Code) != n || len(h.Wind) != n || len(h.Gusts) != n || len(h.Dir) != n || len(h.IsDay) != n { return Forecast{}, errors.New("open-meteo: incomplete hourly forecast") } f := Forecast{Source: "Open-Meteo", Now: Hour{ Time: parse(r.Current.Time), Temp: r.Current.Temp, Feels: r.Current.Feels, Precip: r.Current.Precip, Code: r.Current.Code, Wind: r.Current.Wind, Gusts: r.Current.Gusts, WindDirection: r.Current.Dir, IsDay: r.Current.IsDay == 1, }} for i := range n { f.Hours = append(f.Hours, Hour{ Time: parse(h.Time[i]), Temp: h.Temp[i], Feels: h.Feels[i], PrecipProb: h.PrecipProb[i], Precip: h.Precip[i], Code: h.Code[i], Wind: h.Wind[i], Gusts: h.Gusts[i], WindDirection: h.Dir[i], IsDay: h.IsDay[i] == 1, }) } // The current hour's rain chance is the best guess for "now". f.Now.PrecipProb = f.Hours[0].PrecipProb // Today's sunrise/sunset, or tomorrow's once today's sun has set. for i := range r.Daily.Sunset { f.Sunrise, f.Sunset = parse(r.Daily.Sunrise[i]), parse(r.Daily.Sunset[i]) if f.Sunset.After(f.Now.Time) { break } } return f, nil } // Describe turns a WMO weather code into words and an emoji. func Describe(code int) (string, string) { switch { case code == 0: return "clear sky", "☀️" case code == 1: return "mostly clear", "🌤️" case code == 2: return "partly cloudy", "⛅" case code == 3: return "overcast", "☁️" case code == 45 || code == 48: return "fog", "🌫️" case code >= 51 && code <= 55: return "drizzle", "🌦️" case code == 56 || code == 57: return "freezing drizzle", "🧊" case code >= 61 && code <= 65: return "rain", "🌧️" case code == 66 || code == 67: return "freezing rain", "🧊" case code >= 71 && code <= 77: return "snow", "🌨️" case code >= 80 && code <= 82: return "rain showers", "🌦️" case code == 85 || code == 86: return "snow showers", "🌨️" case code >= 95: return "thunderstorm", "⛈️" } return "unknown", "🌡️" } // Icon is Describe's emoji, with a moon for clear nights. func (h Hour) Icon() string { _, icon := Describe(h.Code) if !h.IsDay && h.Code <= 1 { return "🌙" } return icon } // Chance is the rain chance as text, "–" when unknown. func (h Hour) Chance() string { if h.PrecipProb < 0 { return "–" } return fmt.Sprintf("%d%%", h.PrecipProb) } // Compass turns a wind direction in degrees into N, NE, E, ... func Compass(deg int) string { dirs := []string{"N", "NE", "E", "SE", "S", "SW", "W", "NW"} return dirs[((deg%360+360)%360+22)/45%8] }