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