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 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)

View File

@ -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"]:

View File

@ -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)