summaryrefslogtreecommitdiff
path: root/weather
diff options
context:
space:
mode:
authorLaria Carolin Chabowski <laria@laria.me>2022-01-11 23:39:30 +0100
committerLaria Carolin Chabowski <laria@laria.me>2022-01-11 23:40:14 +0100
commit2d73e0567c9ac6e03cfc8bfc62a8f24ef4fadebc (patch)
tree6eb596532249711e9f01f3c8f214cc8d61bc0ca5 /weather
parent67852eb58e1797a54a34df44e3444829d9031ad7 (diff)
downloadstartpage-2d73e0567c9ac6e03cfc8bfc62a8f24ef4fadebc.tar.gz
startpage-2d73e0567c9ac6e03cfc8bfc62a8f24ef4fadebc.tar.bz2
startpage-2d73e0567c9ac6e03cfc8bfc62a8f24ef4fadebc.zip
Use new met.no locationforecast 2.0 APIHEADmaster
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.go193
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
}