summaryrefslogtreecommitdiff
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
parent67852eb58e1797a54a34df44e3444829d9031ad7 (diff)
downloadstartpage-master.tar.gz
startpage-master.tar.bz2
startpage-master.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.
-rw-r--r--README.markdown5
-rw-r--r--config.go4
-rw-r--r--http_getter/http_getter.go19
-rw-r--r--main.go25
-rw-r--r--templates/template.html4
-rw-r--r--weather/weather.go193
6 files changed, 189 insertions, 61 deletions
diff --git a/README.markdown b/README.markdown
index aa87dbb..0a4037c 100644
--- a/README.markdown
+++ b/README.markdown
@@ -15,7 +15,10 @@ Here is an example with all fields filled out.
{
// The place for which to get the weather data. If omitted, no weather will be shown
- "WeatherPlace": "Germany/Hamburg/Hamburg",
+ "WeatherCoords": {
+ "Lat": "53.6",
+ "Lon": "10.0"
+ },
// A list of links to show. Can be omitted.
"Links": [
diff --git a/config.go b/config.go
index 9464291..ec4f8ec 100644
--- a/config.go
+++ b/config.go
@@ -10,7 +10,9 @@ import (
// Config contains all configuration options that are read from .config/startpage/config.json
type Config struct {
// The place for which to get the weather data. If omitted, no weather will be shown
- WeatherPlace string
+ WeatherCoords struct {
+ Lat, Lon string
+ }
// A list of links to show
Links []Link
diff --git a/http_getter/http_getter.go b/http_getter/http_getter.go
index 5df3ac2..9fdf7a5 100644
--- a/http_getter/http_getter.go
+++ b/http_getter/http_getter.go
@@ -4,16 +4,27 @@ import (
"net/http"
)
-// Get is like http.Get, but we're sending our own user agent.
-func Get(url string) (*http.Response, error) {
- client := &http.Client{}
-
+func BuildGetRequest(url string) (*http.Request, error) {
req, err := http.NewRequest("GET", url, nil)
if err != nil {
return nil, err
}
req.Header.Add("User-Agent", "github.com/slivasur/startpage")
+ return req, nil
+}
+func Do(req *http.Request) (*http.Response, error) {
+ client := &http.Client{}
return client.Do(req)
}
+
+// Get is like http.Get, but we're sending our own user agent.
+func Get(url string) (*http.Response, error) {
+ req, err := BuildGetRequest(url)
+ if err != nil {
+ return nil, err
+ }
+
+ return Do(req)
+}
diff --git a/main.go b/main.go
index 01c8c60..776b1a1 100644
--- a/main.go
+++ b/main.go
@@ -7,6 +7,7 @@ import (
"fmt"
"html/template"
"log"
+ "math"
"net/http"
"os"
"path"
@@ -52,11 +53,11 @@ func loadTemplate(templateDir string) error {
}
func buildWeatherProvider(config Config) *weather.WeatherProvider {
- if config.WeatherPlace == "" {
+ if config.WeatherCoords.Lat == "" || config.WeatherCoords.Lon == "" {
return nil
}
- return weather.NewWeatherProvider(config.WeatherPlace)
+ return weather.NewWeatherProvider(config.WeatherCoords.Lat, config.WeatherCoords.Lon)
}
func buildRedditImageProvider(config Config) *reddit_background.RedditImageProvider {
@@ -96,9 +97,23 @@ func main() {
log.Fatal(http.ListenAndServe(*laddr, nil))
}
+type TplWeather struct {
+ Temp int
+}
+
+func convertWeather(ts *weather.TimeseriesEntry) *TplWeather {
+ if ts == nil {
+ return nil
+ }
+
+ return &TplWeather{
+ Temp: int(math.Round(ts.Temperature())),
+ }
+}
+
type TplData struct {
BgImage *reddit_background.RedditImageForAjax
- Weather *weather.Weather
+ Weather *TplWeather
Links []Link
CanSaveBg bool
}
@@ -109,7 +124,7 @@ func startpage(config Config, redditImageProvider *reddit_background.RedditImage
return func(rw http.ResponseWriter, req *http.Request) {
defer req.Body.Close()
- var curWeather *weather.Weather = nil
+ var curWeather *weather.TimeseriesEntry = nil
if weatherProvider != nil {
var err error
if curWeather, err = weatherProvider.CurrentWeather(); err != nil {
@@ -119,7 +134,7 @@ func startpage(config Config, redditImageProvider *reddit_background.RedditImage
if err := tpl.Execute(rw, &TplData{
redditImageProvider.Image().ForAjax(),
- curWeather,
+ convertWeather(curWeather),
config.Links,
config.BackgroundSavepath != "",
}); err != nil {
diff --git a/templates/template.html b/templates/template.html
index 4b20536..7592b50 100644
--- a/templates/template.html
+++ b/templates/template.html
@@ -80,8 +80,7 @@
<body {{ if .BgImage }} style="background-color: black; background-image: url(/bgimg);"{{ end }}>
{{ if .Weather }}
<div id="weather">
- <a href="{{ .Weather.URL }}"><img src="{{ .Weather.Icon }}" alt="" /></a>
- <span id="temp">{{ .Weather.Temp.Value }}°</span>
+ <span id="temp">{{ .Weather.Temp }}°</span>
</div>
{{ end }}
{{ if .Links }}
@@ -94,7 +93,6 @@
</div>
{{ end }}
-
<footer>
{{ if .Weather }}
<div id="yr_no_credit">Weather forecast from Yr, delivered by the Norwegian Meteorological Institute and NRK</div>
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
}