Elara Musayelyan
ac7c626f56
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
303 lines
7.2 KiB
Go
303 lines
7.2 KiB
Go
package main
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"fmt"
|
|
"math"
|
|
"net/http"
|
|
"net/url"
|
|
"strconv"
|
|
"strings"
|
|
"time"
|
|
|
|
"go.elara.ws/infinitime"
|
|
"go.elara.ws/infinitime/weather"
|
|
"go.elara.ws/logger/log"
|
|
)
|
|
|
|
// METResponse represents a response from
|
|
// the MET Norway API
|
|
type METResponse struct {
|
|
Properties struct {
|
|
Timeseries []struct {
|
|
Time time.Time
|
|
Data METData
|
|
}
|
|
}
|
|
}
|
|
|
|
// METData represents data in a METResponse
|
|
type METData struct {
|
|
Instant struct {
|
|
Details struct {
|
|
AirPressure float32 `json:"air_pressure_at_sea_level"`
|
|
AirTemperature float32 `json:"air_temperature"`
|
|
DewPoint float32 `json:"dew_point_temperature"`
|
|
CloudAreaFraction float32 `json:"cloud_area_fraction"`
|
|
FogAreaFraction float32 `json:"fog_area_fraction"`
|
|
RelativeHumidity float32 `json:"relative_humidity"`
|
|
UVIndex float32 `json:"ultraviolet_index_clear_sky"`
|
|
WindDirection float32 `json:"wind_from_direction"`
|
|
WindSpeed float32 `json:"wind_speed"`
|
|
}
|
|
}
|
|
NextHour struct {
|
|
Summary struct {
|
|
SymbolCode string `json:"symbol_code"`
|
|
}
|
|
Details struct {
|
|
PrecipitationAmount float32 `json:"precipitation_amount"`
|
|
}
|
|
} `json:"next_1_hours"`
|
|
}
|
|
|
|
// OSMData represents lat/long data from
|
|
// OpenStreetMap Nominatim
|
|
type OSMData []struct {
|
|
Lat string `json:"lat"`
|
|
Lon string `json:"lon"`
|
|
}
|
|
|
|
var sendWeatherCh = make(chan struct{}, 1)
|
|
|
|
func sleepCtx(ctx context.Context, d time.Duration) {
|
|
select {
|
|
case <-time.After(d):
|
|
case <-ctx.Done():
|
|
}
|
|
}
|
|
|
|
func initWeather(ctx context.Context, wg WaitGroup, dev *infinitime.Device) error {
|
|
if !k.Bool("weather.enabled") {
|
|
return nil
|
|
}
|
|
|
|
// Get location based on string in config
|
|
lat, lon, err := getLocation(ctx, k.String("weather.location"))
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
timer := time.NewTimer(time.Hour)
|
|
|
|
wg.Add(1)
|
|
go func() {
|
|
defer wg.Done("weather")
|
|
for {
|
|
select {
|
|
case _, ok := <-ctx.Done():
|
|
if !ok {
|
|
return
|
|
}
|
|
default:
|
|
}
|
|
|
|
// Attempt to get weather
|
|
data, err := getWeather(ctx, lat, lon)
|
|
if err != nil {
|
|
log.Warn("Error getting weather data").Err(err).Send()
|
|
// Wait 15 minutes before retrying
|
|
sleepCtx(ctx, 15*time.Minute)
|
|
continue
|
|
}
|
|
|
|
// Get current data
|
|
current := data.Properties.Timeseries[0]
|
|
currentData := current.Data.Instant.Details
|
|
|
|
// Add temperature event
|
|
err = dev.AddWeatherEvent(weather.TemperatureEvent{
|
|
TimelineHeader: weather.NewHeader(
|
|
weather.EventTypeTemperature,
|
|
time.Hour,
|
|
),
|
|
Temperature: int16(round(currentData.AirTemperature * 100)),
|
|
DewPoint: int16(round(currentData.DewPoint)),
|
|
})
|
|
if err != nil {
|
|
log.Error("Error adding temperature event").Err(err).Send()
|
|
}
|
|
|
|
// Add precipitation event
|
|
err = dev.AddWeatherEvent(weather.PrecipitationEvent{
|
|
TimelineHeader: weather.NewHeader(
|
|
weather.EventTypePrecipitation,
|
|
time.Hour,
|
|
),
|
|
Type: parseSymbol(current.Data.NextHour.Summary.SymbolCode),
|
|
Amount: uint8(round(current.Data.NextHour.Details.PrecipitationAmount)),
|
|
})
|
|
if err != nil {
|
|
log.Error("Error adding precipitation event").Err(err).Send()
|
|
}
|
|
|
|
// Add wind event
|
|
err = dev.AddWeatherEvent(weather.WindEvent{
|
|
TimelineHeader: weather.NewHeader(
|
|
weather.EventTypeWind,
|
|
time.Hour,
|
|
),
|
|
SpeedMin: uint8(round(currentData.WindSpeed)),
|
|
SpeedMax: uint8(round(currentData.WindSpeed)),
|
|
DirectionMin: uint8(round(currentData.WindDirection)),
|
|
DirectionMax: uint8(round(currentData.WindDirection)),
|
|
})
|
|
if err != nil {
|
|
log.Error("Error adding wind event").Err(err).Send()
|
|
}
|
|
|
|
// Add cloud event
|
|
err = dev.AddWeatherEvent(weather.CloudsEvent{
|
|
TimelineHeader: weather.NewHeader(
|
|
weather.EventTypeClouds,
|
|
time.Hour,
|
|
),
|
|
Amount: uint8(round(currentData.CloudAreaFraction)),
|
|
})
|
|
if err != nil {
|
|
log.Error("Error adding clouds event").Err(err).Send()
|
|
}
|
|
|
|
// Add humidity event
|
|
err = dev.AddWeatherEvent(weather.HumidityEvent{
|
|
TimelineHeader: weather.NewHeader(
|
|
weather.EventTypeHumidity,
|
|
time.Hour,
|
|
),
|
|
Humidity: uint8(round(currentData.RelativeHumidity)),
|
|
})
|
|
if err != nil {
|
|
log.Error("Error adding humidity event").Err(err).Send()
|
|
}
|
|
|
|
// Add pressure event
|
|
err = dev.AddWeatherEvent(weather.PressureEvent{
|
|
TimelineHeader: weather.NewHeader(
|
|
weather.EventTypePressure,
|
|
time.Hour,
|
|
),
|
|
Pressure: int16(round(currentData.AirPressure)),
|
|
})
|
|
if err != nil {
|
|
log.Error("Error adding pressure event").Err(err).Send()
|
|
}
|
|
|
|
// Reset timer to 1 hour
|
|
timer.Stop()
|
|
timer.Reset(time.Hour)
|
|
|
|
// Wait for timer to fire or manual update signal
|
|
select {
|
|
case <-timer.C:
|
|
case <-sendWeatherCh:
|
|
case <-ctx.Done():
|
|
return
|
|
}
|
|
}
|
|
}()
|
|
return nil
|
|
}
|
|
|
|
// getLocation returns the latitude and longitude
|
|
// given a location
|
|
func getLocation(ctx context.Context, loc string) (lat, lon float64, err error) {
|
|
// Create request URL and perform GET request
|
|
reqURL := fmt.Sprintf("https://nominatim.openstreetmap.org/search.php?q=%s&format=jsonv2", url.QueryEscape(loc))
|
|
req, err := http.NewRequestWithContext(ctx, http.MethodGet, reqURL, nil)
|
|
if err != nil {
|
|
return
|
|
}
|
|
res, err := http.DefaultClient.Do(req)
|
|
if err != nil {
|
|
return
|
|
}
|
|
|
|
// Decode JSON from response into OSMData
|
|
data := OSMData{}
|
|
err = json.NewDecoder(res.Body).Decode(&data)
|
|
if err != nil {
|
|
return
|
|
}
|
|
// If no data points
|
|
if len(data) == 0 {
|
|
return
|
|
}
|
|
|
|
// Get first data point
|
|
out := data[0]
|
|
|
|
// Attempt to parse latitude
|
|
lat, err = strconv.ParseFloat(out.Lat, 64)
|
|
if err != nil {
|
|
return
|
|
}
|
|
// Attempt to parse longitude
|
|
lon, err = strconv.ParseFloat(out.Lon, 64)
|
|
if err != nil {
|
|
return
|
|
}
|
|
|
|
return
|
|
}
|
|
|
|
// getWeather gets weather data given a latitude and longitude
|
|
func getWeather(ctx context.Context, lat, lon float64) (*METResponse, error) {
|
|
// Create new GET request
|
|
req, err := http.NewRequestWithContext(
|
|
ctx,
|
|
http.MethodGet,
|
|
fmt.Sprintf(
|
|
"https://api.met.no/weatherapi/locationforecast/2.0/complete?lat=%.2f&lon=%.2f",
|
|
lat,
|
|
lon,
|
|
),
|
|
nil,
|
|
)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
// Set identifying user agent as per NMI requirements
|
|
req.Header.Set("User-Agent", fmt.Sprintf("ITD/%s gitea.arsenm.dev/Arsen6331/itd", version))
|
|
|
|
// Perform request
|
|
res, err := http.DefaultClient.Do(req)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
// Decode JSON from response to METResponse struct
|
|
out := &METResponse{}
|
|
err = json.NewDecoder(res.Body).Decode(out)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
return out, nil
|
|
}
|
|
|
|
// parseSymbol determines what type of precipitation a symbol code
|
|
// codes for.
|
|
func parseSymbol(symCode string) weather.PrecipitationType {
|
|
switch {
|
|
case strings.Contains(symCode, "lightrain"):
|
|
return weather.PrecipitationTypeRain
|
|
case strings.Contains(symCode, "rain"):
|
|
return weather.PrecipitationTypeRain
|
|
case strings.Contains(symCode, "snow"):
|
|
return weather.PrecipitationTypeSnow
|
|
case strings.Contains(symCode, "sleet"):
|
|
return weather.PrecipitationTypeSleet
|
|
case strings.Contains(symCode, "snow"):
|
|
return weather.PrecipitationTypeSnow
|
|
default:
|
|
return weather.PrecipitationTypeNone
|
|
}
|
|
}
|
|
|
|
// round rounds 32-bit floats to 32-bit integers
|
|
func round(f float32) int32 {
|
|
return int32(math.Round(float64(f)))
|
|
}
|