weather_agent.rb 7.3 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205
  1. require 'date'
  2. require 'cgi'
  3. module Agents
  4. class WeatherAgent < Agent
  5. cannot_receive_events!
  6. gem_dependency_check { defined?(ForecastIO) }
  7. description <<~MD
  8. The Weather Agent creates an event for the day's weather at a given `location`.
  9. #{'## Include `forecast_io` in your Gemfile to use this Agent!' if dependencies_missing?}
  10. You also must select when you would like to get the weather forecast for using the `which_day` option, where the number 1 represents today, 2 represents tomorrow and so on. Weather forecast inforation is only returned for at most one week at a time.
  11. The weather forecast information is provided by Pirate Weather, a drop-in replacement for the Dark Sky API (which no longer has a free tier).
  12. The `location` must be a comma-separated string of map co-ordinates (longitude, latitude). For example, San Francisco would be `37.7771,-122.4196`.
  13. You must set up an [API key for Pirate Weather](https://pirate-weather.apiable.io/) in order to use this Agent.
  14. Set `expected_update_period_in_days` to the maximum amount of time that you'd expect to pass between Events being created by this Agent.
  15. MD
  16. event_description <<~MD
  17. Events look like this:
  18. {
  19. "location": "12345",
  20. "date": {
  21. "epoch": "1357959600",
  22. "pretty": "10:00 PM EST on January 11, 2013"
  23. },
  24. "high": {
  25. "fahrenheit": "64",
  26. "celsius": "18"
  27. },
  28. "low": {
  29. "fahrenheit": "52",
  30. "celsius": "11"
  31. },
  32. "conditions": "Rain Showers",
  33. "icon": "rain",
  34. "icon_url": "https://icons-ak.wxug.com/i/c/k/rain.gif",
  35. "skyicon": "mostlycloudy",
  36. ...
  37. }
  38. MD
  39. default_schedule "8pm"
  40. def working?
  41. event_created_within?((interpolated['expected_update_period_in_days'].presence || 2).to_i) && !recent_error_logs? && key_setup?
  42. end
  43. def key_setup?
  44. interpolated['api_key'].present? && interpolated['api_key'] != "your-key" && interpolated['api_key'] != "put-your-key-here"
  45. end
  46. def default_options
  47. {
  48. 'api_key' => 'your-key',
  49. 'location' => '37.779329,-122.41915',
  50. 'which_day' => '1',
  51. 'expected_update_period_in_days' => '2',
  52. 'language' => 'en'
  53. }
  54. end
  55. def check
  56. if key_setup?
  57. create_event payload: model(which_day).merge('location' => location)
  58. end
  59. end
  60. private
  61. def which_day
  62. (interpolated["which_day"].presence || 1).to_i
  63. end
  64. def location
  65. interpolated["location"].presence || interpolated["zipcode"]
  66. end
  67. def coordinates
  68. location.split(',').map { |e| e.to_f }
  69. end
  70. def language
  71. interpolated["language"].presence || "en"
  72. end
  73. def wunderground?
  74. interpolated["service"].presence && interpolated["service"].presence.downcase == "wunderground"
  75. end
  76. def darksky?
  77. interpolated["service"].presence && interpolated["service"].presence.downcase == "darksky"
  78. end
  79. VALID_COORDS_REGEX = /^\s*-?\d{1,3}\.\d+\s*,\s*-?\d{1,3}\.\d+\s*$/
  80. def validate_location
  81. errors.add(:base, "location is required") unless location.present?
  82. if location =~ VALID_COORDS_REGEX
  83. lat, lon = coordinates
  84. errors.add :base, "too low of a latitude" unless lat > -90
  85. errors.add :base, "too big of a latitude" unless lat < 90
  86. errors.add :base, "too low of a longitude" unless lon > -180
  87. errors.add :base, "too high of a longitude" unless lon < 180
  88. else
  89. errors.add(
  90. :base,
  91. "Location #{location} is malformed. Location for " +
  92. 'Pirate Weather must be in the format "-00.000,-00.00000". The ' +
  93. "number of decimal places does not matter."
  94. )
  95. end
  96. end
  97. def validate_options
  98. errors.add(:base,
  99. "The Weather Underground API has been disabled since Jan 1st 2018, please switch to Pirate Weather") if wunderground?
  100. errors.add(:base, "The Dark Sky API has been disabled since March 31, 2023, please switch to Pirate Weather") if darksky?
  101. validate_location
  102. errors.add(:base, "api_key is required") unless interpolated['api_key'].present?
  103. errors.add(:base, "which_day selection is required") unless which_day.present?
  104. end
  105. def pirate_weather
  106. if key_setup?
  107. ForecastIO.api_key = interpolated['api_key']
  108. lat, lng = coordinates
  109. ForecastIO.forecast(lat, lng, params: { lang: language.downcase })['daily']['data']
  110. end
  111. end
  112. def model(which_day)
  113. value = pirate_weather[which_day - 1]
  114. if value
  115. timestamp = Time.at(value.time)
  116. {
  117. 'date' => {
  118. 'epoch' => value.time.to_s,
  119. 'pretty' => timestamp.strftime("%l:%M %p %Z on %B %d, %Y"),
  120. 'day' => timestamp.day,
  121. 'month' => timestamp.month,
  122. 'year' => timestamp.year,
  123. 'yday' => timestamp.yday,
  124. 'hour' => timestamp.hour,
  125. 'min' => timestamp.strftime("%M"),
  126. 'sec' => timestamp.sec,
  127. 'isdst' => timestamp.isdst ? 1 : 0,
  128. 'monthname' => timestamp.strftime("%B"),
  129. 'monthname_short' => timestamp.strftime("%b"),
  130. 'weekday_short' => timestamp.strftime("%a"),
  131. 'weekday' => timestamp.strftime("%A"),
  132. 'ampm' => timestamp.strftime("%p"),
  133. 'tz_short' => timestamp.zone
  134. },
  135. 'period' => which_day.to_i,
  136. 'high' => {
  137. 'fahrenheit' => value.temperatureMax.round.to_s,
  138. 'epoch' => value.temperatureMaxTime.to_s,
  139. 'fahrenheit_apparent' => value.apparentTemperatureMax.round.to_s,
  140. 'epoch_apparent' => value.apparentTemperatureMaxTime.to_s,
  141. 'celsius' => ((5 * (Float(value.temperatureMax) - 32)) / 9).round.to_s
  142. },
  143. 'low' => {
  144. 'fahrenheit' => value.temperatureMin.round.to_s,
  145. 'epoch' => value.temperatureMinTime.to_s,
  146. 'fahrenheit_apparent' => value.apparentTemperatureMin.round.to_s,
  147. 'epoch_apparent' => value.apparentTemperatureMinTime.to_s,
  148. 'celsius' => ((5 * (Float(value.temperatureMin) - 32)) / 9).round.to_s
  149. },
  150. 'conditions' => value.summary,
  151. 'icon' => value.icon,
  152. 'avehumidity' => (value.humidity * 100).to_i,
  153. 'sunriseTime' => value.sunriseTime.to_s,
  154. 'sunsetTime' => value.sunsetTime.to_s,
  155. 'moonPhase' => value.moonPhase.to_s,
  156. 'precip' => {
  157. 'intensity' => value.precipIntensity.to_s,
  158. 'intensity_max' => value.precipIntensityMax.to_s,
  159. 'intensity_max_epoch' => value.precipIntensityMaxTime.to_s,
  160. 'probability' => value.precipProbability.to_s,
  161. 'type' => value.precipType
  162. },
  163. 'dewPoint' => value.dewPoint.to_s,
  164. 'avewind' => {
  165. 'mph' => value.windSpeed.round.to_s,
  166. 'kph' => (Float(value.windSpeed) * 1.609344).round.to_s,
  167. 'degrees' => value.windBearing.to_s
  168. },
  169. 'visibility' => value.visibility.to_s,
  170. 'cloudCover' => value.cloudCover.to_s,
  171. 'pressure' => value.pressure.to_s,
  172. 'ozone' => value.ozone.to_s
  173. }
  174. end
  175. end
  176. end
  177. end