Merge pull request #315 from mrbwburns/fullscreen_weather_module

Fullscreen weather module
This commit is contained in:
Ace 2024-02-09 15:53:33 +03:00 committed by GitHub
commit 34d479c853
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
27 changed files with 1409 additions and 398 deletions

16
.devcontainer/Dockerfile Normal file
View 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

View File

@ -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"
]
}
}

View 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
View File

@ -0,0 +1,3 @@
* text=auto eol=lf
*.{cmd,[cC][mM][dD]} text eol=crlf
*.{bat,[bB][aA][tT]} text eol=crlf

View File

@ -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
View 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

View File

@ -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)

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.5 KiB

BIN
icons/ui-icons/humidity.bmp Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

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

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 KiB

View File

View File

@ -0,0 +1 @@
*.png

View 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

View File

@ -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

View File

@ -1,3 +1,2 @@
from .functions import *
from .inkycal_exceptions import *
from .openweathermap_wrapper import OpenWeatherMap
from .inkycal_exceptions import *

View File

@ -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]))

View File

@ -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()

View File

@ -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

View File

@ -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

View File

@ -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")

View 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")

View File

@ -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")

View File

@ -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()

View File

@ -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