""" Inkycal weather module Copyright by aceinnolab """ import decimal import logging import math from typing import Tuple import arrow 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_wind_speed": { "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 param not 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_wind_speed = 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']: 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 logger.debug(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.debug(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.debug('Connection test passed') else: logger.error("Network not reachable. Please check your connection.") 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: Image, xy: Tuple[int, int], box_size: Tuple[int, int], icon: str, rotation=None): """Custom function to add icons of weather font on the image. Args: - image: the image on which image should the text be added - xy: coordinates as tuple -> (x,y) - box_size: size of text-box -> (width,height) - icon: icon-unicode, looks this up in weather-icons 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, '\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)) 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) # noqa icon_fc1 = (col4, row1 + row_height) # noqa temp_fc1 = (col4, row3) # noqa # Positions for forecast 2 stamp_fc2 = (col5, row1) # noqa icon_fc2 = (col5, row1 + row_height) # noqa temp_fc2 = (col5, row3) # noqa # Positions for forecast 3 stamp_fc3 = (col6, row1) # noqa icon_fc3 = (col6, row1 + row_height) # noqa temp_fc3 = (col6, row3) # noqa # Positions for forecast 4 stamp_fc4 = (col7, row1) # noqa icon_fc4 = (col7, row1 + row_height) # noqa temp_fc4 = (col7, row3) # noqa # 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_wind_speed == 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'] # check if we're using daily forecasts if "day" in stamp: stamp = arrow.get(fc_data[f'fc{pos}']['stamp'], "dddd").format("dddd", locale=self.locale) 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')