""" Inkycal fullscreen weather module Copyright by mrbwburns """ import locale import logging import math import os from datetime import datetime import matplotlib.dates as mdates import matplotlib.pyplot as plt import matplotlib.ticker as ticker import numpy as np 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 owm_forecasts from inkycal.custom.functions import fonts from inkycal.custom.functions import get_image_from_plot from inkycal.custom.functions import internet_available from inkycal.custom.functions import top_level from inkycal.custom.inkycal_exceptions import NetworkNotReachableError from inkycal.modules.template import inkycal_module logger = logging.getLogger(__name__) logger.setLevel("DEBUG") icons_dir = os.path.join(top_level, "icons", "ui-icons") def outline(image: Image, size: int, color: tuple) -> Image: # Create a canvas for the outline image outlined = Image.new("RGBA", image.size, (0, 0, 0, 0)) # Make a black outline for x in range(image.width): for y in range(image.height): pixel = image.getpixel((x, y)) if pixel[0] != 0 or pixel[1] != 0 or pixel[2] != 0: outlined.putpixel((x, y), color) # Enlarge the outlined image, and paste the original image on top to create a shadow effect outlined = outlined.resize((outlined.width + size, outlined.height + size)) paste_position = ((outlined.width - image.width) // 2, (outlined.height - image.height) // 2) outlined.paste(image, paste_position, image) # Create a mask to prevent transparent pixels from overwriting mask = Image.new("L", outlined.size, 255) outlined = Image.composite(outlined, Image.new("RGBA", outlined.size, (0, 0, 0, 0)), mask) return outlined class Fullweather(inkycal_module): """Fullscreen Weather class gets weather details from openweathermap and plots a nice fullscreen forecast picture """ name = "Fullscreen 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 ID found in the url " + "e.g. https://openweathermap.org/city/4893171 -> ID is 4893171" }, } optional = { "temp_units": { "label": "Which temperature unit should be used?", "options": ["celsius", "fahrenheit"], }, "wind_units": { "label": "Which wind speed unit should be used?", "options": ["beaufort", "knots", "miles_hour", "km_hour", "meters_sec"], }, "wind_gusts": { "label": "Should current wind gust speed also be displayed?", "options": [True, False], }, "keep_history": { "label": "Should the weather data be written to local json files (one per query)?", "options": [True, False], }, "min_max_annotations": { "label": "Should the temperature plot have min/max annotation labels?", "options": [True, False], }, "locale": { "label": "Your locale", "options": ["de_DE.UTF-8", "en_GB.UTF-8"], }, "tz": { "label": "Your timezone", "options": ["Europe/Berlin", "UTC"], }, "font_family": { "label": "Font family to use for the entire screen", "options": ["Roboto", "NotoSans", "Poppins"], }, "chart_title": { "label": "Title of the temperature and precipitation plot", "options": ["Temperatur und Niederschlag", "Temperature and precipitation"], }, "weekly_title": { "label": "Title of the weekly weather forecast", "options": ["Tageswerte", "Weekly forecast"], }, "icon_outline": { "label": "Should the weather icons have outlines?", "options": [True, False], }, } def __init__(self, config): """Initialize inkycal_weather module""" super().__init__(config) config = config["config"] # 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 = int(config["location"]) self.font_size = int(config["fontsize"]) # optional parameters if "wind_units" in config: self.wind_units = config["wind_units"] else: self.wind_units = "meters_sec" if self.wind_units == "beaufort": self.windDispUnit = "bft" elif self.wind_units == "knots": self.windDispUnit = "kn" elif self.wind_units == "km_hour": self.windDispUnit = "km/h" elif self.wind_units == "miles_hour": self.windDispUnit = "mph" else: self.windDispUnit = "m/s" if "wind_gusts" in config: self.wind_gusts = bool(config["wind_gusts"]) else: self.wind_gusts = True if "temp_units" in config: self.temp_units = config["temp_units"] else: self.temp_units = "celsius" if self.temp_units == "fahrenheit": self.tempDispUnit = "F" elif self.temp_units == "celsius": self.tempDispUnit = "°" if "weekly_title" in config: self.weekly_title = config["weekly_title"] else: self.weekly_title = "Weekly forecast" if "chart_title" in config: self.chart_title = config["chart_title"] else: self.chart_title = "Temperature and precipitation" if "keep_history" in config: self.keep_history = config["keep_history"] else: self.keep_history = False if "min_max_annotations" in config: self.min_max_annotations = bool(config["min_max_annotations"]) else: self.min_max_annotations = False if "locale" in config: self.locale = config["locale"] else: self.locale = "en_GB.UTF-8" 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: self.icon_outline = True if "font_family" in config: self.font_family = config["font_family"] else: self.font_family = "Roboto" # some calculations for scalability # TODO: make this work for all sizes self.screen_width_in = 163 / 25.4 # 163 mm for 7in5 self.screen_height_in = 98 / 25.4 # 98 mm for 7in5 self.dpi = math.sqrt( (float(self.width) ** 2 + float(self.height) ** 2) / (self.screen_width_in**2 + self.screen_height_in**2) ) self.left_section_width = int(self.width / 4) # give an OK message print(f"{__name__} loaded") def createBaseImage(self): """ Creates background and adds current date """ # Create white image self.image = Image.new("RGB", (self.width, self.height), (255, 255, 255)) image_draw = ImageDraw.Draw(self.image) # Create black rectangle for the current weather section rect_width = int(self.width / 4) image_draw.rectangle((0, 0, rect_width, self.height), fill=0) # Add text with current date now = datetime.now() dateString = now.strftime("%d. %B") dateFont = self.get_font(family=self.font_family, style="Bold", size=self.font_size) # Get the width of the text dateStringbbox = dateFont.getbbox(dateString) dateW = dateStringbbox[2] - dateStringbbox[0] # Draw the current date centered image_draw.text(((rect_width - dateW) / 2, 5), dateString, font=dateFont, fill=(255, 255, 255)) def addUserSection(self): """ Adds user-defined section to the given image """ ## Create drawing object for image image_draw = ImageDraw.Draw(self.image) if False: # self.mqtt_sub == True: # Add icon for Home homeTempIcon = Image.open(os.path.join(icons_dir, "home_temp.png")) homeTempIcon = ImageOps.invert(homeTempIcon) homeTempIcon = homeTempIcon.resize((40, 40)) homeTemp_y = int(self.height * 0.8125) self.image.paste(homeTempIcon, (15, homeTemp_y)) # Home temperature # my_home = mqtt_temperature(host=mqtt_host, port=mqtt_port, user=mqtt_user, password=mqtt_pass, topic=mqtt_topic) # homeTemp = None # while homeTemp == None: # homeTemp = my_home.get_temperature() # homeTempString = f"{homeTemp:.1f} {tempDispUnit}" # homeTempFont = font.font(font_family, "Bold", 28) # image_draw.text((65, homeTemp_y), homeTempString, font=homeTempFont, fill=(255, 255, 255)) # Add icon for rH humidityIcon = Image.open(os.path.join(icons_dir, "humidity.bmp")) humidityIcon = humidityIcon.resize((40, 40)) humidity_y = int(self.height * 0.90625) self.image.paste(humidityIcon, (15, humidity_y)) # rel. humidity # rH = None # while rH == None: # rH = my_home.get_rH() # humidityString = f"{rH:.0f} %" # humidityFont = font.font(font_family, "Bold", 28) # image_draw.text((65, humidity_y), humidityString, font=humidityFont, fill=(255, 255, 255)) else: # Add icon for Humidity humidityIcon = Image.open(os.path.join(icons_dir, "humidity.bmp")) humidityIcon = humidityIcon.resize((40, 40)) humidity_y = int(self.height * 0.8125) self.image.paste(humidityIcon, (15, humidity_y)) # Humidity humidityString = f"{self.current_weather.humidity} %" humidityFont = self.get_font(self.font_family, "Bold", self.font_size + 8) image_draw.text((65, humidity_y), humidityString, font=humidityFont, fill=(255, 255, 255)) # Add icon for uv uvIcon = Image.open(os.path.join(icons_dir, "uv.bmp")) uvIcon = uvIcon.resize((40, 40)) ux_y = int(self.height * 0.90625) self.image.paste(uvIcon, (15, ux_y)) # uvindex uvString = f"{self.current_weather.uvi if self.current_weather.uvi else '0'}" uvFont = self.get_font(self.font_family, "Bold", self.font_size + 8) image_draw.text((65, ux_y), uvString, font=uvFont, fill=(255, 255, 255)) def addCurrentWeather(self): """ Adds current weather situation to the left section of the image """ ## Create drawing object for image image_draw = ImageDraw.Draw(self.image) ## Add detailed weather status text to the image sumString = self.current_weather.detailed_status.replace(" ", "\n ") sumFont = self.get_font(self.font_family, "Regular", self.font_size + 8) maxW = 0 totalH = 0 for word in sumString.split("\n "): sumStringbbox = sumFont.getbbox(word) sumW = sumStringbbox[2] - sumStringbbox[0] sumH = sumStringbbox[3] - sumStringbbox[1] maxW = max(maxW, sumW) totalH += sumH sumtext_x = int((self.left_section_width - maxW) / 2) sumtext_y = int(self.height * 0.19) - totalH image_draw.multiline_text((sumtext_x, sumtext_y), sumString, font=sumFont, fill=(255, 255, 255), align="center") logger.debug(f"Added current weather detailed status text: {sumString} at x:{sumtext_x}/y:{sumtext_y}.") ## Add current weather icon to the image icon = get_weather_icon(icon_name=self.current_weather.weather_icon_name, size=150) # Create a mask from the alpha channel of the weather icon if len(icon.split()) == 4: mask = icon.split()[-1] else: mask = None # Paste the foreground of the icon onto the background with the help of the mask icon_x = int((self.left_section_width - icon.width) / 2) icon_y = int(self.height * 0.2) self.image.paste(icon, (icon_x, icon_y), mask) ## Add current temperature to the image tempString = f"{self.current_weather.temperature(self.temp_units)['feels_like']:.0f}{self.tempDispUnit}" tempFont = self.get_font(self.font_family, "Bold", 68) # Get the width of the text tempStringbbox = tempFont.getbbox(tempString) tempW = tempStringbbox[2] - tempStringbbox[0] temp_x = int((self.left_section_width - tempW) / 2) temp_y = int(self.height * 0.4375) # Draw the current temp centered image_draw.text((temp_x, temp_y), tempString, font=tempFont, fill=(255, 255, 255)) # Add icon for rain forecast rainIcon = Image.open(os.path.join(icons_dir, "rain-chance.bmp")) rainIcon = rainIcon.resize((40, 40)) rain_y = int(self.height * 0.625) self.image.paste(rainIcon, (15, rain_y)) # Amount of precipitation within next 3h rain = self.hourly_forecasts[0]["precip_3h_mm"] precipString = f"{rain:.1g} mm" if rain > 0.0 else "0 mm" precipFont = self.get_font(self.font_family, "Bold", self.font_size + 8) image_draw.text((65, rain_y), precipString, font=precipFont, fill=(255, 255, 255)) # Add icon for wind speed windIcon = Image.open(os.path.join(icons_dir, "wind.bmp")) windIcon = windIcon.resize((40, 40)) wind_y = int(self.height * 0.719) self.image.paste(windIcon, (15, wind_y)) # Max. wind speed within next 3h wind_gust = f"{self.hourly_forecasts[0]['wind_gust']:.0f}" wind = f"{self.hourly_forecasts[0]['wind']:.0f}" if self.wind_gusts: if wind == wind_gust: windString = f"{wind} {self.windDispUnit}" else: windString = f"{wind} - {wind_gust} {self.windDispUnit}" else: windString = f"{wind} {self.windDispUnit}" windFont = self.get_font(self.font_family, "Bold", self.font_size + 8) image_draw.text((65, wind_y), windString, font=windFont, fill=(255, 255, 255)) def addHourlyForecast(self): """ Adds a plot for temperature and amount of rain for the upcoming hours """ ## Create drawing object for image image_draw = ImageDraw.Draw(self.image) ## Draw hourly chart title title_x = self.left_section_width + 20 # X-coordinate of the title title_y = 5 chartTitleFont = self.get_font(self.font_family, "ExtraBold", self.font_size) image_draw.text((title_x, title_y), self.chart_title, font=chartTitleFont, fill=0) ## Plot the data # Define the chart parameters w, h = int(0.75 * self.width), int(0.45 * self.height) # Width and height of the graph # Length of our time axis num_ticks_x = 22 # ticks*3 hours timestamps = [item["datetime"] for item in self.hourly_forecasts][:num_ticks_x] temperatures = np.array([item["temp"] for item in self.hourly_forecasts])[:num_ticks_x] precipitation = np.array([item["precip_3h_mm"] for item in self.hourly_forecasts])[:num_ticks_x] # Create the figure fig, ax1 = plt.subplots(figsize=(w / self.dpi, h / self.dpi), dpi=self.dpi) # Plot Temperature as line plot in red ax1.plot(timestamps, temperatures, marker=".", linestyle="-", color="r") temp_base = 3 if self.temp_units == "celsius" else 5 fig.gca().yaxis.set_major_locator(ticker.MultipleLocator(base=temp_base)) ax1.tick_params(axis="y", colors="red") ax1.set_yticks(ax1.get_yticks()) ax1.set_yticklabels([f"{int(value)}{self.tempDispUnit}" for value in ax1.get_yticks()]) ax1.grid(visible=True, axis="both") # Adding grid if self.min_max_annotations == True: # Calculate min_temp and max_temp values based on the minimum and maximum temperatures in the hourly data min_temp = np.min(temperatures) max_temp = np.max(temperatures) # Find positions of min and max values min_temp_index = np.argmin(temperatures) max_temp_index = np.argmax(temperatures) ax1.text( timestamps[min_temp_index], min_temp, f"Min: {min_temp:.1f}{self.tempDispUnit}", ha="left", va="top", color="blue", fontsize=12, ) ax1.text( timestamps[max_temp_index], max_temp, f"Max: {max_temp:.1f}{self.tempDispUnit}", ha="left", va="bottom", color="red", fontsize=12, ) # Create the second part of the plot as a bar chart for amount of precipitation ax2 = ax1.twinx() width = np.min(np.diff(mdates.date2num(timestamps))) ax2.bar(timestamps, precipitation, color="blue", width=width, alpha=0.2) ax2.tick_params(axis="y", colors="blue") ax2.set_ylim([0, 10]) 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.tight_layout() # Adjust layout to prevent clipping of labels # Get image from plot and add it to the image hourly_forecast_plot = get_image_from_plot(plt) plot_x = self.left_section_width + 5 plot_y = title_y + 30 self.image.paste(hourly_forecast_plot, (plot_x, plot_y)) 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}") # Check if internet is available if internet_available(): logger.info("Connection test passed") else: raise NetworkNotReachableError # Get the weather (self.current_weather, self.hourly_forecasts) = owm_forecasts.get_owm_data( token=self.api_key, city_id=self.location, temp_units=self.temp_units, wind_units=self.wind_units, language=self.language, ) ## Create Base Image self.createBaseImage() ## Add Current Weather to the left section self.addCurrentWeather() ## Add user-configurable section to the bottom left corner self.addUserSection() ## Add Hourly Forecast self.addHourlyForecast() ## Add Daily Forecast # my_image = addDailyForecast(display=display, image=my_image, hourly_forecasts=hourly_forecasts) self.image.save("./openweather_full.png") logger.info("Fullscreen weather forecast generated successfully.") # Return the images ready for the display # tbh, I have no idea why I need to return two separate images here return self.image, self.image @staticmethod def get_font(family, style, size): # Returns the TrueType font object with the given characteristics if family == "Roboto" and style == "ExtraBold": style = "Black" elif family in ["Ubuntu", "NotoSansUI"] and style in ["ExtraBold", "Black"]: style = "Bold" elif family == "OpenSans" and style == "Black": style = "ExtraBold" return ImageFont.truetype(fonts[f"{family}-{style}"], size=size) if __name__ == "__main__": print(f"running {__name__} in standalone mode")