-
Notifications
You must be signed in to change notification settings - Fork 5
/
Copy pathcontroller_aerisweather.go
346 lines (297 loc) · 11.8 KB
/
controller_aerisweather.go
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
package main
import (
"context"
"encoding/json"
"fmt"
"net/http"
"net/url"
"strconv"
"strings"
"sync"
"time"
"github.com/jackc/pgtype"
"go.uber.org/zap"
"gorm.io/gorm"
)
// AerisWeatherController holds our connection along with some mutexes for operation
type AerisWeatherController struct {
ctx context.Context
wg *sync.WaitGroup
config *Config
AerisWeatherConfig AerisWeatherConfig
logger *zap.SugaredLogger
DB *TimescaleDBClient
}
type AerisWeatherConfig struct {
APIClientID string `yaml:"api-client-id"`
APIClientSecret string `yaml:"api-client-secret"`
APIEndpoint string `yaml:"api-endpoint,omitempty"`
Location string `yaml:"location"`
}
type AerisWeatherForecastResponse struct {
Success bool `json:"success"`
Error string `json:"error"`
AerisForecastData []AerisWeatherForecastResponseData `json:"response"`
}
type AerisWeatherForecastResponseData struct {
Periods []AerisWeatherForecastPeriod `json:"periods"`
}
type AerisWeatherForecastPeriod struct {
ForecastIntervalStart time.Time `json:"dateTimeISO" gorm:"not null"`
MaxTempF int16 `json:"maxTempF"`
MinTempF int16 `json:"minTempF"`
AvgTempF int16 `json:"avgTempF"`
PrecipProbability int16 `json:"pop"`
PrecipInches float32 `json:"precipIN"`
IceInches float32 `json:"iceaccumIN"`
SnowInches float32 `json:"snowIN"`
MaxFeelsLike int16 `json:"maxFeelslikeF"`
MinFeelsLike int16 `json:"minFeelslikeF"`
WindSpeedMPH int16 `json:"windSpeedMPH"`
WindSpeedMax int16 `json:"windSpeedMaxMPH"`
WindDir string `json:"windDir"`
WindDirDeg uint16 `json:"windDirDEG"`
Weather string `json:"weather"`
WeatherCoded string `json:"weatherPrimaryCoded"`
WeatherIcon string `json:"weatherIcon"`
CompactWeather string `json:"compactWeather"`
}
type AerisWeatherForecastRecord struct {
gorm.Model
ForecastSpanHours int16 `gorm:"uniqueIndex:idx_location_span,not null"`
Location string `gorm:"uniqueIndex:idx_location_span,not null"`
Data pgtype.JSONB `gorm:"type:jsonb;default:'[]';not null"`
}
func (AerisWeatherForecastRecord) TableName() string {
return "aeris_weather_forecasts"
}
func NewAerisWeatherController(ctx context.Context, wg *sync.WaitGroup, c *Config, ac AerisWeatherConfig, logger *zap.SugaredLogger) (*AerisWeatherController, error) {
a := AerisWeatherController{
ctx: ctx,
wg: wg,
config: c,
AerisWeatherConfig: ac,
logger: logger,
}
if a.config.Storage.TimescaleDB.ConnectionString == "" {
return &AerisWeatherController{}, fmt.Errorf("TimescaleDB storage must be configured for the Aeris controller to function")
}
if a.AerisWeatherConfig.APIClientID == "" {
return &AerisWeatherController{}, fmt.Errorf("API client id must be set (this is provided by Aeris Weather)")
}
if a.AerisWeatherConfig.APIClientSecret == "" {
return &AerisWeatherController{}, fmt.Errorf("API client secret must be set (this is provided by Aeris Weather)")
}
if a.AerisWeatherConfig.APIEndpoint == "" {
// Set a default API endpoint if not provided
a.AerisWeatherConfig.APIEndpoint = "https://api.aerisapi.com"
}
if a.AerisWeatherConfig.Location == "" {
return &AerisWeatherController{}, fmt.Errorf("forecast location must be set")
}
a.DB = NewTimescaleDBClient(c, logger)
// Connect to TimescaleDB for purposes of storing Aeris data for future client requests
err := a.DB.connectToTimescaleDB(c.Storage)
if err != nil {
return &AerisWeatherController{}, fmt.Errorf("could not connect to TimescaleDB: %v", err)
}
err = a.CreateTables()
if err != nil {
return &AerisWeatherController{}, err
}
return &a, nil
}
func (a *AerisWeatherController) StartController() error {
log.Info("Starting Aeris Weather controller...")
a.wg.Add(1)
defer a.wg.Done()
// Start a refresh of the weekly forecast
go a.refreshForecastPeriodically(7, 24)
// Start a refresh of the hourly forecast
go a.refreshForecastPeriodically(24, 1)
return nil
}
func (a *AerisWeatherController) refreshForecastPeriodically(numPeriods int16, periodHours int16) {
a.wg.Add(1)
defer a.wg.Done()
// time.Ticker's only begin to fire *after* the interval has elapsed. Since we're dealing with
// very long intervals, we will fire the fetcher now, before we start the ticker.
forecast, err := a.fetchAndStoreForecast(numPeriods, periodHours)
if err != nil {
log.Error("error fetching forecast from Aeris Weather:", err)
}
// Save our forecast record to the database
err = a.DB.db.Model(&AerisWeatherForecastRecord{}).Where("forecast_span_hours = ?", numPeriods*periodHours).Update("data", forecast.Data).Error
if err != nil {
log.Errorf("error saving forecast to database: %v", err)
}
// Convert periodHours into a time.Duration
spanInterval, err := time.ParseDuration(fmt.Sprintf("%vh", periodHours))
if err != nil {
log.Errorf("error parsing Aeris Weather refresh interval:", err)
}
// We will refresh our forecasts four times in every period.
// For example: for a daily forecast, we refresh every 6 hours.
refreshInterval := spanInterval / 4
log.Infof("Starting Aeris Weather fetcher for %v hours, every %v minutes", numPeriods*periodHours, refreshInterval.Minutes())
ticker := time.NewTicker(refreshInterval)
defer ticker.Stop()
for {
select {
case <-ticker.C:
log.Info("Updating forecast from Aeris Weather...")
forecast, err := a.fetchAndStoreForecast(numPeriods, periodHours)
if err != nil {
log.Error("error fetching forecast from Aeris Weather:", err)
}
// Save our forecast record to the database
err = a.DB.db.Model(&AerisWeatherForecastRecord{}).Where("forecast_span_hours = ?", numPeriods*periodHours).Update("data", forecast.Data).Error
if err != nil {
log.Errorf("error saving forecast to database: %v", err)
}
case <-a.ctx.Done():
return
}
}
}
func (a *AerisWeatherController) fetchAndStoreForecast(numPeriods int16, periodHours int16) (*AerisWeatherForecastRecord, error) {
v := url.Values{}
// Add authentication
v.Set("client_id", a.AerisWeatherConfig.APIClientID)
v.Set("client_secret", a.AerisWeatherConfig.APIClientSecret)
v.Set("filter", fmt.Sprintf("%vh", strconv.FormatInt(int64(periodHours), 10)))
v.Set("limit", strconv.FormatInt(int64(numPeriods), 10))
client := http.Client{
Timeout: 5 * time.Second,
}
url := fmt.Sprint(a.AerisWeatherConfig.APIEndpoint + "/forecasts/" + a.AerisWeatherConfig.Location + "?" + v.Encode())
req, err := http.NewRequest("GET", url, nil)
if err != nil {
return &AerisWeatherForecastRecord{}, fmt.Errorf("error creating Aeris Weather API HTTP request: %v", err)
}
log.Debugf("Making request to Aeris Weather: %v", url)
req = req.WithContext(a.ctx)
resp, err := client.Do(req)
if err != nil {
return &AerisWeatherForecastRecord{}, fmt.Errorf("error making request to Aeris Weather: %v", err)
}
response := &AerisWeatherForecastResponse{}
decoder := json.NewDecoder(resp.Body)
err = decoder.Decode(response)
if err != nil {
return &AerisWeatherForecastRecord{}, fmt.Errorf("unable to decode Aeris Weather API response: %v", err)
}
if !response.Success {
return &AerisWeatherForecastRecord{}, fmt.Errorf("bad response from Aeris Weather server %+v", response)
}
// This is a map of Aeris Weather weather codes to symbols from the Weather Icons font set
// See: https://www.aerisweather.com/support/docs/api/reference/weather-codes/
// https://erikflowers.github.io/weather-icons/
var iconMap = map[string]string{
"A": "", // hail
"BD": "", // blowing dust
"BN": "", // blowing sand
"BR": "", // mist
"BS": "", // blowing snow
"BY": "", // blowing spray
"F": "", // fog
"FC": "", // funnel cloud
"FR": "", // frost
"H": "", // haze
"IC": "", // ice crystals
"IF": "", // ice fog
"IP": "", // ice pellets/sleet
"K": "", // smoke
"L": "", // drizzle
"R": "", // rain
"RW": "", // rain showers
"RS": "", // rain-snow mix
"SI": "", // snow-sleet mix
"WM": "", // wintry mix
"S": "", // snow
"SW": "", // snow showers
"T": "", // thunderstorm
"UP": "", // unknown precip
"VA": "", // volcanic ash
"WP": "", // waterspouts
"ZF": "", // freezing fog
"ZL": "", // freezing drizzle
"ZR": "", // freezing rain
"ZY": "", // freezing spray
"CL": "", // clear
"FW": "", // mostly sunny
"SC": "", // partly cloudy
"BK": "", // mostly cloudy
"OV": "", // cloudy/overcast
}
// This is a map of Aeris Weather weather codes to compact descriptions of the weather
// See: https://www.aerisweather.com/support/docs/api/reference/weather-codes/
var compactWeatherMap = map[string]string{
"A": "Hail", // hail
"BD": "Blowing Dust", // blowing dust
"BN": "Blowing Sand", // blowing sand
"BR": "Mist", // mist
"BS": "Blowing Snow", // blowing snow
"BY": "Blowing Spray", // blowing spray
"F": "Fog", // fog
"FC": "Funnel Clouds", // funnel cloud
"FR": "Frost", // frost
"H": "Haze", // haze
"IC": "Ice Crystals", // ice crystals
"IF": "Ice Fog", // ice fog
"IP": "Sleet", // ice pellets/sleet
"K": "Smoke", // smoke
"L": "Drizzle", // drizzle
"R": "Rain", // rain
"RW": "Rain Showers", // rain showers
"RS": "Rain-Snow Mix", // rain-snow mix
"SI": "Snow-Sleet Mix", // snow-sleet mix
"WM": "Wintry Mix", // wintry mix
"S": "Snow", // snow
"SW": "Snow Showers", // snow showers
"T": "Thunderstorms", // thunderstorm
"UP": "Unknown Precip", // unknown precip
"VA": "Volcanic Ash", // volcanic ash
"WP": "Waterspouts", // waterspouts
"ZF": "Freezing Fog", // freezing fog
"ZL": "Freezing Drizzle", // freezing drizzle
"ZR": "Freezing Rain", // freezing rain
"ZY": "Freezing Spray", // freezing spray
"CL": "Clear", // clear
"FW": "Mostly Sunny", // mostly sunny
"SC": "Partly Cloudy", // partly cloudy
"BK": "Mostly Cloudy", // mostly cloudy
"OV": "Cloudy", // cloudy/overcast
}
// Add icons to the period data
for k, p := range response.AerisForecastData[0].Periods {
forecastParts := strings.Split(p.WeatherCoded, ":")
if forecastParts[2] != "" {
response.AerisForecastData[0].Periods[k].WeatherIcon = iconMap[forecastParts[2]]
response.AerisForecastData[0].Periods[k].CompactWeather = compactWeatherMap[forecastParts[2]]
} else {
response.AerisForecastData[0].Periods[k].WeatherIcon = "?"
response.AerisForecastData[0].Periods[k].CompactWeather = ""
}
}
forecastPeriodsJSON, err := json.Marshal(response.AerisForecastData[0].Periods)
if err != nil {
return &AerisWeatherForecastRecord{}, fmt.Errorf("could not marshall forecast periods to JSON: %v", err)
}
// The request was succesful, so we need to add timespan and location information that will be stored
// along side the forecast data in the database. Together, these constitute a composite primary key
// for the table. Only one combination of span hours + location will be permitted.
record := AerisWeatherForecastRecord{
ForecastSpanHours: numPeriods * periodHours,
Location: a.AerisWeatherConfig.Location,
}
record.Data.Set(forecastPeriodsJSON)
return &record, nil
}
func (a *AerisWeatherController) CreateTables() error {
err := a.DB.db.AutoMigrate(AerisWeatherForecastRecord{})
if err != nil {
return fmt.Errorf("error creating or migrating Aeris forecast record database table: %v", err)
}
return nil
}