diff --git a/internal/analysis/realtime.go b/internal/analysis/realtime.go index a08dd616..e3e4b0e4 100644 --- a/internal/analysis/realtime.go +++ b/internal/analysis/realtime.go @@ -19,8 +19,8 @@ import ( "github.com/tphakala/birdnet-go/internal/diskmanager" "github.com/tphakala/birdnet-go/internal/httpcontroller" "github.com/tphakala/birdnet-go/internal/myaudio" - "github.com/tphakala/birdnet-go/internal/openweather" "github.com/tphakala/birdnet-go/internal/telemetry" + "github.com/tphakala/birdnet-go/internal/weather" ) // audioLevelChan is a channel to send audio level updates @@ -144,7 +144,7 @@ func RealtimeAnalysis(settings *conf.Settings) error { } // start weather polling - if settings.Realtime.OpenWeather.Enabled { + if settings.Realtime.Weather.Provider != "none" { startWeatherPolling(&wg, settings, dataStore, quitChan) } @@ -190,10 +190,17 @@ func startClipCleanupMonitor(wg *sync.WaitGroup, settings *conf.Settings, dataSt // startWeatherPolling initializes and starts the weather polling routine in a new goroutine. func startWeatherPolling(wg *sync.WaitGroup, settings *conf.Settings, dataStore datastore.Interface, quitChan chan struct{}) { + // Create new weather service + weatherService, err := weather.NewService(settings, dataStore) + if err != nil { + log.Printf("Failed to initialize weather service: %v", err) + return + } + wg.Add(1) go func() { defer wg.Done() - openweather.StartWeatherPolling(settings, dataStore, quitChan) + weatherService.StartPolling(quitChan) }() } diff --git a/internal/conf/config.go b/internal/conf/config.go index 538ca781..7357e95d 100644 --- a/internal/conf/config.go +++ b/internal/conf/config.go @@ -89,13 +89,19 @@ type BirdweatherSettings struct { LocationAccuracy float64 // accuracy of location in meters } +// WeatherSettings contains all weather-related settings +type WeatherSettings struct { + Provider string // "none", "yrno" or "openweather" + PollInterval int // weather data polling interval in minutes + Debug bool // true to enable debug mode + OpenWeather OpenWeatherSettings // OpenWeather integration settings +} + // OpenWeatherSettings contains settings for OpenWeather integration. type OpenWeatherSettings struct { - Enabled bool // true to enable OpenWeather integration - Debug bool // true to enable debug mode + Enabled bool // true to enable OpenWeather integration, for legacy support APIKey string // OpenWeather API key Endpoint string // OpenWeather API endpoint - Interval int // interval for fetching weather data in minutes Units string // units of measurement: standard, metric, or imperial Language string // language code for the response } @@ -149,13 +155,14 @@ type RealtimeSettings struct { Path string // path to OBS chat log } Birdweather BirdweatherSettings // Birdweather integration settings - OpenWeather OpenWeatherSettings // OpenWeather integration settings + OpenWeather OpenWeatherSettings `yaml:"-"` // OpenWeather integration settings PrivacyFilter PrivacyFilterSettings // Privacy filter settings DogBarkFilter DogBarkFilterSettings // Dog bark filter settings RTSP RTSPSettings // RTSP settings MQTT MQTTSettings // MQTT settings Telemetry TelemetrySettings // Telemetry settings Species SpeciesSettings // Custom thresholds and actions for species + Weather WeatherSettings // Weather provider related settings } // SpeciesSettings holds custom thresholds and action configurations for species. @@ -545,3 +552,18 @@ func GenerateRandomSecret() string { } return base64.RawURLEncoding.EncodeToString(bytes) } + +// GetWeatherSettings returns the appropriate weather settings based on the configuration +func (s *Settings) GetWeatherSettings() (provider string, openweather OpenWeatherSettings) { + // First check new format + if s.Realtime.Weather.Provider != "" { + return s.Realtime.Weather.Provider, s.Realtime.Weather.OpenWeather + } + + if s.Realtime.OpenWeather.Enabled { + return "openweather", s.Realtime.OpenWeather + } + + // Default to YrNo if nothing is configured + return "yrno", OpenWeatherSettings{} +} diff --git a/internal/conf/config.yaml b/internal/conf/config.yaml index d8f4e2b0..678d7f5a 100644 --- a/internal/conf/config.yaml +++ b/internal/conf/config.yaml @@ -86,12 +86,15 @@ realtime: debug: false # true to enable birdweather api debug mode id: "" # birdweather ID - openweather: - enabled: false - apikey: "" # OpenWeather API key - endpoint: "https://api.openweathermap.org/data/2.5/weather" # OpenWeather API endpoint - units: metric # metric or imperial - language: en # language code + weather: + provider: yrno + pollinterval: 60 + debug: false + openweather: + apikey: "" # OpenWeather API key + endpoint: "https://api.openweathermap.org/data/2.5/weather" # OpenWeather API endpoint + units: metric # metric or imperial + language: en # language code mqtt: enabled: false # true to enable MQTT diff --git a/internal/conf/defaults.go b/internal/conf/defaults.go index e7712ade..b1c84893 100644 --- a/internal/conf/defaults.go +++ b/internal/conf/defaults.go @@ -98,13 +98,26 @@ func setDefaultConfig() { viper.SetDefault("realtime.birdweather.locationaccuracy", 500) // OpenWeather configuration - viper.SetDefault("realtime.OpenWeather.Enabled", false) - viper.SetDefault("realtime.OpenWeather.Debug", false) - viper.SetDefault("realtime.OpenWeather.APIKey", "") - viper.SetDefault("realtime.OpenWeather.Endpoint", "https://api.openweathermap.org/data/2.5/weather") - viper.SetDefault("realtime.OpenWeather.Interval", 60) // default to fetch every 60 minutes - viper.SetDefault("realtime.OpenWeather.Units", "standard") - viper.SetDefault("realtime.OpenWeather.Language", "en") + /* + viper.SetDefault("realtime.OpenWeather.Enabled", false) + viper.SetDefault("realtime.OpenWeather.Debug", false) + viper.SetDefault("realtime.OpenWeather.APIKey", "") + viper.SetDefault("realtime.OpenWeather.Endpoint", "https://api.openweathermap.org/data/2.5/weather") + viper.SetDefault("realtime.OpenWeather.Interval", 60) // default to fetch every 60 minutes + viper.SetDefault("realtime.OpenWeather.Units", "standard") + viper.SetDefault("realtime.OpenWeather.Language", "en") + */ + + // New weather configuration + viper.SetDefault("realtime.weather.debug", false) + viper.SetDefault("realtime.weather.pollinterval", 60) + viper.SetDefault("realtime.weather.provider", "yrno") + + // OpenWeather specific configuration + viper.SetDefault("realtime.weather.openweather.apikey", "") + viper.SetDefault("realtime.weather.openweather.endpoint", "https://api.openweathermap.org/data/2.5/weather") + viper.SetDefault("realtime.weather.openweather.units", "metric") + viper.SetDefault("realtime.weather.openweather.language", "en") // RTSP configuration viper.SetDefault("realtime.rtsp.urls", []string{}) diff --git a/internal/conf/validate.go b/internal/conf/validate.go index 8a37f46c..47185a3f 100644 --- a/internal/conf/validate.go +++ b/internal/conf/validate.go @@ -32,11 +32,6 @@ func ValidateSettings(settings *Settings) error { ve.Errors = append(ve.Errors, err.Error()) } - // Validate OpenWeather settings - if err := validateOpenWeatherSettings(&settings.Realtime.OpenWeather); err != nil { - ve.Errors = append(ve.Errors, err.Error()) - } - // Validate WebServer settings if err := validateWebServerSettings(&settings.WebServer); err != nil { ve.Errors = append(ve.Errors, err.Error()) @@ -67,6 +62,11 @@ func ValidateSettings(settings *Settings) error { ve.Errors = append(ve.Errors, err.Error()) } + // Validate Weather settings + if err := validateWeatherSettings(&settings.Realtime.Weather); err != nil { + ve.Errors = append(ve.Errors, err.Error()) + } + // If there are any errors, return the ValidationError if len(ve.Errors) > 0 { return ve @@ -126,25 +126,6 @@ func validateBirdNETSettings(settings *BirdNETConfig) error { return nil } -// validateOpenWeatherSettings validates the OpenWeather-specific settings -func validateOpenWeatherSettings(settings *OpenWeatherSettings) error { - if settings.Enabled { - // Check if API key is provided when enabled - if settings.APIKey == "" { - return errors.New("OpenWeather API key is required when enabled") - } - // Check if endpoint is provided when enabled - if settings.Endpoint == "" { - return errors.New("OpenWeather endpoint is required when enabled") - } - // Check if interval is at least 1 minute - if settings.Interval < 1 { - return errors.New("OpenWeather interval must be at least 1 minute") - } - } - return nil -} - // validateWebServerSettings validates the WebServer-specific settings func validateWebServerSettings(settings *struct { Enabled bool @@ -296,3 +277,12 @@ func validateDashboardSettings(settings *Dashboard) error { return nil } + +// validateWeatherSettings validates weather-specific settings +func validateWeatherSettings(settings *WeatherSettings) error { + // Validate poll interval (minimum 15 minutes) + if settings.PollInterval < 15 { + return fmt.Errorf("weather poll interval must be at least 15 minutes, got %d", settings.PollInterval) + } + return nil +} diff --git a/internal/datastore/interfaces.go b/internal/datastore/interfaces.go index e8ee23c9..165a215a 100644 --- a/internal/datastore/interfaces.go +++ b/internal/datastore/interfaces.go @@ -405,27 +405,13 @@ func performAutoMigration(db *gorm.DB, debug bool, dbType, connectionInfo string // SaveDailyEvents saves daily events data to the database. func (ds *DataStore) SaveDailyEvents(dailyEvents *DailyEvents) error { - // Check if daily events data already exists for the date - var existingDailyEvents DailyEvents - err := ds.DB.Where("date = ?", dailyEvents.Date).First(&existingDailyEvents).Error - if err != nil { - if errors.Is(err, gorm.ErrRecordNotFound) { - // Insert new daily events data - if err := ds.DB.Create(dailyEvents).Error; err != nil { - return err - } - return nil - } - return err - } + // Use upsert to handle the unique date constraint + result := ds.DB.Where("date = ?", dailyEvents.Date). + Assign(*dailyEvents). + FirstOrCreate(dailyEvents) - // Update existing daily events data - existingDailyEvents.Sunrise = dailyEvents.Sunrise - existingDailyEvents.Sunset = dailyEvents.Sunset - existingDailyEvents.Country = dailyEvents.Country - existingDailyEvents.CityName = dailyEvents.CityName - if err := ds.DB.Save(&existingDailyEvents).Error; err != nil { - return err + if result.Error != nil { + return fmt.Errorf("failed to save daily events: %w", result.Error) } return nil @@ -442,27 +428,50 @@ func (ds *DataStore) GetDailyEvents(date string) (DailyEvents, error) { // SaveHourlyWeather saves hourly weather data to the database. func (ds *DataStore) SaveHourlyWeather(hourlyWeather *HourlyWeather) error { - if err := ds.DB.Create(hourlyWeather).Error; err != nil { - return err + // Basic validation + if hourlyWeather.Time.IsZero() { + return fmt.Errorf("invalid time value in hourly weather data") } + + // Use upsert to avoid duplicates for the same timestamp + result := ds.DB.Where("time = ?", hourlyWeather.Time). + Assign(*hourlyWeather). + FirstOrCreate(hourlyWeather) + + if result.Error != nil { + return fmt.Errorf("failed to save hourly weather: %w", result.Error) + } + return nil } // GetHourlyWeather retrieves hourly weather data by date from the database. func (ds *DataStore) GetHourlyWeather(date string) ([]HourlyWeather, error) { var hourlyWeather []HourlyWeather - if err := ds.DB.Where("date(time) = ?", date).Find(&hourlyWeather).Error; err != nil { - return nil, err + + err := ds.DB.Where("DATE(time) = ?", date). + Order("time ASC"). + Find(&hourlyWeather).Error + + if err != nil { + return nil, fmt.Errorf("failed to get hourly weather for date %s: %w", date, err) } + return hourlyWeather, nil } // LatestHourlyWeather retrieves the latest hourly weather entry from the database. func (ds *DataStore) LatestHourlyWeather() (*HourlyWeather, error) { var weather HourlyWeather - if err := ds.DB.Order("time DESC").First(&weather).Error; err != nil { - return nil, err + + err := ds.DB.Order("time DESC").First(&weather).Error + if err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return nil, fmt.Errorf("no weather data found") + } + return nil, fmt.Errorf("failed to get latest weather: %w", err) } + return &weather, nil } diff --git a/internal/httpcontroller/handlers/detections.go b/internal/httpcontroller/handlers/detections.go index f563ae8c..d6254f25 100644 --- a/internal/httpcontroller/handlers/detections.go +++ b/internal/httpcontroller/handlers/detections.go @@ -12,6 +12,7 @@ import ( "github.com/tphakala/birdnet-go/internal/conf" "github.com/tphakala/birdnet-go/internal/datastore" "github.com/tphakala/birdnet-go/internal/suncalc" + "github.com/tphakala/birdnet-go/internal/weather" ) // DetectionRequest represents the common parameters for detection requests @@ -30,7 +31,7 @@ type DetectionRequest struct { type NoteWithWeather struct { datastore.Note Weather *datastore.HourlyWeather - TimeOfDay TimeOfDay + TimeOfDay weather.TimeOfDay } // ListDetections handles requests for hourly, species-specific, and search detections @@ -83,8 +84,8 @@ func (h *Handlers) Detections(c echo.Context) error { return h.NewHandlerError(err, "Failed to get detections", http.StatusInternalServerError) } - // Check if OpenWeather is enabled, used to show weather data in the UI if enabled - weatherEnabled := h.Settings.Realtime.OpenWeather.Enabled + // Check if weather provider is set, used to show weather data in the UI if enabled + weatherEnabled := h.Settings.Realtime.Weather.Provider != "none" // Add weather and time of day information to the notes notesWithWeather, err := h.addWeatherAndTimeOfDay(notes) @@ -231,7 +232,7 @@ func (h *Handlers) addWeatherAndTimeOfDay(notes []datastore.Note) ([]NoteWithWea notesWithWeather := make([]NoteWithWeather, len(notes)) // Check if weather data is enabled in the settings - weatherEnabled := h.Settings.Realtime.OpenWeather.Enabled + weatherEnabled := h.Settings.Realtime.Weather.Provider != "none" // Get the local time zone once to avoid repeated calls localLoc, err := conf.GetLocalTimezone() diff --git a/internal/httpcontroller/handlers/weather.go b/internal/httpcontroller/handlers/weather.go new file mode 100644 index 00000000..5971e142 --- /dev/null +++ b/internal/httpcontroller/handlers/weather.go @@ -0,0 +1,52 @@ +// internal/httpcontroller/weather_icons.go +package handlers + +import ( + "html/template" + "time" + + "github.com/tphakala/birdnet-go/internal/suncalc" + "github.com/tphakala/birdnet-go/internal/weather" +) + +// GetWeatherIconFunc returns a function that returns an SVG icon for a given weather main +func (h *Handlers) GetWeatherIconFunc() func(weatherCode string, timeOfDay weather.TimeOfDay) template.HTML { + return func(weatherCode string, timeOfDay weather.TimeOfDay) template.HTML { + // Strip 'd' or 'n' suffix if present + if len(weatherCode) > 2 { + weatherCode = weatherCode[:2] + } + iconCode := weather.IconCode(weatherCode) + + return weather.GetWeatherIcon(iconCode, timeOfDay) + } +} + +// GetSunPositionIconFunc returns a function that returns an SVG icon for a given time of day +func (h *Handlers) GetSunPositionIconFunc() func(timeOfDay weather.TimeOfDay) template.HTML { + return func(timeOfDay weather.TimeOfDay) template.HTML { + return weather.GetTimeOfDayIcon(timeOfDay) + } +} + +// CalculateTimeOfDay determines the time of day based on the note time and sun events +func (h *Handlers) CalculateTimeOfDay(noteTime time.Time, sunEvents suncalc.SunEventTimes) weather.TimeOfDay { + return weather.CalculateTimeOfDay(noteTime, sunEvents) +} + +// TimeOfDayToInt converts a string representation of a time of day to a TimeOfDay value +func (h *Handlers) TimeOfDayToInt(s string) weather.TimeOfDay { + return weather.StringToTimeOfDay(s) +} + +// GetWeatherDescriptionFunc returns a function that returns a description for a given weather code +func (h *Handlers) GetWeatherDescriptionFunc() func(weatherCode string) string { + return func(weatherCode string) string { + // Strip 'd' or 'n' suffix if present + if len(weatherCode) > 2 { + weatherCode = weatherCode[:2] + } + iconCode := weather.IconCode(weatherCode) + return weather.GetIconDescription(iconCode) + } +} diff --git a/internal/httpcontroller/handlers/weather_icons.go b/internal/httpcontroller/handlers/weather_icons.go deleted file mode 100644 index a96094fc..00000000 --- a/internal/httpcontroller/handlers/weather_icons.go +++ /dev/null @@ -1,141 +0,0 @@ -// internal/httpcontroller/weather_icons.go -package handlers - -import ( - "html/template" - "time" - - "github.com/tphakala/birdnet-go/internal/suncalc" -) - -// TimeOfDay represents different times of the day -type TimeOfDay int - -const ( - day TimeOfDay = iota - night - dawn - dusk -) - -// getWeatherIconFunc returns a function that returns an SVG icon for a given weather main -func (h *Handlers) GetWeatherIconFunc() func(weather string, timeOfDay TimeOfDay) template.HTML { - // Map of weather main to icons for different times of day - icons := map[string]map[TimeOfDay]string{ - "Clear": { - day: ``, - night: ``, - dawn: ``, - dusk: ``, - }, - "Clouds": { - day: ``, - night: ``, - }, - "Rain": { - day: ``, - night: ``, - }, - "Snow": { - day: ``, - night: ``, - }, - "Thunderstorm": { - day: ``, - }, - "Drizzle": { - day: ``, - }, - "Mist": { - day: ``, - }, - "Fog": { - day: ``, - }, - "Smoke": { - day: ``, - }, - "Haze": { - day: ``, - }, - "Dust": { - day: ``, - }, - "Sand": { - day: ``, - }, - "Ash": { - day: ``, - }, - "Squall": { - day: ``, - }, - "Tornado": { - day: ``, - }, - } - - // Return a function that returns the icon for a given weather main and time of day - return func(weatherMain string, timeOfDay TimeOfDay) template.HTML { - if iconSet, ok := icons[weatherMain]; ok { - if icon, exists := iconSet[timeOfDay]; exists { - return template.HTML(icon) - } - // If specific time of day version doesn't exist, return day version - return template.HTML(iconSet[day]) - } - // Return a default icon (i) if the weather main is not found - return template.HTML(``) - } -} - -// GetSunPositionIconFunc returns a function that returns an SVG icon for a given time of day -func (h *Handlers) GetSunPositionIconFunc() func(timeOfDay TimeOfDay) template.HTML { - icons := map[TimeOfDay]string{ - night: ``, - dawn: ``, - day: ``, - dusk: ``, - } - - // Return a function that returns the icon for a given time of day - return func(timeOfDay TimeOfDay) template.HTML { - if icon, ok := icons[timeOfDay]; ok { - return template.HTML(icon) - } - // Return a default icon (i) if the time of day is not found - return template.HTML(``) - } -} - -// calculateTimeOfDay determines the time of day based on the note time and sun events -func (h *Handlers) CalculateTimeOfDay(noteTime time.Time, sunEvents suncalc.SunEventTimes) TimeOfDay { - switch { - case noteTime.Before(sunEvents.CivilDawn): - return night - case noteTime.Before(sunEvents.Sunrise): - return dawn - case noteTime.Before(sunEvents.Sunset): - return day - case noteTime.Before(sunEvents.CivilDusk): - return dusk - default: - return night - } -} - -// TimeOfDayToInt converts a string representation of a time of day to a TimeOfDay value -func (h *Handlers) TimeOfDayToInt(s string) TimeOfDay { - switch s { - case "night": - return night - case "dawn": - return dawn - case "day": - return day - case "dusk": - return dusk - default: - return day - } -} diff --git a/internal/httpcontroller/template_functions.go b/internal/httpcontroller/template_functions.go index 01f8bed0..714dbe6d 100644 --- a/internal/httpcontroller/template_functions.go +++ b/internal/httpcontroller/template_functions.go @@ -48,6 +48,7 @@ func (s *Server) GetTemplateFunctions() template.FuncMap { "getHourlyHeaderData": getHourlyHeaderData, "getHourlyCounts": getHourlyCounts, "sumHourlyCountsRange": sumHourlyCountsRange, + "weatherDescription": s.Handlers.GetWeatherDescriptionFunc(), } } diff --git a/internal/openweather/openweather.go b/internal/openweather/openweather.go deleted file mode 100644 index 16c366fc..00000000 --- a/internal/openweather/openweather.go +++ /dev/null @@ -1,268 +0,0 @@ -package openweather - -import ( - "encoding/json" - "fmt" - "io" - "log" - "net/http" - "time" - - "github.com/tphakala/birdnet-go/internal/conf" - "github.com/tphakala/birdnet-go/internal/datastore" -) - -const ( - MaxRetries = 3 - CooldownDuration = 10 * time.Minute - RequestTimeout = 10 * time.Second - UserAgent = "BirdNET-Go" -) - -// WeatherData represents the structure of weather data returned by the OpenWeather API -type WeatherData struct { - Coord struct { - Lon float64 `json:"lon"` - Lat float64 `json:"lat"` - } `json:"coord"` - Weather []struct { - ID int `json:"id"` - Main string `json:"main"` - Description string `json:"description"` - Icon string `json:"icon"` - } `json:"weather"` - Base string `json:"base"` - Main struct { - Temp float64 `json:"temp"` - FeelsLike float64 `json:"feels_like"` - TempMin float64 `json:"temp_min"` - TempMax float64 `json:"temp_max"` - Pressure int `json:"pressure"` - Humidity int `json:"humidity"` - SeaLevel int `json:"sea_level"` - GrndLevel int `json:"grnd_level"` - } `json:"main"` - Visibility int `json:"visibility"` - Wind struct { - Speed float64 `json:"speed"` - Deg int `json:"deg"` - Gust float64 `json:"gust"` - } `json:"wind"` - Clouds struct { - All int `json:"all"` - } `json:"clouds"` - Dt int64 `json:"dt"` - Sys struct { - Type int `json:"type"` - ID int `json:"id"` - Country string `json:"country"` - Sunrise int64 `json:"sunrise"` - Sunset int64 `json:"sunset"` - } `json:"sys"` - Timezone int `json:"timezone"` - ID int `json:"id"` - Name string `json:"name"` - Cod int `json:"cod"` -} - -// FetchWeather fetches weather data from the OpenWeather API -func FetchWeather(settings *conf.Settings) (*WeatherData, error) { - if !settings.Realtime.OpenWeather.Enabled { - return nil, fmt.Errorf("OpenWeather integration is disabled") - } - - // Construct the URL for the OpenWeather API requests - url := fmt.Sprintf("%s?lat=%f&lon=%f&appid=%s&units=%s&lang=%s", - settings.Realtime.OpenWeather.Endpoint, - settings.BirdNET.Latitude, - settings.BirdNET.Longitude, - settings.Realtime.OpenWeather.APIKey, - settings.Realtime.OpenWeather.Units, - settings.Realtime.OpenWeather.Language, - ) - - client := &http.Client{ - Timeout: RequestTimeout, - } - - // Create a new HTTP request - req, err := http.NewRequest("GET", url, http.NoBody) - if err != nil { - return nil, fmt.Errorf("error creating request: %w", err) - } - - req.Header.Set("User-Agent", UserAgent) - - // Perform the HTTP request - resp, err := client.Do(req) - if err != nil { - return nil, fmt.Errorf("error fetching weather data: %w", err) - } - defer resp.Body.Close() - - if resp.StatusCode != http.StatusOK { - return nil, fmt.Errorf("received non-200 response: %d", resp.StatusCode) - } - - // Read the response body - body, err := io.ReadAll(resp.Body) - if err != nil { - return nil, fmt.Errorf("error reading response body: %w", err) - } - - // Unmarshal the JSON response into the WeatherData struct - var weatherData WeatherData - if err := json.Unmarshal(body, &weatherData); err != nil { - return nil, fmt.Errorf("error unmarshaling weather data: %w", err) - } - - return &weatherData, nil -} - -// SaveWeatherData saves the fetched weather data into the database -func SaveWeatherData(db datastore.Interface, weatherData *WeatherData) error { - // Check for incomplete or invalid weather data - if weatherData == nil || weatherData.Sys.Country == "" { - return fmt.Errorf("incomplete or invalid weather data") - } - - dailyEvents := &datastore.DailyEvents{ - Date: time.Unix(weatherData.Dt, 0).Format("2006-01-02"), - Sunrise: weatherData.Sys.Sunrise, - Sunset: weatherData.Sys.Sunset, - Country: weatherData.Sys.Country, - CityName: weatherData.Name, - } - - // Save daily events data - if err := db.SaveDailyEvents(dailyEvents); err != nil { - return fmt.Errorf("failed to save daily events: %w", err) - } - - // Create hourly weather data - hourlyWeather := &datastore.HourlyWeather{ - DailyEventsID: dailyEvents.ID, - Time: time.Unix(weatherData.Dt, 0), - Temperature: weatherData.Main.Temp, - FeelsLike: weatherData.Main.FeelsLike, - TempMin: weatherData.Main.TempMin, - TempMax: weatherData.Main.TempMax, - Pressure: weatherData.Main.Pressure, - Humidity: weatherData.Main.Humidity, - Visibility: weatherData.Visibility, - WindSpeed: weatherData.Wind.Speed, - WindDeg: weatherData.Wind.Deg, - WindGust: weatherData.Wind.Gust, - Clouds: weatherData.Clouds.All, - WeatherMain: weatherData.Weather[0].Main, - WeatherDesc: weatherData.Weather[0].Description, - WeatherIcon: weatherData.Weather[0].Icon, - } - - // Validation checks for data integrity - if hourlyWeather.Temperature < -273.15 { - return fmt.Errorf("temperature cannot be below absolute zero: %f", hourlyWeather.Temperature) - } - if hourlyWeather.WindSpeed < 0 { - return fmt.Errorf("wind speed cannot be negative: %f", hourlyWeather.WindSpeed) - } - - // Save hourly weather data - if err := db.SaveHourlyWeather(hourlyWeather); err != nil { - return fmt.Errorf("failed to save hourly weather: %w", err) - } - - return nil -} - -// StartWeatherPolling starts a ticker to fetch weather data at the configured interval -func StartWeatherPolling(settings *conf.Settings, db datastore.Interface, stopChan <-chan struct{}) { - var initialDelay time.Duration - var interval = settings.Realtime.OpenWeather.Interval - - // Get the latest hourly weather entry from the database - latestWeather, err := db.LatestHourlyWeather() - if err != nil && err.Error() != "record not found" { - // Log other errors and continue with immediate polling - log.Printf("Error retrieving latest hourly weather: %v", err) - initialDelay = 0 - } else if err == nil { - // Calculate the time since the latest entry - timeSinceLastEntry := time.Since(latestWeather.Time) - if timeSinceLastEntry > time.Duration(interval)*time.Minute { - // If the last entry is older than the polling interval, poll immediately - initialDelay = 0 - } else { - // Otherwise, delay until the next interval - initialDelay = time.Duration(interval)*time.Minute - timeSinceLastEntry - } - } - - log.Printf("Starting weather polling with %v min interval", interval) - - // Create a ticker with the specified interval - ticker := time.NewTicker(time.Duration(interval) * time.Minute) - defer ticker.Stop() - - // Use a timer for the initial delay - initialTimer := time.NewTimer(initialDelay) - defer initialTimer.Stop() - - for { - select { - case <-initialTimer.C: - // Perform the initial fetch and save - if err := fetchAndSaveWeatherData(settings, db); err != nil { - log.Printf("Error during initial weather fetch: %v", err) - } - - case <-ticker.C: - // Perform the scheduled fetch and save - if err := fetchAndSaveWeatherData(settings, db); err != nil { - log.Printf("Error during scheduled weather fetch: %v", err) - } - - case <-stopChan: - return - } - } -} - -// fetchAndSaveWeatherData is a helper function to fetch and save weather data with retry and cooldown logic -func fetchAndSaveWeatherData(settings *conf.Settings, db datastore.Interface) error { - var lastErr error - - // Retry fetching and saving weather data up to MaxRetries times - for i := 0; i < MaxRetries; i++ { - weatherData, err := FetchWeather(settings) - if err != nil { - lastErr = err - if settings.Realtime.OpenWeather.Debug { - log.Printf("Retry %d/%d: error fetching weather data: %v", i+1, MaxRetries, err) - } - time.Sleep(2 * time.Second) // Brief sleep before retrying - continue - } - - // Save the fetched weather data - if err := SaveWeatherData(db, weatherData); err != nil { - lastErr = err - if settings.Realtime.OpenWeather.Debug { - log.Printf("Retry %d/%d: error saving weather data: %v", i+1, MaxRetries, err) - } - time.Sleep(2 * time.Second) // Brief sleep before retrying - continue - } - - // If we reached here, the fetch and save were successful - if settings.Realtime.OpenWeather.Debug { - log.Printf("Fetched and saved weather data: %v", weatherData) - } - return nil - } - - // If we reached here, all retries failed - log.Printf("Failed to fetch weather data from openweather.org: %v", lastErr) - time.Sleep(CooldownDuration) - return fmt.Errorf("all retries failed: %w", lastErr) -} diff --git a/internal/weather/icon_codes.go b/internal/weather/icon_codes.go new file mode 100644 index 00000000..875c566d --- /dev/null +++ b/internal/weather/icon_codes.go @@ -0,0 +1,72 @@ +package weather + +// IconCode represents a standardized weather icon code +type IconCode string + +// YrNoSymbolToIcon maps Yr.no symbol codes to standardized icon codes +var YrNoSymbolToIcon = map[string]IconCode{ + "clearsky_day": "01", + "clearsky_night": "01", + "fair_day": "02", + "fair_night": "02", + "partlycloudy_day": "03", + "cloudy": "04", + "rainshowers_day": "09", + "rain": "10", + "thunder": "11", + "sleet": "12", + "snow": "13", + "fog": "50", + // Add more mappings as needed +} + +// OpenWeatherToIcon maps OpenWeather icon codes to standardized icon codes +var OpenWeatherToIcon = map[string]IconCode{ + "01d": "01", // clear sky + "01n": "01", + "02d": "02", // few clouds + "02n": "02", + "03d": "03", // scattered clouds + "03n": "03", + "04d": "04", // broken clouds + "04n": "04", + "09d": "09", // shower rain + "09n": "09", + "10d": "10", // rain + "10n": "10", + "11d": "11", // thunderstorm + "11n": "11", + "13d": "13", // snow + "13n": "13", + "50d": "50", // mist + "50n": "50", +} + +// IconDescription maps standardized icon codes to human-readable descriptions +var IconDescription = map[IconCode]string{ + "01": "Clear Sky", + "02": "Fair", + "03": "Partly Cloudy", + "04": "Cloudy", + "09": "Rain Showers", + "10": "Rain", + "11": "Thunderstorm", + "12": "Sleet", + "13": "Snow", + "50": "Fog", +} + +// GetStandardIconCode converts provider-specific weather codes to our standard icon codes +func GetStandardIconCode(code string, provider string) IconCode { + switch provider { + case "yrno": + if iconCode, ok := YrNoSymbolToIcon[code]; ok { + return iconCode + } + case "openweather": + if iconCode, ok := OpenWeatherToIcon[code]; ok { + return iconCode + } + } + return "01" // default to clear sky if no mapping found +} diff --git a/internal/weather/icons.go b/internal/weather/icons.go new file mode 100644 index 00000000..a3122750 --- /dev/null +++ b/internal/weather/icons.go @@ -0,0 +1,126 @@ +package weather + +import ( + "html/template" + "time" + + "github.com/tphakala/birdnet-go/internal/suncalc" +) + +// TimeOfDay represents different times of the Day +type TimeOfDay int + +const ( + Day TimeOfDay = iota + Night + Dawn + Dusk +) + +// weatherIcons maps weather conditions to their SVG icons for different times of Day +var weatherIcons = map[IconCode]map[TimeOfDay]string{ + "01": { + Day: ``, + Night: ``, + Dawn: ``, + Dusk: ``, + }, + "02": { + Day: ``, + Night: ``, + }, + "03": { + Day: ``, + Night: ``, + }, + "04": { + Day: ``, + Night: ``, + }, + "09": { + Day: ``, + }, + "10": { + Day: ``, + Night: ``, + }, + "11": { + Day: ``, + }, + "13": { + Day: ``, + Night: ``, + }, + "50": { + Day: ``, + }, +} + +// timeOfDayIcons maps TimeOfDay to their SVG icons +var timeOfDayIcons = map[TimeOfDay]string{ + Night: ``, + Dawn: ``, + Day: ``, + Dusk: ``, +} + +// GetWeatherIcon returns the appropriate weather icon for the given condition and time of Day +func GetWeatherIcon(code IconCode, timeOfDay TimeOfDay) template.HTML { + if iconSet, ok := weatherIcons[code]; ok { + if icon, exists := iconSet[timeOfDay]; exists { + return template.HTML(icon) + } + // If specific time of Day version doesn't exist, return Day version + return template.HTML(iconSet[Day]) + } + // Return a default icon if the weather condition is not found + return template.HTML(``) +} + +// GetIconDescription returns a human-readable description for a weather icon code +func GetIconDescription(code IconCode) string { + if desc, ok := IconDescription[code]; ok { + return desc + } + return "Unknown" +} + +// GetTimeOfDayIcon returns an SVG icon for a given time of Day +func GetTimeOfDayIcon(timeOfDay TimeOfDay) template.HTML { + if icon, ok := timeOfDayIcons[timeOfDay]; ok { + return template.HTML(icon) + } + return template.HTML(``) +} + +// CalculateTimeOfDay determines the time of Day based on the note time and sun events +func CalculateTimeOfDay(noteTime time.Time, sunEvents suncalc.SunEventTimes) TimeOfDay { + switch { + case noteTime.Before(sunEvents.CivilDawn): + return Night + case noteTime.Before(sunEvents.Sunrise): + return Dawn + case noteTime.Before(sunEvents.Sunset): + return Day + case noteTime.Before(sunEvents.CivilDusk): + return Dusk + default: + return Night + } +} + +// StringToTimeOfDay converts a string representation of a time of Day to a TimeOfDay value +func StringToTimeOfDay(s string) TimeOfDay { + switch s { + case "night": + return Night + case "dawn": + return Dawn + case "day": + return Day + case "dusk": + return Dusk + default: + return Day + } +} diff --git a/internal/weather/provider_common.go b/internal/weather/provider_common.go new file mode 100644 index 00000000..88e15dd9 --- /dev/null +++ b/internal/weather/provider_common.go @@ -0,0 +1,10 @@ +package weather + +import "time" + +const ( + RequestTimeout = 10 * time.Second + UserAgent = "BirdNET-Go https://github.com/tphakala/birdnet-go" + RetryDelay = 2 * time.Second + MaxRetries = 3 +) diff --git a/internal/weather/provider_openweather.go b/internal/weather/provider_openweather.go new file mode 100644 index 00000000..63b07f31 --- /dev/null +++ b/internal/weather/provider_openweather.go @@ -0,0 +1,132 @@ +package weather + +import ( + "encoding/json" + "fmt" + "io" + "net/http" + "time" + + "github.com/tphakala/birdnet-go/internal/conf" +) + +// OpenWeatherResponse represents the structure of weather data returned by the OpenWeather API +type OpenWeatherResponse struct { + Coord struct { + Lon float64 `json:"lon"` + Lat float64 `json:"lat"` + } `json:"coord"` + Weather []struct { + ID int `json:"id"` + Main string `json:"main"` + Description string `json:"description"` + Icon string `json:"icon"` + } `json:"weather"` + Main struct { + Temp float64 `json:"temp"` + FeelsLike float64 `json:"feels_like"` + TempMin float64 `json:"temp_min"` + TempMax float64 `json:"temp_max"` + Pressure int `json:"pressure"` + Humidity int `json:"humidity"` + } `json:"main"` + Visibility int `json:"visibility"` + Wind struct { + Speed float64 `json:"speed"` + Deg int `json:"deg"` + Gust float64 `json:"gust"` + } `json:"wind"` + Clouds struct { + All int `json:"all"` + } `json:"clouds"` + Dt int64 `json:"dt"` + Sys struct { + Country string `json:"country"` + Sunrise int64 `json:"sunrise"` + Sunset int64 `json:"sunset"` + } `json:"sys"` + Name string `json:"name"` +} + +// FetchWeather implements the Provider interface for OpenWeatherProvider +func (p *OpenWeatherProvider) FetchWeather(settings *conf.Settings) (*WeatherData, error) { + if settings.Realtime.Weather.OpenWeather.APIKey == "" { + return nil, fmt.Errorf("OpenWeather API key not configured") + } + + url := fmt.Sprintf("%s?lat=%.3f&lon=%.3f&appid=%s&units=%s&lang=en", + settings.Realtime.Weather.OpenWeather.Endpoint, + settings.BirdNET.Latitude, + settings.BirdNET.Longitude, + settings.Realtime.Weather.OpenWeather.APIKey, + settings.Realtime.Weather.OpenWeather.Units, + ) + + client := &http.Client{ + Timeout: RequestTimeout, + } + + req, err := http.NewRequest("GET", url, nil) + if err != nil { + return nil, fmt.Errorf("error creating request: %w", err) + } + + req.Header.Set("User-Agent", UserAgent) + + var weatherData OpenWeatherResponse + for i := 0; i < MaxRetries; i++ { + resp, err := client.Do(req) + if err != nil { + if i == MaxRetries-1 { + return nil, fmt.Errorf("error fetching weather data: %w", err) + } + time.Sleep(RetryDelay) + continue + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + if i == MaxRetries-1 { + return nil, fmt.Errorf("received non-200 response: %d", resp.StatusCode) + } + time.Sleep(RetryDelay) + continue + } + + body, err := io.ReadAll(resp.Body) + if err != nil { + return nil, fmt.Errorf("error reading response body: %w", err) + } + + if err := json.Unmarshal(body, &weatherData); err != nil { + return nil, fmt.Errorf("error unmarshaling weather data: %w", err) + } + + break + } + + // Safety check for weather data + if len(weatherData.Weather) == 0 { + return nil, fmt.Errorf("no weather conditions returned from API") + } + + return &WeatherData{ + Time: time.Unix(weatherData.Dt, 0), + Location: Location{ + Latitude: weatherData.Coord.Lat, + Longitude: weatherData.Coord.Lon, + }, + Temperature: Temperature{ + Current: weatherData.Main.Temp, + }, + Wind: Wind{ + Speed: weatherData.Wind.Speed, + Deg: weatherData.Wind.Deg, + }, + Clouds: weatherData.Clouds.All, + Pressure: weatherData.Main.Pressure, + Humidity: weatherData.Main.Humidity, + Description: weatherData.Weather[0].Description, + Icon: string(GetStandardIconCode(weatherData.Weather[0].Icon, "openweather")), + }, nil +} diff --git a/internal/weather/provider_yrno.go b/internal/weather/provider_yrno.go new file mode 100644 index 00000000..c60f5f6e --- /dev/null +++ b/internal/weather/provider_yrno.go @@ -0,0 +1,145 @@ +package weather + +import ( + "compress/gzip" + "encoding/json" + "fmt" + "io" + "net/http" + "time" + + "github.com/tphakala/birdnet-go/internal/conf" +) + +const ( + YrNoBaseURL = "https://api.met.no/weatherapi/locationforecast/2.0/complete" +) + +// YrResponse represents the structure of the Yr.no API response +type YrResponse struct { + Properties struct { + Timeseries []struct { + Time time.Time `json:"time"` + Data struct { + Instant struct { + Details struct { + AirPressure float64 `json:"air_pressure_at_sea_level"` + AirTemperature float64 `json:"air_temperature"` + CloudArea float64 `json:"cloud_area_fraction"` + RelHumidity float64 `json:"relative_humidity"` + WindSpeed float64 `json:"wind_speed"` + WindDirection float64 `json:"wind_from_direction"` + } `json:"details"` + } `json:"instant"` + Next1Hours struct { + Summary struct { + SymbolCode string `json:"symbol_code"` + } `json:"summary"` + Details struct { + PrecipitationAmount float64 `json:"precipitation_amount"` + } `json:"details"` + } `json:"next_1_hours"` + } `json:"data"` + } `json:"timeseries"` + } `json:"properties"` +} + +// FetchWeather implements the Provider interface for YrNoProvider +func (p *YrNoProvider) FetchWeather(settings *conf.Settings) (*WeatherData, error) { + url := fmt.Sprintf("%s?lat=%.3f&lon=%.3f", YrNoBaseURL, + settings.BirdNET.Latitude, + settings.BirdNET.Longitude) + + client := &http.Client{ + Timeout: RequestTimeout, + } + + req, err := http.NewRequest("GET", url, nil) + if err != nil { + return nil, fmt.Errorf("error creating request: %w", err) + } + + req.Header.Set("User-Agent", UserAgent) + req.Header.Set("Accept-Encoding", "gzip, deflate") + + if p.lastModified != "" { + req.Header.Set("If-Modified-Since", p.lastModified) + } + + var response YrResponse + for i := 0; i < MaxRetries; i++ { + resp, err := client.Do(req) + if err != nil { + if i == MaxRetries-1 { + return nil, fmt.Errorf("error fetching weather data: %w", err) + } + time.Sleep(RetryDelay) + continue + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + if i == MaxRetries-1 { + return nil, fmt.Errorf("received non-200 response: %d", resp.StatusCode) + } + time.Sleep(RetryDelay) + continue + } + + if resp.StatusCode == http.StatusNotModified { + return nil, fmt.Errorf("no new data available") + } + + if lastMod := resp.Header.Get("Last-Modified"); lastMod != "" { + p.lastModified = lastMod + } + + // Handle gzip compression + var reader io.Reader = resp.Body + if resp.Header.Get("Content-Encoding") == "gzip" { + gzReader, err := gzip.NewReader(resp.Body) + if err != nil { + return nil, fmt.Errorf("error creating gzip reader: %w", err) + } + defer gzReader.Close() + reader = gzReader + } + + body, err := io.ReadAll(reader) + if err != nil { + return nil, fmt.Errorf("error reading response body: %w", err) + } + + if err := json.Unmarshal(body, &response); err != nil { + return nil, fmt.Errorf("error unmarshaling weather data: %w", err) + } + + break + } + + if len(response.Properties.Timeseries) == 0 { + return nil, fmt.Errorf("no weather data available") + } + + current := response.Properties.Timeseries[0] + + return &WeatherData{ + Time: current.Time, + Location: Location{ + Latitude: settings.BirdNET.Latitude, + Longitude: settings.BirdNET.Longitude, + }, + Temperature: Temperature{ + Current: current.Data.Instant.Details.AirTemperature, + }, + Wind: Wind{ + Speed: current.Data.Instant.Details.WindSpeed, + Deg: int(current.Data.Instant.Details.WindDirection), + }, + Clouds: int(current.Data.Instant.Details.CloudArea), + Pressure: int(current.Data.Instant.Details.AirPressure), + Humidity: int(current.Data.Instant.Details.RelHumidity), + Description: current.Data.Next1Hours.Summary.SymbolCode, + Icon: string(GetStandardIconCode(current.Data.Next1Hours.Summary.SymbolCode, "yrno")), + }, nil +} diff --git a/internal/weather/providers.go b/internal/weather/providers.go new file mode 100644 index 00000000..64bfcab2 --- /dev/null +++ b/internal/weather/providers.go @@ -0,0 +1,17 @@ +package weather + +// NewYrNoProvider creates a new Yr.no weather provider +func NewYrNoProvider() Provider { + return &YrNoProvider{} +} + +// NewOpenWeatherProvider creates a new OpenWeather provider +func NewOpenWeatherProvider() Provider { + return &OpenWeatherProvider{} +} + +// Provider implementations +type YrNoProvider struct { + lastModified string +} +type OpenWeatherProvider struct{} diff --git a/internal/weather/weather.go b/internal/weather/weather.go new file mode 100644 index 00000000..84b82081 --- /dev/null +++ b/internal/weather/weather.go @@ -0,0 +1,231 @@ +package weather + +import ( + "fmt" + "time" + + "github.com/tphakala/birdnet-go/internal/conf" + "github.com/tphakala/birdnet-go/internal/datastore" +) + +// Provider represents a weather data provider interface +type Provider interface { + FetchWeather(settings *conf.Settings) (*WeatherData, error) +} + +// Service handles weather data operations +type Service struct { + provider Provider + db datastore.Interface + settings *conf.Settings +} + +// WeatherData represents the common structure for weather data across providers +type WeatherData struct { + Time time.Time + Location Location + Temperature Temperature + Wind Wind + Precipitation Precipitation + Clouds int + Visibility int + Pressure int + Humidity int + Description string + Icon string +} + +type Location struct { + Latitude float64 + Longitude float64 + Country string + City string +} + +type Temperature struct { + Current float64 + FeelsLike float64 + Min float64 + Max float64 +} + +type Wind struct { + Speed float64 + Deg int + Gust float64 +} + +type Precipitation struct { + Amount float64 + Type string // rain, snow, etc. +} + +// NewService creates a new weather service with the specified provider +func NewService(settings *conf.Settings, db datastore.Interface) (*Service, error) { + var provider Provider + + // Select weather provider based on configuration + switch settings.Realtime.Weather.Provider { + case "yrno": + provider = NewYrNoProvider() + case "openweather": + provider = NewOpenWeatherProvider() + default: + return nil, fmt.Errorf("invalid weather provider: %s", settings.Realtime.Weather.Provider) + } + + return &Service{ + provider: provider, + db: db, + settings: settings, + }, nil +} + +// SaveWeatherData saves the weather data to the database +func (s *Service) SaveWeatherData(data *WeatherData) error { + // Create daily events data + dailyEvents := &datastore.DailyEvents{ + Date: data.Time.Format("2006-01-02"), + Country: data.Location.Country, + CityName: data.Location.City, + } + + // Save daily events data + if err := s.db.SaveDailyEvents(dailyEvents); err != nil { + return fmt.Errorf("failed to save daily events: %w", err) + } + + // Create hourly weather data + hourlyWeather := &datastore.HourlyWeather{ + DailyEventsID: dailyEvents.ID, + Time: data.Time, + Temperature: data.Temperature.Current, + FeelsLike: data.Temperature.FeelsLike, + TempMin: data.Temperature.Min, + TempMax: data.Temperature.Max, + Pressure: data.Pressure, + Humidity: data.Humidity, + Visibility: data.Visibility, + WindSpeed: data.Wind.Speed, + WindDeg: data.Wind.Deg, + WindGust: data.Wind.Gust, + Clouds: data.Clouds, + WeatherDesc: data.Description, + WeatherIcon: data.Icon, + } + + // Basic validation + if err := validateWeatherData(hourlyWeather); err != nil { + return err + } + + // Save hourly weather data + if err := s.db.SaveHourlyWeather(hourlyWeather); err != nil { + return fmt.Errorf("failed to save hourly weather: %w", err) + } + + return nil +} + +// validateWeatherData performs basic validation on weather data +func validateWeatherData(data *datastore.HourlyWeather) error { + if data.Temperature < -273.15 { + return fmt.Errorf("temperature cannot be below absolute zero: %f", data.Temperature) + } + if data.WindSpeed < 0 { + return fmt.Errorf("wind speed cannot be negative: %f", data.WindSpeed) + } + return nil +} + +// StartPolling starts the weather polling service +func (s *Service) StartPolling(stopChan <-chan struct{}) { + interval := time.Duration(s.settings.Realtime.Weather.PollInterval) * time.Minute + + if s.settings.Realtime.Weather.Debug { + fmt.Printf("[weather] Starting weather polling service\n"+ + " Provider: %s\n"+ + " Interval: %d minutes\n", + s.settings.Realtime.Weather.Provider, + s.settings.Realtime.Weather.PollInterval) + } + + ticker := time.NewTicker(interval) + defer ticker.Stop() + + // Initial fetch + if err := s.fetchAndSave(); err != nil { + if s.settings.Realtime.Weather.Debug { + fmt.Printf("[weather] Initial weather fetch failed: %v\n", err) + } + } + + for { + select { + case <-ticker.C: + if s.settings.Realtime.Weather.Debug { + fmt.Printf("[weather] Polling weather data...\n") + } + if err := s.fetchAndSave(); err != nil { + if s.settings.Realtime.Weather.Debug { + fmt.Printf("[weather] Weather fetch failed: %v\n", err) + } + } + case <-stopChan: + if s.settings.Realtime.Weather.Debug { + fmt.Printf("[weather] Stopping weather polling service\n") + } + return + } + } +} + +// fetchAndSave fetches weather data and saves it to the database +func (s *Service) fetchAndSave() error { + if s.settings.Realtime.Weather.Debug { + fmt.Printf("[weather] Fetching weather data from provider %s\n", + s.settings.Realtime.Weather.Provider) + } + + data, err := s.provider.FetchWeather(s.settings) + if err != nil { + if s.settings.Realtime.Weather.Debug { + fmt.Printf("[weather] Error fetching weather data from provider %s: %v\n", + s.settings.Realtime.Weather.Provider, err) + } + return fmt.Errorf("failed to fetch weather data: %w", err) + } + + if s.settings.Realtime.Weather.Debug { + fmt.Printf("[weather] Successfully fetched weather data:\n"+ + " Provider: %s\n"+ + " Time: %v\n"+ + " Temperature: %.1f°C\n"+ + " Wind: %.1f m/s, %d°\n"+ + " Humidity: %d%%\n"+ + " Pressure: %d hPa\n"+ + " Description: %s\n", + s.settings.Realtime.Weather.Provider, + data.Time.Format("2006-01-02 15:04:05"), + data.Temperature.Current, + data.Wind.Speed, + data.Wind.Deg, + data.Humidity, + data.Pressure, + data.Description, + ) + } + + if err := s.SaveWeatherData(data); err != nil { + if s.settings.Realtime.Weather.Debug { + fmt.Printf("[weather] Error saving weather data: %v\n", err) + } + return fmt.Errorf("failed to save weather data: %w", err) + } + + if s.settings.Realtime.Weather.Debug { + fmt.Printf("[weather] Successfully saved weather data to database\n") + } + + return nil +} diff --git a/views/fragments/listDetections.html b/views/fragments/listDetections.html index 113780ae..e881c3a2 100644 --- a/views/fragments/listDetections.html +++ b/views/fragments/listDetections.html @@ -91,8 +91,8 @@ {{if .Weather}}
- {{weatherIcon .Weather.WeatherMain .TimeOfDay}} - {{.Weather.WeatherMain}} + {{weatherIcon .Weather.WeatherIcon .TimeOfDay}} + {{weatherDescription .Weather.WeatherIcon}}
{{else}}
No weather data
diff --git a/views/settings/integrationSettings.html b/views/settings/integrationSettings.html index e6493bb9..91323c9b 100644 --- a/views/settings/integrationSettings.html +++ b/views/settings/integrationSettings.html @@ -123,172 +123,6 @@ - -
- - - - -
-
- -
- - changed - -
-
- -

Get weather data from OpenWeatherMap

-
- - - -
-
- -
- Enable or disable integration with OpenWeather service. -
-
- -
- -
- -
- Enable debug mode for additional logging information. -
-
- -
- - - - {{template "passwordField" dict - "id" "openWeatherApiKey" - "model" "openWeather.apiKey" - "name" "realtime.openweather.apikey" - "label" "OpenWeather API Key" - "tooltip" "Your OpenWeather API key. Keep this secret!" - }} - -
- - -
- The OpenWeather API endpoint URL. -
-
- -
- - -
- How often to fetch new weather data, in minutes. -
-
- -
- - -
- Choose the units system for weather data. -
-
- -
- - -
- Language code for the API response (e.g., 'en' for English). -
-
- -
- -
-
-
- -
+ + + + +
+
+ +
+ + changed + +
+
+ +

Configure weather data collection

+
+ +
+ +
+ + +
+ Select the weather data provider or choose 'None' to disable weather data collection. +
+
+ + +
+
+

No weather data will be retrieved.

+
+
+ + +
+
+

Weather forecast data is provided by Yr.no, a joint service by the Norwegian Meteorological Institute (met.no) and the Norwegian Broadcasting Corporation (NRK).

+

Yr is a free weather data service. For more information, visit Yr.no.

+
+
+ + +
+
+

Use of OpenWeather requires an API key, sign up for a free API key at OpenWeather.

+
+
+ +
+ +
+ {{template "passwordField" dict + "id" "openWeatherApiKey" + "model" "weather.openWeather.apiKey" + "name" "realtime.weather.openweather.apikey" + "label" "API Key" + "tooltip" "Your OpenWeather API key. Keep this secret!" + }} + +
+ + +
+ The OpenWeather API endpoint URL. +
+
+ +
+ + +
+ Choose the units system for weather data. +
+
+ +
+ + +
+ Language code for the API response (e.g., 'en' for English). +
+
+
+ +
+
+
+ {{end}} \ No newline at end of file