Implement weather via MET Norway

This commit is contained in:
Elara 2022-02-21 16:18:52 -08:00
parent 4b2694ee0d
commit b4d302caf6
6 changed files with 299 additions and 5 deletions

3
.gitignore vendored
View File

@ -1,3 +1,4 @@
/itctl /itctl
/itd /itd
/itgui /itgui
/version.txt

View File

@ -3,13 +3,14 @@ BIN_PREFIX = $(DESTDIR)$(PREFIX)/bin
SERVICE_PREFIX = $(DESTDIR)$(PREFIX)/lib/systemd/user SERVICE_PREFIX = $(DESTDIR)$(PREFIX)/lib/systemd/user
CFG_PREFIX = $(DESTDIR)/etc CFG_PREFIX = $(DESTDIR)/etc
all: all: version
go build $(GOFLAGS) go build $(GOFLAGS)
go build ./cmd/itctl $(GOFLAGS) go build ./cmd/itctl $(GOFLAGS)
clean: clean:
rm -f itctl rm -f itctl
rm -f itd rm -f itd
printf "unknown" > version.txt
install: install:
install -Dm755 ./itd $(BIN_PREFIX)/itd install -Dm755 ./itd $(BIN_PREFIX)/itd
@ -23,4 +24,7 @@ uninstall:
rm $(SERVICE_PREFIX)/itd.service rm $(SERVICE_PREFIX)/itd.service
rm $(CFG_PREFIX)/itd.toml rm $(CFG_PREFIX)/itd.toml
.PHONY: all clean install uninstall version:
printf "r%s.%s" "$(shell git rev-list --count HEAD)" "$(shell git rev-parse --short HEAD)" > version.txt
.PHONY: all clean install uninstall version

View File

@ -185,4 +185,12 @@ This will compile `itd` and `itctl` for Linux aarch64 which is what runs on the
This daemon places a config file at `/etc/itd.toml`. This is the global config. `itd` will also look for a config at `~/.config/itd.toml`. This daemon places a config file at `/etc/itd.toml`. This is the global config. `itd` will also look for a config at `~/.config/itd.toml`.
Most of the time, the daemon does not need to be restarted for config changes to take effect. Most of the time, the daemon does not need to be restarted for config changes to take effect.
---
### Attribution
Location data from OpenStreetMap Nominatim, © [OpenStreetMap](https://www.openstreetmap.org/copyright) contributors
Weather data from the [Norwegian Meteorological Institute](https://www.met.no/en)

View File

@ -26,3 +26,5 @@
[music] [music]
vol.interval = 5 vol.interval = 5
[weather]
location = "Los Angeles, CA"

10
main.go
View File

@ -19,20 +19,23 @@
package main package main
import ( import (
_ "embed"
"fmt" "fmt"
"os" "os"
"strconv" "strconv"
"time" "time"
"github.com/gen2brain/dlgs" "github.com/gen2brain/dlgs"
"github.com/knadh/koanf"
"github.com/mattn/go-isatty" "github.com/mattn/go-isatty"
"github.com/rs/zerolog/log" "github.com/rs/zerolog/log"
"go.arsenm.dev/infinitime" "go.arsenm.dev/infinitime"
"github.com/knadh/koanf"
) )
var k = koanf.New(".") var k = koanf.New(".")
//go:embed version.txt
var version string
var ( var (
firmwareUpdating = false firmwareUpdating = false
@ -78,7 +81,10 @@ func main() {
} }
} }
// FS must be updated on reconnect
updateFS = true updateFS = true
// Resend weather on reconnect
sendWeatherCh <- struct{}{}
} }
// Get firmware version // Get firmware version
@ -123,6 +129,8 @@ func main() {
log.Error().Err(err).Msg("Error initializing notification relay") log.Error().Err(err).Msg("Error initializing notification relay")
} }
initWeather(dev)
// Start control socket // Start control socket
err = startSocket(dev) err = startSocket(dev)
if err != nil { if err != nil {

271
weather.go Normal file
View File

@ -0,0 +1,271 @@
package main
import (
"encoding/json"
"fmt"
"math"
"net/http"
"net/url"
"strconv"
"strings"
"time"
"github.com/rs/zerolog/log"
"go.arsenm.dev/infinitime"
"go.arsenm.dev/infinitime/weather"
)
// 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 initWeather(dev *infinitime.Device) error {
// Get location based on string in config
lat, lon, err := getLocation(k.String("weather.location"))
if err != nil {
return err
}
timer := time.NewTimer(time.Hour)
go func() {
for {
// Attempt to get weather
data, err := getWeather(lat, lon)
if err != nil {
log.Warn().Err(err).Msg("Error getting weather data")
// Wait 15 minutes before retrying
time.Sleep(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().Err(err).Msg("Error adding temperature event")
}
// 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().Err(err).Msg("Error adding precipitation event")
}
// 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().Err(err).Msg("Error adding wind event")
}
// 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().Err(err).Msg("Error adding clouds event")
}
// 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().Err(err).Msg("Error adding humidity event")
}
// 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().Err(err).Msg("Error adding pressure event")
}
timer.Reset(time.Hour)
select {
case <-timer.C:
timer.Stop()
case <-sendWeatherCh:
timer.Stop()
}
}
}()
return nil
}
// getLocation returns the latitude and longitude
// given a location
func getLocation(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))
res, err := http.Get(reqURL)
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(lat, lon float64) (*METResponse, error) {
// Create new GET request
req, err := http.NewRequest(
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)))
}