diff options
author | Laria Carolin Chabowski <laria@laria.me> | 2022-01-11 23:39:30 +0100 |
---|---|---|
committer | Laria Carolin Chabowski <laria@laria.me> | 2022-01-11 23:40:14 +0100 |
commit | 2d73e0567c9ac6e03cfc8bfc62a8f24ef4fadebc (patch) | |
tree | 6eb596532249711e9f01f3c8f214cc8d61bc0ca5 /weather | |
parent | 67852eb58e1797a54a34df44e3444829d9031ad7 (diff) | |
download | startpage-master.tar.gz startpage-master.tar.bz2 startpage-master.zip |
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.
Diffstat (limited to 'weather')
-rw-r--r-- | weather/weather.go | 193 |
1 files changed, 146 insertions, 47 deletions
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 } |