diff --git a/.gitignore b/.gitignore index 6b81a96..0d8701b 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,4 @@ /itctl /itd -/itgui \ No newline at end of file +/itgui +/version.txt \ No newline at end of file diff --git a/Makefile b/Makefile index 700bcc3..0f8b6b4 100644 --- a/Makefile +++ b/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 \ No newline at end of file +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 \ No newline at end of file diff --git a/README.md b/README.md index 911a2cd..98f2e7c 100644 --- a/README.md +++ b/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. \ No newline at end of file +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) \ No newline at end of file diff --git a/itd.toml b/itd.toml index 9e94c70..0ff45e7 100644 --- a/itd.toml +++ b/itd.toml @@ -26,3 +26,5 @@ [music] vol.interval = 5 +[weather] + location = "Los Angeles, CA" \ No newline at end of file diff --git a/main.go b/main.go index 8529b33..f6c5bd7 100644 --- a/main.go +++ b/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 { diff --git a/weather.go b/weather.go new file mode 100644 index 0000000..f7bb5b9 --- /dev/null +++ b/weather.go @@ -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))) +}