fix timezone handling for weather modules
This commit is contained in:
		| @@ -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: | ||||
|         """ | ||||
| @@ -71,10 +77,14 @@ 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 | ||||
|  | ||||
| @@ -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) | ||||
|   | ||||
| @@ -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"]: | ||||
|   | ||||
| @@ -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) | ||||
|  | ||||
|   | ||||
		Reference in New Issue
	
	Block a user
	 mrbwburns
					mrbwburns