step 1: actual conditions working

This commit is contained in:
mrbwburns 2024-01-15 19:08:46 +01:00
parent 2abc15652c
commit d2d7c91bb5
15 changed files with 664 additions and 6 deletions

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

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,28 @@
import os
import urllib
from PIL import Image
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
"""
weatherdir = os.path.dirname(os.path.abspath(__file__))
iconpath = os.path.join(weatherdir, "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

@ -35,7 +35,7 @@ for path, dirs, files in os.walk(fonts_location):
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()]

View File

@ -0,0 +1,141 @@
import logging
from datetime import datetime
from datetime import timedelta
import arrow
from dateutil import tz
from pyowm import OWM
from pyowm.utils.config import get_default_config
from inkycal.custom.functions import get_system_tz
## Configure logger instance for local logging
logger = logging.getLogger(__name__)
logger.setLevel(logging.DEBUG)
def is_timestamp_within_range(timestamp, start_time, end_time):
# Check if the timestamp is within the range
return start_time <= timestamp <= end_time
def get_owm_data(city_id: int, token: str, temp_units: str, wind_units: str, language: str):
config_dict = get_default_config()
config_dict["language"] = language
tz_zone = tz.gettz(get_system_tz())
owm = OWM(token, config_dict)
mgr = owm.weather_manager()
current_observation = mgr.weather_at_id(id=city_id)
current_weather = current_observation.weather
hourly_forecasts = mgr.forecast_at_id(id=city_id, interval="3h")
# Forecasts are provided for every 3rd full hour
# - find out how many hours there are until the next 3rd full hour
now = arrow.utcnow()
if (now.hour % 3) != 0:
hour_gap = 3 - (now.hour % 3)
else:
hour_gap = 3
# Create timings for hourly forcasts
steps = [i * 3 for i in range(40)]
forecast_timings = [now.shift(hours=+hour_gap + step).floor("hour") for step in steps]
# Create forecast objects for given timings
forecasts = [hourly_forecasts.get_weather_at(forecast_time.datetime) for forecast_time in forecast_timings]
# Add forecast-data to fc_data list of dictionaries
hourly_data_dict = []
for forecast in forecasts:
temp = forecast.temperature(unit=temp_units)["temp"]
min_temp = forecast.temperature(unit=temp_units)["temp_min"]
max_temp = forecast.temperature(unit=temp_units)["temp_max"]
wind = forecast.wind(unit=wind_units)["speed"]
wind_gust = forecast.wind(unit=wind_units)["gust"]
# combined precipitation (snow + rain)
precip_mm = 0.0
if "3h" in forecast.rain.keys():
precip_mm = +forecast.rain["3h"]
if "3h" in forecast.snow.keys():
precip_mm = +forecast.snow["3h"]
icon = forecast.weather_icon_name
hourly_data_dict.append(
{
"temp": temp,
"min_temp": min_temp,
"max_temp": max_temp,
"precip_3h_mm": precip_mm,
"wind": wind,
"wind_gust": wind_gust,
"icon": icon,
"datetime": forecast_timings[forecasts.index(forecast)].datetime.astimezone(tz=tz_zone),
}
)
return (current_weather, hourly_data_dict)
def get_forecast_for_day(days_from_today: int, hourly_forecasts: list) -> dict:
"""Get temperature range, rain and most frequent icon code for forecast
days_from_today should be int from 0-4: e.g. 2 -> 2 days from today
"""
# Calculate the start and end times for the specified number of days from now
current_time = datetime.now()
tz_zone = tz.gettz(get_system_tz())
start_time = (
(current_time + timedelta(days=days_from_today))
.replace(hour=0, minute=0, second=0, microsecond=0)
.astimezone(tz=tz_zone)
)
end_time = (start_time + timedelta(days=1)).astimezone(tz=tz_zone)
# Get all the forecasts for that day's time range
forecasts = [
f
for f in hourly_forecasts
if is_timestamp_within_range(timestamp=f["datetime"], start_time=start_time, end_time=end_time)
]
# if all the forecasts are from the next day, at least use the first one in the list to be able to return something
if forecasts == []:
forecasts.append(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.timestamp(),
"icon": icon,
"temp_min": min(temps),
"temp_max": max(temps),
"precip_mm": rain,
}
return day_data
def main():
config_dict = get_default_config()
config_dict["language"] = "en"
token = "daa8543f445b602da5d827e90f1d22b3"
city_id = 2867714
print(get_owm_data(city_id=city_id, token=token, temp_units="fahrenheit", wind_units="knots", language="en"))
if __name__ == "__main__":
main()

View File

@ -10,3 +10,4 @@ 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

View File

@ -0,0 +1,442 @@
"""
Inkycal fullscreen weather module
Copyright by mrbwburns
"""
import locale
import logging
import math
import os
from datetime import datetime
import matplotlib.dates as mdates
import matplotlib.pyplot as plt
import matplotlib.ticker as ticker
import numpy as np
from PIL import Image
from PIL import ImageDraw
from PIL import ImageFont
from PIL import ImageOps
from icons.weather_icons.weather_icons import get_weather_icon
from inkycal.custom import owm_forecasts
from inkycal.custom.functions import fonts
from inkycal.custom.functions import internet_available
from inkycal.custom.functions import top_level
from inkycal.custom.inkycal_exceptions import NetworkNotReachableError
from inkycal.modules.template import inkycal_module
logger = logging.getLogger(__name__)
logger.setLevel("DEBUG")
icons_dir = os.path.join(top_level, "icons", "ui-icons")
def outline(image: Image, size: int, color: tuple) -> Image:
# Create a canvas for the outline image
outlined = Image.new("RGBA", image.size, (0, 0, 0, 0))
# Make a black outline
for x in range(image.width):
for y in range(image.height):
pixel = image.getpixel((x, y))
if pixel[0] != 0 or pixel[1] != 0 or pixel[2] != 0:
outlined.putpixel((x, y), color)
# Enlarge the outlined image, and paste the original image on top to create a shadow effect
outlined = outlined.resize((outlined.width + size, outlined.height + size))
paste_position = ((outlined.width - image.width) // 2, (outlined.height - image.height) // 2)
outlined.paste(image, paste_position, image)
# Create a mask to prevent transparent pixels from overwriting
mask = Image.new("L", outlined.size, 255)
outlined = Image.composite(outlined, Image.new("RGBA", outlined.size, (0, 0, 0, 0)), mask)
return outlined
class Fullweather(inkycal_module):
"""Fullscreen Weather class
gets weather details from openweathermap and plots a nice fullscreen forecast picture
"""
name = "Fullscreen weather (openweathermap) - Get weather forecasts from openweathermap"
requires = {
"api_key": {
"label": "Please enter openweathermap api-key. You can create one for free on openweathermap",
},
"location": {
"label": "Please enter your location ID found in the url "
+ "e.g. https://openweathermap.org/city/4893171 -> ID is 4893171"
},
}
optional = {
"temp_units": {
"label": "Which temperature unit should be used?",
"options": ["celsius", "fahrenheit"],
},
"wind_units": {
"label": "Which wind speed unit should be used?",
"options": ["beaufort", "knots", "miles_hour", "km_hour", "meters_sec"],
},
"wind_gusts": {
"label": "Should current wind gust speed also be displayed?",
"options": [True, False],
},
"keep_history": {
"label": "Should the weather data be written to local json files (one per query)?",
"options": [True, False],
},
"min_max_annotations": {
"label": "Should the temperature plot have min/max annotation labels?",
"options": [True, False],
},
"locale": {
"label": "Your locale",
"options": ["de_DE.UTF-8", "en_GB.UTF-8"],
},
"tz": {
"label": "Your timezone",
"options": ["Europe/Berlin", "UTC"],
},
"font_family": {
"label": "Font family to use for the entire screen",
"options": ["Roboto", "NotoSans", "Poppins"],
},
"chart_title": {
"label": "Title of the temperature and precipitation plot",
"options": ["Temperatur und Niederschlag", "Temperature and precipitation"],
},
"weekly_title": {
"label": "Title of the weekly weather forecast",
"options": ["Tageswerte", "Weekly forecast"],
},
"icon_outline": {
"label": "Should the weather icons have outlines?",
"options": [True, False],
},
}
def __init__(self, config):
"""Initialize inkycal_weather module"""
super().__init__(config)
config = config["config"]
# Check if all required parameters are present
for param in self.requires:
if not param in config:
raise Exception(f"config is missing {param}")
# required parameters
self.api_key = config["api_key"]
self.location = int(config["location"])
self.font_size = int(config["fontsize"])
# optional parameters
if "wind_units" in config:
self.wind_units = config["wind_units"]
else:
self.wind_units = "meters_sec"
if self.wind_units == "beaufort":
self.windDispUnit = "bft"
elif self.wind_units == "knots":
self.windDispUnit = "kn"
elif self.wind_units == "km_hour":
self.windDispUnit = "km/h"
elif self.wind_units == "miles_hour":
self.windDispUnit = "mph"
else:
self.windDispUnit = "m/s"
if "wind_gusts" in config:
self.wind_gusts = bool(config["wind_gusts"])
else:
self.wind_gusts = True
if "temp_units" in config:
self.temp_units = config["temp_units"]
else:
self.temp_units = "celsius"
if self.temp_units == "fahrenheit":
self.tempDispUnit = "F"
elif self.temp_units == "celsius":
self.tempDispUnit = "°"
if "weekly_title" in config:
self.weekly_title = config["weekly_title"]
else:
self.weekly_title = "Weekly forecast"
if "chart_title" in config:
self.chart_title = config["chart_title"]
else:
self.chart_title = "Temperature and precipitation"
if "keep_history" in config:
self.keep_history = config["keep_history"]
else:
self.keep_history = False
if "min_max_annotations" in config:
self.min_max_annotations = bool(config["min_max_annotations"])
else:
self.min_max_annotations = False
if "locale" in config:
self.locale = config["locale"]
else:
self.locale = "en_GB.UTF-8"
locale.setlocale(locale.LC_TIME, self.locale)
self.language = self.locale.split("_")[0]
if "tz" in config:
self.tz = config["tz"]
else:
self.tz = "UTC"
if "icon_outline" in config:
self.icon_outline = config["icon_outline"]
else:
self.icon_outline = True
if "font_family" in config:
self.font_family = config["font_family"]
else:
self.font_family = "Roboto"
# some calculations for scalability
# TODO: make this work for all sizes
self.screen_width_in = 163 / 25.4 # 163 mm for 7in5
self.screen_height_in = 98 / 25.4 # 98 mm for 7in5
self.dpi = math.sqrt(
(float(self.width) ** 2 + float(self.height) ** 2)
/ (self.screen_width_in**2 + self.screen_height_in**2)
)
self.left_section_width = int(self.width / 4)
# give an OK message
print(f"{__name__} loaded")
def createBaseImage(self):
"""
Creates background and adds current date
"""
# Create white image
self.image = Image.new("RGB", (self.width, self.height), (255, 255, 255))
image_draw = ImageDraw.Draw(self.image)
# Create black rectangle for the current weather section
rect_width = int(self.width / 4)
image_draw.rectangle((0, 0, rect_width, self.height), fill=0)
# Add text with current date
now = datetime.now()
dateString = now.strftime("%d. %B")
dateFont = self.get_font(family=self.font_family, style="Bold", size=self.font_size)
# Get the width of the text
dateStringbbox = dateFont.getbbox(dateString)
dateW = dateStringbbox[2] - dateStringbbox[0]
# Draw the current date centered
image_draw.text(((rect_width - dateW) / 2, 5), dateString, font=dateFont, fill=(255, 255, 255))
def addUserSection(self):
"""
Adds user-defined section to the given image
"""
## Create drawing object for image
image_draw = ImageDraw.Draw(self.image)
if False: # self.mqtt_sub == True:
# Add icon for Home
homeTempIcon = Image.open(os.path.join(icons_dir, "home_temp.png"))
homeTempIcon = ImageOps.invert(homeTempIcon)
homeTempIcon = homeTempIcon.resize((40, 40))
homeTemp_y = int(self.height * 0.8125)
self.image.paste(homeTempIcon, (15, homeTemp_y))
# Home temperature
# my_home = mqtt_temperature(host=mqtt_host, port=mqtt_port, user=mqtt_user, password=mqtt_pass, topic=mqtt_topic)
# homeTemp = None
# while homeTemp == None:
# homeTemp = my_home.get_temperature()
# homeTempString = f"{homeTemp:.1f} {tempDispUnit}"
# homeTempFont = font.font(font_family, "Bold", 28)
# image_draw.text((65, homeTemp_y), homeTempString, font=homeTempFont, fill=(255, 255, 255))
# Add icon for rH
humidityIcon = Image.open(os.path.join(icons_dir, "humidity.bmp"))
humidityIcon = humidityIcon.resize((40, 40))
humidity_y = int(self.height * 0.90625)
self.image.paste(humidityIcon, (15, humidity_y))
# rel. humidity
# rH = None
# while rH == None:
# rH = my_home.get_rH()
# humidityString = f"{rH:.0f} %"
# humidityFont = font.font(font_family, "Bold", 28)
# image_draw.text((65, humidity_y), humidityString, font=humidityFont, fill=(255, 255, 255))
else:
# Add icon for Humidity
humidityIcon = Image.open(os.path.join(icons_dir, "humidity.bmp"))
humidityIcon = humidityIcon.resize((40, 40))
humidity_y = int(self.height * 0.8125)
self.image.paste(humidityIcon, (15, humidity_y))
# Humidity
humidityString = f"{self.current_weather.humidity} %"
humidityFont = self.get_font(self.font_family, "Bold", 28)
image_draw.text((65, humidity_y), humidityString, font=humidityFont, fill=(255, 255, 255))
# Add icon for uv
uvIcon = Image.open(os.path.join(icons_dir, "uv.bmp"))
uvIcon = uvIcon.resize((40, 40))
ux_y = int(self.height * 0.90625)
self.image.paste(uvIcon, (15, ux_y))
# uvindex
uvString = f"{self.current_weather.uvi if self.current_weather.uvi else '0'}"
uvFont = self.get_font(self.font_family, "Bold", 28)
image_draw.text((65, ux_y), uvString, font=uvFont, fill=(255, 255, 255))
def addCurrentWeather(self):
"""
Adds current weather situation to the left section of the image
"""
## Create drawing object for image
image_draw = ImageDraw.Draw(self.image)
## Add detailed weather status text to the image
sumString = self.current_weather.detailed_status.replace(" ", "\n ")
sumFont = self.get_font(self.font_family, "Regular", self.font_size + 8)
maxW = 0
totalH = 0
for word in sumString.split("\n "):
sumStringbbox = sumFont.getbbox(word)
sumW = sumStringbbox[2] - sumStringbbox[0]
sumH = sumStringbbox[3] - sumStringbbox[1]
maxW = max(maxW, sumW)
totalH += sumH
sumtext_x = int((self.left_section_width - maxW) / 2)
sumtext_y = int(self.height * 0.19) - totalH
image_draw.multiline_text((sumtext_x, sumtext_y), sumString, font=sumFont, fill=(255, 255, 255), align="center")
logger.debug(f"Added current weather detailed status text: {sumString} at x:{sumtext_x}/y:{sumtext_y}.")
## Add current weather icon to the image
icon = get_weather_icon(icon_name=self.current_weather.weather_icon_name, size=150)
# Create a mask from the alpha channel of the weather icon
if len(icon.split()) == 4:
mask = icon.split()[-1]
else:
mask = None
# Paste the foreground of the icon onto the background with the help of the mask
icon_x = int((self.left_section_width - icon.width) / 2)
icon_y = int(self.height * 0.2)
self.image.paste(icon, (icon_x, icon_y), mask)
## Add current temperature to the image
tempString = f"{self.current_weather.temperature(self.temp_units)['feels_like']:.0f}{self.tempDispUnit}"
tempFont = self.get_font(self.font_family, "Bold", 68)
# Get the width of the text
tempStringbbox = tempFont.getbbox(tempString)
tempW = tempStringbbox[2] - tempStringbbox[0]
temp_x = int((self.left_section_width - tempW) / 2)
temp_y = int(self.height * 0.4375)
# Draw the current temp centered
image_draw.text((temp_x, temp_y), tempString, font=tempFont, fill=(255, 255, 255))
# Add icon for rain forecast
rainIcon = Image.open(os.path.join(icons_dir, "rain-chance.bmp"))
rainIcon = rainIcon.resize((40, 40))
rain_y = int(self.height * 0.625)
self.image.paste(rainIcon, (15, rain_y))
# Amount of precipitation within next 3h
rain = self.hourly_forecasts[0]["precip_3h_mm"]
precipString = f"{rain:.1g} mm" if rain > 0.0 else "0 mm"
precipFont = self.get_font(self.font_family, "Bold", 28)
image_draw.text((65, rain_y), precipString, font=precipFont, fill=(255, 255, 255))
# Add icon for wind speed
windIcon = Image.open(os.path.join(icons_dir, "wind.bmp"))
windIcon = windIcon.resize((40, 40))
wind_y = int(self.height * 0.719)
self.image.paste(windIcon, (15, wind_y))
# Max. wind speed within next 3h
wind_gust = f"{self.hourly_forecasts[0]['wind_gust']:.0f}"
wind = f"{self.hourly_forecasts[0]['wind']:.0f}"
if self.wind_gusts:
if wind == wind_gust:
windString = f"{wind} {self.windDispUnit}"
else:
windString = f"{wind} - {wind_gust} {self.windDispUnit}"
else:
windString = f"{wind} {self.windDispUnit}"
windFont = self.get_font(self.font_family, "Bold", 28)
image_draw.text((65, wind_y), windString, font=windFont, fill=(255, 255, 255))
def generate_image(self):
"""Generate image for this module"""
# Define new image size with respect to padding
im_width = int(self.width - (2 * self.padding_left))
im_height = int(self.height - (2 * self.padding_top))
im_size = im_width, im_height
logger.info(f"Image size: {im_size}")
# Check if internet is available
if internet_available():
logger.info("Connection test passed")
else:
raise NetworkNotReachableError
# Get the weather
(self.current_weather, self.hourly_forecasts) = owm_forecasts.get_owm_data(
token=self.api_key,
city_id=self.location,
temp_units=self.temp_units,
wind_units=self.wind_units,
language=self.language,
)
## Create Base Image
self.createBaseImage()
## Add Current Weather to the left section
self.addCurrentWeather()
## Add user-configurable section to the bottom left corner
self.addUserSection()
## Add Hourly Forecast
# my_image = addHourlyForecast(display=display, image=my_image, hourly_forecasts=hourly_forecasts)
## Add Daily Forecast
# my_image = addDailyForecast(display=display, image=my_image, hourly_forecasts=hourly_forecasts)
self.image.save("./openweather_full.png")
logger.info("Fullscreen weather forecast generated successfully.")
# Return the images ready for the display
# tbh, I have no idea why I need to return two separate images here
return self.image, self.image
@staticmethod
def get_font(family, style, size):
# Returns the TrueType font object with the given characteristics
if family == "Roboto" and style == "ExtraBold":
style = "Black"
elif family == "Ubuntu" and style in ["ExtraBold", "Black"]:
style = "Bold"
elif family == "OpenSans" and style == "Black":
style = "ExtraBold"
return ImageFont.truetype(fonts[f"{family}-{style}"], size=size)
if __name__ == "__main__":
print(f"running {__name__} in standalone mode")

View File

@ -1,33 +1,52 @@
appdirs==1.4.4
arrow==1.3.0
asyncio==3.4.3
beautifulsoup4==4.12.2
certifi==2023.7.22
charset-normalizer==3.3.2
colorzero==2.0
contourpy==1.2.0
cycler==0.12.1
feedparser==6.0.10
fonttools==4.45.1
frozendict==2.4.0
geojson==2.5.0
gpiozero==2.0
html2text==2020.1.16
html5lib==1.1
htmlwebshot==0.1.2
icalendar==5.0.11
idna==3.6
kiwisolver==1.4.5
lgpio==0.0.0.2
lxml==4.9.3
matplotlib==3.8.2
multitasking==0.0.11
numpy==1.26.2
packaging==23.2
pandas==2.1.4
peewee==3.17.0
Pillow==10.1.0
pyowm==3.3.0
pyparsing==3.1.1
PySocks==1.7.1
python-dateutil==2.8.2
python-dotenv==1.0.0
pytz==2023.3.post1
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
yfinance==0.2.32
htmlwebshot~=0.1.2
webencodings==0.5.1
x-wr-timezone==0.0.6
xkcd==2.4.2
yfinance==0.2.32