forked from Elara6331/itd
		
	Implement weather via MET Norway
This commit is contained in:
		
							
								
								
									
										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))) | ||||||
|  | } | ||||||
		Reference in New Issue
	
	Block a user