Rain notifier

Posted on
AWS Terraform Go

A scheduled Lambda in Go.

Background

I don’t own a car and don’t plan on doing so any time soon. I love my bike and I bike almost everywhere; it keeps me happy and healthy. Furthermore, me driving would result in me being under-utilized while I’m performing the mundane act.

Problem

I hate rainy (and cold, and cloudy) weather and it’s one of the biggest reasons why we moved to California from Seattle. Rain is also incompatible with biking, since not only do I have to get wet, but so does my beloved bike.

Solution 1 (bad)

Be proactive about polling the weather, and make sure that I schedule a carpool with Scoop before their nightly deadline of 9 P.M. so that I’ve got a ride to work the next day.

This is suboptimal because I have to remember to check the weather. Sometimes I forget, in which case I have to pay for a more expensive Lyft ride. Also, it’s usually sunny here, so it doesn’t make sense to poll the weather every night before a workday when chances are it won’t rain anyway.

Solution 2 (better)

Obviously this can be automated, so I wrote yet another scheduled Lambda to do it for me.

Developing the thing

Developing the thing

Basically, it

  1. wakes up every night before a worknight at 8 P.M. (PST)
  2. calls the Dark Sky API
  3. checks if there’s a higher than tolerable chance of rain in the most relevant hours of the next working day
  4. if so, sends an SMS and email with the computation and raw data

Details

I used Go for the application code, Terraform for the infrastructure deployment, and AWS as the cloud provider. I wanted to see how quickly I could do this in Go compared to Python, as I’m interested in using Go more and more, and Python less and less (I’ve been using it for years).

The API response by default includes a bunch of data, but we can ask only for what we want when making the request

func makeRequest() (*http.Request, error) {
	req, err := http.NewRequest("GET", fullURL, nil)
	if err != nil {
		return nil, fmt.Errorf("creating request: %v", err)
	}

	exclude := []string{"currently", "minutely", "daily", "alerts", "flags"}

	qs := url.Values{}
	qs.Add("exclude", strings.Join(exclude, ","))

	req.URL.RawQuery = qs.Encode()
	return req, nil
}

and thus represent the response with

type datum struct {
	// epoch time according to decodedResponse.Timezone
	Time              int64
	PrecipProbability float64
}

type decodedResponse struct {
	Timezone string
	Hourly   struct{ Data []datum }
}

Since the hourly values are guaranteed to be sorted by time, we can do a binary search for the indices we want to examine, and call out any values that indicate a likely chance of rain.

// search does a binary search to return the first index in data for which the UNIX timestamp .Time is after t.
//
// This relies on data being sorted by .Time, which is currently (4/30/2018) guaranteed by the API.
func search(data []datum, t time.Time) int {
	ts := t.Unix()
	cmp := func(i int) bool {
		return data[i].Time >= ts
	}
	return sort.Search(len(data), cmp)
}

// ...

type rainEvent struct {
	when string
	prob float64
}

// ...

sIndex := search(data, sTime)
fIndex := search(data, fTime)

log.Printf("Found index %d for time %s\n", sIndex, sTime.Format(timeFormat))
log.Printf("Found index %d for time %s\n", fIndex, fTime.Format(timeFormat))

var rs []rainEvent

for j := sIndex; j <= fIndex; j++ {
  d := data[j]
  when := time.Unix(d.Time, 0).In(location).Format(timeFormat)
  prob := 100 * d.PrecipProbability

  if prob >= 30 {
    log.Printf("Uh oh! %.0f%% chance of rain at %s!\n", prob, when)
    rs = append(rs, rainEvent{when, prob})
  } else {
    log.Printf("Phew! Only a %.0f%% chance of rain at %s.\n", prob, when)
  }
}

The interesting case is when there’s a chance of rain.

We start two goroutines, one to send an email, and another to send an SMS. The I/O-bound goroutines receive a channel to send any errors.

var (
  wg   sync.WaitGroup
  errc = make(chan error, 2)
)

attachment, err := json.Marshal(rsp)
if err != nil {
  return fmt.Errorf("creating attachment: %v", err)
}

message := makeMessage(rs)

wg.Add(2)
go email(message, attachment, &wg, errc)
go publish(message, &wg, errc)
wg.Wait()

close(errc)

for err = range errc {
  log.Printf("Got an error: %v", err)
}
return err

Note that

  • errc is of length 2 so that both goroutines can send to errc without blocking, thus preventing a deadlock in case of error
  • we close and drain errc after the goroutines are done, logging none or all errors and returning the last, since the Lambda handler signature is constrained to return one error
  • both goroutines are given a chance to run to completion, even if one of them errors

Improvements

  • currently the API key is stored in the Lambda runtime as an environment variable; it should be downloaded and decrypted from an external data store as needed
  • Scoop doesn’t have an API (I filed a support ticket), otherwise I would have proactively scheduled a carpool in case inclement weather was guaranteed
  • I’m allowing this Lambda to assume an IAM role from an existing Lambda, which has more permissions than necessary for this functionality, which isn’t a good security posture

Conclusion

This was pretty fast to implement and I got some experience deploying code in a language I’m interested in using more seriously. I also appreciated the compile-time checking of the Go compiler, especially since Lambda doesn’t really have a great development environment by default.

So far there hasn’t been any sign of bad weather; hopefully it stays that way!

No rain

No rain forthcoming