diff --git a/.devcontainer/Dockerfile b/.devcontainer/Dockerfile new file mode 100644 index 0000000..a6abcde --- /dev/null +++ b/.devcontainer/Dockerfile @@ -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 diff --git a/.devcontainer/devcontainer.json b/.devcontainer/devcontainer.json index 1b65fe2..1bf5540 100644 --- a/.devcontainer/devcontainer.json +++ b/.devcontainer/devcontainer.json @@ -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" ] } } diff --git a/.devcontainer/postCreate.sh b/.devcontainer/postCreate.sh new file mode 100644 index 0000000..700f67b --- /dev/null +++ b/.devcontainer/postCreate.sh @@ -0,0 +1,4 @@ +#!/bin/bash +python3 -m venv venv +source ./venv/bin/activate +pip3 install -r requirements.txt \ No newline at end of file diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..5dc46e6 --- /dev/null +++ b/.gitattributes @@ -0,0 +1,3 @@ +* text=auto eol=lf +*.{cmd,[cC][mM][dD]} text eol=crlf +*.{bat,[bB][aA][tT]} text eol=crlf \ No newline at end of file diff --git a/.github/CONTRIBUTORS.md b/.github/CONTRIBUTORS.md index 93f2782..a6c6b35 100644 --- a/.github/CONTRIBUTORS.md +++ b/.github/CONTRIBUTORS.md @@ -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 | diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml new file mode 100644 index 0000000..3c49c00 --- /dev/null +++ b/.pre-commit-config.yaml @@ -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 diff --git a/Changelog.md b/Changelog.md index 1e7df95..cc5fc69 100644 --- a/Changelog.md +++ b/Changelog.md @@ -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) diff --git a/icons/ui-icons/home_temp.png b/icons/ui-icons/home_temp.png new file mode 100644 index 0000000..afcd5fc Binary files /dev/null and b/icons/ui-icons/home_temp.png differ diff --git a/icons/ui-icons/humidity.bmp b/icons/ui-icons/humidity.bmp new file mode 100644 index 0000000..7248bbb Binary files /dev/null and b/icons/ui-icons/humidity.bmp differ diff --git a/icons/ui-icons/outline_thermostat_white_48dp.bmp b/icons/ui-icons/outline_thermostat_white_48dp.bmp new file mode 100644 index 0000000..9c2a338 Binary files /dev/null and b/icons/ui-icons/outline_thermostat_white_48dp.bmp differ diff --git a/icons/ui-icons/rain-chance.bmp b/icons/ui-icons/rain-chance.bmp new file mode 100644 index 0000000..dacb1fe Binary files /dev/null and b/icons/ui-icons/rain-chance.bmp differ diff --git a/icons/ui-icons/uv.bmp b/icons/ui-icons/uv.bmp new file mode 100644 index 0000000..12294b7 Binary files /dev/null and b/icons/ui-icons/uv.bmp differ diff --git a/icons/ui-icons/wind.bmp b/icons/ui-icons/wind.bmp new file mode 100644 index 0000000..8991cc2 Binary files /dev/null and b/icons/ui-icons/wind.bmp differ diff --git a/icons/weather_icons/__init__.py b/icons/weather_icons/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/icons/weather_icons/owm_icons_cache/.gitignore b/icons/weather_icons/owm_icons_cache/.gitignore new file mode 100644 index 0000000..aab52d9 --- /dev/null +++ b/icons/weather_icons/owm_icons_cache/.gitignore @@ -0,0 +1 @@ +*.png \ No newline at end of file diff --git a/icons/weather_icons/weather_icons.py b/icons/weather_icons/weather_icons.py new file mode 100644 index 0000000..e365d2e --- /dev/null +++ b/icons/weather_icons/weather_icons.py @@ -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 diff --git a/inkycal/__init__.py b/inkycal/__init__.py index 1ae709a..0c6a244 100644 --- a/inkycal/__init__.py +++ b/inkycal/__init__.py @@ -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 diff --git a/inkycal/custom/__init__.py b/inkycal/custom/__init__.py index f9d1efb..ddd9b61 100644 --- a/inkycal/custom/__init__.py +++ b/inkycal/custom/__init__.py @@ -1,3 +1,2 @@ from .functions import * -from .inkycal_exceptions import * -from .openweathermap_wrapper import OpenWeatherMap \ No newline at end of file +from .inkycal_exceptions import * \ No newline at end of file diff --git a/inkycal/custom/functions.py b/inkycal/custom/functions.py index c1bd12a..f0c9356 100644 --- a/inkycal/custom/functions.py +++ b/inkycal/custom/functions.py @@ -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])) diff --git a/inkycal/custom/openweathermap_wrapper.py b/inkycal/custom/openweathermap_wrapper.py index 20c050f..6cd4405 100644 --- a/inkycal/custom/openweathermap_wrapper.py +++ b/inkycal/custom/openweathermap_wrapper.py @@ -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() diff --git a/inkycal/main.py b/inkycal/main.py index d1fa84f..69758a9 100644 --- a/inkycal/main.py +++ b/inkycal/main.py @@ -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 diff --git a/inkycal/modules/__init__.py b/inkycal/modules/__init__.py index 9895d6c..d82ae99 100755 --- a/inkycal/modules/__init__.py +++ b/inkycal/modules/__init__.py @@ -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 diff --git a/inkycal/modules/inky_image.py b/inkycal/modules/inky_image.py index 4ec859e..d259e71 100755 --- a/inkycal/modules/inky_image.py +++ b/inkycal/modules/inky_image.py @@ -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") diff --git a/inkycal/modules/inkycal_fullweather.py b/inkycal/modules/inkycal_fullweather.py new file mode 100644 index 0000000..55ce044 --- /dev/null +++ b/inkycal/modules/inkycal_fullweather.py @@ -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") diff --git a/inkycal/modules/inkycal_image.py b/inkycal/modules/inkycal_image.py index aed26e0..ea2a7a9 100755 --- a/inkycal/modules/inkycal_image.py +++ b/inkycal/modules/inkycal_image.py @@ -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") diff --git a/inkycal/modules/inkycal_weather.py b/inkycal/modules/inkycal_weather.py index 6ad2a03..7aeb845 100644 --- a/inkycal/modules/inkycal_weather.py +++ b/inkycal/modules/inkycal_weather.py @@ -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() diff --git a/requirements.txt b/requirements.txt index ac197de..f2b6248 100644 --- a/requirements.txt +++ b/requirements.txt @@ -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 \ No newline at end of file