Inkycal/inkycal/modules/inkycal_weather.py
2024-01-26 14:40:10 -08:00

544 lines
19 KiB
Python

"""
Inkycal weather module
Copyright by aceinnolab
"""
import arrow
import decimal
import logging
import math
from PIL import Image
from PIL import ImageDraw
from PIL import ImageFont
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.INFO)
class Weather(inkycal_module):
"""Weather class
parses weather details from openweathermap
"""
name = "Weather (openweathermap) - Get weather forecasts from openweathermap"
requires = {
"api_key": {
"label": "Please enter openweathermap api-key. You can create one for free on openweathermap",
},
"location": {
"label": "Please enter your location in the following format: City, Country-Code. " +
"You can also enter the location ID found in the url " +
"e.g. https://openweathermap.org/city/4893171 -> ID is 4893171"
}
}
optional = {
"round_temperature": {
"label": "Round temperature to the nearest degree?",
"options": [True, False],
},
"round_windspeed": {
"label": "Round windspeed?",
"options": [True, False],
},
"forecast_interval": {
"label": "Please select the forecast interval",
"options": ["daily", "hourly"],
},
"units": {
"label": "Which units should be used?",
"options": ["metric", "imperial"],
},
"hour_format": {
"label": "Which hour format do you prefer?",
"options": [24, 12],
},
"use_beaufort": {
"label": "Use beaufort scale for windspeed?",
"options": [True, False],
},
}
def __init__(self, config):
"""Initialize inkycal_weather module"""
super().__init__(config)
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:
raise Exception(f'config is missing {param}')
# required parameters
self.api_key = config['api_key']
self.location = config['location']
# optional parameters
self.round_temperature = config['round_temperature']
self.round_windspeed = config['round_windspeed']
self.forecast_interval = config['forecast_interval']
self.hour_format = int(config['hour_format'])
if config['units'] == "imperial":
self.temp_unit = "fahrenheit"
else:
self.temp_unit = "celsius"
if config['use_beaufort'] == True:
self.wind_unit = "beaufort"
elif config['units'] == "imperial":
self.wind_unit = "miles_hour"
else:
self.wind_unit = "meters_sec"
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,
tz_name=self.timezone
)
self.weatherfont = ImageFont.truetype(
fonts['weathericons-regular-webfont'], size=self.fontsize)
if self.wind_unit == "beaufort":
self.windDispUnit = "bft"
elif self.wind_unit == "knots":
self.windDispUnit = "kn"
elif self.wind_unit == "km_hour":
self.windDispUnit = "km/h"
elif self.wind_unit == "miles_hour":
self.windDispUnit = "mph"
else:
self.windDispUnit = "m/s"
if self.temp_unit == "fahrenheit":
self.tempDispUnit = "F"
elif self.temp_unit == "celsius":
self.tempDispUnit = "°"
# give an OK message
print(f"{__name__} loaded")
def generate_image(self):
"""Generate image for this module"""
# Define new image size with respect to padding
im_width = int(self.width - (2 * self.padding_left))
im_height = int(self.height - (2 * self.padding_top))
im_size = im_width, im_height
logger.info(f'Image size: {im_size}')
# Create an image for black pixels and one for coloured pixels
im_black = Image.new('RGB', size=im_size, color='white')
im_colour = Image.new('RGB', size=im_size, color='white')
# Check if internet is available
if internet_available():
logger.info('Connection test passed')
else:
raise NetworkNotReachableError
def get_moon_phase():
"""Calculate the current (approximate) moon phase
Returns:
The corresponding moonphase-icon.
"""
dec = decimal.Decimal
diff = now - arrow.get(2001, 1, 1)
days = dec(diff.days) + (dec(diff.seconds) / dec(86400))
lunations = dec("0.20439731") + (days * dec("0.03386319269"))
position = lunations % dec(1)
index = math.floor((position * dec(8)) + dec("0.5"))
return {
0: '\uf095',
1: '\uf099',
2: '\uf09c',
3: '\uf0a0',
4: '\uf0a3',
5: '\uf0a7',
6: '\uf0aa',
7: '\uf0ae'
}[int(index) & 7]
def is_negative(temp:str):
"""Check if temp is below freezing point of water (0°C/32°F)
returns True if temp below freezing point, else False"""
answer = False
if self.temp_unit == 'celsius' and round(float(temp.split(self.tempDispUnit)[0])) <= 0:
answer = True
elif self.temp_unit == 'fahrenheit' and round(float(temp.split(self.tempDispUnit)[0])) <= 32:
answer = True
return answer
# Lookup-table for weather icons and weather codes
weather_icons = {
'01d': '\uf00d',
'02d': '\uf002',
'03d': '\uf013',
'04d': '\uf012',
'09d': '\uf01a',
'10d': '\uf019',
'11d': '\uf01e',
'13d': '\uf01b',
'50d': '\uf014',
'01n': '\uf02e',
'02n': '\uf013',
'03n': '\uf013',
'04n': '\uf013',
'09n': '\uf037',
'10n': '\uf036',
'11n': '\uf03b',
'13n': '\uf038',
'50n': '\uf023'
}
def draw_icon(image, xy, box_size, icon, rotation=None):
"""Custom function to add icons of weather font on image
image = on which image should the text be added?
xy = xy-coordinates as tuple -> (x,y)
box_size = size of text-box -> (width,height)
icon = icon-unicode, looks this up in weathericons dictionary
"""
icon_size_correction = {
'\uf00d': 10 / 60,
'\uf02e': 51 / 150,
'\uf019': 21 / 60,
'\uf01b': 21 / 60,
'\uf0b5': 51 / 150,
'\uf050': 25 / 60,
'\uf013': 51 / 150,
'\uf002': 0,
'\uf031': 29 / 100,
'\uf015': 21 / 60,
'\uf01e': 52 / 150,
'\uf056': 51 / 150,
'\uf053': 14 / 150,
'\uf012': 51 / 150,
'\uf01a': 51 / 150,
'\uf014': 51 / 150,
'\uf037': 42 / 150,
'\uf036': 42 / 150,
'\uf03b': 42 / 150,
'\uf038': 42 / 150,
'\uf023': 35 / 150,
'\uf07a': 35 / 150,
'\uf051': 18 / 150,
'\uf052': 18 / 150,
'\uf0aa': 0,
'\uf095': 0,
'\uf099': 0,
'\uf09c': 0,
'\uf0a0': 0,
'\uf0a3': 0,
'\uf0a7': 0,
'\uf0aa': 0,
'\uf0ae': 0
}
x, y = xy
box_width, box_height = box_size
text = icon
font = self.weatherfont
# Increase fontsize to fit specified height and width of text box
size = 8
font = ImageFont.truetype(font.path, size)
text_width, text_height = font.getbbox(text)[2:]
while (text_width < int(box_width * 0.9) and
text_height < int(box_height * 0.9)):
size += 1
font = ImageFont.truetype(font.path, size)
text_width, text_height = font.getbbox(text)[2:]
text_width, text_height = font.getbbox(text)[2:]
# Align text to desired position
x = int((box_width / 2) - (text_width / 2))
y = int((box_height / 2) - (text_height / 2))
# Draw the text in the text-box
draw = ImageDraw.Draw(image)
space = Image.new('RGBA', (box_width, box_height))
ImageDraw.Draw(space).text((x, y), text, fill='black', font=font)
if rotation:
space.rotate(rotation, expand=True)
# Update only region with text (add text with transparent background)
image.paste(space, xy, space)
# column1 column2 column3 column4 column5 column6 column7
# |----------|----------|----------|----------|----------|----------|----------|
# | time | temperat.| moonphase| forecast1| forecast2| forecast3| forecast4|
# | current |----------|----------|----------|----------|----------|----------|
# | weather | humidity | sunrise | icon1 | icon2 | icon3 | icon4 |
# | icon |----------|----------|----------|----------|----------|----------|
# | | windspeed| sunset | temperat.| temperat.| temperat.| temperat.|
# |----------|----------|----------|----------|----------|----------|----------|
# Calculate size rows and columns
col_width = im_width // 7
# Ratio width height
image_ratio = im_width / im_height
if image_ratio >= 4:
row_height = im_height // 3
else:
logger.info('Please consider decreasing the height.')
row_height = int((im_height * (1 - im_height / im_width)) / 3)
logger.debug(f"row_height: {row_height} | col_width: {col_width}")
# Calculate spacings for better centering
spacing_top = int((im_width % col_width) / 2)
spacing_left = int((im_height % row_height) / 2)
# Define sizes for weather icons
icon_small = int(col_width / 3)
icon_medium = icon_small * 2
icon_large = icon_small * 3
# Calculate the x-axis position of each col
col1 = spacing_top
col2 = col1 + col_width
col3 = col2 + col_width
col4 = col3 + col_width
col5 = col4 + col_width
col6 = col5 + col_width
col7 = col6 + col_width
# Calculate the y-axis position of each row
line_gap = int((im_height - spacing_top - 3 * row_height) // 4)
row1 = line_gap
row2 = row1 + line_gap + row_height
row3 = row2 + line_gap + row_height
# Draw lines on each row and border
############################################################################
## draw = ImageDraw.Draw(im_black)
## draw.line((0, 0, im_width, 0), fill='red')
## draw.line((0, im_height-1, im_width, im_height-1), fill='red')
## draw.line((0, row1, im_width, row1), fill='black')
## draw.line((0, row1+row_height, im_width, row1+row_height), fill='black')
## draw.line((0, row2, im_width, row2), fill='black')
## draw.line((0, row2+row_height, im_width, row2+row_height), fill='black')
## draw.line((0, row3, im_width, row3), fill='black')
## draw.line((0, row3+row_height, im_width, row3+row_height), fill='black')
############################################################################
# Positions for current weather details
weather_icon_pos = (col1, 0)
temperature_icon_pos = (col2, row1)
temperature_pos = (col2 + icon_small, row1)
humidity_icon_pos = (col2, row2)
humidity_pos = (col2 + icon_small, row2)
windspeed_icon_pos = (col2, row3)
windspeed_pos = (col2 + icon_small, row3)
# Positions for sunrise, sunset, moonphase
moonphase_pos = (col3, row1)
sunrise_icon_pos = (col3, row2)
sunrise_time_pos = (col3 + icon_small, row2)
sunset_icon_pos = (col3, row3)
sunset_time_pos = (col3 + icon_small, row3)
# Positions for forecast 1
stamp_fc1 = (col4, row1)
icon_fc1 = (col4, row1 + row_height)
temp_fc1 = (col4, row3)
# Positions for forecast 2
stamp_fc2 = (col5, row1)
icon_fc2 = (col5, row1 + row_height)
temp_fc2 = (col5, row3)
# Positions for forecast 3
stamp_fc3 = (col6, row1)
icon_fc3 = (col6, row1 + row_height)
temp_fc3 = (col6, row3)
# Positions for forecast 4
stamp_fc4 = (col7, row1)
icon_fc4 = (col7, row1 + row_height)
temp_fc4 = (col7, row3)
# Create current-weather and weather-forecast objects
logging.debug('looking up location by ID')
current_weather = self.owm.get_current_weather()
weather_forecasts = self.owm.get_weather_forecast()
# Set decimals
dec_temp = 0 if self.round_temperature == True else 1
dec_wind = 0 if self.round_windspeed == True else 1
logging.debug(f'temperature unit: {self.temp_unit}')
logging.debug(f'decimals temperature: {dec_temp} | decimals wind: {dec_wind}')
# Get current time
now = arrow.utcnow().to(self.timezone)
fc_data = {}
if self.forecast_interval == 'hourly':
logger.debug("getting hourly forecasts")
# Add next 4 forecasts to fc_data dictionary, since we only have
fc_data = {}
for index, forecast in enumerate(weather_forecasts[0:4]):
fc_data['fc' + str(index + 1)] = {
'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")
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': f'{forecast["temp_min"]:.{dec_temp}f}{self.tempDispUnit}/{forecast["temp_max"]:.{dec_temp}f}{self.tempDispUnit}',
'icon': forecast['icon'],
'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))
# Get some current weather details
temperature = f"{current_weather['temp']:.{dec_temp}f}{self.tempDispUnit}"
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)
logger.debug(f'weather_icon: {weather_icon}')
if self.hour_format == 12:
logger.debug('using 12 hour format for sunrise/sunset')
sunrise = sunrise_raw.format('h:mm a')
sunset = sunset_raw.format('h:mm a')
else:
# 24 hours format
logger.debug('using 24 hour format for sunrise/sunset')
sunrise = sunrise_raw.format('H:mm')
sunset = sunset_raw.format('H:mm')
# Format the wind-speed to user preference
logging.debug(f'getting wind speed in {self.windDispUnit}')
wind = f"{current_weather['wind']:.{dec_wind}f} {self.windDispUnit}"
moon_phase = get_moon_phase()
# Fill weather details in col 1 (current weather icon)
draw_icon(im_colour, weather_icon_pos, (col_width, im_height),
weather_icons[weather_icon])
# Fill weather details in col 2 (temp, humidity, wind)
draw_icon(im_colour, temperature_icon_pos, (icon_small, row_height),
'\uf053')
if is_negative(temperature):
write(im_black, temperature_pos, (col_width - icon_small, row_height),
temperature, font=self.font)
else:
write(im_black, temperature_pos, (col_width - icon_small, row_height),
temperature, font=self.font)
draw_icon(im_colour, humidity_icon_pos, (icon_small, row_height),
'\uf07a')
write(im_black, humidity_pos, (col_width - icon_small, row_height),
humidity + '%', font=self.font)
draw_icon(im_colour, windspeed_icon_pos, (icon_small, icon_small),
'\uf050')
write(im_black, windspeed_pos, (col_width - icon_small, row_height),
wind, font=self.font)
# Fill weather details in col 3 (moonphase, sunrise, sunset)
draw_icon(im_colour, moonphase_pos, (col_width, row_height), moon_phase)
draw_icon(im_colour, sunrise_icon_pos, (icon_small, icon_small), '\uf051')
write(im_black, sunrise_time_pos, (col_width - icon_small, row_height),
sunrise, font=self.font)
draw_icon(im_colour, sunset_icon_pos, (icon_small, icon_small), '\uf052')
write(im_black, sunset_time_pos, (col_width - icon_small, row_height), sunset,
font=self.font)
# Add the forecast data to the correct places
for pos in range(1, len(fc_data) + 1):
stamp = fc_data[f'fc{pos}']['stamp']
icon = weather_icons[fc_data[f'fc{pos}']['icon']]
temp = fc_data[f'fc{pos}']['temp']
write(im_black, eval(f'stamp_fc{pos}'), (col_width, row_height),
stamp, font=self.font)
draw_icon(im_colour, eval(f'icon_fc{pos}'), (col_width, row_height + line_gap * 2),
icon)
write(im_black, eval(f'temp_fc{pos}'), (col_width, row_height),
temp, font=self.font)
border_h = row3 + row_height
border_w = col_width - 3 # leave 3 pixels gap
# Add borders around each subsection
draw_border(im_black, (col1, row1), (col_width * 3 - 3, border_h),
shrinkage=(0, 0))
for _ in range(4, 8):
draw_border(im_black, (eval(f'col{_}'), row1), (border_w, border_h),
shrinkage=(0, 0))
# return the images ready for the display
return im_black, im_colour
if __name__ == '__main__':
print(f'running {__name__} in standalone mode')