Implement weather via MET Norway
This commit is contained in:
parent
57768acb94
commit
032f735526
3
.gitignore
vendored
3
.gitignore
vendored
@ -1,3 +1,4 @@
|
||||
/itctl
|
||||
/itd
|
||||
/itgui
|
||||
/itgui
|
||||
/version.txt
|
8
Makefile
8
Makefile
@ -3,13 +3,14 @@ BIN_PREFIX = $(DESTDIR)$(PREFIX)/bin
|
||||
SERVICE_PREFIX = $(DESTDIR)$(PREFIX)/lib/systemd/user
|
||||
CFG_PREFIX = $(DESTDIR)/etc
|
||||
|
||||
all:
|
||||
all: version
|
||||
go build $(GOFLAGS)
|
||||
go build ./cmd/itctl $(GOFLAGS)
|
||||
|
||||
clean:
|
||||
rm -f itctl
|
||||
rm -f itd
|
||||
printf "unknown" > version.txt
|
||||
|
||||
install:
|
||||
install -Dm755 ./itd $(BIN_PREFIX)/itd
|
||||
@ -23,4 +24,7 @@ uninstall:
|
||||
rm $(SERVICE_PREFIX)/itd.service
|
||||
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
|
10
README.md
10
README.md
@ -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`.
|
||||
|
||||
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)
|
2
itd.toml
2
itd.toml
@ -26,3 +26,5 @@
|
||||
[music]
|
||||
vol.interval = 5
|
||||
|
||||
[weather]
|
||||
location = "Los Angeles, CA"
|
10
main.go
10
main.go
@ -19,20 +19,23 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
_ "embed"
|
||||
"fmt"
|
||||
"os"
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
"github.com/gen2brain/dlgs"
|
||||
"github.com/knadh/koanf"
|
||||
"github.com/mattn/go-isatty"
|
||||
"github.com/rs/zerolog/log"
|
||||
"go.arsenm.dev/infinitime"
|
||||
"github.com/knadh/koanf"
|
||||
)
|
||||
|
||||
var k = koanf.New(".")
|
||||
|
||||
//go:embed version.txt
|
||||
var version string
|
||||
|
||||
var (
|
||||
firmwareUpdating = false
|
||||
@ -78,7 +81,10 @@ func main() {
|
||||
}
|
||||
}
|
||||
|
||||
// FS must be updated on reconnect
|
||||
updateFS = true
|
||||
// Resend weather on reconnect
|
||||
sendWeatherCh <- struct{}{}
|
||||
}
|
||||
|
||||
// Get firmware version
|
||||
@ -123,6 +129,8 @@ func main() {
|
||||
log.Error().Err(err).Msg("Error initializing notification relay")
|
||||
}
|
||||
|
||||
initWeather(dev)
|
||||
|
||||
// Start control socket
|
||||
err = startSocket(dev)
|
||||
if err != nil {
|
||||
|
271
weather.go
Normal file
271
weather.go
Normal 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)))
|
||||
}
|
Loading…
Reference in New Issue
Block a user