Skip to content

Commit 40ba92d

Browse files
authored
Add Open Meteo weather client (#196)
1 parent c550f00 commit 40ba92d

15 files changed

+844
-23
lines changed

docs/app_advanced.md

Lines changed: 17 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -65,7 +65,23 @@ The `pkg/storage` package defines a `Client` interface and multiple implementati
6565
This setup will allow for easily adding more storage clients in the future.
6666

6767
### Weather Client
68-
`pkg/weather` defines a `Client` interface. Currently there is only an implementation for Netatmo weather stations which can be setup with a configuration like this:
68+
`pkg/weather` defines a `Client` interface. There are two implementations available:
69+
70+
#### OpenMeteo (Recommended)
71+
[OpenMeteo](https://open-meteo.com) is a free, open-source weather API that requires no API key and no special hardware. This is the easiest option to get started with:
72+
73+
```yaml
74+
weather:
75+
type: "openmeteo"
76+
options:
77+
latitude: 37.7749
78+
longitude: -122.4194
79+
```
80+
81+
Simply provide your location's latitude and longitude coordinates. You can find these using Google Maps or any mapping service.
82+
83+
#### Netatmo
84+
If you have a Netatmo weather station, you can use it for more accurate, location-specific weather data:
6985

7086
```yaml
7187
weather:

garden-app/pkg/weather/client.go

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ import (
1111
"github.com/calvinmclean/automated-garden/garden-app/clock"
1212
"github.com/calvinmclean/automated-garden/garden-app/pkg/weather/fake"
1313
"github.com/calvinmclean/automated-garden/garden-app/pkg/weather/netatmo"
14+
"github.com/calvinmclean/automated-garden/garden-app/pkg/weather/openmeteo"
1415
"github.com/calvinmclean/babyapi"
1516
"github.com/patrickmn/go-cache"
1617
"github.com/prometheus/client_golang/prometheus"
@@ -84,6 +85,8 @@ func NewClient(c *Config, storageCallback func(map[string]any) error) (client Cl
8485
switch strings.ToLower(c.Type) {
8586
case "netatmo":
8687
client, err = netatmo.NewClient(c.Options, storageCallback)
88+
case "openmeteo":
89+
client, err = openmeteo.NewClient(c.Options)
8790
case "fake":
8891
client, err = fake.NewClient(c.Options)
8992
default:

garden-app/pkg/weather/client_test.go

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,27 @@ func TestNewWeatherClientInvalidType(t *testing.T) {
4141
assert.Equal(t, "invalid type 'DNE'", err.Error())
4242
}
4343

44+
func TestNewWeatherClientOpenMeteo(t *testing.T) {
45+
client, err := NewClient(&Config{
46+
Type: "openmeteo",
47+
Options: map[string]any{
48+
"latitude": 37.7749,
49+
"longitude": -122.4194,
50+
},
51+
}, func(m map[string]any) error { return nil })
52+
assert.NoError(t, err)
53+
assert.NotNil(t, client)
54+
}
55+
56+
func TestNewWeatherClientOpenMeteoInvalidConfig(t *testing.T) {
57+
_, err := NewClient(&Config{
58+
Type: "openmeteo",
59+
Options: map[string]any{},
60+
}, func(m map[string]any) error { return nil })
61+
assert.Error(t, err)
62+
assert.Contains(t, err.Error(), "latitude and longitude must be provided")
63+
}
64+
4465
func TestCachedWeatherClient(t *testing.T) {
4566
client, err := NewClient(&Config{
4667
Type: "fake",
Lines changed: 190 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,190 @@
1+
package openmeteo
2+
3+
import (
4+
"encoding/json"
5+
"errors"
6+
"fmt"
7+
"io"
8+
"net/http"
9+
"net/url"
10+
"time"
11+
12+
"github.com/calvinmclean/automated-garden/garden-app/clock"
13+
"github.com/mitchellh/mapstructure"
14+
)
15+
16+
// Config is specific to the OpenMeteo API and holds the necessary fields for location
17+
type Config struct {
18+
Latitude float32 `json:"latitude" yaml:"latitude" mapstructure:"latitude"`
19+
Longitude float32 `json:"longitude" yaml:"longitude" mapstructure:"longitude"`
20+
}
21+
22+
// Client is used to interact with OpenMeteo API
23+
type Client struct {
24+
*Config
25+
httpClient *http.Client
26+
baseURL string
27+
}
28+
29+
const (
30+
minRainInterval = 24 * time.Hour
31+
minTemperatureInterval = 72 * time.Hour
32+
defaultBaseURL = "https://api.open-meteo.com"
33+
)
34+
35+
// openMeteoResponse represents the structure of the API response
36+
type openMeteoResponse struct {
37+
Daily struct {
38+
Time []string `json:"time"`
39+
Temperature2mMax []float32 `json:"temperature_2m_max"`
40+
PrecipitationSum []float32 `json:"precipitation_sum"`
41+
} `json:"daily"`
42+
}
43+
44+
// NewClient creates a new OpenMeteo API client from configuration
45+
func NewClient(options map[string]any) (*Client, error) {
46+
return NewClientWithHTTPClient(options, http.DefaultClient)
47+
}
48+
49+
// NewClientWithHTTPClient creates a new OpenMeteo API client with a custom HTTP client (used for testing)
50+
func NewClientWithHTTPClient(options map[string]any, httpClient *http.Client) (*Client, error) {
51+
client := &Client{
52+
Config: &Config{},
53+
httpClient: httpClient,
54+
baseURL: defaultBaseURL,
55+
}
56+
57+
err := mapstructure.WeakDecode(options, &client.Config)
58+
if err != nil {
59+
return nil, err
60+
}
61+
62+
if client.Latitude == 0 || client.Longitude == 0 {
63+
return nil, errors.New("latitude and longitude must be provided")
64+
}
65+
66+
return client, nil
67+
}
68+
69+
// fetchData makes the API request to OpenMeteo and returns the parsed response
70+
func (c *Client) fetchData(pastDays int, dailyVars ...string) (*openMeteoResponse, error) {
71+
u, err := url.Parse(c.baseURL + "/v1/forecast")
72+
if err != nil {
73+
return nil, err
74+
}
75+
76+
q := u.Query()
77+
q.Set("latitude", fmt.Sprintf("%f", c.Latitude))
78+
q.Set("longitude", fmt.Sprintf("%f", c.Longitude))
79+
q.Set("past_days", fmt.Sprintf("%d", pastDays))
80+
q.Set("timezone", "auto")
81+
82+
// Add daily variables
83+
for _, v := range dailyVars {
84+
q.Add("daily", v)
85+
}
86+
87+
u.RawQuery = q.Encode()
88+
89+
req, err := http.NewRequest(http.MethodGet, u.String(), nil)
90+
if err != nil {
91+
return nil, err
92+
}
93+
94+
resp, err := c.httpClient.Do(req)
95+
if err != nil {
96+
return nil, fmt.Errorf("error making API request: %w", err)
97+
}
98+
defer resp.Body.Close()
99+
100+
if resp.StatusCode != http.StatusOK {
101+
body, _ := io.ReadAll(resp.Body)
102+
return nil, fmt.Errorf("API returned status %d: %s", resp.StatusCode, string(body))
103+
}
104+
105+
body, err := io.ReadAll(resp.Body)
106+
if err != nil {
107+
return nil, fmt.Errorf("error reading response body: %w", err)
108+
}
109+
110+
var data openMeteoResponse
111+
err = json.Unmarshal(body, &data)
112+
if err != nil {
113+
return nil, fmt.Errorf("error parsing response: %w", err)
114+
}
115+
116+
return &data, nil
117+
}
118+
119+
// GetTotalRain returns the sum of all precipitation in millimeters in the given period
120+
func (c *Client) GetTotalRain(since time.Duration) (float32, error) {
121+
// Time to check from must always be at least 24 hours to get valid data
122+
if since < minRainInterval {
123+
since = minRainInterval
124+
}
125+
126+
// Calculate past days needed (round up)
127+
pastDays := int(since.Hours()/24) + 1
128+
129+
data, err := c.fetchData(pastDays, "precipitation_sum")
130+
if err != nil {
131+
return 0, fmt.Errorf("error fetching precipitation data: %w", err)
132+
}
133+
134+
if len(data.Daily.PrecipitationSum) == 0 {
135+
return 0, errors.New("no precipitation data returned")
136+
}
137+
138+
// Sum all precipitation values
139+
var total float32
140+
for _, precip := range data.Daily.PrecipitationSum {
141+
total += precip
142+
}
143+
144+
return total, nil
145+
}
146+
147+
// GetAverageHighTemperature returns the average daily high temperature between the given time and the end of
148+
// yesterday (since daily high can be misleading if queried mid-day)
149+
func (c *Client) GetAverageHighTemperature(since time.Duration) (float32, error) {
150+
// Time to check since must always be at least 3 days
151+
if since < minTemperatureInterval {
152+
since = minTemperatureInterval
153+
}
154+
155+
// Calculate past days needed (round up)
156+
pastDays := int(since.Hours()/24) + 1
157+
158+
data, err := c.fetchData(pastDays, "temperature_2m_max")
159+
if err != nil {
160+
return 0, fmt.Errorf("error fetching temperature data: %w", err)
161+
}
162+
163+
if len(data.Daily.Temperature2mMax) == 0 {
164+
return 0, errors.New("no temperature data returned")
165+
}
166+
167+
// Calculate average of daily max temperatures
168+
now := clock.Now()
169+
endOfYesterday := time.Date(now.Year(), now.Month(), now.Day()-1, 23, 59, 59, 0, time.Local)
170+
171+
var sum float32
172+
var count int
173+
for i, t := range data.Daily.Time {
174+
date, err := time.Parse("2006-01-02", t)
175+
if err != nil {
176+
continue
177+
}
178+
// Only include days up to end of yesterday
179+
if date.Before(endOfYesterday) || date.Equal(endOfYesterday) {
180+
sum += data.Daily.Temperature2mMax[i]
181+
count++
182+
}
183+
}
184+
185+
if count == 0 {
186+
return 0, errors.New("no valid temperature data for the specified period")
187+
}
188+
189+
return sum / float32(count), nil
190+
}

0 commit comments

Comments
 (0)