Merge pull request #315 from mrbwburns/fullscreen_weather_module
Fullscreen weather module
This commit is contained in:
		
							
								
								
									
										16
									
								
								.devcontainer/Dockerfile
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										16
									
								
								.devcontainer/Dockerfile
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,16 @@ | ||||
| FROM python:3.11-slim-bookworm as development | ||||
| WORKDIR /app | ||||
| RUN apt-get -y update && apt-get install -yqq dos2unix \ | ||||
|     libxi6 libgconf-2-4 \ | ||||
|     tzdata git gcc | ||||
| RUN apt-get install -y locales && \ | ||||
|     sed -i -e 's/# en_GB.UTF-8 UTF-8/en_GB.UTF-8 UTF-8/' /etc/locale.gen && \ | ||||
|     dpkg-reconfigure --frontend=noninteractive locales && \ | ||||
|     locale-gen | ||||
| ENV LANG en_GB.UTF-8 | ||||
| ENV LC_ALL en_GB.UTF-8     | ||||
| RUN git config --global --add safe.directory /app | ||||
| RUN python3 -m pip install --upgrade pip | ||||
| RUN python3 -m pip install --user virtualenv | ||||
| ENV TZ=Europe/Berlin | ||||
| RUN ln -snf /usr/share/zoneinfo/$TZ /etc/localtime && echo $TZ > /etc/timezone | ||||
| @@ -1,18 +1,23 @@ | ||||
| // For format details, see https://aka.ms/devcontainer.json. | ||||
| { | ||||
| 	"name": "Inkycal-dev", | ||||
| 	"image": "python:3.9-bullseye", | ||||
|     "build": { | ||||
|             "dockerfile": "Dockerfile", | ||||
|             "target": "development" | ||||
|     }, | ||||
|  | ||||
|     // This is the settings.json mount | ||||
| 	"mounts": ["source=/c/temp/settings_test.json,target=/boot/settings.json,type=bind,consistency=cached"], | ||||
|  | ||||
| 	// Use 'postCreateCommand' to run commands after the container is created. | ||||
| 	"postCreateCommand": "pip3 install --upgrade pip && pip3 install --user -r requirements.txt", | ||||
| 	"postCreateCommand": "dos2unix ./.devcontainer/postCreate.sh && chmod +x ./.devcontainer/postCreate.sh && ./.devcontainer/postCreate.sh", | ||||
|  | ||||
| 	"customizations": { | ||||
| 		"vscode": { | ||||
| 			"extensions": [ | ||||
| 				"ms-python.python" | ||||
| 				"ms-python.python", | ||||
| 				"ms-python.black-formatter", | ||||
| 				"ms-azuretools.vscode-docker" | ||||
| 			] | ||||
| 		} | ||||
| 	} | ||||
|   | ||||
							
								
								
									
										4
									
								
								.devcontainer/postCreate.sh
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										4
									
								
								.devcontainer/postCreate.sh
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,4 @@ | ||||
| #!/bin/bash | ||||
| python3 -m venv venv | ||||
| source ./venv/bin/activate | ||||
| pip3 install -r requirements.txt | ||||
							
								
								
									
										3
									
								
								.gitattributes
									
									
									
									
										vendored
									
									
										Normal file
									
								
							
							
						
						
									
										3
									
								
								.gitattributes
									
									
									
									
										vendored
									
									
										Normal file
									
								
							| @@ -0,0 +1,3 @@ | ||||
| * text=auto eol=lf | ||||
| *.{cmd,[cC][mM][dD]} text eol=crlf | ||||
| *.{bat,[bB][aA][tT]} text eol=crlf | ||||
							
								
								
									
										5
									
								
								.github/CONTRIBUTORS.md
									
									
									
									
										vendored
									
									
								
							
							
						
						
									
										5
									
								
								.github/CONTRIBUTORS.md
									
									
									
									
										vendored
									
									
								
							| @@ -2,11 +2,11 @@ | ||||
| | username | Name | Contribution details | | ||||
| | --- | --- | --- | | ||||
| | **mgfcf** | [Max G.](https://github.com/mgfcf) | for first refactoring of inkycal-software | | ||||
| | **Atrejoe**| [Robert Sirre](https://github.com/Atrejoe)| for various suggestions, help with refacotring, implementing gitflow and a lot more...| | ||||
| | **Atrejoe**| [Robert Sirre](https://github.com/Atrejoe)| for various suggestions, help with refactoring, implementing gitflow and a lot more...| | ||||
| | **vitasam** | [vitasam](https://github.com/vitasam)| for help with refactoring, code improvements, modularity and a lot more... | | ||||
|  | ||||
| ## BETA testers | ||||
| The following people have voluteered to test the beta release (pre-release). Thank you all very much for your suggestions, improvements, critics, feedback, time and commitment for Inkycal. | ||||
| The following people have volunteered to test the beta release (pre-release). Thank you all very much for your suggestions, improvements, critics, feedback, time and commitment for Inkycal. | ||||
|  | ||||
| | username | Link | | ||||
| | --- | --- | | ||||
| @@ -25,6 +25,7 @@ The following people have voluteered to test the beta release (pre-release). Tha | ||||
| | --- | --- | --- | | ||||
| | **efredericks** | [Erik Fredericks](https://github.com/efredericks) | for adding Jokes module | | ||||
| | **worstface** | [worstface](https://github.com/worstface)| for adding Stocks module | | ||||
|   **mrbwburns** | [mrbwburns](https://github.com/mrbwburns) | for adding fullscreen weather module |  | ||||
|  | ||||
| ## Special help | ||||
| | username | Name | Contribution details | | ||||
|   | ||||
							
								
								
									
										26
									
								
								.pre-commit-config.yaml
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										26
									
								
								.pre-commit-config.yaml
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,26 @@ | ||||
| repos: | ||||
|   - repo: https://github.com/psf/black | ||||
|     rev: 22.3.0 | ||||
|     hooks: | ||||
|       - id: black | ||||
|         args: | ||||
|           - "--line-length=120" | ||||
|   - repo: https://github.com/pre-commit/pre-commit-hooks | ||||
|     rev: v1.4.0 | ||||
|     hooks: | ||||
|       - id: trailing-whitespace | ||||
|       - id: check-docstring-first | ||||
|       - id: check-json | ||||
|       - id: check-yaml | ||||
|       - id: debug-statements | ||||
|       - id: flake8 | ||||
|         args: | ||||
|           - "--ignore=E, W" | ||||
|   - repo: https://github.com/asottile/reorder_python_imports | ||||
|     rev: v1.1.0 | ||||
|     hooks: | ||||
|       - id: reorder-python-imports | ||||
|   - repo: meta | ||||
|     hooks: | ||||
|       - id: check-hooks-apply | ||||
|       - id: check-useless-excludes | ||||
| @@ -4,6 +4,12 @@ The order is from latest to oldest and structured in the following way: | ||||
| * Version name with date of publishing | ||||
| * Sections with either 'added', 'fixed', 'updated' and 'changed' | ||||
|  | ||||
| ## [2.0.3] 2024 | ||||
| ### Added | ||||
| * Added fullscreen weather module | ||||
| * Own OWM API abstraction as a replacement for PyOWM module | ||||
|  | ||||
|  | ||||
| ## [2.0.2] 2022 | ||||
| ### Added | ||||
| * Added support of 12.48" E-Paper display (all variants) | ||||
|   | ||||
							
								
								
									
										
											BIN
										
									
								
								icons/ui-icons/home_temp.png
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										
											BIN
										
									
								
								icons/ui-icons/home_temp.png
									
									
									
									
									
										Normal file
									
								
							
										
											Binary file not shown.
										
									
								
							| After Width: | Height: | Size: 4.5 KiB | 
							
								
								
									
										
											BIN
										
									
								
								icons/ui-icons/humidity.bmp
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										
											BIN
										
									
								
								icons/ui-icons/humidity.bmp
									
									
									
									
									
										Normal file
									
								
							
										
											Binary file not shown.
										
									
								
							| After Width: | Height: | Size: 1.2 KiB | 
							
								
								
									
										
											BIN
										
									
								
								icons/ui-icons/outline_thermostat_white_48dp.bmp
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										
											BIN
										
									
								
								icons/ui-icons/outline_thermostat_white_48dp.bmp
									
									
									
									
									
										Normal file
									
								
							
										
											Binary file not shown.
										
									
								
							| After Width: | Height: | Size: 1.2 KiB | 
							
								
								
									
										
											BIN
										
									
								
								icons/ui-icons/rain-chance.bmp
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										
											BIN
										
									
								
								icons/ui-icons/rain-chance.bmp
									
									
									
									
									
										Normal file
									
								
							
										
											Binary file not shown.
										
									
								
							| After Width: | Height: | Size: 12 KiB | 
							
								
								
									
										
											BIN
										
									
								
								icons/ui-icons/uv.bmp
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										
											BIN
										
									
								
								icons/ui-icons/uv.bmp
									
									
									
									
									
										Normal file
									
								
							
										
											Binary file not shown.
										
									
								
							| After Width: | Height: | Size: 63 KiB | 
							
								
								
									
										
											BIN
										
									
								
								icons/ui-icons/wind.bmp
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										
											BIN
										
									
								
								icons/ui-icons/wind.bmp
									
									
									
									
									
										Normal file
									
								
							
										
											Binary file not shown.
										
									
								
							| After Width: | Height: | Size: 1.2 KiB | 
							
								
								
									
										0
									
								
								icons/weather_icons/__init__.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										0
									
								
								icons/weather_icons/__init__.py
									
									
									
									
									
										Normal file
									
								
							
							
								
								
									
										1
									
								
								icons/weather_icons/owm_icons_cache/.gitignore
									
									
									
									
										vendored
									
									
										Normal file
									
								
							
							
						
						
									
										1
									
								
								icons/weather_icons/owm_icons_cache/.gitignore
									
									
									
									
										vendored
									
									
										Normal file
									
								
							| @@ -0,0 +1 @@ | ||||
| *.png | ||||
							
								
								
									
										33
									
								
								icons/weather_icons/weather_icons.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										33
									
								
								icons/weather_icons/weather_icons.py
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,33 @@ | ||||
| import os | ||||
| import urllib | ||||
|  | ||||
| from PIL import Image | ||||
|  | ||||
|  | ||||
| HERE = os.path.dirname(os.path.abspath(__file__)) | ||||
| OWM_ICONS_CACHE = os.path.join(HERE, "owm_icons_cache/") | ||||
|  | ||||
| if not os.path.exists(OWM_ICONS_CACHE): | ||||
|     os.mkdir(OWM_ICONS_CACHE) | ||||
|  | ||||
|  | ||||
| def get_weather_icon(icon_name, size) -> Image: | ||||
|     """ | ||||
|     Gets the requested weather icon as Image and returns it in the requested size | ||||
|     :param icon_name: | ||||
|         icon_name for the weather | ||||
|     :param size: | ||||
|         size of the icon in pixels | ||||
|     :return: | ||||
|         the resized weather icon | ||||
|     """ | ||||
|  | ||||
|     iconpath = os.path.join(OWM_ICONS_CACHE, f"{icon_name}.png") | ||||
|  | ||||
|     if not os.path.exists(iconpath): | ||||
|         urllib.request.urlretrieve(url=f"https://openweathermap.org/img/wn/{icon_name}@2x.png", filename=f"{iconpath}") | ||||
|     icon = Image.open(iconpath) | ||||
|  | ||||
|     icon = icon.resize((size, size)) | ||||
|  | ||||
|     return icon | ||||
| @@ -13,6 +13,7 @@ import inkycal.modules.inkycal_slideshow | ||||
| import inkycal.modules.inkycal_stocks | ||||
| import inkycal.modules.inkycal_webshot | ||||
| import inkycal.modules.inkycal_xkcd | ||||
| import inkycal.modules.inkycal_fullweather | ||||
|  | ||||
| # Main file | ||||
| from inkycal.main import Inkycal | ||||
|   | ||||
| @@ -1,3 +1,2 @@ | ||||
| from .functions import * | ||||
| from .inkycal_exceptions import * | ||||
| from .openweathermap_wrapper import OpenWeatherMap | ||||
| from .inkycal_exceptions import * | ||||
| @@ -3,39 +3,43 @@ Inkycal custom-functions for ease-of-use | ||||
|  | ||||
| Copyright by aceinnolab | ||||
| """ | ||||
| import json | ||||
| import logging | ||||
| import os | ||||
| import time | ||||
| import traceback | ||||
|  | ||||
| import arrow | ||||
| import PIL | ||||
| import requests | ||||
| from PIL import ImageFont, ImageDraw, Image | ||||
| import tzlocal | ||||
| from PIL import Image | ||||
| from PIL import ImageDraw | ||||
| from PIL import ImageFont | ||||
|  | ||||
| logs = logging.getLogger(__name__) | ||||
| logs.setLevel(level=logging.INFO) | ||||
|  | ||||
| # Get the path to the Inkycal folder | ||||
| top_level = os.path.dirname( | ||||
|     os.path.abspath(os.path.dirname(__file__))).split('/inkycal')[0] | ||||
| top_level = os.path.dirname(os.path.abspath(os.path.dirname(__file__))).split("/inkycal")[0] | ||||
|  | ||||
| # Get path of 'fonts' and 'images' folders within Inkycal folder | ||||
| fonts_location = top_level + '/fonts/' | ||||
| image_folder = top_level + '/image_folder/' | ||||
| fonts_location = os.path.join(top_level, "fonts/") | ||||
| image_folder = os.path.join(top_level, "image_folder/") | ||||
|  | ||||
| # Get available fonts within fonts folder | ||||
| fonts = {} | ||||
|  | ||||
| for path, dirs, files in os.walk(fonts_location): | ||||
|     for _ in files: | ||||
|         if _.endswith('.otf'): | ||||
|             name = _.split('.otf')[0] | ||||
|         if _.endswith(".otf"): | ||||
|             name = _.split(".otf")[0] | ||||
|             fonts[name] = os.path.join(path, _) | ||||
|  | ||||
|         if _.endswith('.ttf'): | ||||
|             name = _.split('.ttf')[0] | ||||
|         if _.endswith(".ttf"): | ||||
|             name = _.split(".ttf")[0] | ||||
|             fonts[name] = os.path.join(path, _) | ||||
|  | ||||
| logs.debug(f"Found fonts: {json.dumps(fonts, indent=4, sort_keys=True)}") | ||||
| available_fonts = [key for key, values in fonts.items()] | ||||
|  | ||||
|  | ||||
| @@ -60,14 +64,14 @@ def get_fonts(): | ||||
|         print(fonts) | ||||
|  | ||||
|  | ||||
| def get_system_tz(): | ||||
| def get_system_tz() -> str: | ||||
|     """Gets the system-timezone | ||||
|  | ||||
|     Gets the timezone set by the system. | ||||
|  | ||||
|     Returns: | ||||
|       - A timezone if a system timezone was found. | ||||
|       - None if no timezone was found. | ||||
|       - UTC if no timezone was found. | ||||
|  | ||||
|     The extracted timezone can be used to show the local time instead of UTC. e.g. | ||||
|  | ||||
| @@ -76,29 +80,31 @@ def get_system_tz(): | ||||
|       >>> print(arrow.now(tz=get_system_tz()) # prints timezone aware time. | ||||
|     """ | ||||
|     try: | ||||
|         local_tz = time.tzname[1] | ||||
|         local_tz = tzlocal.get_localzone().key | ||||
|         logs.debug(f"Local system timezone is {local_tz}.") | ||||
|     except: | ||||
|         print('System timezone could not be parsed!') | ||||
|         print('Please set timezone manually!. Setting timezone to None...') | ||||
|         local_tz = None | ||||
|         logs.error("System timezone could not be parsed!") | ||||
|         logs.error("Please set timezone manually!. Falling back to UTC...") | ||||
|         local_tz = "UTC" | ||||
|     logs.debug(f"The time is {arrow.now(tz=local_tz).format('YYYY-MM-DD HH:mm:ss ZZ')}.") | ||||
|     return local_tz | ||||
|  | ||||
|  | ||||
| def auto_fontsize(font, max_height): | ||||
|     """Scales a given font to 80% of max_height. | ||||
|  | ||||
|       Gets the height of a font and scales it until 80% of the max_height | ||||
|       is filled. | ||||
|     Gets the height of a font and scales it until 80% of the max_height | ||||
|     is filled. | ||||
|  | ||||
|  | ||||
|       Args: | ||||
|           - font: A PIL Font object. | ||||
|           - max_height: An integer representing the height to adjust the font to | ||||
|             which the given font should be scaled to. | ||||
|     Args: | ||||
|         - font: A PIL Font object. | ||||
|         - max_height: An integer representing the height to adjust the font to | ||||
|           which the given font should be scaled to. | ||||
|  | ||||
|       Returns: | ||||
|           A PIL font object with modified height. | ||||
|       """ | ||||
|     Returns: | ||||
|         A PIL font object with modified height. | ||||
|     """ | ||||
|     text_bbox = font.getbbox("hg") | ||||
|     text_height = text_bbox[3] | ||||
|     fontsize = text_height | ||||
| @@ -134,8 +140,7 @@ def write(image, xy, box_size, text, font=None, **kwargs): | ||||
|       - fill_height: Decimal representing a percentage e.g. 0.9 # 90%. Fill | ||||
|         maximum of 90% of the size of the full height of the text-box. | ||||
|     """ | ||||
|     allowed_kwargs = ['alignment', 'autofit', 'colour', 'rotation', | ||||
|                       'fill_width', 'fill_height'] | ||||
|     allowed_kwargs = ["alignment", "autofit", "colour", "rotation", "fill_width", "fill_height"] | ||||
|  | ||||
|     # Validate kwargs | ||||
|     for key, value in kwargs.items(): | ||||
| @@ -143,12 +148,12 @@ def write(image, xy, box_size, text, font=None, **kwargs): | ||||
|             print(f'{key} does not exist') | ||||
|  | ||||
|     # Set kwargs if given, it not, use defaults | ||||
|     alignment = kwargs['alignment'] if 'alignment' in kwargs else 'center' | ||||
|     autofit = kwargs['autofit'] if 'autofit' in kwargs else False | ||||
|     fill_width = kwargs['fill_width'] if 'fill_width' in kwargs else 1.0 | ||||
|     fill_height = kwargs['fill_height'] if 'fill_height' in kwargs else 0.8 | ||||
|     colour = kwargs['colour'] if 'colour' in kwargs else 'black' | ||||
|     rotation = kwargs['rotation'] if 'rotation' in kwargs else None | ||||
|     alignment = kwargs["alignment"] if "alignment" in kwargs else "center" | ||||
|     autofit = kwargs["autofit"] if "autofit" in kwargs else False | ||||
|     fill_width = kwargs["fill_width"] if "fill_width" in kwargs else 1.0 | ||||
|     fill_height = kwargs["fill_height"] if "fill_height" in kwargs else 0.8 | ||||
|     colour = kwargs["colour"] if "colour" in kwargs else "black" | ||||
|     rotation = kwargs["rotation"] if "rotation" in kwargs else None | ||||
|  | ||||
|     x, y = xy | ||||
|     box_width, box_height = box_size | ||||
| @@ -162,8 +167,7 @@ def write(image, xy, box_size, text, font=None, **kwargs): | ||||
|         text_bbox_height = font.getbbox("hg") | ||||
|         text_height = text_bbox_height[3] - text_bbox_height[1] | ||||
|  | ||||
|         while (text_width < int(box_width * fill_width) and | ||||
|                text_height < int(box_height * fill_height)): | ||||
|         while text_width < int(box_width * fill_width) and text_height < int(box_height * fill_height): | ||||
|             size += 1 | ||||
|             font = ImageFont.truetype(font.path, size) | ||||
|             text_bbox = font.getbbox(text) | ||||
| @@ -178,7 +182,7 @@ def write(image, xy, box_size, text, font=None, **kwargs): | ||||
|  | ||||
|     # Truncate text if text is too long, so it can fit inside the box | ||||
|     if (text_width, text_height) > (box_width, box_height): | ||||
|         logs.debug(('truncating {}'.format(text))) | ||||
|         logs.debug(("truncating {}".format(text))) | ||||
|         while (text_width, text_height) > (box_width, box_height): | ||||
|             text = text[0:-1] | ||||
|             text_bbox = font.getbbox(text) | ||||
| @@ -190,9 +194,9 @@ def write(image, xy, box_size, text, font=None, **kwargs): | ||||
|     # Align text to desired position | ||||
|     if alignment == "center" or None: | ||||
|         x = int((box_width / 2) - (text_width / 2)) | ||||
|     elif alignment == 'left': | ||||
|     elif alignment == "left": | ||||
|         x = 0 | ||||
|     elif alignment == 'right': | ||||
|     elif alignment == "right": | ||||
|         x = int(box_width - text_width) | ||||
|  | ||||
|     # Draw the text in the text-box | ||||
| @@ -235,10 +239,10 @@ def text_wrap(text, font=None, max_width=None): | ||||
|     if text_width < max_width: | ||||
|         lines.append(text) | ||||
|     else: | ||||
|         words = text.split(' ') | ||||
|         words = text.split(" ") | ||||
|         i = 0 | ||||
|         while i < len(words): | ||||
|             line = '' | ||||
|             line = "" | ||||
|             while i < len(words) and font.getlength(line + words[i]) <= max_width: | ||||
|                 line = line + words[i] + " " | ||||
|                 i += 1 | ||||
| @@ -266,7 +270,7 @@ def internet_available(): | ||||
|     """ | ||||
|     for attempt in range(3): | ||||
|         try: | ||||
|             requests.get('https://google.com', timeout=5) | ||||
|             requests.get("https://google.com", timeout=5) | ||||
|             return True | ||||
|         except: | ||||
|             print(f"Network could not be reached: {traceback.print_exc()}") | ||||
| @@ -296,7 +300,7 @@ def draw_border(image, xy, size, radius=5, thickness=1, shrinkage=(0.1, 0.1)): | ||||
|         border by 20% | ||||
|     """ | ||||
|  | ||||
|     colour = 'black' | ||||
|     colour = "black" | ||||
|  | ||||
|     # size from function parameter | ||||
|     width, height = int(size[0] * (1 - shrinkage[0])), int(size[1] * (1 - shrinkage[1])) | ||||
|   | ||||
| @@ -1,43 +1,334 @@ | ||||
| """ | ||||
| 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 enum import Enum | ||||
| from datetime import datetime | ||||
| from datetime import timedelta | ||||
| from typing import Dict | ||||
| from typing import List | ||||
| from typing import Literal | ||||
|  | ||||
| import requests | ||||
| import json | ||||
| from dateutil import tz | ||||
|  | ||||
| TEMP_UNITS = Literal["celsius", "fahrenheit"] | ||||
| WIND_UNITS = Literal["meters_sec", "km_hour", "miles_hour", "knots", "beaufort"] | ||||
| WEATHER_TYPE = Literal["current", "forecast"] | ||||
| API_VERSIONS = Literal["2.5", "3.0"] | ||||
|  | ||||
| API_BASE_URL = "https://api.openweathermap.org/data" | ||||
|  | ||||
| logger = logging.getLogger(__name__) | ||||
| logger.setLevel(level=logging.INFO) | ||||
|  | ||||
| class WEATHER_OPTIONS(Enum): | ||||
|     CURRENT_WEATHER = "weather" | ||||
|  | ||||
| class FORECAST_INTERVAL(Enum): | ||||
|     THREE_HOURS = "3h" | ||||
|     FIVE_DAYS = "5d" | ||||
| def is_timestamp_within_range(timestamp: datetime, start_time: datetime, end_time: datetime) -> bool: | ||||
|     # Check if the timestamp is within the range | ||||
|     return start_time <= timestamp <= end_time | ||||
|  | ||||
|  | ||||
| def get_json_from_url(request_url): | ||||
|     response = requests.get(request_url) | ||||
|     if not response.ok: | ||||
|         raise AssertionError( | ||||
|             f"Failure getting the current weather: code {response.status_code}. Reason: {response.text}" | ||||
|         ) | ||||
|     return json.loads(response.text) | ||||
|  | ||||
|  | ||||
| class OpenWeatherMap: | ||||
|     def __init__(self, api_key:str, city_id:int, units:str) -> None: | ||||
|     def __init__( | ||||
|         self, | ||||
|         api_key: str, | ||||
|         city_id: int = None, | ||||
|         lat: float = None, | ||||
|         lon: float = None, | ||||
|         api_version: API_VERSIONS = "2.5", | ||||
|         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 | ||||
|         assert (units  in ["metric", "imperial"] ) | ||||
|         self.units = units | ||||
|         self._api_version = "2.5" | ||||
|         self._base_url = f"https://api.openweathermap.org/data/{self._api_version}" | ||||
|         self.temp_unit = temp_unit | ||||
|         self.wind_unit = wind_unit | ||||
|         self.language = language | ||||
|         self._api_version = api_version | ||||
|         if self._api_version == "3.0": | ||||
|             assert type(lat) is float and type(lon) is float | ||||
|         self.location_substring = ( | ||||
|             f"lat={str(lat)}&lon={str(lon)}" if (lat is not None and lon is not None) else f"id={str(city_id)}" | ||||
|         ) | ||||
|  | ||||
|         self.tz_zone = tz.gettz(tz_name) | ||||
|         logger.info( | ||||
|             f"OWM wrapper initialized for API version {self._api_version}, language {self.language} and timezone {tz_name}." | ||||
|         ) | ||||
|  | ||||
|     def get_weather_data_from_owm(self, weather: WEATHER_TYPE): | ||||
|         # Gets current weather or forecast from the configured OWM API. | ||||
|  | ||||
|         if weather == "current": | ||||
|             # Gets current weather status from the 2.5 API: https://openweathermap.org/current | ||||
|             # This is primarily using the 2.5 API since the 3.0 API actually has less info | ||||
|             weather_url = f"{API_BASE_URL}/2.5/weather?{self.location_substring}&appid={self.api_key}&units=Metric&lang={self.language}" | ||||
|             weather_data = get_json_from_url(weather_url) | ||||
|             # Only if we do have a 3.0 API-enabled key, we can also get the UVI reading from that endpoint: https://openweathermap.org/api/one-call-3 | ||||
|             if self._api_version == "3.0": | ||||
|                 weather_url = f"{API_BASE_URL}/3.0/onecall?{self.location_substring}&appid={self.api_key}&exclude=minutely,hourly,daily&units=Metric&lang={self.language}" | ||||
|                 weather_data["uvi"] = get_json_from_url(weather_url)["current"]["uvi"] | ||||
|         elif weather == "forecast": | ||||
|             # Gets weather forecasts from the 2.5 API: https://openweathermap.org/forecast5 | ||||
|             # This is only using the 2.5 API since the 3.0 API actually has less info | ||||
|             weather_url = f"{API_BASE_URL}/2.5/forecast?{self.location_substring}&appid={self.api_key}&units=Metric&lang={self.language}" | ||||
|             weather_data = get_json_from_url(weather_url)["list"] | ||||
|         return weather_data | ||||
|  | ||||
|     def get_current_weather(self) -> Dict: | ||||
|         """ | ||||
|         Decodes the OWM current weather data for our purposes | ||||
|         :return: | ||||
|             Current weather as dictionary | ||||
|         """ | ||||
|  | ||||
|         current_data = self.get_weather_data_from_owm(weather="current") | ||||
|  | ||||
|         current_weather = {} | ||||
|         current_weather["detailed_status"] = current_data["weather"][0]["description"] | ||||
|         current_weather["weather_icon_name"] = current_data["weather"][0]["icon"] | ||||
|         current_weather["temp"] = self.get_converted_temperature( | ||||
|             current_data["main"]["temp"] | ||||
|         )  # OWM Unit Default: Kelvin, Metric: Celsius, Imperial: Fahrenheit | ||||
|         current_weather["temp_feels_like"] = self.get_converted_temperature(current_data["main"]["feels_like"]) | ||||
|         current_weather["min_temp"] = self.get_converted_temperature(current_data["main"]["temp_min"]) | ||||
|         current_weather["max_temp"] = self.get_converted_temperature(current_data["main"]["temp_max"]) | ||||
|         current_weather["humidity"] = current_data["main"]["humidity"]  #  OWM Unit: % rH | ||||
|         current_weather["wind"] = self.get_converted_windspeed( | ||||
|             current_data["wind"]["speed"] | ||||
|         )  # OWM Unit Default: meter/sec, Metric: meter/sec | ||||
|         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"] | ||||
|         if "uvi" in current_data:  # this is only supported in v3.0 API | ||||
|             current_weather["uvi"] = current_data["uvi"] | ||||
|         else: | ||||
|             current_weather["uvi"] = None | ||||
|         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]: | ||||
|         """ | ||||
|         Decodes the OWM weather forecast for our purposes | ||||
|         What you get is a list of 40 forecasts for 3-hour time slices, totaling to 5 days. | ||||
|         :return: | ||||
|             Forecasts data dictionary | ||||
|         """ | ||||
|         # | ||||
|         forecast_data = self.get_weather_data_from_owm(weather="forecast") | ||||
|  | ||||
|         # Add forecast data to hourly_data_dict list of dictionaries | ||||
|         hourly_forecasts = [] | ||||
|         for forecast in forecast_data: | ||||
|             # calculate combined precipitation (snow + rain) | ||||
|             precip_mm = 0.0 | ||||
|             if "rain" in forecast.keys(): | ||||
|                 precip_mm = +forecast["rain"]["3h"]  # OWM Unit: mm | ||||
|             if "snow" in forecast.keys(): | ||||
|                 precip_mm = +forecast["snow"]["3h"]  # OWM Unit: mm | ||||
|             hourly_forecasts.append( | ||||
|                 { | ||||
|                     "temp": self.get_converted_temperature( | ||||
|                         forecast["main"]["temp"] | ||||
|                     ),  # OWM Unit Default: Kelvin, Metric: Celsius, Imperial: Fahrenheit | ||||
|                     "min_temp": self.get_converted_temperature(forecast["main"]["temp_min"]), | ||||
|                     "max_temp": self.get_converted_temperature(forecast["main"]["temp_max"]), | ||||
|                     "precip_3h_mm": precip_mm, | ||||
|                     "wind": self.get_converted_windspeed( | ||||
|                         forecast["wind"]["speed"] | ||||
|                     ),  # OWM Unit Default: meter/sec, Metric: meter/sec, Imperial: miles/hour | ||||
|                     "wind_gust": self.get_converted_windspeed(forecast["wind"]["gust"]), | ||||
|                     "pressure": forecast["main"]["pressure"],  #  OWM Unit: hPa | ||||
|                     "humidity": forecast["main"]["humidity"],  #  OWM Unit: % rH | ||||
|                     "precip_probability": forecast["pop"] | ||||
|                     * 100.0,  #  OWM value is unitless, directly converting to % scale | ||||
|                     "icon": forecast["weather"][0]["icon"], | ||||
|                     "datetime": datetime.fromtimestamp(forecast["dt"], tz=self.tz_zone), | ||||
|                 } | ||||
|             ) | ||||
|             logger.debug( | ||||
|                 f"Added rain forecast at {datetime.fromtimestamp(forecast['dt'], tz=self.tz_zone)}: {precip_mm}" | ||||
|             ) | ||||
|  | ||||
|         self.hourly_forecasts = hourly_forecasts | ||||
|  | ||||
|         return self.hourly_forecasts | ||||
|  | ||||
|     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. | ||||
|         "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: | ||||
|             Forecast dictionary | ||||
|         """ | ||||
|         # Make sure hourly forecasts are up to date | ||||
|         _ = self.get_weather_forecast() | ||||
|  | ||||
|         # Calculate the start and end times for the specified number of days from 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) | ||||
|             .astimezone(tz=self.tz_zone) | ||||
|         ) | ||||
|         end_time = (start_time + timedelta(days=1)).astimezone(tz=self.tz_zone) | ||||
|  | ||||
|         # Get all the forecasts for that day's time range | ||||
|         forecasts = [ | ||||
|             f | ||||
|             for f in self.hourly_forecasts | ||||
|             if is_timestamp_within_range(timestamp=f["datetime"], start_time=start_time, end_time=end_time) | ||||
|         ] | ||||
|  | ||||
|         # In case the next available forecast is already for the next day, use that one for the less than 3 remaining hours of today | ||||
|         if forecasts == []: | ||||
|             forecasts.append(self.hourly_forecasts[0]) | ||||
|  | ||||
|         # Get rain and temperatures for that day | ||||
|         temps = [f["temp"] for f in forecasts] | ||||
|         rain = sum([f["precip_3h_mm"] for f in forecasts]) | ||||
|  | ||||
|         # Get all weather icon codes for this day | ||||
|         icons = [f["icon"] for f in forecasts] | ||||
|         day_icons = [icon for icon in icons if "d" in icon] | ||||
|  | ||||
|         # Use the day icons if possible | ||||
|         icon = max(set(day_icons), key=icons.count) if len(day_icons) > 0 else max(set(icons), key=icons.count) | ||||
|  | ||||
|         # Return a dict with that day's data | ||||
|         day_data = { | ||||
|             "datetime": start_time, | ||||
|             "icon": icon, | ||||
|             "temp_min": min(temps), | ||||
|             "temp_max": max(temps), | ||||
|             "precip_mm": rain, | ||||
|         } | ||||
|  | ||||
|         return day_data | ||||
|  | ||||
|     def get_converted_temperature(self, value: float) -> float: | ||||
|         if self.temp_unit == "fahrenheit": | ||||
|             value = self.celsius_to_fahrenheit(value) | ||||
|         return value | ||||
|  | ||||
|     def get_converted_windspeed(self, value: float) -> float: | ||||
|         Literal["meters_sec", "km_hour", "miles_hour", "knots", "beaufort"] | ||||
|         if self.wind_unit == "km_hour": | ||||
|             value = self.celsius_to_fahrenheit(value) | ||||
|         elif self.wind_unit == "km_hour": | ||||
|             value = self.mps_to_kph(value) | ||||
|         elif self.wind_unit == "miles_hour": | ||||
|             value = self.mps_to_mph(value) | ||||
|         elif self.wind_unit == "knots": | ||||
|             value = self.mps_to_knots(value) | ||||
|         elif self.wind_unit == "beaufort": | ||||
|             value = self.mps_to_beaufort(value) | ||||
|         return value | ||||
|  | ||||
|     @staticmethod | ||||
|     def mps_to_beaufort(meters_per_second: float) -> int: | ||||
|         """Map meters per second to the beaufort scale. | ||||
|  | ||||
|         Args: | ||||
|             meters_per_second: | ||||
|                 float representing meters per seconds | ||||
|  | ||||
|         Returns: | ||||
|             an integer of the beaufort scale mapping the input | ||||
|         """ | ||||
|         thresholds = [0.3, 1.6, 3.4, 5.5, 8.0, 10.8, 13.9, 17.2, 20.8, 24.5, 28.5, 32.7] | ||||
|         return next((i for i, threshold in enumerate(thresholds) if meters_per_second < threshold), 12) | ||||
|  | ||||
|     @staticmethod | ||||
|     def mps_to_mph(meters_per_second: float) -> float: | ||||
|         """Map meters per second to miles per hour | ||||
|  | ||||
|         Args: | ||||
|             meters_per_second: | ||||
|                 float representing meters per seconds. | ||||
|  | ||||
|         Returns: | ||||
|             float representing the input value in miles per hour. | ||||
|         """ | ||||
|         # 1 m/s is approximately equal to 2.23694 mph | ||||
|         miles_per_hour = meters_per_second * 2.23694 | ||||
|         return miles_per_hour | ||||
|  | ||||
|     @staticmethod | ||||
|     def mps_to_kph(meters_per_second: float) -> float: | ||||
|         """Map meters per second to kilometers per hour | ||||
|  | ||||
|         Args: | ||||
|             meters_per_second: | ||||
|                 float representing meters per seconds. | ||||
|  | ||||
|         Returns: | ||||
|             float representing the input value in kilometers per hour. | ||||
|         """ | ||||
|         # 1 m/s is equal to 3.6 km/h | ||||
|         kph = meters_per_second * 3.6 | ||||
|         return kph | ||||
|  | ||||
|     @staticmethod | ||||
|     def mps_to_knots(meters_per_second: float) -> float: | ||||
|         """Map meters per second to knots (nautical miles per hour) | ||||
|  | ||||
|         Args: | ||||
|             meters_per_second: | ||||
|                 float representing meters per seconds. | ||||
|  | ||||
|         Returns: | ||||
|             float representing the input value in knots. | ||||
|         """ | ||||
|         # 1 m/s is equal to 1.94384 knots | ||||
|         knots = meters_per_second * 1.94384 | ||||
|         return knots | ||||
|  | ||||
|     @staticmethod | ||||
|     def celsius_to_fahrenheit(celsius: int or float) -> float: | ||||
|         """Converts the given temperate from degrees Celsius to Fahrenheit.""" | ||||
|         fahrenheit = (float(celsius) * 9.0 / 5.0) + 32.0 | ||||
|         return fahrenheit | ||||
|  | ||||
|  | ||||
|     def get_current_weather(self) -> dict: | ||||
|         current_weather_url = f"{self._base_url}/weather?id={self.city_id}&appid={self.api_key}&units={self.units}" | ||||
|         response = requests.get(current_weather_url) | ||||
|         if not response.ok: | ||||
|             raise AssertionError(f"Failure getting the current weather: code {response.status_code}. Reason: {response.text}") | ||||
|         data = json.loads(response.text) | ||||
|         return data | ||||
| def main(): | ||||
|     """Main function, only used for testing purposes""" | ||||
|     key = "" | ||||
|     city = 2643743 | ||||
|     lang = "de" | ||||
|     owm = OpenWeatherMap(api_key=key, city_id=city, language=lang, tz="Europe/Berlin") | ||||
|  | ||||
|     def get_weather_forecast(self) -> dict: | ||||
|         forecast_url = f"{self._base_url}/forecast?id={self.city_id}&appid={self.api_key}&units={self.units}" | ||||
|         response = requests.get(forecast_url) | ||||
|         if not response.ok: | ||||
|             raise AssertionError(f"Failure getting the current weather: code {response.status_code}. Reason: {response.text}") | ||||
|         data = json.loads(response.text)["list"] | ||||
|         return data | ||||
|     current_weather = owm.get_current_weather() | ||||
|     print(current_weather) | ||||
|     _ = owm.get_weather_forecast() | ||||
|     print(owm.get_forecast_for_day(days_from_today=2)) | ||||
|  | ||||
|  | ||||
| if __name__ == "__main__": | ||||
|     main() | ||||
|   | ||||
| @@ -143,11 +143,11 @@ class Inkycal: | ||||
|  | ||||
|             # If a module was not found, print an error message | ||||
|             except ImportError: | ||||
|                 print(f'Could not find module: "{module}". Please try to import manually') | ||||
|                 logger.exception(f'Could not find module: "{module}". Please try to import manually') | ||||
|  | ||||
|             # If something unexpected happened, show the error message | ||||
|             except Exception as e: | ||||
|                 print(str(e)) | ||||
|                 logger.exception(f"Exception: {traceback.format_exc()}.") | ||||
|  | ||||
|         # Path to store images | ||||
|         self.image_folder = image_folder | ||||
| @@ -192,8 +192,8 @@ class Inkycal: | ||||
|         Generated images can be found in the /images folder of Inkycal. | ||||
|         """ | ||||
|  | ||||
|         print(f'Inkycal version: v{self._release}') | ||||
|         print(f'Selected E-paper display: {self.settings["model"]}') | ||||
|         logger.info(f"Inkycal version: v{self._release}") | ||||
|         logger.info(f'Selected E-paper display: {self.settings["model"]}') | ||||
|  | ||||
|         # store module numbers in here | ||||
|         errors = [] | ||||
| @@ -211,15 +211,15 @@ class Inkycal: | ||||
|                     draw_border_2(im=black, xy=(1, 1), size=(black.width - 2, black.height - 2), radius=5) | ||||
|                 black.save(f"{self.image_folder}module{number}_black.png", "PNG") | ||||
|                 colour.save(f"{self.image_folder}module{number}_colour.png", "PNG") | ||||
|                 print('OK!') | ||||
|             except: | ||||
|                 print("OK!") | ||||
|             except Exception: | ||||
|                 errors.append(number) | ||||
|                 self.info += f"module {number}: Error!  " | ||||
|                 print('Error!') | ||||
|                 print(traceback.format_exc()) | ||||
|                 logger.exception("Error!") | ||||
|                 logger.exception(f"Exception: {traceback.format_exc()}.") | ||||
|  | ||||
|         if errors: | ||||
|             print('Error/s in modules:', *errors) | ||||
|             logger.error('Error/s in modules:', *errors) | ||||
|         del errors | ||||
|  | ||||
|         self._assemble() | ||||
| @@ -309,19 +309,18 @@ class Inkycal: | ||||
|                     black.save(f"{self.image_folder}module{number}_black.png", "PNG") | ||||
|                     colour.save(f"{self.image_folder}module{number}_colour.png", "PNG") | ||||
|                     self.info += f"module {number}: OK  " | ||||
|                 except: | ||||
|                 except Exception as e: | ||||
|                     errors.append(number) | ||||
|                     print('error!') | ||||
|                     print(traceback.format_exc()) | ||||
|                     self.info += f"module {number}: error!  " | ||||
|                     logger.exception(f'Exception in module {number}') | ||||
|                     self.info += f"module {number}: Error!  " | ||||
|                     logger.exception("Error!") | ||||
|                     logger.exception(f"Exception: {traceback.format_exc()}.") | ||||
|  | ||||
|             if errors: | ||||
|                 print('error/s in modules:', *errors) | ||||
|                 logger.error("Error/s in modules:", *errors) | ||||
|                 counter = 0 | ||||
|             else: | ||||
|                 counter += 1 | ||||
|                 print('successful') | ||||
|                 logger.info("successful") | ||||
|             del errors | ||||
|  | ||||
|             # Assemble image from each module - add info section if specified | ||||
|   | ||||
| @@ -10,4 +10,5 @@ from .inkycal_slideshow import Slideshow | ||||
| from .inkycal_textfile_to_display import TextToDisplay | ||||
| from .inkycal_webshot import Webshot | ||||
| from .inkycal_xkcd import Xkcd | ||||
| from .inkycal_fullweather import Fullweather | ||||
| from .inkycal_tindie import Tindie | ||||
|   | ||||
| @@ -7,9 +7,10 @@ Copyright by aceinnolab | ||||
| """ | ||||
| import logging | ||||
| import os | ||||
| from typing import Literal | ||||
|  | ||||
| import PIL | ||||
| import numpy | ||||
| import PIL | ||||
| import requests | ||||
| from PIL import Image | ||||
|  | ||||
| @@ -17,8 +18,7 @@ logger = logging.getLogger(__name__) | ||||
|  | ||||
|  | ||||
| class Inkyimage: | ||||
|     """Custom Imgae class written for commonly used image operations. | ||||
|     """ | ||||
|     """Custom Imgae class written for commonly used image operations.""" | ||||
|  | ||||
|     def __init__(self, image=None): | ||||
|         """Initialize InkyImage module""" | ||||
| @@ -27,9 +27,9 @@ class Inkyimage: | ||||
|         self.image = image | ||||
|  | ||||
|         # give an OK message | ||||
|         logger.info(f'{__name__} loaded') | ||||
|         logger.info(f"{__name__} loaded") | ||||
|  | ||||
|     def load(self, path:str) -> None: | ||||
|     def load(self, path: str) -> None: | ||||
|         """loads an image from a URL or filepath. | ||||
|  | ||||
|         Args: | ||||
| @@ -45,54 +45,54 @@ class Inkyimage: | ||||
|         """ | ||||
|         # Try to open the image if it exists and is an image file | ||||
|         try: | ||||
|             if path.startswith('http'): | ||||
|                 logger.info('loading image from URL') | ||||
|             if path.startswith("http"): | ||||
|                 logger.info("loading image from URL") | ||||
|                 image = Image.open(requests.get(path, stream=True).raw) | ||||
|             else: | ||||
|                 logger.info('loading image from local path') | ||||
|                 logger.info("loading image from local path") | ||||
|                 image = Image.open(path) | ||||
|         except FileNotFoundError: | ||||
|             logger.error('No image file found', exc_info=True) | ||||
|             raise Exception('Your file could not be found. Please check the filepath') | ||||
|             logger.error("No image file found", exc_info=True) | ||||
|             raise Exception(f"Your file could not be found. Please check the filepath: {path}") | ||||
|  | ||||
|         except OSError: | ||||
|             logger.error('Invalid Image file provided', exc_info=True) | ||||
|             raise Exception('Please check if the path points to an image file.') | ||||
|             logger.error("Invalid Image file provided", exc_info=True) | ||||
|             raise Exception("Please check if the path points to an image file.") | ||||
|  | ||||
|         logger.info(f'width: {image.width}, height: {image.height}') | ||||
|         logger.info(f"width: {image.width}, height: {image.height}") | ||||
|  | ||||
|         image.convert(mode='RGBA')  # convert to a more suitable format | ||||
|         image.convert(mode="RGBA")  # convert to a more suitable format | ||||
|         self.image = image | ||||
|         logger.info('loaded Image') | ||||
|         logger.info("loaded Image") | ||||
|  | ||||
|     def clear(self): | ||||
|         """Removes currently saved image if present.""" | ||||
|         if self.image: | ||||
|             self.image = None | ||||
|             logger.info('cleared previous image') | ||||
|             logger.info("cleared previous image") | ||||
|  | ||||
|     def _preview(self): | ||||
|         """Preview the image on gpicview (only works on Rapsbian with Desktop)""" | ||||
|         if self._image_loaded(): | ||||
|             path = '/home/pi/Desktop/' | ||||
|             self.image.save(path + 'temp.png') | ||||
|             os.system("gpicview " + path + 'temp.png') | ||||
|             os.system('rm ' + path + 'temp.png') | ||||
|             path = "/home/pi/Desktop/" | ||||
|             self.image.save(path + "temp.png") | ||||
|             os.system("gpicview " + path + "temp.png") | ||||
|             os.system("rm " + path + "temp.png") | ||||
|  | ||||
|     @staticmethod | ||||
|     def preview(image): | ||||
|         """Previews an image on gpicview (only works on Rapsbian with Desktop).""" | ||||
|         path = '~/temp' | ||||
|         image.save(path + '/temp.png') | ||||
|         os.system("gpicview " + path + '/temp.png') | ||||
|         os.system('rm ' + path + '/temp.png') | ||||
|         path = "~/temp" | ||||
|         image.save(path + "/temp.png") | ||||
|         os.system("gpicview " + path + "/temp.png") | ||||
|         os.system("rm " + path + "/temp.png") | ||||
|  | ||||
|     def _image_loaded(self): | ||||
|         """returns True if image was loaded""" | ||||
|         if self.image: | ||||
|             return True | ||||
|         else: | ||||
|             logger.error('image not loaded') | ||||
|             logger.error("image not loaded") | ||||
|             return False | ||||
|  | ||||
|     def flip(self, angle): | ||||
| @@ -105,12 +105,12 @@ class Inkyimage: | ||||
|  | ||||
|             image = self.image | ||||
|             if not angle % 90 == 0: | ||||
|                 logger.error('Angle must be a multiple of 90') | ||||
|                 logger.error("Angle must be a multiple of 90") | ||||
|                 return | ||||
|  | ||||
|             image = image.rotate(angle, expand=True) | ||||
|             self.image = image | ||||
|             logger.info(f'flipped image by {angle} degrees') | ||||
|             logger.info(f"flipped image by {angle} degrees") | ||||
|  | ||||
|     def autoflip(self, layout: str) -> None: | ||||
|         """flips the image automatically to the given layout. | ||||
| @@ -129,17 +129,17 @@ class Inkyimage: | ||||
|         if self._image_loaded(): | ||||
|  | ||||
|             image = self.image | ||||
|             if layout == 'horizontal': | ||||
|             if layout == "horizontal": | ||||
|                 if image.height > image.width: | ||||
|                     logger.info('image width greater than image height, flipping') | ||||
|                     logger.info("image width greater than image height, flipping") | ||||
|                     image = image.rotate(90, expand=True) | ||||
|  | ||||
|             elif layout == 'vertical': | ||||
|             elif layout == "vertical": | ||||
|                 if image.width > image.height: | ||||
|                     logger.info('image width greater than image height, flipping') | ||||
|                     logger.info("image width greater than image height, flipping") | ||||
|                     image = image.rotate(90, expand=True) | ||||
|             else: | ||||
|                 logger.error('layout not supported') | ||||
|                 logger.error("layout not supported") | ||||
|                 return | ||||
|             self.image = image | ||||
|  | ||||
| @@ -153,26 +153,26 @@ class Inkyimage: | ||||
|             image = self.image | ||||
|  | ||||
|             if len(image.getbands()) == 4: | ||||
|                 logger.info('removing alpha channel') | ||||
|                 bg = Image.new('RGBA', (image.width, image.height), 'white') | ||||
|                 logger.info("removing alpha channel") | ||||
|                 bg = Image.new("RGBA", (image.width, image.height), "white") | ||||
|                 im = Image.alpha_composite(bg, image) | ||||
|  | ||||
|                 self.image.paste(im, (0, 0)) | ||||
|                 logger.info('removed transparency') | ||||
|                 logger.info("removed transparency") | ||||
|  | ||||
|     def resize(self, width=None, height=None): | ||||
|         """Resize an image to desired width or height""" | ||||
|         if self._image_loaded(): | ||||
|  | ||||
|             if not width and not height: | ||||
|                 logger.error('no height of width specified') | ||||
|                 logger.error("no height of width specified") | ||||
|                 return | ||||
|  | ||||
|             image = self.image | ||||
|  | ||||
|             if width: | ||||
|                 initial_width = image.width | ||||
|                 wpercent = (width / float(image.width)) | ||||
|                 wpercent = width / float(image.width) | ||||
|                 hsize = int((float(image.height) * float(wpercent))) | ||||
|                 image = image.resize((width, hsize), Image.LANCZOS) | ||||
|                 logger.info(f"resized image from {initial_width} to {image.width}") | ||||
| @@ -180,7 +180,7 @@ class Inkyimage: | ||||
|  | ||||
|             if height: | ||||
|                 initial_height = image.height | ||||
|                 hpercent = (height / float(image.height)) | ||||
|                 hpercent = height / float(image.height) | ||||
|                 wsize = int(float(image.width) * float(hpercent)) | ||||
|                 image = image.resize((wsize, height), Image.LANCZOS) | ||||
|                 logger.info(f"resized image from {initial_height} to {image.height}") | ||||
| @@ -203,131 +203,129 @@ class Inkyimage: | ||||
|  | ||||
|         def clear_white(img): | ||||
|             """Replace all white pixels from image with transparent pixels""" | ||||
|             x = numpy.asarray(img.convert('RGBA')).copy() | ||||
|             x = numpy.asarray(img.convert("RGBA")).copy() | ||||
|             x[:, :, 3] = (255 * (x[:, :, :3] != 255).any(axis=2)).astype(numpy.uint8) | ||||
|             return Image.fromarray(x) | ||||
|  | ||||
|         image2 = clear_white(image2) | ||||
|         image1.paste(image2, (0, 0), image2) | ||||
|         logger.info('merged given images into one') | ||||
|         logger.info("merged given images into one") | ||||
|  | ||||
|         return image1 | ||||
|  | ||||
|     def to_palette(self, palette, dither=True) -> (PIL.Image, PIL.Image): | ||||
|         """Maps an image to a given colour palette. | ||||
|  | ||||
|         Maps each pixel from the image to a colour from the palette. | ||||
| def image_to_palette( | ||||
|     image: Image, palette: Literal = ["bwr", "bwy", "bw", "16gray"], dither: bool = True | ||||
| ) -> (PIL.Image, PIL.Image): | ||||
|     """Maps an image to a given colour palette. | ||||
|  | ||||
|         Args: | ||||
|           - palette: A supported token. (see below) | ||||
|           - dither:->bool. Use dithering? Set to `False` for solid colour fills. | ||||
|     Maps each pixel from the image to a colour from the palette. | ||||
|  | ||||
|         Returns: | ||||
|           - two images: one for the coloured band and one for the black band. | ||||
|     Args: | ||||
|         - palette: A supported token. (see below) | ||||
|         - dither:->bool. Use dithering? Set to `False` for solid colour fills. | ||||
|  | ||||
|         Raises: | ||||
|           - ValueError if palette token is not supported | ||||
|     Returns: | ||||
|         - two images: one for the coloured band and one for the black band. | ||||
|  | ||||
|         Supported palette tokens: | ||||
|     Raises: | ||||
|         - ValueError if palette token is not supported | ||||
|  | ||||
|         >>> 'bwr' # black-white-red | ||||
|         >>> 'bwy' # black-white-yellow | ||||
|         >>> 'bw'  # black-white | ||||
|         >>> '16gray' # 16 shades of gray | ||||
|         """ | ||||
|         # Check if an image is loaded | ||||
|         if self._image_loaded(): | ||||
|             image = self.image.convert('RGB') | ||||
|         else: | ||||
|             raise FileNotFoundError | ||||
|     Supported palette tokens: | ||||
|  | ||||
|         if palette == 'bwr': | ||||
|             # black-white-red palette | ||||
|             pal = [255, 255, 255, 0, 0, 0, 255, 0, 0] | ||||
|     >>> 'bwr' # black-white-red | ||||
|     >>> 'bwy' # black-white-yellow | ||||
|     >>> 'bw'  # black-white | ||||
|     >>> '16gray' # 16 shades of gray | ||||
|     """ | ||||
|  | ||||
|         elif palette == 'bwy': | ||||
|             # black-white-yellow palette | ||||
|             pal = [255, 255, 255, 0, 0, 0, 255, 255, 0] | ||||
|     if palette == "bwr": | ||||
|         # black-white-red palette | ||||
|         pal = [255, 255, 255, 0, 0, 0, 255, 0, 0] | ||||
|  | ||||
|         elif palette == 'bw': | ||||
|             pal = None | ||||
|         elif palette == '16gray': | ||||
|             pal = [x for x in range(0, 256, 16)] * 3 | ||||
|             pal.sort() | ||||
|     elif palette == "bwy": | ||||
|         # black-white-yellow palette | ||||
|         pal = [255, 255, 255, 0, 0, 0, 255, 255, 0] | ||||
|  | ||||
|         else: | ||||
|             logger.error('The given palette is unsupported.') | ||||
|             raise ValueError('The given palette is not supported.') | ||||
|     elif palette == "bw": | ||||
|         pal = None | ||||
|     elif palette == "16gray": | ||||
|         pal = [x for x in range(0, 256, 16)] * 3 | ||||
|         pal.sort() | ||||
|  | ||||
|         if pal: | ||||
|             # The palette needs to have 256 colors, for this, the black-colour | ||||
|             # is added until the | ||||
|             colours = len(pal) // 3 | ||||
|             # print(f'The palette has {colours} colours') | ||||
|     else: | ||||
|         logger.error("The given palette is unsupported.") | ||||
|         raise ValueError("The given palette is not supported.") | ||||
|  | ||||
|             if 256 % colours != 0: | ||||
|                 # print('Filling palette with black') | ||||
|                 pal += (256 % colours) * [0, 0, 0] | ||||
|     if pal: | ||||
|         # The palette needs to have 256 colors, for this, the black-colour | ||||
|         # is added until the | ||||
|         colours = len(pal) // 3 | ||||
|         # print(f'The palette has {colours} colours') | ||||
|  | ||||
|             # print(pal) | ||||
|             colours = len(pal) // 3 | ||||
|             # print(f'The palette now has {colours} colours') | ||||
|         if 256 % colours != 0: | ||||
|             # print('Filling palette with black') | ||||
|             pal += (256 % colours) * [0, 0, 0] | ||||
|  | ||||
|             # Create a dummy image to be used as a palette | ||||
|             palette_im = Image.new('P', (1, 1)) | ||||
|         # print(pal) | ||||
|         colours = len(pal) // 3 | ||||
|         # print(f'The palette now has {colours} colours') | ||||
|  | ||||
|             # Attach the created palette. The palette should have 256 colours | ||||
|             # equivalent to 768 integers | ||||
|             palette_im.putpalette(pal * (256 // colours)) | ||||
|         # Create a dummy image to be used as a palette | ||||
|         palette_im = Image.new("P", (1, 1)) | ||||
|  | ||||
|             # Quantize the image to given palette | ||||
|             quantized_im = image.quantize(palette=palette_im, dither=dither) | ||||
|             quantized_im = quantized_im.convert('RGB') | ||||
|         # Attach the created palette. The palette should have 256 colours | ||||
|         # equivalent to 768 integers | ||||
|         palette_im.putpalette(pal * (256 // colours)) | ||||
|  | ||||
|             # get rgb of the non-black-white colour from the palette | ||||
|             rgb = [pal[x:x + 3] for x in range(0, len(pal), 3)] | ||||
|             rgb = [col for col in rgb if col != [0, 0, 0] and col != [255, 255, 255]][0] | ||||
|             r_col, g_col, b_col = rgb | ||||
|             # print(f'r:{r_col} g:{g_col} b:{b_col}') | ||||
|         # Quantize the image to given palette | ||||
|         quantized_im = image.quantize(palette=palette_im, dither=dither) | ||||
|         quantized_im = quantized_im.convert("RGB") | ||||
|  | ||||
|             # Create an image buffer for black pixels | ||||
|             buffer1 = numpy.array(quantized_im) | ||||
|         # get rgb of the non-black-white colour from the palette | ||||
|         rgb = [pal[x : x + 3] for x in range(0, len(pal), 3)] | ||||
|         rgb = [col for col in rgb if col != [0, 0, 0] and col != [255, 255, 255]][0] | ||||
|         r_col, g_col, b_col = rgb | ||||
|         # print(f'r:{r_col} g:{g_col} b:{b_col}') | ||||
|  | ||||
|             # Get RGB values of each pixel | ||||
|             r, g, b = buffer1[:, :, 0], buffer1[:, :, 1], buffer1[:, :, 2] | ||||
|         # Create an image buffer for black pixels | ||||
|         buffer1 = numpy.array(quantized_im) | ||||
|  | ||||
|             # convert coloured pixels to white | ||||
|             buffer1[numpy.logical_and(r == r_col, g == g_col)] = [255, 255, 255] | ||||
|         # Get RGB values of each pixel | ||||
|         r, g, b = buffer1[:, :, 0], buffer1[:, :, 1], buffer1[:, :, 2] | ||||
|  | ||||
|             # reconstruct image for black-band | ||||
|             im_black = Image.fromarray(buffer1) | ||||
|         # convert coloured pixels to white | ||||
|         buffer1[numpy.logical_and(r == r_col, g == g_col)] = [255, 255, 255] | ||||
|  | ||||
|             # Create a buffer for coloured pixels | ||||
|             buffer2 = numpy.array(quantized_im) | ||||
|         # reconstruct image for black-band | ||||
|         im_black = Image.fromarray(buffer1) | ||||
|  | ||||
|             # Get RGB values of each pixel | ||||
|             r, g, b = buffer2[:, :, 0], buffer2[:, :, 1], buffer2[:, :, 2] | ||||
|         # Create a buffer for coloured pixels | ||||
|         buffer2 = numpy.array(quantized_im) | ||||
|  | ||||
|             # convert black pixels to white | ||||
|             buffer2[numpy.logical_and(r == 0, g == 0)] = [255, 255, 255] | ||||
|         # Get RGB values of each pixel | ||||
|         r, g, b = buffer2[:, :, 0], buffer2[:, :, 1], buffer2[:, :, 2] | ||||
|  | ||||
|             # convert non-white pixels to black | ||||
|             buffer2[numpy.logical_and(g == g_col, b == 0)] = [0, 0, 0] | ||||
|         # convert black pixels to white | ||||
|         buffer2[numpy.logical_and(r == 0, g == 0)] = [255, 255, 255] | ||||
|  | ||||
|             # reconstruct image for colour-band | ||||
|             im_colour = Image.fromarray(buffer2) | ||||
|         # convert non-white pixels to black | ||||
|         buffer2[numpy.logical_and(g == g_col, b == 0)] = [0, 0, 0] | ||||
|  | ||||
|             # self.preview(im_black) | ||||
|             # self.preview(im_colour) | ||||
|         # reconstruct image for colour-band | ||||
|         im_colour = Image.fromarray(buffer2) | ||||
|  | ||||
|         else: | ||||
|             im_black = image.convert('1', dither=dither) | ||||
|             im_colour = Image.new(mode='1', size=im_black.size, color='white') | ||||
|         # self.preview(im_black) | ||||
|         # self.preview(im_colour) | ||||
|  | ||||
|         logger.info('mapped image to specified palette') | ||||
|     else: | ||||
|         im_black = image.convert("1", dither=dither) | ||||
|         im_colour = Image.new(mode="1", size=im_black.size, color="white") | ||||
|  | ||||
|         return im_black, im_colour | ||||
|     logger.info("mapped image to specified palette") | ||||
|  | ||||
|     return im_black, im_colour | ||||
|  | ||||
|  | ||||
| if __name__ == '__main__': | ||||
|     print(f'running {__name__} in standalone/debug mode') | ||||
| if __name__ == "__main__": | ||||
|     print(f"running {__name__} in standalone/debug mode") | ||||
|   | ||||
							
								
								
									
										662
									
								
								inkycal/modules/inkycal_fullweather.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										662
									
								
								inkycal/modules/inkycal_fullweather.py
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,662 @@ | ||||
| """ | ||||
| Inkycal fullscreen weather module | ||||
| Copyright by mrbwburns | ||||
| """ | ||||
| import io | ||||
| 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 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.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(logging.INFO) | ||||
|  | ||||
| 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 | ||||
|  | ||||
|  | ||||
| def get_image_from_plot(fig: plt) -> Image: | ||||
|     buf = io.BytesIO() | ||||
|     fig.savefig(buf) | ||||
|     buf.seek(0) | ||||
|     return Image.open(buf) | ||||
|  | ||||
|  | ||||
| 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", | ||||
|         }, | ||||
|         "latitude": {"label": "Please enter your location' geographical latitude. E.g. 51.51 for London."}, | ||||
|         "longitude": {"label": "Please enter your location' geographical longitude. E.g. -0.13 for London."}, | ||||
|     } | ||||
|  | ||||
|     optional = { | ||||
|         "api_version": { | ||||
|             "label": "Please enter openweathermap api version. Default is '2.5'.", | ||||
|             "options": ["2.5", "3.0"], | ||||
|         }, | ||||
|         "orientation": {"label": "Please select the desired orientation", "options": ["vertical", "horizontal"]}, | ||||
|         "temp_unit": { | ||||
|             "label": "Which temperature unit should be used?", | ||||
|             "options": ["celsius", "fahrenheit"], | ||||
|         }, | ||||
|         "wind_unit": { | ||||
|             "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"], | ||||
|         }, | ||||
|         "font": { | ||||
|             "label": "Font family to use for the entire screen", | ||||
|             "options": ["NotoSans", "Roboto", "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"] | ||||
|  | ||||
|         self.tz = 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_lat = float(config["latitude"]) | ||||
|         self.location_lon = float(config["longitude"]) | ||||
|         self.font_size = int(config["fontsize"]) | ||||
|  | ||||
|         # optional parameters | ||||
|         if "api_version" in config and config["api_version"] == "3.0": | ||||
|             self.owm_api_version = "3.0" | ||||
|         else: | ||||
|             self.owm_api_version = "2.5" | ||||
|         if "orientation" in config: | ||||
|             self.orientation = config["orientation"] | ||||
|             assert self.orientation in ["horizontal", "vertical"] | ||||
|         else: | ||||
|             self.orientation = "horizontal" | ||||
|         if "wind_unit" in config: | ||||
|             self.wind_unit = config["wind_unit"] | ||||
|         else: | ||||
|             self.wind_unit = "meters_sec" | ||||
|         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 "wind_gusts" in config: | ||||
|             self.wind_gusts = bool(config["wind_gusts"]) | ||||
|         else: | ||||
|             self.wind_gusts = True | ||||
|  | ||||
|         if "temp_unit" in config: | ||||
|             self.temp_unit = config["temp_unit"] | ||||
|         else: | ||||
|             self.temp_unit = "celsius" | ||||
|         if self.temp_unit == "fahrenheit": | ||||
|             self.tempDispUnit = "F" | ||||
|         elif self.temp_unit == "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 "icon_outline" in config: | ||||
|             self.icon_outline = config["icon_outline"] | ||||
|         else: | ||||
|             self.icon_outline = True | ||||
|  | ||||
|         if "font" in config: | ||||
|             self.font = config["font"] | ||||
|         else: | ||||
|             self.font = "Roboto" | ||||
|  | ||||
|         # some calculations for scalability | ||||
|         # TODO: make this work for all sizes | ||||
|         if self.orientation == "horizontal": | ||||
|             self.width, self.height = self.height, self.width | ||||
|         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 | ||||
|         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) | ||||
|         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("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 | ||||
|             uvi = self.current_weather["uvi"] if self.current_weather["uvi"] else 0.0 | ||||
|             uvString = f"{uvi:.1f}" | ||||
|             uvFont = self.get_font("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("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['temp_feels_like']:.0f}{self.tempDispUnit}" | ||||
|         tempFont = self.get_font("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("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("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 to the upper right section | ||||
|         """ | ||||
|         ## 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("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_unit == "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, 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 | ||||
|         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 addDailyForecast(self): | ||||
|         """ | ||||
|         Adds daily weather forecasts to the lower right section | ||||
|         """ | ||||
|         ## Create drawing object for image | ||||
|         image_draw = ImageDraw.Draw(self.image) | ||||
|  | ||||
|         ## Draw daily chart title | ||||
|         title_y = int(self.height / 2)  # Y-coordinate of the title | ||||
|         chartTitleFont = self.get_font("Bold", self.font_size) | ||||
|         image_draw.text((self.left_section_width + 20, title_y), self.weekly_title, font=chartTitleFont, fill=0) | ||||
|  | ||||
|         # Define the parameters | ||||
|         number_of_forecast_days = 5  # including today | ||||
|         # Spread evenly, starting from title width | ||||
|         rectangle_width = int((self.width - (self.left_section_width + 40)) / number_of_forecast_days) | ||||
|         # Maximum height for each rectangle (avoid overlapping with title) | ||||
|         rectangle_height = int(self.height / 2 - 20) | ||||
|  | ||||
|         # Rain icon is static | ||||
|         rainIcon = Image.open(os.path.join(icons_dir, "rain-chance.bmp")) | ||||
|         rainIcon.convert("L") | ||||
|         rainIcon = ImageOps.invert(rainIcon) | ||||
|         weeklyRainIcon = rainIcon.resize((20, 20)) | ||||
|  | ||||
|         # Loop through the upcoming days' data and create rectangles | ||||
|         for i in range(number_of_forecast_days): | ||||
|             x_rect = self.left_section_width + 20 + i * rectangle_width  # Start from the title width | ||||
|             y_rect = int(self.height / 2 + 30) | ||||
|  | ||||
|             day_data = self.my_owm.get_forecast_for_day(days_from_today=i) | ||||
|             rect = Image.new("RGBA", (int(rectangle_width), int(rectangle_height)), (255, 255, 255)) | ||||
|             rect_draw = ImageDraw.Draw(rect) | ||||
|  | ||||
|             # 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 = 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 | ||||
|             short_month_day_x = (rectangle_width - short_month_day_text[2] + short_month_day_text[0]) / 2 | ||||
|             rect_draw.text((day_name_x, 0), short_day_name, fill=0, font=short_day_font) | ||||
|             rect_draw.text( | ||||
|                 (short_month_day_x, 30), | ||||
|                 short_month_day, | ||||
|                 fill=0, | ||||
|                 font=short_month_day_font, | ||||
|             ) | ||||
|  | ||||
|             ## Min and max temperature split into diagonal placement | ||||
|             min_temp = day_data["temp_min"] | ||||
|             max_temp = day_data["temp_max"] | ||||
|             temp_text_min = f"{min_temp:.0f}{self.tempDispUnit}" | ||||
|             temp_text_max = f"{max_temp:.0f}{self.tempDispUnit}" | ||||
|             rect_temp_font = self.get_font("ExtraBold", self.font_size + 4) | ||||
|             temp_x_offset = 20 | ||||
|             # this is upper left: max temperature | ||||
|             temp_text_max_x = temp_x_offset | ||||
|             temp_text_max_y = int(rectangle_height * 0.25) | ||||
|             # this is lower right: min temperature | ||||
|             temp_text_min_bbox = rect_draw.textbbox((0, 0), temp_text_min, font=rect_temp_font) | ||||
|             temp_text_min_x = ( | ||||
|                 int((rectangle_width - temp_text_min_bbox[2] + temp_text_min_bbox[0]) / 2) + temp_x_offset + 7 | ||||
|             ) | ||||
|             temp_text_min_y = int(rectangle_height * 0.33) | ||||
|             rect_draw.text((temp_text_min_x, temp_text_min_y), temp_text_min, fill=0, font=rect_temp_font) | ||||
|             rect_draw.text( | ||||
|                 (temp_text_max_x, temp_text_max_y), | ||||
|                 temp_text_max, | ||||
|                 fill=0, | ||||
|                 font=rect_temp_font, | ||||
|             ) | ||||
|  | ||||
|             # Weather icon for the day | ||||
|             icon_code = day_data["icon"] | ||||
|             icon = get_weather_icon(icon_name=icon_code, size=90) | ||||
|             if self.icon_outline: | ||||
|                 icon = outline(image=icon, size=8, color=(0, 0, 0, 255)) | ||||
|             icon_x = int((rectangle_width - icon.width) / 2) | ||||
|             icon_y = int(rectangle_height * 0.4) | ||||
|             # 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 | ||||
|             rect.paste(icon, (int(icon_x), icon_y), mask) | ||||
|  | ||||
|             ## Precipitation icon and text | ||||
|             rain = day_data["precip_mm"] | ||||
|             if rain: | ||||
|                 rain_text = f"{rain:.0f} mm" | ||||
|                 rain_font = self.get_font("ExtraBold", self.font_size) | ||||
|                 # Icon | ||||
|                 rain_icon_x = int((rectangle_width - icon.width) / 2) | ||||
|                 rain_icon_y = int(rectangle_height * 0.82) | ||||
|                 rect.paste(weeklyRainIcon, (rain_icon_x, rain_icon_y)) | ||||
|                 # Text | ||||
|                 rain_text_y = int(rectangle_height * 0.8) | ||||
|                 rect_draw.text( | ||||
|                     (rain_icon_x + weeklyRainIcon.width + 10, rain_text_y), | ||||
|                     rain_text, | ||||
|                     fill=0, | ||||
|                     font=rain_font, | ||||
|                     align="right", | ||||
|                 ) | ||||
|  | ||||
|             self.image.paste(rect, (int(x_rect), int(y_rect))) | ||||
|  | ||||
|     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.my_owm = OpenWeatherMap( | ||||
|             api_key=self.api_key, | ||||
|             api_version=self.owm_api_version, | ||||
|             lat=self.location_lat, | ||||
|             lon=self.location_lon, | ||||
|             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() | ||||
|  | ||||
|         ## 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 to the top right section | ||||
|         self.addHourlyForecast() | ||||
|  | ||||
|         ## Add Daily Forecast to the bottom right section | ||||
|         self.addDailyForecast() | ||||
|  | ||||
|         if self.orientation == "horizontal": | ||||
|             self.image = self.image.rotate(90, expand=True) | ||||
|  | ||||
|         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) | ||||
|  | ||||
|         # Return the images ready for the display | ||||
|         return im_black, im_colour | ||||
|  | ||||
|     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"]: | ||||
|             style = "Bold" | ||||
|         elif self.font == "OpenSans" and style == "Black": | ||||
|             style = "ExtraBold" | ||||
|         return ImageFont.truetype(fonts[f"{self.font}-{style}"], size=size) | ||||
|  | ||||
|  | ||||
| if __name__ == "__main__": | ||||
|     print(f"running {__name__} in standalone mode") | ||||
| @@ -2,8 +2,8 @@ | ||||
| Inkycal Image Module | ||||
| Copyright by aceinnolab | ||||
| """ | ||||
|  | ||||
| from inkycal.custom import * | ||||
| from inkycal.modules.inky_image import image_to_palette | ||||
| from inkycal.modules.inky_image import Inkyimage as Images | ||||
| from inkycal.modules.template import inkycal_module | ||||
|  | ||||
| @@ -11,36 +11,21 @@ logger = logging.getLogger(__name__) | ||||
|  | ||||
|  | ||||
| class Inkyimage(inkycal_module): | ||||
|     """Displays an image from URL or local path | ||||
|     """ | ||||
|     """Displays an image from URL or local path""" | ||||
|  | ||||
|     name = "Inkycal Image - show an image from a URL or local path" | ||||
|  | ||||
|     requires = { | ||||
|  | ||||
|         "path": { | ||||
|             "label": "Path to a local folder, e.g. /home/pi/Desktop/images. " | ||||
|                      "Only PNG and JPG/JPEG images are used for the slideshow." | ||||
|             "Only PNG and JPG/JPEG images are used for the slideshow." | ||||
|         }, | ||||
|  | ||||
|         "palette": { | ||||
|             "label": "Which palette should be used for converting images?", | ||||
|             "options": ["bw", "bwr", "bwy"] | ||||
|         } | ||||
|  | ||||
|         "palette": {"label": "Which palette should be used for converting images?", "options": ["bw", "bwr", "bwy"]}, | ||||
|     } | ||||
|  | ||||
|     optional = { | ||||
|  | ||||
|         "autoflip": { | ||||
|             "label": "Should the image be flipped automatically?", | ||||
|             "options": [True, False] | ||||
|         }, | ||||
|  | ||||
|         "orientation": { | ||||
|             "label": "Please select the desired orientation", | ||||
|             "options": ["vertical", "horizontal"] | ||||
|         } | ||||
|         "autoflip": {"label": "Should the image be flipped automatically?", "options": [True, False]}, | ||||
|         "orientation": {"label": "Please select the desired orientation", "options": ["vertical", "horizontal"]}, | ||||
|     } | ||||
|  | ||||
|     def __init__(self, config): | ||||
| @@ -48,24 +33,24 @@ class Inkyimage(inkycal_module): | ||||
|  | ||||
|         super().__init__(config) | ||||
|  | ||||
|         config = config['config'] | ||||
|         config = config["config"] | ||||
|  | ||||
|         # required parameters | ||||
|         for param in self.requires: | ||||
|             if not param in config: | ||||
|                 raise Exception(f'config is missing {param}') | ||||
|                 raise Exception(f"config is missing {param}") | ||||
|  | ||||
|         # optional parameters | ||||
|         self.path = config['path'] | ||||
|         self.palette = config['palette'] | ||||
|         self.autoflip = config['autoflip'] | ||||
|         self.orientation = config['orientation'] | ||||
|         self.path = config["path"] | ||||
|         self.palette = config["palette"] | ||||
|         self.autoflip = config["autoflip"] | ||||
|         self.orientation = config["orientation"] | ||||
|         self.dither = True | ||||
|         if 'dither' in config and config["dither"] == False: | ||||
|         if "dither" in config and config["dither"] == False: | ||||
|             self.dither = False | ||||
|  | ||||
|         # give an OK message | ||||
|         print(f'{__name__} loaded') | ||||
|         print(f"{__name__} loaded") | ||||
|  | ||||
|     def generate_image(self): | ||||
|         """Generate image for this module""" | ||||
| @@ -75,7 +60,7 @@ class Inkyimage(inkycal_module): | ||||
|         im_height = int(self.height - (2 * self.padding_top)) | ||||
|         im_size = im_width, im_height | ||||
|  | ||||
|         logger.info(f'Image size: {im_size}') | ||||
|         logger.info(f"Image size: {im_size}") | ||||
|  | ||||
|         # initialize custom image class | ||||
|         im = Images() | ||||
| @@ -94,7 +79,7 @@ class Inkyimage(inkycal_module): | ||||
|         im.resize(width=im_width, height=im_height) | ||||
|  | ||||
|         # convert images according to specified palette | ||||
|         im_black, im_colour = im.to_palette(self.palette, self.dither) | ||||
|         im_black, im_colour = image_to_palette(image=im, palette=self.palette, dither=self.dither) | ||||
|  | ||||
|         # with the images now send, clear the current image | ||||
|         im.clear() | ||||
| @@ -103,5 +88,5 @@ class Inkyimage(inkycal_module): | ||||
|         return im_black, im_colour | ||||
|  | ||||
|  | ||||
| if __name__ == '__main__': | ||||
|     print(f'running {__name__} in standalone/debug mode') | ||||
| if __name__ == "__main__": | ||||
|     print(f"running {__name__} in standalone/debug mode") | ||||
|   | ||||
| @@ -3,16 +3,26 @@ Inkycal weather module | ||||
| Copyright by aceinnolab | ||||
| """ | ||||
|  | ||||
| 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 import OpenWeatherMap | ||||
| 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): | ||||
| @@ -75,6 +85,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: | ||||
| @@ -88,54 +100,52 @@ class Weather(inkycal_module): | ||||
|         self.round_temperature = config['round_temperature'] | ||||
|         self.round_windspeed = config['round_windspeed'] | ||||
|         self.forecast_interval = config['forecast_interval'] | ||||
|         self.units = config['units'] | ||||
|         self.hour_format = int(config['hour_format']) | ||||
|         self.use_beaufort = config['use_beaufort'] | ||||
|  | ||||
|         # additional configuration | ||||
|         self.owm = OpenWeatherMap(api_key=self.api_key, city_id=self.location, units=config['units']) | ||||
|         self.timezone = get_system_tz() | ||||
|         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") | ||||
|  | ||||
|     @staticmethod | ||||
|     def mps_to_beaufort(meters_per_second: float) -> int: | ||||
|         """Map meters per second to the beaufort scale. | ||||
|  | ||||
|         Args: | ||||
|             meters_per_second: | ||||
|                 float representing meters per seconds | ||||
|  | ||||
|         Returns: | ||||
|             an integer of the beaufort scale mapping the input | ||||
|         """ | ||||
|         thresholds = [0.3, 1.6, 3.4, 5.5, 8.0, 10.8, 13.9, 17.2, 20.7, 24.5, 28.4] | ||||
|         return next((i for i, threshold in enumerate(thresholds) if meters_per_second < threshold), 11) | ||||
|  | ||||
|     @staticmethod | ||||
|     def mps_to_mph(meters_per_second: float) -> float: | ||||
|         """Map meters per second to miles per hour, rounded to one decimal place. | ||||
|  | ||||
|         Args: | ||||
|             meters_per_second: | ||||
|                 float representing meters per seconds. | ||||
|  | ||||
|         Returns: | ||||
|             float representing the input value in miles per hour. | ||||
|         """ | ||||
|         # 1 m/s is approximately equal to 2.23694 mph | ||||
|         miles_per_hour = meters_per_second * 2.23694 | ||||
|         return round(miles_per_hour, 1) | ||||
|  | ||||
|     @staticmethod | ||||
|     def celsius_to_fahrenheit(celsius: int or float): | ||||
|         """Converts the given temperate from degrees Celsius to Fahrenheit.""" | ||||
|         fahrenheit = (celsius * 9 / 5) + 32 | ||||
|         return fahrenheit | ||||
|      | ||||
|  | ||||
|     def generate_image(self): | ||||
|         """Generate image for this module""" | ||||
| @@ -180,14 +190,14 @@ class Weather(inkycal_module): | ||||
|                 7: '\uf0ae' | ||||
|             }[int(index) & 7] | ||||
|  | ||||
|         def is_negative(temp): | ||||
|             """Check if temp is below freezing point of water (0°C/30°F) | ||||
|         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 temp_unit == 'celsius' and round(float(temp.split('°')[0])) <= 0: | ||||
|             if self.temp_unit == 'celsius' and round(float(temp.split(self.tempDispUnit)[0])) <= 0: | ||||
|                 answer = True | ||||
|             elif temp_unit == 'fahrenheit' and round(float(temp.split('°')[0])) <= 0: | ||||
|             elif self.temp_unit == 'fahrenheit' and round(float(temp.split(self.tempDispUnit)[0])) <= 32: | ||||
|                 answer = True | ||||
|             return answer | ||||
|  | ||||
| @@ -389,24 +399,18 @@ class Weather(inkycal_module): | ||||
|  | ||||
|         # Create current-weather and weather-forecast objects | ||||
|         logging.debug('looking up location by ID') | ||||
|         weather = self.owm.get_current_weather() | ||||
|         forecast = self.owm.get_weather_forecast() | ||||
|         current_weather = self.owm.get_current_weather() | ||||
|         weather_forecasts = self.owm.get_weather_forecast() | ||||
|  | ||||
|         # Set decimals | ||||
|         dec_temp = None if self.round_temperature == True else 1 | ||||
|         dec_wind = None if self.round_windspeed == True else 1 | ||||
|         dec_temp = 0 if self.round_temperature == True else 1 | ||||
|         dec_wind = 0 if self.round_windspeed == True else 1 | ||||
|  | ||||
|         # Set correct temperature units | ||||
|         if self.units == 'metric': | ||||
|             temp_unit = 'celsius' | ||||
|         elif self.units == 'imperial': | ||||
|             temp_unit = 'fahrenheit' | ||||
|  | ||||
|         logging.debug(f'temperature unit: {self.units}') | ||||
|         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() | ||||
|         now = arrow.utcnow().to(self.timezone) | ||||
|  | ||||
|         fc_data = {} | ||||
|  | ||||
| @@ -414,90 +418,41 @@ 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 | ||||
|             forecasts = [_ for _ in forecast if arrow.get(_["dt"]) 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 forecast in forecasts: | ||||
|                 if self.units == "metric": | ||||
|                     temp = f"{round(weather['main']['temp'], ndigits=dec_temp)}°C" | ||||
|                 else: | ||||
|                     temp = f"{round(self.celsius_to_fahrenheit(weather['main']['temp']), ndigits=dec_temp)}°F" | ||||
|  | ||||
|                 icon = forecast["weather"][0]["icon"] | ||||
|                 fc_data['fc' + str(forecasts.index(forecast) + 1)] = { | ||||
|                     'temp': temp, | ||||
|                     'icon': icon, | ||||
|                     'stamp': forecast_timings[forecasts.index(forecast)].to( | ||||
|                         get_system_tz()).format('H.00' if self.hour_format == 24 else 'h a') | ||||
|                 } | ||||
|             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") | ||||
|  | ||||
|             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 | ||||
|                 """ | ||||
|             daily_forecasts = [self.owm.get_forecast_for_day(days) for days in range(1, 5)] | ||||
|  | ||||
|                 # 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 | ||||
|                 forecasts = [_ for _ in forecast if arrow.get(_["dt"]) in time_range] | ||||
|  | ||||
|                 # Get all temperatures for this day | ||||
|                 daily_temp = [round(_["main"]["temp"]) for _ in forecasts] | ||||
|                 # Calculate min. and max. temp for this day | ||||
|                 temp_range = f'{min(daily_temp)}°/{max(daily_temp)}°' | ||||
|  | ||||
|                 # Get all weather icon codes for this day | ||||
|                 daily_icons = [_["weather"][0]["icon"] for _ in 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} | ||||
|  | ||||
|             forecasts = [calculate_forecast(days) for days in range(1, 5)] | ||||
|  | ||||
|             for forecast in forecasts: | ||||
|                 fc_data['fc' + str(forecasts.index(forecast) + 1)] = { | ||||
|                     'temp': forecast['temp'], | ||||
|             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['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)) | ||||
|  | ||||
|         # Get some current weather details | ||||
|         if dec_temp != 0: | ||||
|             temperature = f"{round(weather['main']['temp'])}°" | ||||
|         else: | ||||
|             temperature = f"{round(weather['main']['temp'], ndigits=dec_temp)}°" | ||||
|  | ||||
|         weather_icon = weather["weather"][0]["icon"] | ||||
|         humidity = str(weather["main"]["humidity"]) | ||||
|         sunrise_raw = arrow.get(weather["sys"]["sunrise"]).to(self.timezone) | ||||
|         sunset_raw = arrow.get(weather["sys"]["sunset"]).to(self.timezone) | ||||
|         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}') | ||||
|  | ||||
| @@ -512,16 +467,8 @@ class Weather(inkycal_module): | ||||
|             sunset = sunset_raw.format('H:mm') | ||||
|  | ||||
|         # Format the wind-speed to user preference | ||||
|         if self.use_beaufort: | ||||
|             logger.debug("using beaufort for wind") | ||||
|             wind = str(self.mps_to_beaufort(weather["wind"]["speed"])) | ||||
|         else: | ||||
|             if self.units == 'metric': | ||||
|                 logging.debug('getting wind speed in meters per second') | ||||
|                 wind = f"{weather['wind']['speed']} m/s" | ||||
|             else: | ||||
|                 logging.debug('getting wind speed in imperial unit') | ||||
|                 wind = f"{self.mps_to_mph(weather['wind']['speed'])} miles/h" | ||||
|         logging.debug(f'getting wind speed in {self.windDispUnit}') | ||||
|         wind = f"{current_weather['wind']:.{dec_wind}f} {self.windDispUnit}" | ||||
|  | ||||
|         moon_phase = get_moon_phase() | ||||
|  | ||||
|   | ||||
| @@ -1,29 +1,58 @@ | ||||
| appdirs==1.4.4 | ||||
| arrow==1.3.0 | ||||
| asyncio==3.4.3 | ||||
| beautifulsoup4==4.12.2 | ||||
| certifi==2023.7.22 | ||||
| cfgv==3.4.0 | ||||
| charset-normalizer==3.3.2 | ||||
| colorzero==2.0 | ||||
| contourpy==1.2.0 | ||||
| cycler==0.12.1 | ||||
| distlib==0.3.8 | ||||
| feedparser==6.0.10 | ||||
| filelock==3.13.1 | ||||
| fonttools==4.45.1 | ||||
| frozendict==2.4.0 | ||||
| gpiozero==2.0 | ||||
| html2text==2020.1.16 | ||||
| html5lib==1.1 | ||||
| htmlwebshot==0.1.2 | ||||
| icalendar==5.0.11 | ||||
| identify==2.5.33 | ||||
| idna==3.6 | ||||
| kiwisolver==1.4.5 | ||||
| lgpio==0.0.0.2 | ||||
| lxml==4.9.3 | ||||
| matplotlib==3.8.0 | ||||
| numpy==1.24.4 | ||||
| matplotlib==3.8.2 | ||||
| multitasking==0.0.11 | ||||
| nodeenv==1.8.0 | ||||
| numpy==1.26.2 | ||||
| packaging==23.2 | ||||
| Pillow==10.2.0 | ||||
| pandas==2.1.4 | ||||
| peewee==3.17.0 | ||||
| pillow==10.2.0 | ||||
| platformdirs==4.1.0 | ||||
| pre-commit==3.6.0 | ||||
| pyparsing==3.1.1 | ||||
| PySocks==1.7.1 | ||||
| python-dateutil==2.8.2 | ||||
| python-dotenv==1.0.0 | ||||
| pytz==2023.3.post1 | ||||
| PyYAML==6.0.1 | ||||
| recurring-ical-events==2.1.1 | ||||
| requests==2.31.0 | ||||
| RPi.GPIO==0.7.1 | ||||
| sgmllib3k==1.0.0 | ||||
| six==1.16.0 | ||||
| soupsieve==2.5 | ||||
| spidev==3.5 | ||||
| todoist-api-python==2.1.3 | ||||
| types-python-dateutil==2.8.19.20240106 | ||||
| typing_extensions==4.8.0 | ||||
| tzdata==2023.4 | ||||
| tzlocal==5.2 | ||||
| urllib3==2.1.0 | ||||
| python-dotenv==1.0.0 | ||||
| setuptools==69.0.2 | ||||
| html2text==2020.1.16 | ||||
| virtualenv==20.25.0 | ||||
| webencodings==0.5.1 | ||||
| x-wr-timezone==0.0.6 | ||||
| xkcd==2.4.2 | ||||
| yfinance==0.2.32 | ||||
| htmlwebshot~=0.1.2 | ||||
| xkcd==2.4.2 | ||||
		Reference in New Issue
	
	Block a user