fix timezone handling for weather modules

This commit is contained in:
mrbwburns 2024-01-26 14:23:21 -08:00
parent b32a709967
commit 04b951eb13
3 changed files with 75 additions and 102 deletions

View File

@ -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 json
import logging import logging
from datetime import datetime from datetime import datetime
@ -9,13 +15,11 @@ from typing import Literal
import requests import requests
from dateutil import tz from dateutil import tz
from inkycal.custom.functions import get_system_tz
TEMP_UNITS = Literal["celsius", "fahrenheit"] TEMP_UNITS = Literal["celsius", "fahrenheit"]
WIND_UNITS = Literal["meters_sec", "km_hour", "miles_hour", "knots", "beaufort"] WIND_UNITS = Literal["meters_sec", "km_hour", "miles_hour", "knots", "beaufort"]
logger = logging.getLogger(__name__) 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: 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", temp_unit: TEMP_UNITS = "celsius",
wind_unit: WIND_UNITS = "meters_sec", wind_unit: WIND_UNITS = "meters_sec",
language: str = "en", language: str = "en",
tz_name: str = "UTC"
) -> None: ) -> None:
self.api_key = api_key self.api_key = api_key
self.city_id = city_id self.city_id = city_id
@ -39,7 +44,8 @@ class OpenWeatherMap:
self.language = language self.language = language
self._api_version = "2.5" self._api_version = "2.5"
self._base_url = f"https://api.openweathermap.org/data/{self._api_version}" 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: 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}" f"Failure getting the current weather: code {response.status_code}. Reason: {response.text}"
) )
current_data = json.loads(response.text) current_data = json.loads(response.text)
current_weather = {} current_weather = {}
current_weather["detailed_status"] = current_data["weather"][0]["description"] current_weather["detailed_status"] = current_data["weather"][0]["description"]
current_weather["weather_icon_name"] = current_data["weather"][0]["icon"] current_weather["weather_icon_name"] = current_data["weather"][0]["icon"]
@ -71,13 +77,17 @@ class OpenWeatherMap:
current_weather["wind"] = self.get_converted_windspeed( current_weather["wind"] = self.get_converted_windspeed(
current_data["wind"]["speed"] current_data["wind"]["speed"]
) # OWM Unit Default: meter/sec, Metric: meter/sec ) # 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["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["sunrise"] = datetime.fromtimestamp(current_data["sys"]["sunrise"], tz=self.tz_zone) # unix timestamp -> to our timezone
current_weather["sunset"] = current_data["sys"]["sunset"] current_weather["sunset"] = datetime.fromtimestamp(current_data["sys"]["sunset"], tz=self.tz_zone)
self.current_weather = current_weather self.current_weather = current_weather
return current_weather return current_weather
def get_weather_forecast(self) -> List[Dict]: def get_weather_forecast(self) -> List[Dict]:
@ -136,7 +146,8 @@ class OpenWeatherMap:
def get_forecast_for_day(self, days_from_today: int) -> Dict: def get_forecast_for_day(self, days_from_today: int) -> Dict:
""" """
Get temperature range, rain and most frequent icon code 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: :param days_from_today:
should be int from 0-4: e.g. 2 -> 2 days from today should be int from 0-4: e.g. 2 -> 2 days from today
:return: :return:
@ -146,7 +157,7 @@ class OpenWeatherMap:
_ = self.get_weather_forecast() _ = self.get_weather_forecast()
# Calculate the start and end times for the specified number of days from now # 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 = ( start_time = (
(current_time + timedelta(days=days_from_today)) (current_time + timedelta(days=days_from_today))
.replace(hour=0, minute=0, second=0, microsecond=0) .replace(hour=0, minute=0, second=0, microsecond=0)
@ -178,7 +189,7 @@ class OpenWeatherMap:
# Return a dict with that day's data # Return a dict with that day's data
day_data = { day_data = {
"datetime": start_time.timestamp(), "datetime": start_time,
"icon": icon, "icon": icon,
"temp_min": min(temps), "temp_min": min(temps),
"temp_max": max(temps), "temp_max": max(temps),
@ -277,7 +288,7 @@ def main():
key = "" key = ""
city = 2643743 city = 2643743
lang = "de" 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() current_weather = owm.get_current_weather()
print(current_weather) print(current_weather)

View File

@ -13,22 +13,24 @@ import matplotlib.dates as mdates
import matplotlib.pyplot as plt import matplotlib.pyplot as plt
import matplotlib.ticker as ticker import matplotlib.ticker as ticker
import numpy as np import numpy as np
from dateutil import tz
from PIL import Image from PIL import Image
from PIL import ImageDraw from PIL import ImageDraw
from PIL import ImageFont from PIL import ImageFont
from PIL import ImageOps from PIL import ImageOps
from icons.weather_icons.weather_icons import get_weather_icon 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 fonts
from inkycal.custom.functions import get_system_tz
from inkycal.custom.functions import internet_available from inkycal.custom.functions import internet_available
from inkycal.custom.functions import top_level from inkycal.custom.functions import top_level
from inkycal.custom.inkycal_exceptions import NetworkNotReachableError 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.inky_image import image_to_palette
from inkycal.modules.template import inkycal_module from inkycal.modules.template import inkycal_module
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
logger.setLevel("INFO") logger.setLevel(logging.INFO)
icons_dir = os.path.join(top_level, "icons", "ui-icons") icons_dir = os.path.join(top_level, "icons", "ui-icons")
@ -106,10 +108,6 @@ class Fullweather(inkycal_module):
"label": "Your locale", "label": "Your locale",
"options": ["de_DE.UTF-8", "en_GB.UTF-8"], "options": ["de_DE.UTF-8", "en_GB.UTF-8"],
}, },
"tz": {
"label": "Your timezone",
"options": ["Europe/Berlin", "UTC"],
},
"font": { "font": {
"label": "Font family to use for the entire screen", "label": "Font family to use for the entire screen",
"options": ["NotoSans", "Roboto", "Poppins"], "options": ["NotoSans", "Roboto", "Poppins"],
@ -135,6 +133,8 @@ class Fullweather(inkycal_module):
config = config["config"] config = config["config"]
self.tz = get_system_tz()
# Check if all required parameters are present # Check if all required parameters are present
for param in self.requires: for param in self.requires:
if not param in config: if not param in config:
@ -207,11 +207,6 @@ class Fullweather(inkycal_module):
locale.setlocale(locale.LC_TIME, self.locale) locale.setlocale(locale.LC_TIME, self.locale)
self.language = self.locale.split("_")[0] self.language = self.locale.split("_")[0]
if "tz" in config:
self.tz = config["tz"]
else:
self.tz = "UTC"
if "icon_outline" in config: if "icon_outline" in config:
self.icon_outline = config["icon_outline"] self.icon_outline = config["icon_outline"]
else: else:
@ -250,8 +245,8 @@ class Fullweather(inkycal_module):
image_draw.rectangle((0, 0, rect_width, self.height), fill=0) image_draw.rectangle((0, 0, rect_width, self.height), fill=0)
# Add text with current date # Add text with current date
now = datetime.now() tz_info = tz.gettz(self.tz)
dateString = now.strftime("%d. %B") dateString = datetime.now(tz=tz_info).strftime("%d. %B")
dateFont = self.get_font(style="Bold", size=self.font_size) dateFont = self.get_font(style="Bold", size=self.font_size)
# Get the width of the text # Get the width of the text
dateStringbbox = dateFont.getbbox(dateString) dateStringbbox = dateFont.getbbox(dateString)
@ -467,9 +462,9 @@ class Fullweather(inkycal_module):
ax2.set_yticks(ax2.get_yticks()) ax2.set_yticks(ax2.get_yticks())
ax2.set_yticklabels([f"{value:.0f}" for value in 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_locator(mdates.DayLocator(interval=1, tz=self.tz))
fig.gca().xaxis.set_major_formatter(mdates.DateFormatter("%a")) fig.gca().xaxis.set_major_formatter(mdates.DateFormatter(fmt="%a", tz=self.tz))
fig.gca().xaxis.set_minor_locator(mdates.HourLocator(interval=3)) fig.gca().xaxis.set_minor_locator(mdates.HourLocator(interval=3, tz=self.tz))
fig.tight_layout() # Adjust layout to prevent clipping of labels fig.tight_layout() # Adjust layout to prevent clipping of labels
# Get image from plot and add it to the image # 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 # Date string: Day of week on line 1, date on line 2
short_day_font = self.get_font("ExtraBold", self.font_size + 4) short_day_font = self.get_font("ExtraBold", self.font_size + 4)
short_month_day_font = self.get_font("Bold", 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_day_name = day_data["datetime"].strftime("%a")
short_month_day = datetime.fromtimestamp(day_data["datetime"]).strftime("%b %d") 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_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) 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 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 raise NetworkNotReachableError
# Get the weather # Get the weather
self.my_owm = openweathermap_wrapper.OpenWeatherMap( self.my_owm = OpenWeatherMap(
api_key=self.api_key, api_key=self.api_key,
city_id=self.location, city_id=self.location,
temp_unit=self.temp_unit, temp_unit=self.temp_unit,
wind_unit=self.wind_unit, wind_unit=self.wind_unit,
language=self.language, language=self.language,
tz_name=self.tz,
) )
self.current_weather = self.my_owm.get_current_weather() self.current_weather = self.my_owm.get_current_weather()
self.hourly_forecasts = self.my_owm.get_weather_forecast() 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 ## Create Base Image
self.createBaseImage() self.createBaseImage()
@ -640,9 +629,6 @@ class Fullweather(inkycal_module):
if self.orientation == "horizontal": if self.orientation == "horizontal":
self.image = self.image.rotate(90, expand=True) 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.") logger.info("Fullscreen weather forecast generated successfully.")
# Convert images according to specified palette # Convert images according to specified palette
im_black, im_colour = image_to_palette(image=self.image, palette="bwr", dither=True) 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): def get_font(self, style, size):
# Returns the TrueType font object with the given characteristics # 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": if self.font == "Roboto" and style == "ExtraBold":
style = "Black" style = "Black"
elif self.font in ["Ubuntu", "NotoSansUI"] and style in ["ExtraBold", "Black"]: elif self.font in ["Ubuntu", "NotoSansUI"] and style in ["ExtraBold", "Black"]:

View File

@ -3,16 +3,27 @@ Inkycal weather module
Copyright by aceinnolab Copyright by aceinnolab
""" """
import datetime
import arrow
import decimal import decimal
import logging
import math 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.custom.openweathermap_wrapper import OpenWeatherMap
from inkycal.modules.template import inkycal_module from inkycal.modules.template import inkycal_module
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
logger.setLevel(level=logging.DEBUG)
class Weather(inkycal_module): class Weather(inkycal_module):
@ -75,6 +86,8 @@ class Weather(inkycal_module):
config = config['config'] config = config['config']
self.timezone = get_system_tz()
# Check if all required parameters are present # Check if all required parameters are present
for param in self.requires: for param in self.requires:
if not param in config: if not param in config:
@ -103,8 +116,14 @@ class Weather(inkycal_module):
self.locale = config['language'] self.locale = config['language']
# additional configuration # 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.owm = OpenWeatherMap(
self.timezone = get_system_tz() 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( self.weatherfont = ImageFont.truetype(
fonts['weathericons-regular-webfont'], size=self.fontsize) 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}') logging.debug(f'decimals temperature: {dec_temp} | decimals wind: {dec_wind}')
# Get current time # Get current time
now = arrow.utcnow() now = arrow.utcnow().to(self.timezone)
fc_data = {} fc_data = {}
@ -400,73 +419,28 @@ class Weather(inkycal_module):
logger.debug("getting hourly forecasts") logger.debug("getting hourly forecasts")
# Forecasts are provided for every 3rd full hour # Add next 4 forecasts to fc_data dictionary, since we only have
# 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
fc_data = {} fc_data = {}
for index, forecast in enumerate(hourly_forecasts): for index, forecast in enumerate(weather_forecasts[0:4]):
temp = f"{forecast['temp']:.{dec_temp}f}{self.tempDispUnit}"
icon = forecast["icon"]
fc_data['fc' + str(index + 1)] = { fc_data['fc' + str(index + 1)] = {
'temp': temp, 'temp': f"{forecast['temp']:.{dec_temp}f}{self.tempDispUnit}",
'icon': icon, 'icon': forecast["icon"],
'stamp': forecast_timings[index].to( 'stamp': forecast["datetime"].strftime("%I %p" if self.hour_format == 12 else "%H:%M")}
get_system_tz()).format('H.00' if self.hour_format == 24 else 'h a')
}
elif self.forecast_interval == 'daily': elif self.forecast_interval == 'daily':
logger.debug("getting daily forecasts") logger.debug("getting daily forecasts")
def calculate_forecast(days_from_today) -> dict: daily_forecasts = [self.owm.get_forecast_for_day(days) for days in range(1, 5)]
"""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)]
for index, forecast in enumerate(daily_forecasts): for index, forecast in enumerate(daily_forecasts):
fc_data['fc' + str(index +1)] = { 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'], '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(): for key, val in fc_data.items():
logger.debug((key, val)) logger.debug((key, val))
@ -477,6 +451,7 @@ class Weather(inkycal_module):
weather_icon = current_weather["weather_icon_name"] weather_icon = current_weather["weather_icon_name"]
humidity = str(current_weather["humidity"]) humidity = str(current_weather["humidity"])
sunrise_raw = arrow.get(current_weather["sunrise"]).to(self.timezone) sunrise_raw = arrow.get(current_weather["sunrise"]).to(self.timezone)
sunset_raw = arrow.get(current_weather["sunset"]).to(self.timezone) sunset_raw = arrow.get(current_weather["sunset"]).to(self.timezone)