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
|
/itctl
|
||||||
/itd
|
/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
|
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
|
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`.
|
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]
|
[music]
|
||||||
vol.interval = 5
|
vol.interval = 5
|
||||||
|
|
||||||
|
[weather]
|
||||||
|
location = "Los Angeles, CA"
|
10
main.go
10
main.go
@ -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
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