diff --git a/inkycal/custom/openweathermap_wrapper.py b/inkycal/custom/openweathermap_wrapper.py index b157a7d..f1c69fd 100644 --- a/inkycal/custom/openweathermap_wrapper.py +++ b/inkycal/custom/openweathermap_wrapper.py @@ -1,3 +1,9 @@ +""" +Inkycal opwenweather API abstraction +- retrieves free weather data from OWM 2.5 API endpoints (given provided API key) +- handles unit, language and timezone conversions +- provides ready-to-use current weather, hourly and daily forecasts +""" import json import logging from datetime import datetime @@ -9,13 +15,11 @@ from typing import Literal import requests from dateutil import tz -from inkycal.custom.functions import get_system_tz - TEMP_UNITS = Literal["celsius", "fahrenheit"] WIND_UNITS = Literal["meters_sec", "km_hour", "miles_hour", "knots", "beaufort"] logger = logging.getLogger(__name__) -logger.setLevel("DEBUG") +logger.setLevel(level=logging.INFO) def is_timestamp_within_range(timestamp: datetime, start_time: datetime, end_time: datetime) -> bool: @@ -31,6 +35,7 @@ class OpenWeatherMap: temp_unit: TEMP_UNITS = "celsius", wind_unit: WIND_UNITS = "meters_sec", language: str = "en", + tz_name: str = "UTC" ) -> None: self.api_key = api_key self.city_id = city_id @@ -39,7 +44,8 @@ class OpenWeatherMap: self.language = language self._api_version = "2.5" self._base_url = f"https://api.openweathermap.org/data/{self._api_version}" - self.tz_zone = tz.gettz(get_system_tz()) + self.tz_zone = tz.gettz(tz_name) + logger.info(f"OWM wrapper initialized for city id {self.city_id}, language {self.language} and timezone {tz_name}.") def get_current_weather(self) -> Dict: """ @@ -57,7 +63,7 @@ class OpenWeatherMap: f"Failure getting the current weather: code {response.status_code}. Reason: {response.text}" ) current_data = json.loads(response.text) - + current_weather = {} current_weather["detailed_status"] = current_data["weather"][0]["description"] current_weather["weather_icon_name"] = current_data["weather"][0]["icon"] @@ -71,13 +77,17 @@ class OpenWeatherMap: current_weather["wind"] = self.get_converted_windspeed( current_data["wind"]["speed"] ) # OWM Unit Default: meter/sec, Metric: meter/sec - current_weather["wind_gust"] = self.get_converted_windspeed(current_data["wind"]["gust"]) + if "gust" in current_data["wind"]: + current_weather["wind_gust"] = self.get_converted_windspeed(current_data["wind"]["gust"]) + else: + logger.info(f"OpenWeatherMap response did not contain a wind gust speed. Using base wind: {current_weather['wind']} m/s.") + current_weather["wind_gust"] = current_weather["wind"] current_weather["uvi"] = None # TODO: this is no longer supported with 2.5 API, find alternative - current_weather["sunrise"] = current_data["sys"]["sunrise"] # unix timestamp - current_weather["sunset"] = current_data["sys"]["sunset"] + current_weather["sunrise"] = datetime.fromtimestamp(current_data["sys"]["sunrise"], tz=self.tz_zone) # unix timestamp -> to our timezone + current_weather["sunset"] = datetime.fromtimestamp(current_data["sys"]["sunset"], tz=self.tz_zone) self.current_weather = current_weather - + return current_weather def get_weather_forecast(self) -> List[Dict]: @@ -136,7 +146,8 @@ class OpenWeatherMap: def get_forecast_for_day(self, days_from_today: int) -> Dict: """ Get temperature range, rain and most frequent icon code - for the day that is days_from_today away + for the day that is days_from_today away. + "Today" is based on our local system timezone. :param days_from_today: should be int from 0-4: e.g. 2 -> 2 days from today :return: @@ -146,7 +157,7 @@ class OpenWeatherMap: _ = self.get_weather_forecast() # Calculate the start and end times for the specified number of days from now - current_time = datetime.now() + current_time = datetime.now(tz=self.tz_zone) start_time = ( (current_time + timedelta(days=days_from_today)) .replace(hour=0, minute=0, second=0, microsecond=0) @@ -178,7 +189,7 @@ class OpenWeatherMap: # Return a dict with that day's data day_data = { - "datetime": start_time.timestamp(), + "datetime": start_time, "icon": icon, "temp_min": min(temps), "temp_max": max(temps), @@ -277,7 +288,7 @@ def main(): key = "" city = 2643743 lang = "de" - owm = OpenWeatherMap(api_key=key, city_id=city, language=lang) + owm = OpenWeatherMap(api_key=key, city_id=city, language=lang, tz="Europe/Berlin") current_weather = owm.get_current_weather() print(current_weather) diff --git a/inkycal/modules/inkycal_fullweather.py b/inkycal/modules/inkycal_fullweather.py index fd3993a..d0ef28e 100644 --- a/inkycal/modules/inkycal_fullweather.py +++ b/inkycal/modules/inkycal_fullweather.py @@ -13,22 +13,24 @@ import matplotlib.dates as mdates import matplotlib.pyplot as plt import matplotlib.ticker as ticker import numpy as np +from dateutil import tz from PIL import Image from PIL import ImageDraw from PIL import ImageFont from PIL import ImageOps from icons.weather_icons.weather_icons import get_weather_icon -from inkycal.custom import openweathermap_wrapper from inkycal.custom.functions import fonts +from inkycal.custom.functions import get_system_tz from inkycal.custom.functions import internet_available from inkycal.custom.functions import top_level from inkycal.custom.inkycal_exceptions import NetworkNotReachableError +from inkycal.custom.openweathermap_wrapper import OpenWeatherMap from inkycal.modules.inky_image import image_to_palette from inkycal.modules.template import inkycal_module logger = logging.getLogger(__name__) -logger.setLevel("INFO") +logger.setLevel(logging.INFO) icons_dir = os.path.join(top_level, "icons", "ui-icons") @@ -106,10 +108,6 @@ class Fullweather(inkycal_module): "label": "Your locale", "options": ["de_DE.UTF-8", "en_GB.UTF-8"], }, - "tz": { - "label": "Your timezone", - "options": ["Europe/Berlin", "UTC"], - }, "font": { "label": "Font family to use for the entire screen", "options": ["NotoSans", "Roboto", "Poppins"], @@ -135,6 +133,8 @@ class Fullweather(inkycal_module): config = config["config"] + self.tz = get_system_tz() + # Check if all required parameters are present for param in self.requires: if not param in config: @@ -207,11 +207,6 @@ class Fullweather(inkycal_module): locale.setlocale(locale.LC_TIME, self.locale) self.language = self.locale.split("_")[0] - if "tz" in config: - self.tz = config["tz"] - else: - self.tz = "UTC" - if "icon_outline" in config: self.icon_outline = config["icon_outline"] else: @@ -250,8 +245,8 @@ class Fullweather(inkycal_module): image_draw.rectangle((0, 0, rect_width, self.height), fill=0) # Add text with current date - now = datetime.now() - dateString = now.strftime("%d. %B") + tz_info = tz.gettz(self.tz) + dateString = datetime.now(tz=tz_info).strftime("%d. %B") dateFont = self.get_font(style="Bold", size=self.font_size) # Get the width of the text dateStringbbox = dateFont.getbbox(dateString) @@ -467,9 +462,9 @@ class Fullweather(inkycal_module): ax2.set_yticks(ax2.get_yticks()) ax2.set_yticklabels([f"{value:.0f}" for value in ax2.get_yticks()]) - fig.gca().xaxis.set_major_locator(mdates.DayLocator(interval=1)) - fig.gca().xaxis.set_major_formatter(mdates.DateFormatter("%a")) - fig.gca().xaxis.set_minor_locator(mdates.HourLocator(interval=3)) + fig.gca().xaxis.set_major_locator(mdates.DayLocator(interval=1, tz=self.tz)) + fig.gca().xaxis.set_major_formatter(mdates.DateFormatter(fmt="%a", tz=self.tz)) + fig.gca().xaxis.set_minor_locator(mdates.HourLocator(interval=3, tz=self.tz)) fig.tight_layout() # Adjust layout to prevent clipping of labels # Get image from plot and add it to the image @@ -515,8 +510,8 @@ class Fullweather(inkycal_module): # Date string: Day of week on line 1, date on line 2 short_day_font = self.get_font("ExtraBold", self.font_size + 4) short_month_day_font = self.get_font("Bold", self.font_size - 4) - short_day_name = datetime.fromtimestamp(day_data["datetime"]).strftime("%a") - short_month_day = datetime.fromtimestamp(day_data["datetime"]).strftime("%b %d") + short_day_name = day_data["datetime"].strftime("%a") + short_month_day = day_data["datetime"].strftime("%b %d") short_day_name_text = rect_draw.textbbox((0, 0), short_day_name, font=short_day_font) short_month_day_text = rect_draw.textbbox((0, 0), short_month_day, font=short_month_day_font) day_name_x = (rectangle_width - short_day_name_text[2] + short_day_name_text[0]) / 2 @@ -605,22 +600,16 @@ class Fullweather(inkycal_module): raise NetworkNotReachableError # Get the weather - self.my_owm = openweathermap_wrapper.OpenWeatherMap( + self.my_owm = OpenWeatherMap( api_key=self.api_key, city_id=self.location, temp_unit=self.temp_unit, wind_unit=self.wind_unit, language=self.language, + tz_name=self.tz, ) self.current_weather = self.my_owm.get_current_weather() self.hourly_forecasts = self.my_owm.get_weather_forecast() - # (self.current_weather, self.hourly_forecasts) = owm_forecasts.get_owm_data( - # token=self.api_key, - # city_id=self.location, - # temp_unit=self.temp_unit, - # wind_unit=self.wind_unit, - # language=self.language, - # ) ## Create Base Image self.createBaseImage() @@ -640,9 +629,6 @@ class Fullweather(inkycal_module): if self.orientation == "horizontal": self.image = self.image.rotate(90, expand=True) - # TODO: only for debugging, remove this: - self.image.save("./openweather_full.png") - logger.info("Fullscreen weather forecast generated successfully.") # Convert images according to specified palette im_black, im_colour = image_to_palette(image=self.image, palette="bwr", dither=True) @@ -652,6 +638,7 @@ class Fullweather(inkycal_module): def get_font(self, style, size): # Returns the TrueType font object with the given characteristics + # Some workarounds for typefaces that do not exist in some fonts out there if self.font == "Roboto" and style == "ExtraBold": style = "Black" elif self.font in ["Ubuntu", "NotoSansUI"] and style in ["ExtraBold", "Black"]: diff --git a/inkycal/modules/inkycal_weather.py b/inkycal/modules/inkycal_weather.py index 9cf6174..300deef 100644 --- a/inkycal/modules/inkycal_weather.py +++ b/inkycal/modules/inkycal_weather.py @@ -3,16 +3,27 @@ Inkycal weather module Copyright by aceinnolab """ +import datetime +import arrow import decimal +import logging import math -import arrow +from PIL import Image +from PIL import ImageDraw +from PIL import ImageFont -from inkycal.custom import * +from inkycal.custom.functions import draw_border +from inkycal.custom.functions import fonts +from inkycal.custom.functions import get_system_tz +from inkycal.custom.functions import internet_available +from inkycal.custom.functions import write +from inkycal.custom.inkycal_exceptions import NetworkNotReachableError from inkycal.custom.openweathermap_wrapper import OpenWeatherMap from inkycal.modules.template import inkycal_module logger = logging.getLogger(__name__) +logger.setLevel(level=logging.DEBUG) class Weather(inkycal_module): @@ -75,6 +86,8 @@ class Weather(inkycal_module): config = config['config'] + self.timezone = get_system_tz() + # Check if all required parameters are present for param in self.requires: if not param in config: @@ -103,8 +116,14 @@ class Weather(inkycal_module): self.locale = config['language'] # additional configuration - self.owm = OpenWeatherMap(api_key=self.api_key, city_id=self.location, wind_unit=self.wind_unit, temp_unit=self.temp_unit,language=self.locale) - self.timezone = get_system_tz() + self.owm = OpenWeatherMap( + api_key=self.api_key, + city_id=self.location, + wind_unit=self.wind_unit, + temp_unit=self.temp_unit, + language=self.locale, + tz_name=self.timezone + ) self.weatherfont = ImageFont.truetype( fonts['weathericons-regular-webfont'], size=self.fontsize) @@ -392,7 +411,7 @@ class Weather(inkycal_module): logging.debug(f'decimals temperature: {dec_temp} | decimals wind: {dec_wind}') # Get current time - now = arrow.utcnow() + now = arrow.utcnow().to(self.timezone) fc_data = {} @@ -400,73 +419,28 @@ class Weather(inkycal_module): logger.debug("getting hourly forecasts") - # Forecasts are provided for every 3rd full hour - # find out how many hours there are until the next 3rd full hour - if (now.hour % 3) != 0: - hour_gap = 3 - (now.hour % 3) - else: - hour_gap = 3 - - # Create timings for hourly forecasts - forecast_timings = [now.shift(hours=+ hour_gap + _).floor('hour') - for _ in range(0, 12, 3)] - - # Create forecast objects for given timings - hourly_forecasts = [_ for _ in weather_forecasts if arrow.get(_["datetime"]) in forecast_timings] - - # Add forecast-data to fc_data dictionary + # Add next 4 forecasts to fc_data dictionary, since we only have fc_data = {} - for index, forecast in enumerate(hourly_forecasts): - temp = f"{forecast['temp']:.{dec_temp}f}{self.tempDispUnit}" - - icon = forecast["icon"] + for index, forecast in enumerate(weather_forecasts[0:4]): fc_data['fc' + str(index + 1)] = { - 'temp': temp, - 'icon': icon, - 'stamp': forecast_timings[index].to( - get_system_tz()).format('H.00' if self.hour_format == 24 else 'h a') - } + 'temp': f"{forecast['temp']:.{dec_temp}f}{self.tempDispUnit}", + 'icon': forecast["icon"], + 'stamp': forecast["datetime"].strftime("%I %p" if self.hour_format == 12 else "%H:%M")} elif self.forecast_interval == 'daily': logger.debug("getting daily forecasts") - def calculate_forecast(days_from_today) -> dict: - """Get temperature range and most frequent icon code for forecast - days_from_today should be int from 1-4: e.g. 2 -> 2 days from today - """ - - # Create a list containing time-objects for every 3rd hour of the day - time_range = list( - arrow.Arrow.range('hour', - now.shift(days=days_from_today).floor('day'), - now.shift(days=days_from_today).ceil('day') - ))[::3] - - # Get forecasts for each time-object - my_forecasts = [_ for _ in weather_forecasts if arrow.get(_["datetime"]) in time_range] - - # Get all temperatures for this day - daily_temp = [round(_["temp"], ndigits=dec_temp) for _ in my_forecasts] - # Calculate min. and max. temp for this day - temp_range = f'{min(daily_temp)}{self.tempDispUnit}/{max(daily_temp)}{self.tempDispUnit}' - - # Get all weather icon codes for this day - daily_icons = [_["icon"] for _ in my_forecasts] - # Find most common element from all weather icon codes - status = max(set(daily_icons), key=daily_icons.count) - - weekday = now.shift(days=days_from_today).format('ddd', locale=self.locale) - return {'temp': temp_range, 'icon': status, 'stamp': weekday} - - daily_forecasts = [calculate_forecast(days) for days in range(1, 5)] + daily_forecasts = [self.owm.get_forecast_for_day(days) for days in range(1, 5)] for index, forecast in enumerate(daily_forecasts): fc_data['fc' + str(index +1)] = { - 'temp': forecast['temp'], + 'temp': f'{forecast["temp_min"]:.{dec_temp}f}{self.tempDispUnit}/{forecast["temp_max"]:.{dec_temp}f}{self.tempDispUnit}', 'icon': forecast['icon'], - 'stamp': forecast['stamp'] + 'stamp': forecast['datetime'].strftime("%A") } + else: + logger.error(f"Invalid forecast interval specified: {self.forecast_interval}. Check your settings!") for key, val in fc_data.items(): logger.debug((key, val)) @@ -477,6 +451,7 @@ class Weather(inkycal_module): weather_icon = current_weather["weather_icon_name"] humidity = str(current_weather["humidity"]) + sunrise_raw = arrow.get(current_weather["sunrise"]).to(self.timezone) sunset_raw = arrow.get(current_weather["sunset"]).to(self.timezone)