From 2d73e0567c9ac6e03cfc8bfc62a8f24ef4fadebc Mon Sep 17 00:00:00 2001 From: Laria Carolin Chabowski Date: Tue, 11 Jan 2022 23:39:30 +0100 Subject: Use new met.no locationforecast 2.0 API The old XML API will be discontinued next month and is already returning errors occasionally (apparently to nudge lazy devs like me to update their code :D). We're also doing a much better job at caching now, as suggested by the API documentation. We're not yet showing a weather icon, I still have to figure out how to do this now (apparently the weather icons are a separate API / actually just a tag.gz archive that the weather API references?), but at least we're not panic()ing any more in the request handlers due to the unexpected error responses :/. See https://developer.yr.no/doc/GettingStarted/ for an intorduction to the new API. --- weather/weather.go | 193 ++++++++++++++++++++++++++++++++++++++++------------- 1 file changed, 146 insertions(+), 47 deletions(-) (limited to 'weather/weather.go') diff --git a/weather/weather.go b/weather/weather.go index 2a1d23f..0aa6cbb 100644 --- a/weather/weather.go +++ b/weather/weather.go @@ -2,67 +2,94 @@ package weather import ( - "encoding/xml" - "github.com/silvasur/startpage/http_getter" - "github.com/silvasur/startpage/interval" + "encoding/json" + "fmt" "log" + "net/http" + "strings" "time" + + "github.com/silvasur/startpage/http_getter" + "github.com/silvasur/startpage/interval" ) -func toTime(s string) time.Time { - t, _ := time.Parse("2006-01-02T15:04:05", s) - return t +type TimeseriesEntry struct { + Time time.Time `json:"time"` + Data struct { + Instant struct { + Details struct { + AirTemperature float64 `json:"air_temperature"` + } `json:"details"` + } `json:"instant"` + Next1Hours struct { + Summary struct { + SymbolCode string `json:"symbol_code"` + } `json:"summary"` + } `json:"next_1_hours"` + } `json:"data"` } -type Temperature struct { - Value int `xml:"value,attr"` - Unit string `xml:"unit,attr"` +func (te TimeseriesEntry) Temperature() float64 { + return te.Data.Instant.Details.AirTemperature } -type Weather struct { - Temp Temperature `xml:"temperature"` - Symbol struct { - Var string `xml:"var,attr"` - } `xml:"symbol"` - From string `xml:"from,attr"` - URL string - Icon string +func (te TimeseriesEntry) SymbolCode() string { + return te.Data.Next1Hours.Summary.SymbolCode } -func (w *Weather) prepIcon() { - w.Icon = "http://symbol.yr.no/grafikk/sym/b100/" + w.Symbol.Var + ".png" -} +type Timeseries []TimeseriesEntry -type weatherdata struct { - Forecast []*Weather `xml:"forecast>tabular>time"` -} +func (ts Timeseries) Current(now time.Time) *TimeseriesEntry { + if ts == nil { + return nil + } -func CurrentWeather(place string) (*Weather, error) { - url := "http://www.yr.no/place/" + place + "/forecast_hour_by_hour.xml" + found := false + var out TimeseriesEntry - resp, err := http_getter.Get(url) - if err != nil { - return nil, err + for _, entry := range ts { + if entry.Time.After(now) { + // Is in the future, not interested + continue + } + + if !found || now.Sub(out.Time) < now.Sub(entry.Time) { + out = entry + found = true + } } - defer resp.Body.Close() - var wd weatherdata - dec := xml.NewDecoder(resp.Body) - if err := dec.Decode(&wd); err != nil { - return nil, err + if found { + return &out + } else { + return nil } +} - w := wd.Forecast[0] - w.URL = "http://www.yr.no/place/" + place - w.prepIcon() +type ApiResponse struct { + Properties struct { + Timeseries Timeseries `json:"timeseries"` + } `json:"properties"` +} - return w, nil +func truncateCoord(coord string) string { + parts := strings.SplitN(coord, ".", 2) + if len(parts) == 1 { + return parts[0] + ".0" + } + tail := parts[1] + if len(tail) > 4 { + tail = tail[0:4] + } + return parts[0] + "." + tail } type WeatherProvider struct { - place string + lat, lon string intervalRunner *interval.IntervalRunner - weather *Weather + timeseries Timeseries + expires time.Time + lastModified time.Time err error } @@ -71,26 +98,98 @@ const ( RETRY_INTERVAL = 1 * time.Minute ) -func NewWeatherProvider(place string) *WeatherProvider { +func NewWeatherProvider(lat, lon string) *WeatherProvider { return &WeatherProvider{ - place: place, + lat: lat, + lon: lon, intervalRunner: interval.NewIntervalRunner(UPDATE_INTERVAL, RETRY_INTERVAL), } } -func (wp *WeatherProvider) CurrentWeather() (*Weather, error) { +func parseTimeFromHeader(header http.Header, key string) (*time.Time, error) { + raw := header.Get(key) + if raw == "" { + return nil, fmt.Errorf("Could not parse time from header %s: Not set or empty", key) + } + + t, err := http.ParseTime(raw) + if err != nil { + return nil, fmt.Errorf("Could not parse time from header %s: %w", key, err) + } + + return &t, nil +} + +func updateTimeFromHeaderIfOK(header http.Header, key string, time *time.Time) { + newTime, err := parseTimeFromHeader(header, key) + if err != nil { + log.Printf("Will not update time for key=%s: %s", key, err) + return + } + + *time = *newTime +} + +func (wp *WeatherProvider) update() error { + if time.Now().Before(wp.expires) { + log.Printf("Will not update weather yet, as it's not yet expired (expires=%s)", wp.expires) + return nil + } + + url := "https://api.met.no/weatherapi/locationforecast/2.0/compact?lat=" + truncateCoord(wp.lat) + "&lon=" + truncateCoord(wp.lon) + + req, err := http_getter.BuildGetRequest(url) + if err != nil { + return err + } + + if wp.timeseries != nil { + req.Header.Add("If-Modified-Since", wp.lastModified.Format(http.TimeFormat)) + } + + resp, err := http_getter.Do(req) + if err != nil { + return err + } + defer resp.Body.Close() + + if resp.StatusCode == 304 { + log.Println("Weather was not modified yet (got a 304)") + return nil + } else if resp.StatusCode != 200 { + log.Println("Warning: Got a non-200 response from the weather API: %d", resp.StatusCode) + } + + updateTimeFromHeaderIfOK(resp.Header, "Expires", &wp.expires) + updateTimeFromHeaderIfOK(resp.Header, "Last-Modified", &wp.lastModified) + + var apiResponse ApiResponse + dec := json.NewDecoder(resp.Body) + if err := dec.Decode(&apiResponse); err != nil { + return fmt.Errorf("Failed decoding weather API response: %w", err) + } + + wp.timeseries = apiResponse.Properties.Timeseries + + return nil +} + +func (wp *WeatherProvider) CurrentWeather() (*TimeseriesEntry, error) { wp.intervalRunner.Run(func() bool { - log.Printf("Getting new weather data") - wp.weather, wp.err = CurrentWeather(wp.place) + wp.err = wp.update() if wp.err == nil { - log.Printf("Successfully loaded weather data") + log.Printf("Successfully updated weather data") } else { - log.Printf("Failed loading weather data: %s", wp.err) + log.Printf("Failed updating weather data: %s", wp.err) } return wp.err == nil }) - return wp.weather, wp.err + if wp.err != nil { + return nil, wp.err + } + + return wp.timeseries.Current(time.Now()), nil } -- cgit v1.2.3-54-g00ecf