Skip to content

Commit

Permalink
Weather info refactor (#366)
Browse files Browse the repository at this point in the history
Weather Provider Refactoring

* Changes
- Implemented a modular weather provider system supporting multiple data sources
- Added Yr.no as a new weather data provider
- Set Yr.no as the default provider (no API key required)
- Maintained OpenWeatherMap as an optional provider

* Key Features
- Provider Interface: Created a common interface for weather providers, making it easy to add new sources
- Automatic Fallback: System defaults to Yr.no if no provider is specified
- Configuration Flexibility: Users can switch between providers via settings
- Enhanced Accuracy: Yr.no typically provides more accurate forecasts for many locations

* Technical Details
- Implemented provider-specific data models and API clients
- Added standardized weather icon mapping system
- Added proper error handling and retry logic
  • Loading branch information
tphakala authored Jan 5, 2025
1 parent e519b56 commit 050e302
Show file tree
Hide file tree
Showing 20 changed files with 1,038 additions and 651 deletions.
13 changes: 10 additions & 3 deletions internal/analysis/realtime.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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)
}

Expand Down Expand Up @@ -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)
}()
}

Expand Down
30 changes: 26 additions & 4 deletions internal/conf/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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{}
}
15 changes: 9 additions & 6 deletions internal/conf/config.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
27 changes: 20 additions & 7 deletions internal/conf/defaults.go
Original file line number Diff line number Diff line change
Expand Up @@ -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{})
Expand Down
38 changes: 14 additions & 24 deletions internal/conf/validate.go
Original file line number Diff line number Diff line change
Expand Up @@ -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())
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
}
61 changes: 35 additions & 26 deletions internal/datastore/interfaces.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
}

Expand Down
9 changes: 5 additions & 4 deletions internal/httpcontroller/handlers/detections.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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()
Expand Down
52 changes: 52 additions & 0 deletions internal/httpcontroller/handlers/weather.go
Original file line number Diff line number Diff line change
@@ -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)
}
}
Loading

0 comments on commit 050e302

Please sign in to comment.