diff options
-rw-r--r-- | README.markdown | 5 | ||||
-rw-r--r-- | config.go | 4 | ||||
-rw-r--r-- | http_getter/http_getter.go | 19 | ||||
-rw-r--r-- | main.go | 25 | ||||
-rw-r--r-- | templates/template.html | 4 | ||||
-rw-r--r-- | weather/weather.go | 193 |
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": [ @@ -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) +} @@ -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 } |