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. | // For format details, see https://aka.ms/devcontainer.json. | ||||||
| { | { | ||||||
| 	"name": "Inkycal-dev", | 	"name": "Inkycal-dev", | ||||||
| 	"image": "python:3.9-bullseye", |     "build": { | ||||||
|  |             "dockerfile": "Dockerfile", | ||||||
|  |             "target": "development" | ||||||
|  |     }, | ||||||
|  |  | ||||||
|     // This is the settings.json mount |     // This is the settings.json mount | ||||||
| 	"mounts": ["source=/c/temp/settings_test.json,target=/boot/settings.json,type=bind,consistency=cached"], | 	"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. | 	// 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": { | 	"customizations": { | ||||||
| 		"vscode": { | 		"vscode": { | ||||||
| 			"extensions": [ | 			"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 | | | username | Name | Contribution details | | ||||||
| | --- | --- | --- | | | --- | --- | --- | | ||||||
| | **mgfcf** | [Max G.](https://github.com/mgfcf) | for first refactoring of inkycal-software | | | **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... | | | **vitasam** | [vitasam](https://github.com/vitasam)| for help with refactoring, code improvements, modularity and a lot more... | | ||||||
|  |  | ||||||
| ## BETA testers | ## 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 | | | 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 | | | **efredericks** | [Erik Fredericks](https://github.com/efredericks) | for adding Jokes module | | ||||||
| | **worstface** | [worstface](https://github.com/worstface)| for adding Stocks module | | | **worstface** | [worstface](https://github.com/worstface)| for adding Stocks module | | ||||||
|  |   **mrbwburns** | [mrbwburns](https://github.com/mrbwburns) | for adding fullscreen weather module |  | ||||||
|  |  | ||||||
| ## Special help | ## Special help | ||||||
| | username | Name | Contribution details | | | 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 | * Version name with date of publishing | ||||||
| * Sections with either 'added', 'fixed', 'updated' and 'changed' | * 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 | ## [2.0.2] 2022 | ||||||
| ### Added | ### Added | ||||||
| * Added support of 12.48" E-Paper display (all variants) | * 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_stocks | ||||||
| import inkycal.modules.inkycal_webshot | import inkycal.modules.inkycal_webshot | ||||||
| import inkycal.modules.inkycal_xkcd | import inkycal.modules.inkycal_xkcd | ||||||
|  | import inkycal.modules.inkycal_fullweather | ||||||
|  |  | ||||||
| # Main file | # Main file | ||||||
| from inkycal.main import Inkycal | from inkycal.main import Inkycal | ||||||
|   | |||||||
| @@ -1,3 +1,2 @@ | |||||||
| from .functions import * | from .functions import * | ||||||
| from .inkycal_exceptions import * | from .inkycal_exceptions import * | ||||||
| from .openweathermap_wrapper import OpenWeatherMap |  | ||||||
| @@ -3,39 +3,43 @@ Inkycal custom-functions for ease-of-use | |||||||
|  |  | ||||||
| Copyright by aceinnolab | Copyright by aceinnolab | ||||||
| """ | """ | ||||||
|  | import json | ||||||
| import logging | import logging | ||||||
| import os | import os | ||||||
| import time | import time | ||||||
| import traceback | import traceback | ||||||
|  |  | ||||||
|  | import arrow | ||||||
| import PIL | import PIL | ||||||
| import requests | 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 = logging.getLogger(__name__) | ||||||
| logs.setLevel(level=logging.INFO) | logs.setLevel(level=logging.INFO) | ||||||
|  |  | ||||||
| # Get the path to the Inkycal folder | # Get the path to the Inkycal folder | ||||||
| top_level = os.path.dirname( | top_level = os.path.dirname(os.path.abspath(os.path.dirname(__file__))).split("/inkycal")[0] | ||||||
|     os.path.abspath(os.path.dirname(__file__))).split('/inkycal')[0] |  | ||||||
|  |  | ||||||
| # Get path of 'fonts' and 'images' folders within Inkycal folder | # Get path of 'fonts' and 'images' folders within Inkycal folder | ||||||
| fonts_location = top_level + '/fonts/' | fonts_location = os.path.join(top_level, "fonts/") | ||||||
| image_folder = top_level + '/image_folder/' | image_folder = os.path.join(top_level, "image_folder/") | ||||||
|  |  | ||||||
| # Get available fonts within fonts folder | # Get available fonts within fonts folder | ||||||
| fonts = {} | fonts = {} | ||||||
|  |  | ||||||
| for path, dirs, files in os.walk(fonts_location): | for path, dirs, files in os.walk(fonts_location): | ||||||
|     for _ in files: |     for _ in files: | ||||||
|         if _.endswith('.otf'): |         if _.endswith(".otf"): | ||||||
|             name = _.split('.otf')[0] |             name = _.split(".otf")[0] | ||||||
|             fonts[name] = os.path.join(path, _) |             fonts[name] = os.path.join(path, _) | ||||||
|  |  | ||||||
|         if _.endswith('.ttf'): |         if _.endswith(".ttf"): | ||||||
|             name = _.split('.ttf')[0] |             name = _.split(".ttf")[0] | ||||||
|             fonts[name] = os.path.join(path, _) |             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()] | available_fonts = [key for key, values in fonts.items()] | ||||||
|  |  | ||||||
|  |  | ||||||
| @@ -60,14 +64,14 @@ def get_fonts(): | |||||||
|         print(fonts) |         print(fonts) | ||||||
|  |  | ||||||
|  |  | ||||||
| def get_system_tz(): | def get_system_tz() -> str: | ||||||
|     """Gets the system-timezone |     """Gets the system-timezone | ||||||
|  |  | ||||||
|     Gets the timezone set by the system. |     Gets the timezone set by the system. | ||||||
|  |  | ||||||
|     Returns: |     Returns: | ||||||
|       - A timezone if a system timezone was found. |       - 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. |     The extracted timezone can be used to show the local time instead of UTC. e.g. | ||||||
|  |  | ||||||
| @@ -76,11 +80,13 @@ def get_system_tz(): | |||||||
|       >>> print(arrow.now(tz=get_system_tz()) # prints timezone aware time. |       >>> print(arrow.now(tz=get_system_tz()) # prints timezone aware time. | ||||||
|     """ |     """ | ||||||
|     try: |     try: | ||||||
|         local_tz = time.tzname[1] |         local_tz = tzlocal.get_localzone().key | ||||||
|  |         logs.debug(f"Local system timezone is {local_tz}.") | ||||||
|     except: |     except: | ||||||
|         print('System timezone could not be parsed!') |         logs.error("System timezone could not be parsed!") | ||||||
|         print('Please set timezone manually!. Setting timezone to None...') |         logs.error("Please set timezone manually!. Falling back to UTC...") | ||||||
|         local_tz = None |         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 |     return local_tz | ||||||
|  |  | ||||||
|  |  | ||||||
| @@ -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 |       - 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. |         maximum of 90% of the size of the full height of the text-box. | ||||||
|     """ |     """ | ||||||
|     allowed_kwargs = ['alignment', 'autofit', 'colour', 'rotation', |     allowed_kwargs = ["alignment", "autofit", "colour", "rotation", "fill_width", "fill_height"] | ||||||
|                       'fill_width', 'fill_height'] |  | ||||||
|  |  | ||||||
|     # Validate kwargs |     # Validate kwargs | ||||||
|     for key, value in kwargs.items(): |     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') |             print(f'{key} does not exist') | ||||||
|  |  | ||||||
|     # Set kwargs if given, it not, use defaults |     # Set kwargs if given, it not, use defaults | ||||||
|     alignment = kwargs['alignment'] if 'alignment' in kwargs else 'center' |     alignment = kwargs["alignment"] if "alignment" in kwargs else "center" | ||||||
|     autofit = kwargs['autofit'] if 'autofit' in kwargs else False |     autofit = kwargs["autofit"] if "autofit" in kwargs else False | ||||||
|     fill_width = kwargs['fill_width'] if 'fill_width' in kwargs else 1.0 |     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 |     fill_height = kwargs["fill_height"] if "fill_height" in kwargs else 0.8 | ||||||
|     colour = kwargs['colour'] if 'colour' in kwargs else 'black' |     colour = kwargs["colour"] if "colour" in kwargs else "black" | ||||||
|     rotation = kwargs['rotation'] if 'rotation' in kwargs else None |     rotation = kwargs["rotation"] if "rotation" in kwargs else None | ||||||
|  |  | ||||||
|     x, y = xy |     x, y = xy | ||||||
|     box_width, box_height = box_size |     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_bbox_height = font.getbbox("hg") | ||||||
|         text_height = text_bbox_height[3] - text_bbox_height[1] |         text_height = text_bbox_height[3] - text_bbox_height[1] | ||||||
|  |  | ||||||
|         while (text_width < int(box_width * fill_width) and |         while text_width < int(box_width * fill_width) and text_height < int(box_height * fill_height): | ||||||
|                text_height < int(box_height * fill_height)): |  | ||||||
|             size += 1 |             size += 1 | ||||||
|             font = ImageFont.truetype(font.path, size) |             font = ImageFont.truetype(font.path, size) | ||||||
|             text_bbox = font.getbbox(text) |             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 |     # Truncate text if text is too long, so it can fit inside the box | ||||||
|     if (text_width, text_height) > (box_width, box_height): |     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): |         while (text_width, text_height) > (box_width, box_height): | ||||||
|             text = text[0:-1] |             text = text[0:-1] | ||||||
|             text_bbox = font.getbbox(text) |             text_bbox = font.getbbox(text) | ||||||
| @@ -190,9 +194,9 @@ def write(image, xy, box_size, text, font=None, **kwargs): | |||||||
|     # Align text to desired position |     # Align text to desired position | ||||||
|     if alignment == "center" or None: |     if alignment == "center" or None: | ||||||
|         x = int((box_width / 2) - (text_width / 2)) |         x = int((box_width / 2) - (text_width / 2)) | ||||||
|     elif alignment == 'left': |     elif alignment == "left": | ||||||
|         x = 0 |         x = 0 | ||||||
|     elif alignment == 'right': |     elif alignment == "right": | ||||||
|         x = int(box_width - text_width) |         x = int(box_width - text_width) | ||||||
|  |  | ||||||
|     # Draw the text in the text-box |     # 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: |     if text_width < max_width: | ||||||
|         lines.append(text) |         lines.append(text) | ||||||
|     else: |     else: | ||||||
|         words = text.split(' ') |         words = text.split(" ") | ||||||
|         i = 0 |         i = 0 | ||||||
|         while i < len(words): |         while i < len(words): | ||||||
|             line = '' |             line = "" | ||||||
|             while i < len(words) and font.getlength(line + words[i]) <= max_width: |             while i < len(words) and font.getlength(line + words[i]) <= max_width: | ||||||
|                 line = line + words[i] + " " |                 line = line + words[i] + " " | ||||||
|                 i += 1 |                 i += 1 | ||||||
| @@ -266,7 +270,7 @@ def internet_available(): | |||||||
|     """ |     """ | ||||||
|     for attempt in range(3): |     for attempt in range(3): | ||||||
|         try: |         try: | ||||||
|             requests.get('https://google.com', timeout=5) |             requests.get("https://google.com", timeout=5) | ||||||
|             return True |             return True | ||||||
|         except: |         except: | ||||||
|             print(f"Network could not be reached: {traceback.print_exc()}") |             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% |         border by 20% | ||||||
|     """ |     """ | ||||||
|  |  | ||||||
|     colour = 'black' |     colour = "black" | ||||||
|  |  | ||||||
|     # size from function parameter |     # size from function parameter | ||||||
|     width, height = int(size[0] * (1 - shrinkage[0])), int(size[1] * (1 - shrinkage[1])) |     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 | 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 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 = logging.getLogger(__name__) | ||||||
|  | logger.setLevel(level=logging.INFO) | ||||||
|  |  | ||||||
| class WEATHER_OPTIONS(Enum): |  | ||||||
|     CURRENT_WEATHER = "weather" |  | ||||||
|  |  | ||||||
| class FORECAST_INTERVAL(Enum): | def is_timestamp_within_range(timestamp: datetime, start_time: datetime, end_time: datetime) -> bool: | ||||||
|     THREE_HOURS = "3h" |     # Check if the timestamp is within the range | ||||||
|     FIVE_DAYS = "5d" |     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: | 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.api_key = api_key | ||||||
|         self.city_id = city_id |         self.temp_unit = temp_unit | ||||||
|         assert (units  in ["metric", "imperial"] ) |         self.wind_unit = wind_unit | ||||||
|         self.units = units |         self.language = language | ||||||
|         self._api_version = "2.5" |         self._api_version = api_version | ||||||
|         self._base_url = f"https://api.openweathermap.org/data/{self._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: | def main(): | ||||||
|         current_weather_url = f"{self._base_url}/weather?id={self.city_id}&appid={self.api_key}&units={self.units}" |     """Main function, only used for testing purposes""" | ||||||
|         response = requests.get(current_weather_url) |     key = "" | ||||||
|         if not response.ok: |     city = 2643743 | ||||||
|             raise AssertionError(f"Failure getting the current weather: code {response.status_code}. Reason: {response.text}") |     lang = "de" | ||||||
|         data = json.loads(response.text) |     owm = OpenWeatherMap(api_key=key, city_id=city, language=lang, tz="Europe/Berlin") | ||||||
|         return data |  | ||||||
|  |  | ||||||
|     def get_weather_forecast(self) -> dict: |     current_weather = owm.get_current_weather() | ||||||
|         forecast_url = f"{self._base_url}/forecast?id={self.city_id}&appid={self.api_key}&units={self.units}" |     print(current_weather) | ||||||
|         response = requests.get(forecast_url) |     _ = owm.get_weather_forecast() | ||||||
|         if not response.ok: |     print(owm.get_forecast_for_day(days_from_today=2)) | ||||||
|             raise AssertionError(f"Failure getting the current weather: code {response.status_code}. Reason: {response.text}") |  | ||||||
|         data = json.loads(response.text)["list"] |  | ||||||
|         return data |  | ||||||
|  |  | ||||||
|  |  | ||||||
|  | if __name__ == "__main__": | ||||||
|  |     main() | ||||||
|   | |||||||
| @@ -143,11 +143,11 @@ class Inkycal: | |||||||
|  |  | ||||||
|             # If a module was not found, print an error message |             # If a module was not found, print an error message | ||||||
|             except ImportError: |             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 |             # If something unexpected happened, show the error message | ||||||
|             except Exception as e: |             except Exception as e: | ||||||
|                 print(str(e)) |                 logger.exception(f"Exception: {traceback.format_exc()}.") | ||||||
|  |  | ||||||
|         # Path to store images |         # Path to store images | ||||||
|         self.image_folder = image_folder |         self.image_folder = image_folder | ||||||
| @@ -192,8 +192,8 @@ class Inkycal: | |||||||
|         Generated images can be found in the /images folder of Inkycal. |         Generated images can be found in the /images folder of Inkycal. | ||||||
|         """ |         """ | ||||||
|  |  | ||||||
|         print(f'Inkycal version: v{self._release}') |         logger.info(f"Inkycal version: v{self._release}") | ||||||
|         print(f'Selected E-paper display: {self.settings["model"]}') |         logger.info(f'Selected E-paper display: {self.settings["model"]}') | ||||||
|  |  | ||||||
|         # store module numbers in here |         # store module numbers in here | ||||||
|         errors = [] |         errors = [] | ||||||
| @@ -211,15 +211,15 @@ class Inkycal: | |||||||
|                     draw_border_2(im=black, xy=(1, 1), size=(black.width - 2, black.height - 2), radius=5) |                     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") |                 black.save(f"{self.image_folder}module{number}_black.png", "PNG") | ||||||
|                 colour.save(f"{self.image_folder}module{number}_colour.png", "PNG") |                 colour.save(f"{self.image_folder}module{number}_colour.png", "PNG") | ||||||
|                 print('OK!') |                 print("OK!") | ||||||
|             except: |             except Exception: | ||||||
|                 errors.append(number) |                 errors.append(number) | ||||||
|                 self.info += f"module {number}: Error!  " |                 self.info += f"module {number}: Error!  " | ||||||
|                 print('Error!') |                 logger.exception("Error!") | ||||||
|                 print(traceback.format_exc()) |                 logger.exception(f"Exception: {traceback.format_exc()}.") | ||||||
|  |  | ||||||
|         if errors: |         if errors: | ||||||
|             print('Error/s in modules:', *errors) |             logger.error('Error/s in modules:', *errors) | ||||||
|         del errors |         del errors | ||||||
|  |  | ||||||
|         self._assemble() |         self._assemble() | ||||||
| @@ -309,19 +309,18 @@ class Inkycal: | |||||||
|                     black.save(f"{self.image_folder}module{number}_black.png", "PNG") |                     black.save(f"{self.image_folder}module{number}_black.png", "PNG") | ||||||
|                     colour.save(f"{self.image_folder}module{number}_colour.png", "PNG") |                     colour.save(f"{self.image_folder}module{number}_colour.png", "PNG") | ||||||
|                     self.info += f"module {number}: OK  " |                     self.info += f"module {number}: OK  " | ||||||
|                 except: |                 except Exception as e: | ||||||
|                     errors.append(number) |                     errors.append(number) | ||||||
|                     print('error!') |                     self.info += f"module {number}: Error!  " | ||||||
|                     print(traceback.format_exc()) |                     logger.exception("Error!") | ||||||
|                     self.info += f"module {number}: error!  " |                     logger.exception(f"Exception: {traceback.format_exc()}.") | ||||||
|                     logger.exception(f'Exception in module {number}') |  | ||||||
|  |  | ||||||
|             if errors: |             if errors: | ||||||
|                 print('error/s in modules:', *errors) |                 logger.error("Error/s in modules:", *errors) | ||||||
|                 counter = 0 |                 counter = 0 | ||||||
|             else: |             else: | ||||||
|                 counter += 1 |                 counter += 1 | ||||||
|                 print('successful') |                 logger.info("successful") | ||||||
|             del errors |             del errors | ||||||
|  |  | ||||||
|             # Assemble image from each module - add info section if specified |             # 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_textfile_to_display import TextToDisplay | ||||||
| from .inkycal_webshot import Webshot | from .inkycal_webshot import Webshot | ||||||
| from .inkycal_xkcd import Xkcd | from .inkycal_xkcd import Xkcd | ||||||
|  | from .inkycal_fullweather import Fullweather | ||||||
| from .inkycal_tindie import Tindie | from .inkycal_tindie import Tindie | ||||||
|   | |||||||
| @@ -7,9 +7,10 @@ Copyright by aceinnolab | |||||||
| """ | """ | ||||||
| import logging | import logging | ||||||
| import os | import os | ||||||
|  | from typing import Literal | ||||||
|  |  | ||||||
| import PIL |  | ||||||
| import numpy | import numpy | ||||||
|  | import PIL | ||||||
| import requests | import requests | ||||||
| from PIL import Image | from PIL import Image | ||||||
|  |  | ||||||
| @@ -17,8 +18,7 @@ logger = logging.getLogger(__name__) | |||||||
|  |  | ||||||
|  |  | ||||||
| class Inkyimage: | 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): |     def __init__(self, image=None): | ||||||
|         """Initialize InkyImage module""" |         """Initialize InkyImage module""" | ||||||
| @@ -27,7 +27,7 @@ class Inkyimage: | |||||||
|         self.image = image |         self.image = image | ||||||
|  |  | ||||||
|         # give an OK message |         # 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. |         """loads an image from a URL or filepath. | ||||||
| @@ -45,54 +45,54 @@ class Inkyimage: | |||||||
|         """ |         """ | ||||||
|         # Try to open the image if it exists and is an image file |         # Try to open the image if it exists and is an image file | ||||||
|         try: |         try: | ||||||
|             if path.startswith('http'): |             if path.startswith("http"): | ||||||
|                 logger.info('loading image from URL') |                 logger.info("loading image from URL") | ||||||
|                 image = Image.open(requests.get(path, stream=True).raw) |                 image = Image.open(requests.get(path, stream=True).raw) | ||||||
|             else: |             else: | ||||||
|                 logger.info('loading image from local path') |                 logger.info("loading image from local path") | ||||||
|                 image = Image.open(path) |                 image = Image.open(path) | ||||||
|         except FileNotFoundError: |         except FileNotFoundError: | ||||||
|             logger.error('No image file found', exc_info=True) |             logger.error("No image file found", exc_info=True) | ||||||
|             raise Exception('Your file could not be found. Please check the filepath') |             raise Exception(f"Your file could not be found. Please check the filepath: {path}") | ||||||
|  |  | ||||||
|         except OSError: |         except OSError: | ||||||
|             logger.error('Invalid Image file provided', exc_info=True) |             logger.error("Invalid Image file provided", exc_info=True) | ||||||
|             raise Exception('Please check if the path points to an image file.') |             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 |         self.image = image | ||||||
|         logger.info('loaded Image') |         logger.info("loaded Image") | ||||||
|  |  | ||||||
|     def clear(self): |     def clear(self): | ||||||
|         """Removes currently saved image if present.""" |         """Removes currently saved image if present.""" | ||||||
|         if self.image: |         if self.image: | ||||||
|             self.image = None |             self.image = None | ||||||
|             logger.info('cleared previous image') |             logger.info("cleared previous image") | ||||||
|  |  | ||||||
|     def _preview(self): |     def _preview(self): | ||||||
|         """Preview the image on gpicview (only works on Rapsbian with Desktop)""" |         """Preview the image on gpicview (only works on Rapsbian with Desktop)""" | ||||||
|         if self._image_loaded(): |         if self._image_loaded(): | ||||||
|             path = '/home/pi/Desktop/' |             path = "/home/pi/Desktop/" | ||||||
|             self.image.save(path + 'temp.png') |             self.image.save(path + "temp.png") | ||||||
|             os.system("gpicview " + path + 'temp.png') |             os.system("gpicview " + path + "temp.png") | ||||||
|             os.system('rm ' + path + 'temp.png') |             os.system("rm " + path + "temp.png") | ||||||
|  |  | ||||||
|     @staticmethod |     @staticmethod | ||||||
|     def preview(image): |     def preview(image): | ||||||
|         """Previews an image on gpicview (only works on Rapsbian with Desktop).""" |         """Previews an image on gpicview (only works on Rapsbian with Desktop).""" | ||||||
|         path = '~/temp' |         path = "~/temp" | ||||||
|         image.save(path + '/temp.png') |         image.save(path + "/temp.png") | ||||||
|         os.system("gpicview " + path + '/temp.png') |         os.system("gpicview " + path + "/temp.png") | ||||||
|         os.system('rm ' + path + '/temp.png') |         os.system("rm " + path + "/temp.png") | ||||||
|  |  | ||||||
|     def _image_loaded(self): |     def _image_loaded(self): | ||||||
|         """returns True if image was loaded""" |         """returns True if image was loaded""" | ||||||
|         if self.image: |         if self.image: | ||||||
|             return True |             return True | ||||||
|         else: |         else: | ||||||
|             logger.error('image not loaded') |             logger.error("image not loaded") | ||||||
|             return False |             return False | ||||||
|  |  | ||||||
|     def flip(self, angle): |     def flip(self, angle): | ||||||
| @@ -105,12 +105,12 @@ class Inkyimage: | |||||||
|  |  | ||||||
|             image = self.image |             image = self.image | ||||||
|             if not angle % 90 == 0: |             if not angle % 90 == 0: | ||||||
|                 logger.error('Angle must be a multiple of 90') |                 logger.error("Angle must be a multiple of 90") | ||||||
|                 return |                 return | ||||||
|  |  | ||||||
|             image = image.rotate(angle, expand=True) |             image = image.rotate(angle, expand=True) | ||||||
|             self.image = image |             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: |     def autoflip(self, layout: str) -> None: | ||||||
|         """flips the image automatically to the given layout. |         """flips the image automatically to the given layout. | ||||||
| @@ -129,17 +129,17 @@ class Inkyimage: | |||||||
|         if self._image_loaded(): |         if self._image_loaded(): | ||||||
|  |  | ||||||
|             image = self.image |             image = self.image | ||||||
|             if layout == 'horizontal': |             if layout == "horizontal": | ||||||
|                 if image.height > image.width: |                 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) |                     image = image.rotate(90, expand=True) | ||||||
|  |  | ||||||
|             elif layout == 'vertical': |             elif layout == "vertical": | ||||||
|                 if image.width > image.height: |                 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) |                     image = image.rotate(90, expand=True) | ||||||
|             else: |             else: | ||||||
|                 logger.error('layout not supported') |                 logger.error("layout not supported") | ||||||
|                 return |                 return | ||||||
|             self.image = image |             self.image = image | ||||||
|  |  | ||||||
| @@ -153,26 +153,26 @@ class Inkyimage: | |||||||
|             image = self.image |             image = self.image | ||||||
|  |  | ||||||
|             if len(image.getbands()) == 4: |             if len(image.getbands()) == 4: | ||||||
|                 logger.info('removing alpha channel') |                 logger.info("removing alpha channel") | ||||||
|                 bg = Image.new('RGBA', (image.width, image.height), 'white') |                 bg = Image.new("RGBA", (image.width, image.height), "white") | ||||||
|                 im = Image.alpha_composite(bg, image) |                 im = Image.alpha_composite(bg, image) | ||||||
|  |  | ||||||
|                 self.image.paste(im, (0, 0)) |                 self.image.paste(im, (0, 0)) | ||||||
|                 logger.info('removed transparency') |                 logger.info("removed transparency") | ||||||
|  |  | ||||||
|     def resize(self, width=None, height=None): |     def resize(self, width=None, height=None): | ||||||
|         """Resize an image to desired width or height""" |         """Resize an image to desired width or height""" | ||||||
|         if self._image_loaded(): |         if self._image_loaded(): | ||||||
|  |  | ||||||
|             if not width and not height: |             if not width and not height: | ||||||
|                 logger.error('no height of width specified') |                 logger.error("no height of width specified") | ||||||
|                 return |                 return | ||||||
|  |  | ||||||
|             image = self.image |             image = self.image | ||||||
|  |  | ||||||
|             if width: |             if width: | ||||||
|                 initial_width = image.width |                 initial_width = image.width | ||||||
|                 wpercent = (width / float(image.width)) |                 wpercent = width / float(image.width) | ||||||
|                 hsize = int((float(image.height) * float(wpercent))) |                 hsize = int((float(image.height) * float(wpercent))) | ||||||
|                 image = image.resize((width, hsize), Image.LANCZOS) |                 image = image.resize((width, hsize), Image.LANCZOS) | ||||||
|                 logger.info(f"resized image from {initial_width} to {image.width}") |                 logger.info(f"resized image from {initial_width} to {image.width}") | ||||||
| @@ -180,7 +180,7 @@ class Inkyimage: | |||||||
|  |  | ||||||
|             if height: |             if height: | ||||||
|                 initial_height = image.height |                 initial_height = image.height | ||||||
|                 hpercent = (height / float(image.height)) |                 hpercent = height / float(image.height) | ||||||
|                 wsize = int(float(image.width) * float(hpercent)) |                 wsize = int(float(image.width) * float(hpercent)) | ||||||
|                 image = image.resize((wsize, height), Image.LANCZOS) |                 image = image.resize((wsize, height), Image.LANCZOS) | ||||||
|                 logger.info(f"resized image from {initial_height} to {image.height}") |                 logger.info(f"resized image from {initial_height} to {image.height}") | ||||||
| @@ -203,17 +203,20 @@ class Inkyimage: | |||||||
|  |  | ||||||
|         def clear_white(img): |         def clear_white(img): | ||||||
|             """Replace all white pixels from image with transparent pixels""" |             """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) |             x[:, :, 3] = (255 * (x[:, :, :3] != 255).any(axis=2)).astype(numpy.uint8) | ||||||
|             return Image.fromarray(x) |             return Image.fromarray(x) | ||||||
|  |  | ||||||
|         image2 = clear_white(image2) |         image2 = clear_white(image2) | ||||||
|         image1.paste(image2, (0, 0), image2) |         image1.paste(image2, (0, 0), image2) | ||||||
|         logger.info('merged given images into one') |         logger.info("merged given images into one") | ||||||
|  |  | ||||||
|         return image1 |         return image1 | ||||||
|  |  | ||||||
|     def to_palette(self, palette, dither=True) -> (PIL.Image, PIL.Image): |  | ||||||
|  | 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. |     """Maps an image to a given colour palette. | ||||||
|  |  | ||||||
|     Maps each pixel from the image to a colour from the palette. |     Maps each pixel from the image to a colour from the palette. | ||||||
| @@ -235,29 +238,24 @@ class Inkyimage: | |||||||
|     >>> 'bw'  # black-white |     >>> 'bw'  # black-white | ||||||
|     >>> '16gray' # 16 shades of gray |     >>> '16gray' # 16 shades of gray | ||||||
|     """ |     """ | ||||||
|         # Check if an image is loaded |  | ||||||
|         if self._image_loaded(): |  | ||||||
|             image = self.image.convert('RGB') |  | ||||||
|         else: |  | ||||||
|             raise FileNotFoundError |  | ||||||
|  |  | ||||||
|         if palette == 'bwr': |     if palette == "bwr": | ||||||
|         # black-white-red palette |         # black-white-red palette | ||||||
|         pal = [255, 255, 255, 0, 0, 0, 255, 0, 0] |         pal = [255, 255, 255, 0, 0, 0, 255, 0, 0] | ||||||
|  |  | ||||||
|         elif palette == 'bwy': |     elif palette == "bwy": | ||||||
|         # black-white-yellow palette |         # black-white-yellow palette | ||||||
|         pal = [255, 255, 255, 0, 0, 0, 255, 255, 0] |         pal = [255, 255, 255, 0, 0, 0, 255, 255, 0] | ||||||
|  |  | ||||||
|         elif palette == 'bw': |     elif palette == "bw": | ||||||
|         pal = None |         pal = None | ||||||
|         elif palette == '16gray': |     elif palette == "16gray": | ||||||
|         pal = [x for x in range(0, 256, 16)] * 3 |         pal = [x for x in range(0, 256, 16)] * 3 | ||||||
|         pal.sort() |         pal.sort() | ||||||
|  |  | ||||||
|     else: |     else: | ||||||
|             logger.error('The given palette is unsupported.') |         logger.error("The given palette is unsupported.") | ||||||
|             raise ValueError('The given palette is not supported.') |         raise ValueError("The given palette is not supported.") | ||||||
|  |  | ||||||
|     if pal: |     if pal: | ||||||
|         # The palette needs to have 256 colors, for this, the black-colour |         # The palette needs to have 256 colors, for this, the black-colour | ||||||
| @@ -274,7 +272,7 @@ class Inkyimage: | |||||||
|         # print(f'The palette now has {colours} colours') |         # print(f'The palette now has {colours} colours') | ||||||
|  |  | ||||||
|         # Create a dummy image to be used as a palette |         # Create a dummy image to be used as a palette | ||||||
|             palette_im = Image.new('P', (1, 1)) |         palette_im = Image.new("P", (1, 1)) | ||||||
|  |  | ||||||
|         # Attach the created palette. The palette should have 256 colours |         # Attach the created palette. The palette should have 256 colours | ||||||
|         # equivalent to 768 integers |         # equivalent to 768 integers | ||||||
| @@ -282,7 +280,7 @@ class Inkyimage: | |||||||
|  |  | ||||||
|         # Quantize the image to given palette |         # Quantize the image to given palette | ||||||
|         quantized_im = image.quantize(palette=palette_im, dither=dither) |         quantized_im = image.quantize(palette=palette_im, dither=dither) | ||||||
|             quantized_im = quantized_im.convert('RGB') |         quantized_im = quantized_im.convert("RGB") | ||||||
|  |  | ||||||
|         # get rgb of the non-black-white colour from the palette |         # 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 = [pal[x : x + 3] for x in range(0, len(pal), 3)] | ||||||
| @@ -321,13 +319,13 @@ class Inkyimage: | |||||||
|         # self.preview(im_colour) |         # self.preview(im_colour) | ||||||
|  |  | ||||||
|     else: |     else: | ||||||
|             im_black = image.convert('1', dither=dither) |         im_black = image.convert("1", dither=dither) | ||||||
|             im_colour = Image.new(mode='1', size=im_black.size, color='white') |         im_colour = Image.new(mode="1", size=im_black.size, color="white") | ||||||
|  |  | ||||||
|         logger.info('mapped image to specified palette') |     logger.info("mapped image to specified palette") | ||||||
|  |  | ||||||
|     return im_black, im_colour |     return im_black, im_colour | ||||||
|  |  | ||||||
|  |  | ||||||
| if __name__ == '__main__': | if __name__ == "__main__": | ||||||
|     print(f'running {__name__} in standalone/debug mode') |     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 | Inkycal Image Module | ||||||
| Copyright by aceinnolab | Copyright by aceinnolab | ||||||
| """ | """ | ||||||
|  |  | ||||||
| from inkycal.custom import * | 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.inky_image import Inkyimage as Images | ||||||
| from inkycal.modules.template import inkycal_module | from inkycal.modules.template import inkycal_module | ||||||
|  |  | ||||||
| @@ -11,36 +11,21 @@ logger = logging.getLogger(__name__) | |||||||
|  |  | ||||||
|  |  | ||||||
| class Inkyimage(inkycal_module): | 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" |     name = "Inkycal Image - show an image from a URL or local path" | ||||||
|  |  | ||||||
|     requires = { |     requires = { | ||||||
|  |  | ||||||
|         "path": { |         "path": { | ||||||
|             "label": "Path to a local folder, e.g. /home/pi/Desktop/images. " |             "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 = { |     optional = { | ||||||
|  |         "autoflip": {"label": "Should the image be flipped automatically?", "options": [True, False]}, | ||||||
|         "autoflip": { |         "orientation": {"label": "Please select the desired orientation", "options": ["vertical", "horizontal"]}, | ||||||
|             "label": "Should the image be flipped automatically?", |  | ||||||
|             "options": [True, False] |  | ||||||
|         }, |  | ||||||
|  |  | ||||||
|         "orientation": { |  | ||||||
|             "label": "Please select the desired orientation", |  | ||||||
|             "options": ["vertical", "horizontal"] |  | ||||||
|         } |  | ||||||
|     } |     } | ||||||
|  |  | ||||||
|     def __init__(self, config): |     def __init__(self, config): | ||||||
| @@ -48,24 +33,24 @@ class Inkyimage(inkycal_module): | |||||||
|  |  | ||||||
|         super().__init__(config) |         super().__init__(config) | ||||||
|  |  | ||||||
|         config = config['config'] |         config = config["config"] | ||||||
|  |  | ||||||
|         # required parameters |         # required parameters | ||||||
|         for param in self.requires: |         for param in self.requires: | ||||||
|             if not param in config: |             if not param in config: | ||||||
|                 raise Exception(f'config is missing {param}') |                 raise Exception(f"config is missing {param}") | ||||||
|  |  | ||||||
|         # optional parameters |         # optional parameters | ||||||
|         self.path = config['path'] |         self.path = config["path"] | ||||||
|         self.palette = config['palette'] |         self.palette = config["palette"] | ||||||
|         self.autoflip = config['autoflip'] |         self.autoflip = config["autoflip"] | ||||||
|         self.orientation = config['orientation'] |         self.orientation = config["orientation"] | ||||||
|         self.dither = True |         self.dither = True | ||||||
|         if 'dither' in config and config["dither"] == False: |         if "dither" in config and config["dither"] == False: | ||||||
|             self.dither = False |             self.dither = False | ||||||
|  |  | ||||||
|         # give an OK message |         # give an OK message | ||||||
|         print(f'{__name__} loaded') |         print(f"{__name__} loaded") | ||||||
|  |  | ||||||
|     def generate_image(self): |     def generate_image(self): | ||||||
|         """Generate image for this module""" |         """Generate image for this module""" | ||||||
| @@ -75,7 +60,7 @@ class Inkyimage(inkycal_module): | |||||||
|         im_height = int(self.height - (2 * self.padding_top)) |         im_height = int(self.height - (2 * self.padding_top)) | ||||||
|         im_size = im_width, im_height |         im_size = im_width, im_height | ||||||
|  |  | ||||||
|         logger.info(f'Image size: {im_size}') |         logger.info(f"Image size: {im_size}") | ||||||
|  |  | ||||||
|         # initialize custom image class |         # initialize custom image class | ||||||
|         im = Images() |         im = Images() | ||||||
| @@ -94,7 +79,7 @@ class Inkyimage(inkycal_module): | |||||||
|         im.resize(width=im_width, height=im_height) |         im.resize(width=im_width, height=im_height) | ||||||
|  |  | ||||||
|         # convert images according to specified palette |         # 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 |         # with the images now send, clear the current image | ||||||
|         im.clear() |         im.clear() | ||||||
| @@ -103,5 +88,5 @@ class Inkyimage(inkycal_module): | |||||||
|         return im_black, im_colour |         return im_black, im_colour | ||||||
|  |  | ||||||
|  |  | ||||||
| if __name__ == '__main__': | if __name__ == "__main__": | ||||||
|     print(f'running {__name__} in standalone/debug mode') |     print(f"running {__name__} in standalone/debug mode") | ||||||
|   | |||||||
| @@ -3,16 +3,26 @@ Inkycal weather module | |||||||
| Copyright by aceinnolab | Copyright by aceinnolab | ||||||
| """ | """ | ||||||
|  |  | ||||||
|  | import arrow | ||||||
| import decimal | import decimal | ||||||
|  | import logging | ||||||
| import math | import math | ||||||
|  |  | ||||||
| import arrow | from PIL import Image | ||||||
|  | from PIL import ImageDraw | ||||||
|  | from PIL import ImageFont | ||||||
|  |  | ||||||
| from inkycal.custom import * | from inkycal.custom.functions import draw_border | ||||||
| from inkycal.custom import OpenWeatherMap | 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 | from inkycal.modules.template import inkycal_module | ||||||
|  |  | ||||||
| logger = logging.getLogger(__name__) | logger = logging.getLogger(__name__) | ||||||
|  | logger.setLevel(level=logging.INFO) | ||||||
|  |  | ||||||
|  |  | ||||||
| class Weather(inkycal_module): | class Weather(inkycal_module): | ||||||
| @@ -75,6 +85,8 @@ class Weather(inkycal_module): | |||||||
|  |  | ||||||
|         config = config['config'] |         config = config['config'] | ||||||
|  |  | ||||||
|  |         self.timezone = get_system_tz() | ||||||
|  |  | ||||||
|         # Check if all required parameters are present |         # Check if all required parameters are present | ||||||
|         for param in self.requires: |         for param in self.requires: | ||||||
|             if not param in config: |             if not param in config: | ||||||
| @@ -88,54 +100,52 @@ class Weather(inkycal_module): | |||||||
|         self.round_temperature = config['round_temperature'] |         self.round_temperature = config['round_temperature'] | ||||||
|         self.round_windspeed = config['round_windspeed'] |         self.round_windspeed = config['round_windspeed'] | ||||||
|         self.forecast_interval = config['forecast_interval'] |         self.forecast_interval = config['forecast_interval'] | ||||||
|         self.units = config['units'] |  | ||||||
|         self.hour_format = int(config['hour_format']) |         self.hour_format = int(config['hour_format']) | ||||||
|         self.use_beaufort = config['use_beaufort'] |         if config['units'] == "imperial": | ||||||
|  |             self.temp_unit = "fahrenheit" | ||||||
|  |         else: | ||||||
|  |             self.temp_unit = "celsius" | ||||||
|          |          | ||||||
|         # additional configuration |         if config['use_beaufort'] == True: | ||||||
|         self.owm = OpenWeatherMap(api_key=self.api_key, city_id=self.location, units=config['units']) |             self.wind_unit = "beaufort" | ||||||
|         self.timezone = get_system_tz() |         elif config['units'] == "imperial": | ||||||
|  |             self.wind_unit = "miles_hour" | ||||||
|  |         else: | ||||||
|  |             self.wind_unit = "meters_sec" | ||||||
|         self.locale = config['language'] |         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( |         self.weatherfont = ImageFont.truetype( | ||||||
|             fonts['weathericons-regular-webfont'], size=self.fontsize) |             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 |         # give an OK message | ||||||
|         print(f"{__name__} loaded") |         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): |     def generate_image(self): | ||||||
|         """Generate image for this module""" |         """Generate image for this module""" | ||||||
| @@ -180,14 +190,14 @@ class Weather(inkycal_module): | |||||||
|                 7: '\uf0ae' |                 7: '\uf0ae' | ||||||
|             }[int(index) & 7] |             }[int(index) & 7] | ||||||
|  |  | ||||||
|         def is_negative(temp): |         def is_negative(temp:str): | ||||||
|             """Check if temp is below freezing point of water (0°C/30°F) |             """Check if temp is below freezing point of water (0°C/32°F) | ||||||
|             returns True if temp below freezing point, else False""" |             returns True if temp below freezing point, else False""" | ||||||
|             answer = 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 |                 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 |                 answer = True | ||||||
|             return answer |             return answer | ||||||
|  |  | ||||||
| @@ -389,24 +399,18 @@ class Weather(inkycal_module): | |||||||
|  |  | ||||||
|         # Create current-weather and weather-forecast objects |         # Create current-weather and weather-forecast objects | ||||||
|         logging.debug('looking up location by ID') |         logging.debug('looking up location by ID') | ||||||
|         weather = self.owm.get_current_weather() |         current_weather = self.owm.get_current_weather() | ||||||
|         forecast = self.owm.get_weather_forecast() |         weather_forecasts = self.owm.get_weather_forecast() | ||||||
|  |  | ||||||
|         # Set decimals |         # Set decimals | ||||||
|         dec_temp = None if self.round_temperature == True else 1 |         dec_temp = 0 if self.round_temperature == True else 1 | ||||||
|         dec_wind = None if self.round_windspeed == True else 1 |         dec_wind = 0 if self.round_windspeed == True else 1 | ||||||
|  |  | ||||||
|         # Set correct temperature units |         logging.debug(f'temperature unit: {self.temp_unit}') | ||||||
|         if self.units == 'metric': |  | ||||||
|             temp_unit = 'celsius' |  | ||||||
|         elif self.units == 'imperial': |  | ||||||
|             temp_unit = 'fahrenheit' |  | ||||||
|  |  | ||||||
|         logging.debug(f'temperature unit: {self.units}') |  | ||||||
|         logging.debug(f'decimals temperature: {dec_temp} | decimals wind: {dec_wind}') |         logging.debug(f'decimals temperature: {dec_temp} | decimals wind: {dec_wind}') | ||||||
|  |  | ||||||
|         # Get current time |         # Get current time | ||||||
|         now = arrow.utcnow() |         now = arrow.utcnow().to(self.timezone) | ||||||
|  |  | ||||||
|         fc_data = {} |         fc_data = {} | ||||||
|  |  | ||||||
| @@ -414,90 +418,41 @@ class Weather(inkycal_module): | |||||||
|  |  | ||||||
|             logger.debug("getting hourly forecasts") |             logger.debug("getting hourly forecasts") | ||||||
|  |  | ||||||
|             # Forecasts are provided for every 3rd full hour |             # Add next 4 forecasts to fc_data dictionary, since we only have | ||||||
|             # 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 |  | ||||||
|             fc_data = {} |             fc_data = {} | ||||||
|             for forecast in forecasts: |             for index, forecast in enumerate(weather_forecasts[0:4]): | ||||||
|                 if self.units == "metric": |                 fc_data['fc' + str(index + 1)] = { | ||||||
|                     temp = f"{round(weather['main']['temp'], ndigits=dec_temp)}°C" |                     'temp': f"{forecast['temp']:.{dec_temp}f}{self.tempDispUnit}", | ||||||
|                 else: |                     'icon': forecast["icon"], | ||||||
|                     temp = f"{round(self.celsius_to_fahrenheit(weather['main']['temp']), ndigits=dec_temp)}°F" |                     'stamp': forecast["datetime"].strftime("%I %p" if self.hour_format == 12 else "%H:%M")} | ||||||
|  |  | ||||||
|                 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') |  | ||||||
|                 } |  | ||||||
|  |  | ||||||
|         elif self.forecast_interval == 'daily': |         elif self.forecast_interval == 'daily': | ||||||
|  |  | ||||||
|             logger.debug("getting daily forecasts") |             logger.debug("getting daily forecasts") | ||||||
|  |  | ||||||
|             def calculate_forecast(days_from_today) -> dict: |             daily_forecasts = [self.owm.get_forecast_for_day(days) for days in range(1, 5)] | ||||||
|                 """Get temperature range and most frequent icon code for forecast |  | ||||||
|                 days_from_today should be int from 1-4: e.g. 2 -> 2 days from today |  | ||||||
|                 """ |  | ||||||
|  |  | ||||||
|                 # Create a list containing time-objects for every 3rd hour of the day |             for index, forecast in enumerate(daily_forecasts): | ||||||
|                 time_range = list( |                 fc_data['fc' + str(index +1)] = { | ||||||
|                     arrow.Arrow.range('hour', |                     'temp': f'{forecast["temp_min"]:.{dec_temp}f}{self.tempDispUnit}/{forecast["temp_max"]:.{dec_temp}f}{self.tempDispUnit}', | ||||||
|                                       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'], |  | ||||||
|                     'icon': forecast['icon'], |                     '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(): |         for key, val in fc_data.items(): | ||||||
|             logger.debug((key, val)) |             logger.debug((key, val)) | ||||||
|  |  | ||||||
|         # Get some current weather details |         # 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"] |         temperature = f"{current_weather['temp']:.{dec_temp}f}{self.tempDispUnit}" | ||||||
|         humidity = str(weather["main"]["humidity"]) |  | ||||||
|         sunrise_raw = arrow.get(weather["sys"]["sunrise"]).to(self.timezone) |         weather_icon = current_weather["weather_icon_name"] | ||||||
|         sunset_raw = arrow.get(weather["sys"]["sunset"]).to(self.timezone) |         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}') |         logger.debug(f'weather_icon: {weather_icon}') | ||||||
|  |  | ||||||
| @@ -512,16 +467,8 @@ class Weather(inkycal_module): | |||||||
|             sunset = sunset_raw.format('H:mm') |             sunset = sunset_raw.format('H:mm') | ||||||
|  |  | ||||||
|         # Format the wind-speed to user preference |         # Format the wind-speed to user preference | ||||||
|         if self.use_beaufort: |         logging.debug(f'getting wind speed in {self.windDispUnit}') | ||||||
|             logger.debug("using beaufort for wind") |         wind = f"{current_weather['wind']:.{dec_wind}f} {self.windDispUnit}" | ||||||
|             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" |  | ||||||
|  |  | ||||||
|         moon_phase = get_moon_phase() |         moon_phase = get_moon_phase() | ||||||
|  |  | ||||||
|   | |||||||
| @@ -1,29 +1,58 @@ | |||||||
|  | appdirs==1.4.4 | ||||||
| arrow==1.3.0 | arrow==1.3.0 | ||||||
|  | asyncio==3.4.3 | ||||||
|  | beautifulsoup4==4.12.2 | ||||||
| certifi==2023.7.22 | certifi==2023.7.22 | ||||||
|  | cfgv==3.4.0 | ||||||
|  | charset-normalizer==3.3.2 | ||||||
|  | colorzero==2.0 | ||||||
|  | contourpy==1.2.0 | ||||||
| cycler==0.12.1 | cycler==0.12.1 | ||||||
|  | distlib==0.3.8 | ||||||
| feedparser==6.0.10 | feedparser==6.0.10 | ||||||
|  | filelock==3.13.1 | ||||||
| fonttools==4.45.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 | icalendar==5.0.11 | ||||||
|  | identify==2.5.33 | ||||||
|  | idna==3.6 | ||||||
| kiwisolver==1.4.5 | kiwisolver==1.4.5 | ||||||
|  | lgpio==0.0.0.2 | ||||||
| lxml==4.9.3 | lxml==4.9.3 | ||||||
| matplotlib==3.8.0 | matplotlib==3.8.2 | ||||||
| numpy==1.24.4 | multitasking==0.0.11 | ||||||
|  | nodeenv==1.8.0 | ||||||
|  | numpy==1.26.2 | ||||||
| packaging==23.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 | pyparsing==3.1.1 | ||||||
| PySocks==1.7.1 |  | ||||||
| python-dateutil==2.8.2 | python-dateutil==2.8.2 | ||||||
|  | python-dotenv==1.0.0 | ||||||
| pytz==2023.3.post1 | pytz==2023.3.post1 | ||||||
|  | PyYAML==6.0.1 | ||||||
| recurring-ical-events==2.1.1 | recurring-ical-events==2.1.1 | ||||||
| requests==2.31.0 | requests==2.31.0 | ||||||
|  | RPi.GPIO==0.7.1 | ||||||
| sgmllib3k==1.0.0 | sgmllib3k==1.0.0 | ||||||
| six==1.16.0 | six==1.16.0 | ||||||
|  | soupsieve==2.5 | ||||||
|  | spidev==3.5 | ||||||
| todoist-api-python==2.1.3 | todoist-api-python==2.1.3 | ||||||
|  | types-python-dateutil==2.8.19.20240106 | ||||||
| typing_extensions==4.8.0 | typing_extensions==4.8.0 | ||||||
|  | tzdata==2023.4 | ||||||
|  | tzlocal==5.2 | ||||||
| urllib3==2.1.0 | urllib3==2.1.0 | ||||||
| python-dotenv==1.0.0 | virtualenv==20.25.0 | ||||||
| setuptools==69.0.2 | webencodings==0.5.1 | ||||||
| html2text==2020.1.16 | x-wr-timezone==0.0.6 | ||||||
| yfinance==0.2.32 |  | ||||||
| htmlwebshot~=0.1.2 |  | ||||||
| xkcd==2.4.2 | xkcd==2.4.2 | ||||||
|  | yfinance==0.2.32 | ||||||
|   | |||||||
		Reference in New Issue
	
	Block a user