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