b8a20ed8caa04553f01225a5a2f8f57866858ea7
[python_utils.git] / cached / weather_forecast.py
1 #!/usr/bin/env python3
2
3 # © Copyright 2021-2022, Scott Gasch
4
5 """How's the weather going to be tomorrow?"""
6
7 import datetime
8 import logging
9 import os
10 import urllib.request
11 from dataclasses import dataclass
12 from typing import Any
13
14 import astral  # type: ignore
15 import pytz
16 from astral.sun import sun  # type: ignore
17 from bs4 import BeautifulSoup  # type: ignore
18 from overrides import overrides
19
20 import argparse_utils
21 import config
22 import dateparse.dateparse_utils as dp
23 import datetime_utils
24 import persistent
25 import smart_home.thermometers as temps
26 import text_utils
27
28 logger = logging.getLogger(__name__)
29
30 cfg = config.add_commandline_args(
31     f'Cached Weather Forecast ({__file__})',
32     'Arguments controlling detailed weather rendering',
33 )
34 cfg.add_argument(
35     '--weather_forecast_cachefile',
36     type=str,
37     default=f'{os.environ["HOME"]}/cache/.weather_forecast_cache',
38     metavar='FILENAME',
39     help='File in which to cache weather data',
40 )
41 cfg.add_argument(
42     '--weather_forecast_stalest_acceptable',
43     type=argparse_utils.valid_duration,
44     default=datetime.timedelta(seconds=7200),  # 2 hours
45     metavar='DURATION',
46     help='Maximum acceptable age of cached data.  If zero, forces a refetch',
47 )
48
49
50 @dataclass
51 class WeatherForecast:
52     date: datetime.date  # The date
53     sunrise: datetime.datetime  # Sunrise datetime
54     sunset: datetime.datetime  # Sunset datetime
55     description: str  # Textual description of weather
56
57
58 @persistent.persistent_autoloaded_singleton()  # type: ignore
59 class CachedDetailedWeatherForecast(persistent.Persistent):
60     def __init__(self, forecasts=None):
61         if forecasts is not None:
62             self.forecasts = forecasts
63             return
64
65         now = datetime_utils.now_pacific()
66         self.forecasts = {}
67
68         # Ask the raspberry pi about the outside temperature.
69         current_temp = temps.ThermometerRegistry().read_temperature(
70             'house_outside', convert_to_fahrenheit=True
71         )
72
73         # Get a weather forecast for Bellevue.
74         www = urllib.request.urlopen(
75             "https://forecast.weather.gov/MapClick.php?lat=47.652775&lon=-122.170716"
76         )
77         forecast_response = www.read()
78         www.close()
79
80         soup = BeautifulSoup(forecast_response, "html.parser")
81         forecast = soup.find(id='detailed-forecast-body')
82         parser = dp.DateParser()
83
84         said_temp = False
85         last_dt = now
86         dt = now
87         for (day, txt) in zip(
88             forecast.find_all('b'),
89             forecast.find_all(class_='col-sm-10 forecast-text'),
90         ):
91             last_dt = dt
92             try:
93                 dt = parser.parse(day.get_text())
94             except Exception:
95                 dt = last_dt
96             assert dt is not None
97
98             # Compute sunrise/sunset times on dt.
99             city = astral.LocationInfo("Bellevue", "USA", "US/Pacific", 47.653, -122.171)
100             s = sun(city.observer, date=dt, tzinfo=pytz.timezone("US/Pacific"))
101             sunrise = s['sunrise']
102             sunset = s['sunset']
103
104             if dt.date() == now.date() and not said_temp and current_temp is not None:
105                 blurb = f'{day.get_text()}: The current outside tempterature is {current_temp}. '
106                 blurb += txt.get_text()
107                 said_temp = True
108             else:
109                 blurb = f'{day.get_text()}: {txt.get_text()}'
110             blurb = text_utils.wrap_string(blurb, 80)
111
112             if dt.date() in self.forecasts:
113                 self.forecasts[dt.date()].description += '\n' + blurb
114             else:
115                 self.forecasts[dt.date()] = WeatherForecast(
116                     date=dt,
117                     sunrise=sunrise,
118                     sunset=sunset,
119                     description=blurb,
120                 )
121
122     @classmethod
123     @overrides
124     def load(cls) -> Any:
125         if persistent.was_file_written_within_n_seconds(
126             config.config['weather_forecast_cachefile'],
127             config.config['weather_forecast_stalest_acceptable'].total_seconds(),
128         ):
129             import pickle
130
131             with open(config.config['weather_forecast_cachefile'], 'rb') as rf:
132                 weather_data = pickle.load(rf)
133                 return cls(weather_data)
134         return None
135
136     @overrides
137     def save(self) -> bool:
138         import pickle
139
140         with open(config.config['weather_forecast_cachefile'], 'wb') as wf:
141             pickle.dump(
142                 self.forecasts,
143                 wf,
144                 pickle.HIGHEST_PROTOCOL,
145             )
146         return True