fix timezone handling for weather modules
This commit is contained in:
parent
b32a709967
commit
04b951eb13
@ -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)
|
||||
|
@ -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)
|
||||
|
||||
|
Loading…
Reference in New Issue
Block a user