glance/internal/glance/widget-weather.go
2024-11-30 14:24:19 +00:00

326 lines
8.5 KiB
Go

package glance
import (
"context"
"errors"
"fmt"
"html/template"
"math"
"net/http"
"net/url"
"slices"
"strings"
"time"
_ "time/tzdata"
)
var weatherWidgetTemplate = mustParseTemplate("weather.html", "widget-base.html")
type weatherWidget struct {
widgetBase `yaml:",inline"`
Location string `yaml:"location"`
ShowAreaName bool `yaml:"show-area-name"`
HideLocation bool `yaml:"hide-location"`
HourFormat string `yaml:"hour-format"`
Units string `yaml:"units"`
Place *openMeteoPlaceResponseJson `yaml:"-"`
Weather *weather `yaml:"-"`
TimeLabels [12]string `yaml:"-"`
}
var timeLabels12h = [12]string{"2am", "4am", "6am", "8am", "10am", "12pm", "2pm", "4pm", "6pm", "8pm", "10pm", "12am"}
var timeLabels24h = [12]string{"02:00", "04:00", "06:00", "08:00", "10:00", "12:00", "14:00", "16:00", "18:00", "20:00", "22:00", "00:00"}
func (widget *weatherWidget) initialize() error {
widget.withTitle("Weather").withCacheOnTheHour()
if widget.Location == "" {
return fmt.Errorf("location is required")
}
if widget.HourFormat == "" || widget.HourFormat == "12h" {
widget.TimeLabels = timeLabels12h
} else if widget.HourFormat == "24h" {
widget.TimeLabels = timeLabels24h
} else {
return errors.New("hour-format must be either 12h or 24h")
}
if widget.Units == "" {
widget.Units = "metric"
} else if widget.Units != "metric" && widget.Units != "imperial" {
return errors.New("units must be either metric or imperial")
}
return nil
}
func (widget *weatherWidget) update(ctx context.Context) {
if widget.Place == nil {
place, err := fetchOpenMeteoPlaceFromName(widget.Location)
if err != nil {
widget.withError(err).scheduleEarlyUpdate()
return
}
widget.Place = place
}
weather, err := fetchWeatherForOpenMeteoPlace(widget.Place, widget.Units)
if !widget.canContinueUpdateAfterHandlingErr(err) {
return
}
widget.Weather = weather
}
func (widget *weatherWidget) Render() template.HTML {
return widget.renderTemplate(widget, weatherWidgetTemplate)
}
type weather struct {
Temperature int
ApparentTemperature int
WeatherCode int
CurrentColumn int
SunriseColumn int
SunsetColumn int
Columns []weatherColumn
}
func (w *weather) WeatherCodeAsString() string {
if weatherCode, ok := weatherCodeTable[w.WeatherCode]; ok {
return weatherCode
}
return ""
}
type openMeteoPlacesResponseJson struct {
Results []openMeteoPlaceResponseJson
}
type openMeteoPlaceResponseJson struct {
Name string
Area string `json:"admin1"`
Latitude float64
Longitude float64
Timezone string
Country string
location *time.Location
}
type openMeteoWeatherResponseJson struct {
Daily struct {
Sunrise []int64 `json:"sunrise"`
Sunset []int64 `json:"sunset"`
} `json:"daily"`
Hourly struct {
Temperature []float64 `json:"temperature_2m"`
PrecipitationProbability []int `json:"precipitation_probability"`
} `json:"hourly"`
Current struct {
Temperature float64 `json:"temperature_2m"`
ApparentTemperature float64 `json:"apparent_temperature"`
WeatherCode int `json:"weather_code"`
} `json:"current"`
}
type weatherColumn struct {
Temperature int
Scale float64
HasPrecipitation bool
}
var commonCountryAbbreviations = map[string]string{
"US": "United States",
"USA": "United States",
"UK": "United Kingdom",
}
func expandCountryAbbreviations(name string) string {
if expanded, ok := commonCountryAbbreviations[strings.TrimSpace(name)]; ok {
return expanded
}
return name
}
// Separates the location that Open Meteo accepts from the administrative area
// which can then be used to filter to the correct place after the list of places
// has been retrieved. Also expands abbreviations since Open Meteo does not accept
// country names like "US", "USA" and "UK"
func parsePlaceName(name string) (string, string) {
parts := strings.Split(name, ",")
if len(parts) == 1 {
return name, ""
}
if len(parts) == 2 {
return parts[0] + ", " + expandCountryAbbreviations(parts[1]), ""
}
return parts[0] + ", " + expandCountryAbbreviations(parts[2]), strings.TrimSpace(parts[1])
}
func fetchOpenMeteoPlaceFromName(location string) (*openMeteoPlaceResponseJson, error) {
location, area := parsePlaceName(location)
requestUrl := fmt.Sprintf("https://geocoding-api.open-meteo.com/v1/search?name=%s&count=10&language=en&format=json", url.QueryEscape(location))
request, _ := http.NewRequest("GET", requestUrl, nil)
responseJson, err := decodeJsonFromRequest[openMeteoPlacesResponseJson](defaultHTTPClient, request)
if err != nil {
return nil, fmt.Errorf("fetching places data: %v", err)
}
if len(responseJson.Results) == 0 {
return nil, fmt.Errorf("no places found for %s", location)
}
var place *openMeteoPlaceResponseJson
if area != "" {
area = strings.ToLower(area)
for i := range responseJson.Results {
if strings.ToLower(responseJson.Results[i].Area) == area {
place = &responseJson.Results[i]
break
}
}
if place == nil {
return nil, fmt.Errorf("no place found for %s in %s", location, area)
}
} else {
place = &responseJson.Results[0]
}
loc, err := time.LoadLocation(place.Timezone)
if err != nil {
return nil, fmt.Errorf("loading location: %v", err)
}
place.location = loc
return place, nil
}
func fetchWeatherForOpenMeteoPlace(place *openMeteoPlaceResponseJson, units string) (*weather, error) {
query := url.Values{}
var temperatureUnit string
if units == "imperial" {
temperatureUnit = "fahrenheit"
} else {
temperatureUnit = "celsius"
}
query.Add("latitude", fmt.Sprintf("%f", place.Latitude))
query.Add("longitude", fmt.Sprintf("%f", place.Longitude))
query.Add("timeformat", "unixtime")
query.Add("timezone", place.Timezone)
query.Add("forecast_days", "1")
query.Add("current", "temperature_2m,apparent_temperature,weather_code")
query.Add("hourly", "temperature_2m,precipitation_probability")
query.Add("daily", "sunrise,sunset")
query.Add("temperature_unit", temperatureUnit)
requestUrl := "https://api.open-meteo.com/v1/forecast?" + query.Encode()
request, _ := http.NewRequest("GET", requestUrl, nil)
responseJson, err := decodeJsonFromRequest[openMeteoWeatherResponseJson](defaultHTTPClient, request)
if err != nil {
return nil, fmt.Errorf("%w: %v", errNoContent, err)
}
now := time.Now().In(place.location)
bars := make([]weatherColumn, 0, 24)
currentBar := now.Hour() / 2
sunriseBar := (time.Unix(int64(responseJson.Daily.Sunrise[0]), 0).In(place.location).Hour()) / 2
sunsetBar := (time.Unix(int64(responseJson.Daily.Sunset[0]), 0).In(place.location).Hour() - 1) / 2
if sunsetBar < 0 {
sunsetBar = 0
}
if len(responseJson.Hourly.Temperature) == 24 {
temperatures := make([]int, 12)
precipitations := make([]bool, 12)
t := responseJson.Hourly.Temperature
p := responseJson.Hourly.PrecipitationProbability
for i := 0; i < 24; i += 2 {
if i/2 == currentBar {
temperatures[i/2] = int(responseJson.Current.Temperature)
} else {
temperatures[i/2] = int(math.Round((t[i] + t[i+1]) / 2))
}
precipitations[i/2] = (p[i]+p[i+1])/2 > 75
}
minT := slices.Min(temperatures)
maxT := slices.Max(temperatures)
temperaturesRange := float64(maxT - minT)
for i := 0; i < 12; i++ {
bars = append(bars, weatherColumn{
Temperature: temperatures[i],
HasPrecipitation: precipitations[i],
})
if temperaturesRange > 0 {
bars[i].Scale = float64(temperatures[i]-minT) / temperaturesRange
} else {
bars[i].Scale = 1
}
}
}
return &weather{
Temperature: int(responseJson.Current.Temperature),
ApparentTemperature: int(responseJson.Current.ApparentTemperature),
WeatherCode: responseJson.Current.WeatherCode,
CurrentColumn: currentBar,
SunriseColumn: sunriseBar,
SunsetColumn: sunsetBar,
Columns: bars,
}, nil
}
var weatherCodeTable = map[int]string{
0: "Clear Sky",
1: "Mainly Clear",
2: "Partly Cloudy",
3: "Overcast",
45: "Fog",
48: "Rime Fog",
51: "Drizzle",
53: "Drizzle",
55: "Drizzle",
56: "Drizzle",
57: "Drizzle",
61: "Rain",
63: "Moderate Rain",
65: "Heavy Rain",
66: "Freezing Rain",
67: "Freezing Rain",
71: "Snow",
73: "Moderate Snow",
75: "Heavy Snow",
77: "Snow Grains",
80: "Rain",
81: "Moderate Rain",
82: "Heavy Rain",
85: "Snow",
86: "Snow",
95: "Thunderstorm",
96: "Thunderstorm",
99: "Thunderstorm",
}