#!/usr/bin/env python3 # © Copyright 2021-2022, Scott Gasch """How's the weather going to be tomorrow?""" import datetime import logging import os import urllib.request from dataclasses import dataclass from typing import Any import astral # type: ignore import pytz from astral.sun import sun # type: ignore from bs4 import BeautifulSoup # type: ignore from overrides import overrides import argparse_utils import config import dateparse.dateparse_utils as dp import datetime_utils import persistent import smart_home.thermometers as temps import text_utils logger = logging.getLogger(__name__) cfg = config.add_commandline_args( f'Cached Weather Forecast ({__file__})', 'Arguments controlling detailed weather rendering', ) cfg.add_argument( '--weather_forecast_cachefile', type=str, default=f'{os.environ["HOME"]}/cache/.weather_forecast_cache', metavar='FILENAME', help='File in which to cache weather data', ) cfg.add_argument( '--weather_forecast_stalest_acceptable', type=argparse_utils.valid_duration, default=datetime.timedelta(seconds=7200), # 2 hours metavar='DURATION', help='Maximum acceptable age of cached data. If zero, forces a refetch', ) @dataclass class WeatherForecast: date: datetime.date # The date sunrise: datetime.datetime # Sunrise datetime sunset: datetime.datetime # Sunset datetime description: str # Textual description of weather @persistent.persistent_autoloaded_singleton() # type: ignore class CachedDetailedWeatherForecast(persistent.PicklingFileBasedPersistent): def __init__(self, forecasts=None): if forecasts is not None: self.forecasts = forecasts return now = datetime_utils.now_pacific() self.forecasts = {} # Ask the raspberry pi about the outside temperature. current_temp = temps.ThermometerRegistry().read_temperature( 'house_outside', convert_to_fahrenheit=True ) # Get a weather forecast for Bellevue. www = urllib.request.urlopen( "https://forecast.weather.gov/MapClick.php?lat=47.652775&lon=-122.170716" ) forecast_response = www.read() www.close() soup = BeautifulSoup(forecast_response, "html.parser") forecast = soup.find(id='detailed-forecast-body') parser = dp.DateParser() said_temp = False last_dt = now dt = now for (day, txt) in zip( forecast.find_all('b'), forecast.find_all(class_='col-sm-10 forecast-text'), ): last_dt = dt try: dt = parser.parse(day.get_text()) except Exception: dt = last_dt assert dt is not None # Compute sunrise/sunset times on dt. city = astral.LocationInfo("Bellevue", "USA", "US/Pacific", 47.653, -122.171) s = sun(city.observer, date=dt, tzinfo=pytz.timezone("US/Pacific")) sunrise = s['sunrise'] sunset = s['sunset'] if dt.date() == now.date() and not said_temp and current_temp is not None: blurb = f'{day.get_text()}: The current outside tempterature is {current_temp}. ' blurb += txt.get_text() said_temp = True else: blurb = f'{day.get_text()}: {txt.get_text()}' blurb = text_utils.wrap_string(blurb, 80) if dt.date() in self.forecasts: self.forecasts[dt.date()].description += '\n' + blurb else: self.forecasts[dt.date()] = WeatherForecast( date=dt, sunrise=sunrise, sunset=sunset, description=blurb, ) @staticmethod @overrides def get_filename() -> str: return config.config['weather_forecast_cachefile'] @staticmethod @overrides def should_we_save_data(filename: str) -> bool: return True @staticmethod @overrides def should_we_load_data(filename: str) -> bool: return persistent.was_file_written_within_n_seconds( filename, config.config['weather_forecast_stalest_acceptable'].total_seconds(), ) @overrides def get_persistent_data(self) -> Any: return self.forecasts