diff --git a/clear_display.py b/clear_display.py deleted file mode 100644 index be726f3..0000000 --- a/clear_display.py +++ /dev/null @@ -1,12 +0,0 @@ -""" -Clears the display of any content. -""" -from inkycal import Inkycal - -print("loading Inkycal and display driver...") -inky = Inkycal(render=True) # Initialise Inkycal -print("clearing display...") -inky.calibrate(cycles=1) # Calibrate the display -print("clear complete...") - -print("finished!") diff --git a/icons/ui-icons/home_temp.png b/fonts/ui-icons/home_temp.png similarity index 100% rename from icons/ui-icons/home_temp.png rename to fonts/ui-icons/home_temp.png diff --git a/icons/ui-icons/humidity.bmp b/fonts/ui-icons/humidity.bmp similarity index 100% rename from icons/ui-icons/humidity.bmp rename to fonts/ui-icons/humidity.bmp diff --git a/icons/ui-icons/outline_thermostat_white_48dp.bmp b/fonts/ui-icons/outline_thermostat_white_48dp.bmp similarity index 100% rename from icons/ui-icons/outline_thermostat_white_48dp.bmp rename to fonts/ui-icons/outline_thermostat_white_48dp.bmp diff --git a/icons/ui-icons/rain-chance.bmp b/fonts/ui-icons/rain-chance.bmp similarity index 100% rename from icons/ui-icons/rain-chance.bmp rename to fonts/ui-icons/rain-chance.bmp diff --git a/icons/ui-icons/uv.bmp b/fonts/ui-icons/uv.bmp similarity index 100% rename from icons/ui-icons/uv.bmp rename to fonts/ui-icons/uv.bmp diff --git a/icons/ui-icons/wind.bmp b/fonts/ui-icons/wind.bmp similarity index 100% rename from icons/ui-icons/wind.bmp rename to fonts/ui-icons/wind.bmp diff --git a/inky_run.py b/inky_run.py index e2fff6a..9f4d5f7 100644 --- a/inky_run.py +++ b/inky_run.py @@ -1,7 +1,40 @@ +"""Basic Inkycal run script. + +Assumes that the settings.json file is in the /boot directory. +set render=True to render the display, set render=False to only run the modules. +""" import asyncio + from inkycal import Inkycal -inky = Inkycal(render=True) # Initialise Inkycal -# If your settings.json file is not in /boot, use the full path: inky = Inkycal('path/to/settings.json', render=True) -inky.test() # test if Inkycal can be run correctly, running this will show a bit of info for each module -asyncio.run(inky.run()) # If there were no issues, you can run Inkycal nonstop + +async def dry_run(): + # create an instance of Inkycal + # If your settings.json file is not in /boot, use the full path: + # inky = Inkycal('path/to/settings.json', render=True) + inky = Inkycal(render=False) + await inky.run(run_once=True) # dry-run without rendering anything on the display + + +async def clear_display(): + print("loading Inkycal and display driver...") + inky = Inkycal(render=True) # Initialise Inkycal + print("clearing display...") + inky.calibrate(cycles=1) # Calibrate the display + print("clear complete...") + print("finished!") + + +async def run(): + # create an instance of Inkycal + # If your settings.json file is not in /boot, use the full path: + # inky = Inkycal('path/to/settings.json', render=True) + + # when using experimental PiSugar support: + # inky = Inkycal(render=True, use_pi_sugar=True) + inky = Inkycal(render=True) + await inky.run() # If there were no issues, you can run Inkycal nonstop + + +if __name__ == "__main__": + asyncio.run(run()) diff --git a/inkycal/__init__.py b/inkycal/__init__.py index 0c6a244..edb3926 100644 --- a/inkycal/__init__.py +++ b/inkycal/__init__.py @@ -1,20 +1,15 @@ -# Display class (for driving E-Paper displays) -from inkycal.display import Display - # Default modules import inkycal.modules.inkycal_agenda import inkycal.modules.inkycal_calendar -import inkycal.modules.inkycal_weather import inkycal.modules.inkycal_feeds -import inkycal.modules.inkycal_todoist +import inkycal.modules.inkycal_fullweather import inkycal.modules.inkycal_image import inkycal.modules.inkycal_jokes import inkycal.modules.inkycal_slideshow import inkycal.modules.inkycal_stocks +import inkycal.modules.inkycal_todoist +import inkycal.modules.inkycal_weather import inkycal.modules.inkycal_webshot import inkycal.modules.inkycal_xkcd -import inkycal.modules.inkycal_fullweather - -# Main file +from inkycal.display import Display from inkycal.main import Inkycal -import inkycal.modules.inkycal_stocks diff --git a/inkycal/custom/functions.py b/inkycal/custom/functions.py index 0bdb419..2c39b76 100644 --- a/inkycal/custom/functions.py +++ b/inkycal/custom/functions.py @@ -17,20 +17,16 @@ from PIL import Image from PIL import ImageDraw from PIL import ImageFont +from inkycal.settings import Settings + logger = logging.getLogger(__name__) -logger.setLevel(level=logging.INFO) -# Get the path to the Inkycal folder -top_level = "/".join(os.path.dirname(os.path.abspath(os.path.dirname(__file__))).split("/")[:-1]) - -# Get path of 'fonts' and 'images' folders within Inkycal folder -fonts_location = os.path.join(top_level, "fonts/") -image_folder = os.path.join(top_level, "image_folder/") +settings = Settings() # Get available fonts within fonts folder fonts = {} -for path, dirs, files in os.walk(fonts_location): +for path, dirs, files in os.walk(settings.FONT_PATH): for _ in files: if _.endswith(".otf"): name = _.split(".otf")[0] diff --git a/inkycal/display/display.py b/inkycal/display/display.py index 89fdf4c..4eb47f3 100644 --- a/inkycal/display/display.py +++ b/inkycal/display/display.py @@ -2,13 +2,11 @@ Inkycal ePaper driving functions Copyright by aceisace """ -import os from importlib import import_module import PIL from PIL import Image -from inkycal.custom import top_level from inkycal.display.supported_models import supported_models @@ -199,9 +197,7 @@ class Display: >>> Display.get_display_names() """ - driver_files = top_level + '/inkycal/display/drivers/' - drivers = [i for i in os.listdir(driver_files) if i.endswith(".py") and not i.startswith("__") and "_" in i] - return drivers + return list(supported_models.keys()) if __name__ == '__main__': diff --git a/inkycal/display/drivers/10_in_3.py b/inkycal/display/drivers/10_in_3.py index 834e2df..7d7b3fa 100644 --- a/inkycal/display/drivers/10_in_3.py +++ b/inkycal/display/drivers/10_in_3.py @@ -2,22 +2,18 @@ 10.3" driver class Copyright by aceinnolab """ +import os from subprocess import run from PIL import Image -from inkycal.custom import image_folder, top_level +from inkycal.settings import Settings # Display resolution EPD_WIDTH = 1872 EPD_HEIGHT = 1404 -# Please insert VCOM of your display. The Minus sign before is not required -VCOM = "2.0" - -driver_dir = top_level + '/inkycal/display/drivers/parallel_drivers/' - -command = f'sudo {driver_dir}epd -{VCOM} 0 {image_folder + "canvas.bmp"}' +settings = Settings() class EPD: @@ -40,8 +36,8 @@ class EPD: def getbuffer(self, image): """ad-hoc""" image = image.rotate(90, expand=True).transpose(Image.FLIP_LEFT_RIGHT) - image.convert('RGB').save(image_folder + 'canvas.bmp', 'BMP') - command = f'sudo {driver_dir}epd -{VCOM} 0 {image_folder + "canvas.bmp"}' + image.convert('RGB').save(os.path.join(settings.IMAGE_FOLDER, 'canvas.bmp'), 'BMP') + command = f'sudo {settings.PARALLEL_DRIVER_PATH}/epd -{settings.VCOM} 0 {settings.IMAGE_FOLDER + "canvas.bmp"}' print(command) return command diff --git a/inkycal/display/drivers/7_in_8.py b/inkycal/display/drivers/7_in_8.py index fe4d243..5f9cf51 100644 --- a/inkycal/display/drivers/7_in_8.py +++ b/inkycal/display/drivers/7_in_8.py @@ -2,20 +2,16 @@ 7.8" parallel driver class Copyright by aceinnolab """ +import os from subprocess import run -from inkycal.custom import image_folder, top_level +from inkycal.settings import Settings # Display resolution EPD_WIDTH = 1872 EPD_HEIGHT = 1404 -# Please insert VCOM of your display. The Minus sign before is not required -VCOM = "2.0" - -driver_dir = top_level + '/inkycal/display/drivers/parallel_drivers/' - -command = f'sudo {driver_dir}epd -{VCOM} 0 {image_folder + "canvas.bmp"}' +settings = Settings() class EPD: @@ -38,8 +34,8 @@ class EPD: def getbuffer(self, image): """ad-hoc""" image = image.rotate(90, expand=True) - image.convert('RGB').save(image_folder + 'canvas.bmp', 'BMP') - command = f'sudo {driver_dir}epd -{VCOM} 0 {image_folder + "canvas.bmp"}' + image.convert('RGB').save(os.path.join(settings.IMAGE_FOLDER, 'canvas.bmp'), 'BMP') + command = f'sudo {settings.PARALLEL_DRIVER_PATH}/epd -{settings.VCOM} 0 {settings.IMAGE_FOLDER + "canvas.bmp"}' print(command) return command diff --git a/inkycal/display/drivers/9_in_7.py b/inkycal/display/drivers/9_in_7.py index 8c6ec0f..c81feef 100644 --- a/inkycal/display/drivers/9_in_7.py +++ b/inkycal/display/drivers/9_in_7.py @@ -4,18 +4,16 @@ Copyright by aceinnolab """ from subprocess import run -from inkycal.custom import image_folder, top_level +from inkycal.settings import Settings # Display resolution EPD_WIDTH = 1200 EPD_HEIGHT = 825 -# Please insert VCOM of your display. The Minus sign before is not required -VCOM = "2.0" -driver_dir = top_level + '/inkycal/display/drivers/parallel_drivers/' +settings = Settings() -command = f'sudo {driver_dir}epd -{VCOM} 0 {image_folder + "canvas.bmp"}' +command = f'sudo {settings.PARALLEL_DRIVER_PATH}/epd -{settings.VCOM} 0 {settings.IMAGE_FOLDER + "canvas.bmp"}' class EPD: @@ -38,8 +36,8 @@ class EPD: def getbuffer(self, image): """ad-hoc""" image = image.rotate(90, expand=True) - image.convert('RGB').save(image_folder + 'canvas.bmp', 'BMP') - command = f'sudo {driver_dir}epd -{VCOM} 0 {image_folder + "canvas.bmp"}' + image.convert('RGB').save(settings.IMAGE_FOLDER + 'canvas.bmp', 'BMP') + command = f'sudo {settings.PARALLEL_DRIVER_PATH}/epd -{settings.VCOM} 0 {settings.IMAGE_FOLDER + "canvas.bmp"}' print(command) return command diff --git a/inkycal/display/drivers/epdconfig.py b/inkycal/display/drivers/epdconfig.py index 2eaddf4..2a5e819 100644 --- a/inkycal/display/drivers/epdconfig.py +++ b/inkycal/display/drivers/epdconfig.py @@ -28,8 +28,6 @@ THE SOFTWARE. """ import logging -import os -import subprocess import sys import time @@ -128,4 +126,3 @@ implementation = RaspberryPi() for func in [x for x in dir(implementation) if not x.startswith('_')]: setattr(sys.modules[__name__], func, getattr(implementation, func)) - diff --git a/inkycal/loggers.py b/inkycal/loggers.py new file mode 100644 index 0000000..3a59c33 --- /dev/null +++ b/inkycal/loggers.py @@ -0,0 +1,35 @@ +"""Logging configuration for Inkycal.""" +import logging +import os +from logging.handlers import RotatingFileHandler + +from inkycal.settings import Settings + +# On the console, set a logger to show only important logs +# (level ERROR or higher) +stream_handler = logging.StreamHandler() +stream_handler.setLevel(logging.INFO) + +settings = Settings() + +if not os.path.exists(settings.LOG_PATH): + os.mkdir(settings.LOG_PATH) + + +# Save all logs to a file, which contains more detailed output +logging.basicConfig( + level=logging.INFO, + format='%(asctime)s | %(name)s | %(levelname)s: %(message)s', + datefmt='%d-%m-%Y %H:%M:%S', + handlers=[ + stream_handler, # add stream handler from above + RotatingFileHandler( # log to a file too + settings.INKYCAL_LOG_PATH, # file to log + maxBytes=2*1024*1024, # 2MB max filesize + backupCount=5 # create max 5 log files + ) + ] +) + +# Show less logging for PIL module +logging.getLogger("PIL").setLevel(logging.WARNING) \ No newline at end of file diff --git a/inkycal/main.py b/inkycal/main.py index cc4473b..476f683 100644 --- a/inkycal/main.py +++ b/inkycal/main.py @@ -6,44 +6,21 @@ Copyright by aceinnolab import asyncio import glob import hashlib -from logging.handlers import RotatingFileHandler import numpy +from inkycal import loggers # noqa from inkycal.custom import * from inkycal.display import Display from inkycal.modules.inky_image import Inkyimage as Images - -# On the console, set a logger to show only important logs -# (level ERROR or higher) -stream_handler = logging.StreamHandler() -stream_handler.setLevel(logging.ERROR) - -if not os.path.exists(f'{top_level}/logs'): - os.mkdir(f'{top_level}/logs') - -# Save all logs to a file, which contains more detailed output -logging.basicConfig( - level=logging.INFO, - format='%(asctime)s | %(name)s | %(levelname)s: %(message)s', - datefmt='%d-%m-%Y %H:%M:%S', - handlers=[ - stream_handler, # add stream handler from above - RotatingFileHandler( # log to a file too - f'{top_level}/logs/inkycal.log', # file to log - maxBytes=2097152, # 2MB max filesize - backupCount=5 # create max 5 log files - ) - ] -) - -# Show less logging for PIL module -logging.getLogger("PIL").setLevel(logging.WARNING) +from inkycal.utils import JSONCache logger = logging.getLogger(__name__) +settings = Settings() + +CACHE_NAME = "inkycal_main" -# TODO: autostart -> supervisor? class Inkycal: """Inkycal main class @@ -60,43 +37,45 @@ class Inkycal: to improve rendering on E-Papers. Set this to False for 9.7" E-Paper. """ - def __init__(self, settings_path: str or None = None, render: bool = True): + def __init__(self, settings_path: str or None = None, render: bool = True, use_pi_sugar: bool = False): """Initialise Inkycal""" + self._release = "2.0.3" - # Get the release version from setup.py - with open(f'{top_level}/setup.py') as setup_file: - for line in setup_file: - if line.startswith('__version__'): - self._release = line.split("=")[-1].replace("'", "").replace('"', "").replace(" ", "") - break + logger.info(f"Inkycal v{self._release} booting up...") self.render = render self.info = None + logger.info("Checking if a settings file is present...") # load settings file - throw an error if file could not be found if settings_path: + logger.info(f"Custom location for settings.json file specified: {settings_path}") try: with open(settings_path) as settings_file: - settings = json.load(settings_file) - self.settings = settings + self.settings = json.load(settings_file) except FileNotFoundError: raise FileNotFoundError( f"No settings.json file could be found in the specified location: {settings_path}") else: + logger.info("Looking for settings.json file in /boot folder...") try: - with open('/boot/settings.json') as settings_file: - settings = json.load(settings_file) - self.settings = settings + with open('/boot/settings.json', mode="r") as settings_file: + self.settings = json.load(settings_file) except FileNotFoundError: raise SettingsFileNotFoundError self.disable_calibration = self.settings.get('disable_calibration', False) + if self.disable_calibration: + logger.info("Calibration disabled. Please proceed with caution to prevent ghosting.") - if not os.path.exists(image_folder): - os.mkdir(image_folder) + if not os.path.exists(settings.IMAGE_FOLDER): + os.mkdir(settings.IMAGE_FOLDER) + + if not os.path.exists(settings.CACHE_PATH): + os.mkdir(settings.CACHE_PATH) # Option to use epaper image optimisation, reduces colours self.optimize = True @@ -109,10 +88,10 @@ class Inkycal: if self.render: # Init Display class with model in settings file # from inkycal.display import Display - self.Display = Display(settings["model"]) + self.Display = Display(self.settings["model"]) # check if colours can be rendered - self.supports_colour = True if 'colour' in settings['model'] else False + self.supports_colour = True if 'colour' in self.settings['model'] else False # get calibration hours self._calibration_hours = self.settings['calibration_hours'] @@ -122,7 +101,7 @@ class Inkycal: # Load and initialise modules specified in the settings file self._module_number = 1 - for module in settings['modules']: + for module in self.settings['modules']: module_name = module['name'] try: loader = f'from inkycal.modules import {module_name}' @@ -131,10 +110,9 @@ class Inkycal: setup = f'self.module_{self._module_number} = {module_name}({module})' # print(setup) exec(setup) - logger.info(('name : {name} size : {width}x{height} px'.format( - name=module_name, - width=module['config']['size'][0], - height=module['config']['size'][1]))) + width = module['config']['size'][0] + height = module['config']['size'][1] + logger.info(f'name : {module_name} size : {width}x{height} px') self._module_number += 1 @@ -147,57 +125,83 @@ class Inkycal: logger.exception(f"Exception: {traceback.format_exc()}.") # Path to store images - self.image_folder = image_folder + self.image_folder = settings.IMAGE_FOLDER # Remove old hashes self._remove_hashes(self.image_folder) - # Give an OK message - print('loaded inkycal') + # set up cache + if not os.path.exists(os.path.join(settings.CACHE_PATH, CACHE_NAME)): + if not os.path.exists(settings.CACHE_PATH): + os.mkdir(settings.CACHE_PATH) + self.cache = JSONCache(CACHE_NAME) + self.cache_data = self.cache.read() - def countdown(self, interval_mins: int or None = None) -> int: - """Returns the remaining time in seconds until next display update. + self.counter = 0 if "counter" not in self.cache_data else int(self.cache_data["counter"]) + + self.use_pi_sugar = use_pi_sugar + self.battery_capacity = 100 + + if self.use_pi_sugar: + logger.info("PiSugar support enabled.") + from inkycal.utils import PiSugar + self.pisugar = PiSugar() + + self.battery_capacity = self.pisugar.get_battery() + logger.info(f"PiSugar battery capacity: {self.battery_capacity}%") + + if self.battery_capacity < 20: + logger.warning("Battery capacity is below 20%!") + + logger.info("Setting system time to PiSugar time...") + if self.pisugar.rtc_pi2rtc(): + logger.info("RTC time updates successfully") + else: + logger.warning("RTC time could not be set!") + + print( + f"Using PiSigar model: {self.pisugar.get_model()}. Current PiSugar time: {self.pisugar.get_rtc_time()}") + + # Give an OK message + logger.info('Inkycal initialised successfully!') + + def countdown(self, interval_mins: int = None) -> int: + """Returns the remaining time in seconds until the next display update based on the interval. Args: - - interval_mins = int -> the interval in minutes for the update - if no interval is given, the value from the settings file is used. + interval_mins (int): The interval in minutes for the update. If none is given, the value + from the settings file is used. Returns: - - int -> the remaining time in seconds until next update + int: The remaining time in seconds until the next update. """ - - # Check if empty, if empty, use value from settings file + # Default to settings if no interval is provided if interval_mins is None: interval_mins = self.settings["update_interval"] - # Find out at which minutes the update should happen + # Get the current time now = arrow.now() - if interval_mins <= 60: - update_timings = [(60 - interval_mins * updates) for updates in range(60 // interval_mins)][::-1] - # Calculate time in minutes until next update - minutes = [_ for _ in update_timings if _ >= now.minute][0] - now.minute + # Calculate the next update time + # Finding the total minutes from the start of the day + minutes_since_midnight = now.hour * 60 + now.minute - # Print the remaining time in minutes until next update - print(f'{minutes} minutes left until next refresh') + # Finding the next interval point + minutes_to_next_interval = ( + minutes_since_midnight // interval_mins + 1) * interval_mins - minutes_since_midnight + seconds_to_next_interval = minutes_to_next_interval * 60 - now.second - # Calculate time in seconds until next update - remaining_time = minutes * 60 + (60 - now.second) - - # Return seconds until next update - return remaining_time + # Logging the remaining time in appropriate units + hours_to_next_interval = minutes_to_next_interval // 60 + remaining_minutes = minutes_to_next_interval % 60 + if hours_to_next_interval > 0: + print(f'{hours_to_next_interval} hours and {remaining_minutes} minutes left until next refresh') else: - # Calculate time in minutes until next update using the range of 24 hours in steps of every full hour - update_timings = [(60 * 24 - interval_mins * updates) for updates in range(60 * 24 // interval_mins)][::-1] - minutes = [_ for _ in update_timings if _ >= now.minute][0] - now.minute - remaining_time = minutes * 60 + (60 - now.second) + print(f'{remaining_minutes} minutes left until next refresh') - print(f'{round(minutes / 60, 1)} hours left until next refresh') + return seconds_to_next_interval - # Return seconds until next update - return remaining_time - - def test(self): + def dry_run(self): """Tests if Inkycal can run without issues. Attempts to import module names from settings file. Loads the config @@ -206,8 +210,6 @@ class Inkycal: Generated images can be found in the /images folder of Inkycal. """ - - logger.info(f"Inkycal version: v{self._release}") logger.info(f'Selected E-paper display: {self.settings["model"]}') # store module numbers in here @@ -218,20 +220,13 @@ class Inkycal: for number in range(1, self._module_number): name = eval(f"self.module_{number}.name") - module = eval(f'self.module_{number}') - print(f'generating image(s) for {name}...', end="") - try: - black, colour = module.generate_image() - if self.show_border: - 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 Exception: + success = self.process_module(number) + if success: + logger.debug(f'Image of module {name} generated successfully') + else: + logger.warning(f'Generating image of module {name} failed!') errors.append(number) self.info += f"module {number}: Error! " - logger.exception("Error!") - logger.exception(f"Exception: {traceback.format_exc()}.") if errors: logger.error('Error/s in modules:', *errors) @@ -277,76 +272,69 @@ class Inkycal: print("Refresh needed: {a}".format(a=res)) return res - async def run(self): - """Runs main program in nonstop mode. + async def run(self, run_once=False): + """Runs main program in nonstop mode or a single iteration based on the run_once flag. - Uses an infinity loop to run Inkycal nonstop. Inkycal generates the image - from all modules, assembles them in one image, refreshed the E-Paper and - then sleeps until the next scheduled update. + Args: + run_once (bool): If True, runs the updating process once and stops. If False, + runs indefinitely. + + Uses an infinity loop to run Inkycal nonstop or a single time based on run_once. + Inkycal generates the image from all modules, assembles them in one image, + refreshes the E-Paper and then sleeps until the next scheduled update or exits. """ - # Get the time of initial run runtime = arrow.now() # Function to flip images upside down upside_down = lambda image: image.rotate(180, expand=True) - # Count the number of times without any errors - counter = 0 - - 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"]}') while True: + logger.info("Starting new cycle...") current_time = arrow.now(tz=get_system_tz()) - print(f"Date: {current_time.format('D MMM YY')} | " - f"Time: {current_time.format('HH:mm')}") - print('Generating images for all modules...', end='') + logger.info(f"Timestamp: {current_time.format('HH:mm:ss DD.MM.YYYY')}") + self.cache_data["counter"] = self.counter - errors = [] # store module numbers in here + errors = [] # Store module numbers in here - # short info for info-section + # Short info for info-section if not self.settings.get('image_hash', False): self.info = f"{current_time.format('D MMM @ HH:mm')} " else: self.info = "" for number in range(1, self._module_number): - - # name = eval(f"self.module_{number}.name") - module = eval(f'self.module_{number}') - - try: - black, colour = module.generate_image() - if self.show_border: - 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") - self.info += f"module {number}: OK " - except Exception as e: + success = self.process_module(number) + if not success: errors.append(number) - self.info += f"module {number}: Error! " - logger.exception("Error!") - logger.exception(f"Exception: {traceback.format_exc()}.") + self.info += f"im {number}: X " if errors: logger.error("Error/s in modules:", *errors) - counter = 0 + self.counter = 0 + self.cache_data["counter"] = 0 else: - counter += 1 - logger.info("successful") + self.counter += 1 + self.cache_data["counter"] += 1 + logger.info("All images generated successfully!") del errors + if self.battery_capacity < 20: + self.info += "Low battery! " + # Assemble image from each module - add info section if specified self._assemble() # Check if image should be rendered if self.render: + logger.info("Attempting to render image on display...") display = self.Display - self._calibration_check() if self._calibration_state: - # after calibration, we have to forcefully rewrite the screen + # After calibration, we have to forcefully rewrite the screen self._remove_hashes(self.image_folder) if self.supports_colour: @@ -358,17 +346,15 @@ class Inkycal: im_black = upside_down(im_black) im_colour = upside_down(im_colour) - # render the image on the display + # Render the image on the display if not self.settings.get('image_hash', False) or self._needs_image_update([ (f"{self.image_folder}/canvas.png.hash", im_black), (f"{self.image_folder}/canvas_colour.png.hash", im_colour) ]): - # render the image on the display display.render(im_black, im_colour) # Part for black-white ePapers - elif not self.supports_colour: - + else: im_black = self._merge_bands() # Flip the image by 180° if required @@ -376,14 +362,29 @@ class Inkycal: im_black = upside_down(im_black) if not self.settings.get('image_hash', False) or self._needs_image_update([ - (f"{self.image_folder}/canvas.png.hash", im_black), - ]): + (f"{self.image_folder}/canvas.png.hash", im_black), ]): display.render(im_black) - print(f'\nNo errors since {counter} display updates \n' - f'program started {runtime.humanize()}') + logger.info(f'\nNo errors since {self.counter} display updates') + logger.info(f'program started {runtime.humanize()}') + + # store the cache data + self.cache.write(self.cache_data) + + # Exit the loop if run_once is True + if run_once: + break # Exit the loop after one full cycle if run_once is True sleep_time = self.countdown() + + if self.use_pi_sugar: + sleep_time_rtc = arrow.now(tz=get_system_tz()).shift(seconds=sleep_time) + result = self.pisugar.rtc_alarm_set(sleep_time_rtc, 127) + if result: + logger.info(f"Alarm set for {sleep_time_rtc.format('HH:mm:ss')}") + else: + logger.warning(f"Failed to set alarm for {sleep_time_rtc.format('HH:mm:ss')}") + await asyncio.sleep(sleep_time) @staticmethod @@ -392,7 +393,8 @@ class Inkycal: returns the merged image """ - im1_path, im2_path = image_folder + 'canvas.png', image_folder + 'canvas_colour.png' + im1_path = os.path.join(settings.image_folder, "canvas.png") + im2_path = os.path.join(settings.image_folder, "canvas_colour.png") # If there is an image for black and colour, merge them if os.path.exists(im1_path) and os.path.exists(im2_path): @@ -531,7 +533,7 @@ class Inkycal: im_colour = black_to_colour(im_colour) im_colour.paste(im_black, (0, 0), im_black) - im_colour.save(image_folder + 'full-screen.png', 'PNG') + im_colour.save(os.path.join(settings.IMAGE_FOLDER, 'full-screen.png'), 'PNG') @staticmethod def _optimize_im(image, threshold=220): @@ -574,13 +576,40 @@ class Inkycal: @staticmethod def cleanup(): # clean up old images in image_folder - for _file in glob.glob(f"{image_folder}*.png"): + if len(glob.glob(settings.IMAGE_FOLDER)) <= 1: + return + for _file in glob.glob(settings.IMAGE_FOLDER): try: os.remove(_file) except: logger.error(f"could not remove file: {_file}") pass + def process_module(self, number) -> bool or Exception: + """Process individual module to generate images and handle exceptions.""" + module = eval(f'self.module_{number}') + try: + black, colour = module.generate_image() + if self.show_border: + 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") + return True + except Exception: + logger.exception(f"Error in module {number}!") + return False + + def _shutdown_system(self): + """Shutdown the system""" + import subprocess + from time import sleep + try: + logger.info("Shutting down OS in 5 seconds...") + sleep(5) + subprocess.run(["sudo", "shutdown", "-h", "now"], check=True) + except subprocess.CalledProcessError: + logger.warning("Failed to execute shutdown command.") + if __name__ == '__main__': print(f'running inkycal main in standalone/debug mode') diff --git a/inkycal/modules/dev_module.py b/inkycal/modules/dev_module.py index 13d6275..53d8db3 100755 --- a/inkycal/modules/dev_module.py +++ b/inkycal/modules/dev_module.py @@ -156,7 +156,7 @@ class Simple(inkycal_module): # -----------------------------------------------------------------------# # give an OK message - print(f'{__name__} loaded') + logger.debug(f'{__name__} loaded') ############################################################################# # Validation of module specific parameters (optional) # diff --git a/inkycal/modules/inky_image.py b/inkycal/modules/inky_image.py index 67f4a14..5795f0e 100755 --- a/inkycal/modules/inky_image.py +++ b/inkycal/modules/inky_image.py @@ -27,7 +27,7 @@ class Inkyimage: self.image = image # give an OK message - logger.info(f"{__name__} loaded") + logger.debug(f"{__name__} loaded") def load(self, path: str) -> None: """loads an image from a URL or filepath. @@ -59,7 +59,7 @@ class Inkyimage: 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.debug(f"width: {image.width}, height: {image.height}") image.convert(mode="RGBA") # convert to a more suitable format self.image = image diff --git a/inkycal/modules/inkycal_agenda.py b/inkycal/modules/inkycal_agenda.py index f695440..1508a66 100755 --- a/inkycal/modules/inkycal_agenda.py +++ b/inkycal/modules/inkycal_agenda.py @@ -2,9 +2,7 @@ Inkycal Agenda Module Copyright by aceinnolab """ - -import arrow - +import arrow # noqa from inkycal.custom import * from inkycal.modules.ical_parser import iCalendar from inkycal.modules.template import inkycal_module @@ -80,7 +78,7 @@ class Agenda(inkycal_module): self.icon_font = ImageFont.truetype(fonts['MaterialIcons'], size=self.fontsize) # give an OK message - print(f'{__name__} loaded') + logger.debug(f'{__name__} loaded') def generate_image(self): """Generate image for this module""" @@ -90,7 +88,7 @@ class Agenda(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.debug(f'Image size: {im_size}') # Create an image for black pixels and one for coloured pixels im_black = Image.new('RGB', size=im_size, color='white') diff --git a/inkycal/modules/inkycal_calendar.py b/inkycal/modules/inkycal_calendar.py index c17582f..146c272 100755 --- a/inkycal/modules/inkycal_calendar.py +++ b/inkycal/modules/inkycal_calendar.py @@ -84,7 +84,7 @@ class Calendar(inkycal_module): ) # give an OK message - print(f'{__name__} loaded') + logger.debug(f'{__name__} loaded') @staticmethod def flatten(values): @@ -100,7 +100,7 @@ class Calendar(inkycal_module): im_size = im_width, im_height events_height = 0 - logger.info(f'Image size: {im_size}') + logger.debug(f'Image size: {im_size}') # Create an image for black pixels and one for coloured pixels im_black = Image.new('RGB', size=im_size, color='white') @@ -265,7 +265,7 @@ class Calendar(inkycal_module): # find out how many lines can fit at max in the event section line_spacing = 2 text_bbox_height = self.font.getbbox("hg") - line_height = text_bbox_height[3] + line_spacing + line_height = text_bbox_height[3] - text_bbox_height[1] + line_spacing max_event_lines = events_height // (line_height + line_spacing) # generate list of coordinates for each line @@ -322,7 +322,7 @@ class Calendar(inkycal_module): im_colour, grid[days], (icon_width, icon_height), - radius=6, + radius=6 ) # Filter upcoming events until 4 weeks in the future diff --git a/inkycal/modules/inkycal_feeds.py b/inkycal/modules/inkycal_feeds.py index d7bdde3..38a6294 100644 --- a/inkycal/modules/inkycal_feeds.py +++ b/inkycal/modules/inkycal_feeds.py @@ -60,7 +60,7 @@ class Feeds(inkycal_module): self.shuffle_feeds = config["shuffle_feeds"] # give an OK message - print(f'{__name__} loaded') + logger.debug(f'{__name__} loaded') def _validate(self): """Validate module-specific parameters""" @@ -75,7 +75,7 @@ class Feeds(inkycal_module): 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}') + logger.debug(f'Image size: {im_size}') # Create an image for black pixels and one for coloured pixels im_black = Image.new('RGB', size=im_size, color='white') @@ -83,8 +83,9 @@ class Feeds(inkycal_module): # Check if internet is available if internet_available(): - logger.info('Connection test passed') + logger.debug('Connection test passed') else: + logger.error("Network not reachable. Please check your connection.") raise NetworkNotReachableError # Set some parameters for formatting feeds diff --git a/inkycal/modules/inkycal_fullweather.py b/inkycal/modules/inkycal_fullweather.py index 55ce044..5dccc70 100644 --- a/inkycal/modules/inkycal_fullweather.py +++ b/inkycal/modules/inkycal_fullweather.py @@ -23,16 +23,18 @@ 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 +from inkycal.settings import Settings logger = logging.getLogger(__name__) logger.setLevel(logging.INFO) -icons_dir = os.path.join(top_level, "icons", "ui-icons") +settings = Settings() + +icons_dir = os.path.join(settings.FONT_PATH, "ui-icons") def outline(image: Image, size: int, color: tuple) -> Image: @@ -139,7 +141,7 @@ class Fullweather(inkycal_module): # Check if all required parameters are present for param in self.requires: - if not param in config: + if param not in config: raise Exception(f"config is missing {param}") # required parameters @@ -237,7 +239,7 @@ class Fullweather(inkycal_module): self.left_section_width = int(self.width / 4) # give an OK message - print(f"{__name__} loaded") + logger.debug(f"{__name__} loaded") def createBaseImage(self): """ diff --git a/inkycal/modules/inkycal_image.py b/inkycal/modules/inkycal_image.py index 5387b9b..bdf94bd 100755 --- a/inkycal/modules/inkycal_image.py +++ b/inkycal/modules/inkycal_image.py @@ -50,7 +50,7 @@ class Inkyimage(inkycal_module): self.dither = False # give an OK message - print(f"{__name__} loaded") + logger.debug(f"{__name__} loaded") def generate_image(self): """Generate image for this module""" @@ -71,7 +71,7 @@ class Inkyimage(inkycal_module): # Remove background if present im.remove_alpha() - # if autoflip was enabled, flip the image + # if auto-flip was enabled, flip the image if self.autoflip: im.autoflip(self.orientation) diff --git a/inkycal/modules/inkycal_jokes.py b/inkycal/modules/inkycal_jokes.py index 5f0085e..e35fd5e 100755 --- a/inkycal/modules/inkycal_jokes.py +++ b/inkycal/modules/inkycal_jokes.py @@ -30,7 +30,7 @@ class Jokes(inkycal_module): config = config['config'] # give an OK message - print(f'{__name__} loaded') + logger.debug(f'{__name__} loaded') def generate_image(self): """Generate image for this module""" @@ -39,7 +39,7 @@ class Jokes(inkycal_module): 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_width} x {im_height} px') + logger.debug(f'image size: {im_width} x {im_height} px') # Create an image for black pixels and one for coloured pixels im_black = Image.new('RGB', size=im_size, color='white') @@ -47,8 +47,9 @@ class Jokes(inkycal_module): # Check if internet is available if internet_available(): - logger.info('Connection test passed') + logger.debug('Connection test passed') else: + logger.error("Network not reachable. Please check your connection.") raise NetworkNotReachableError # Set some parameters for formatting feeds diff --git a/inkycal/modules/inkycal_server.py b/inkycal/modules/inkycal_server.py index 619c146..4cade83 100755 --- a/inkycal/modules/inkycal_server.py +++ b/inkycal/modules/inkycal_server.py @@ -67,7 +67,7 @@ class Inkyserver(inkycal_module): self.path_body = config['path_body'] # give an OK message - print(f'{__name__} loaded') + logger.debug(f'{__name__} loaded') def generate_image(self): """Generate image for this module""" diff --git a/inkycal/modules/inkycal_slideshow.py b/inkycal/modules/inkycal_slideshow.py index c7481fa..926d72b 100755 --- a/inkycal/modules/inkycal_slideshow.py +++ b/inkycal/modules/inkycal_slideshow.py @@ -8,13 +8,13 @@ from inkycal.custom import * # PIL has a class named Image, use alias for Inkyimage -> Images from inkycal.modules.inky_image import Inkyimage as Images, image_to_palette from inkycal.modules.template import inkycal_module +from inkycal.utils import JSONCache logger = logging.getLogger(__name__) class Slideshow(inkycal_module): - """Cycles through images in a local image folder - """ + """Cycles through images in a local image folder""" name = "Slideshow - cycle through images from a local folder" requires = { @@ -53,7 +53,7 @@ class Slideshow(inkycal_module): # required parameters for param in self.requires: - if not param in config: + if param not in config: raise Exception(f'config is missing {param}') # optional parameters @@ -64,19 +64,20 @@ class Slideshow(inkycal_module): # Get the full path of all png/jpg/jpeg images in the given folder all_files = glob.glob(f'{self.path}/*') - self.images = [i for i in all_files - if i.split('.')[-1].lower() in ('jpg', 'jpeg', 'png')] + self.images = [i for i in all_files if i.split('.')[-1].lower() in ('jpg', 'jpeg', 'png')] if not self.images: - logger.error('No images found in the given folder, please ' - 'double check your path!') + logger.error('No images found in the given folder, please double check your path!') raise Exception('No images found in the given folder path :/') + self.cache = JSONCache('inkycal_slideshow') + self.cache_data = self.cache.read() + # set a 'first run' signal self._first_run = True # give an OK message - print(f'{__name__} loaded') + logger.debug(f'{__name__} loaded') def generate_image(self): """Generate image for this module""" @@ -86,17 +87,19 @@ class Slideshow(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.debug(f'Image size: {im_size}') # rotates list items by 1 index - def rotate(somelist): - return somelist[1:] + somelist[:1] + def rotate(list: list): + return list[1:] + list[:1] # Switch to the next image if this is not the first run if self._first_run: self._first_run = False + self.cache_data["current_index"] = 0 else: self.images = rotate(self.images) + self.cache_data["current_index"] = (self.cache_data["current_index"] + 1) % len(self.images) # initialize custom image class im = Images() @@ -110,7 +113,7 @@ class Slideshow(inkycal_module): # Remove background if present im.remove_alpha() - # if autoflip was enabled, flip the image + # if auto-flip was enabled, flip the image if self.autoflip: im.autoflip(self.orientation) @@ -123,6 +126,8 @@ class Slideshow(inkycal_module): # with the images now send, clear the current image im.clear() + self.cache.write(self.cache_data) + # return images return im_black, im_colour diff --git a/inkycal/modules/inkycal_stocks.py b/inkycal/modules/inkycal_stocks.py index cf58a41..4e7538f 100755 --- a/inkycal/modules/inkycal_stocks.py +++ b/inkycal/modules/inkycal_stocks.py @@ -54,7 +54,7 @@ class Stocks(inkycal_module): self.tickers = config['tickers'] # give an OK message - print(f'{__name__} loaded') + logger.debug(f'{__name__} loaded') def generate_image(self): """Generate image for this module""" @@ -63,7 +63,7 @@ class Stocks(inkycal_module): 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_width} x {im_height} px') + logger.debug(f'image size: {im_width} x {im_height} px') # Create an image for black pixels and one for coloured pixels (required) im_black = Image.new('RGB', size=im_size, color='white') @@ -142,7 +142,7 @@ class Stocks(inkycal_module): logger.warning(f"Failed to get '{stockName}' ticker price hint! Using " "default precision of 2 instead.") - stockHistory = yfTicker.history("30d") + stockHistory = yfTicker.history("1mo") stockHistoryLen = len(stockHistory) logger.info(f'fetched {stockHistoryLen} datapoints ...') previousQuote = (stockHistory.tail(2)['Close'].iloc[0]) diff --git a/inkycal/modules/inkycal_textfile_to_display.py b/inkycal/modules/inkycal_textfile_to_display.py index 7dc4987..dc36ad1 100644 --- a/inkycal/modules/inkycal_textfile_to_display.py +++ b/inkycal/modules/inkycal_textfile_to_display.py @@ -31,7 +31,7 @@ class TextToDisplay(inkycal_module): self.make_request = True if self.filepath.startswith("https://") else False # give an OK message - print(f'{__name__} loaded') + logger.debug(f'{__name__} loaded') def _validate(self): """Validate module-specific parameters""" @@ -45,7 +45,7 @@ class TextToDisplay(inkycal_module): 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}') + logger.debug(f'Image size: {im_size}') # Create an image for black pixels and one for coloured pixels im_black = Image.new('RGB', size=im_size, color='white') diff --git a/inkycal/modules/inkycal_tindie.py b/inkycal/modules/inkycal_tindie.py index 24b51cb..bf2b92b 100755 --- a/inkycal/modules/inkycal_tindie.py +++ b/inkycal/modules/inkycal_tindie.py @@ -32,7 +32,7 @@ class Tindie(inkycal_module): # self.mode = config['mode'] # unshipped_orders, shipped_orders, all_orders # give an OK message - print(f'{__name__} loaded') + logger.debug(f'{__name__} loaded') def generate_image(self): """Generate image for this module""" @@ -40,7 +40,7 @@ class Tindie(inkycal_module): 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_width} x {im_height} px') + logger.debug(f'image size: {im_width} x {im_height} px') # Create an image for black pixels and one for coloured pixels im_black = Image.new('RGB', size=im_size, color='white') @@ -50,6 +50,7 @@ class Tindie(inkycal_module): if internet_available(): logger.info('Connection test passed') else: + logger.error("Network not reachable. Please check your connection.") raise NetworkNotReachableError # Set some parameters for formatting feeds diff --git a/inkycal/modules/inkycal_todoist.py b/inkycal/modules/inkycal_todoist.py index 55e725e..0e95585 100644 --- a/inkycal/modules/inkycal_todoist.py +++ b/inkycal/modules/inkycal_todoist.py @@ -56,7 +56,7 @@ class Todoist(inkycal_module): self._api = TodoistAPI(config['api_key']) # give an OK message - print(f'{__name__} loaded') + logger.debug(f'{__name__} loaded') def _validate(self): """Validate module-specific parameters""" @@ -70,7 +70,7 @@ class Todoist(inkycal_module): 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}') + logger.debug(f'Image size: {im_size}') # Create an image for black pixels and one for coloured pixels im_black = Image.new('RGB', size=im_size, color='white') @@ -80,6 +80,7 @@ class Todoist(inkycal_module): if internet_available(): logger.info('Connection test passed') else: + logger.error("Network not reachable. Please check your connection.") raise NetworkNotReachableError # Set some parameters for formatting todos diff --git a/inkycal/modules/inkycal_weather.py b/inkycal/modules/inkycal_weather.py index 6bd61c1..2c32b0b 100644 --- a/inkycal/modules/inkycal_weather.py +++ b/inkycal/modules/inkycal_weather.py @@ -143,7 +143,7 @@ class Weather(inkycal_module): self.tempDispUnit = "°" # give an OK message - print(f"{__name__} loaded") + logger.debug(f"{__name__} loaded") def generate_image(self): """Generate image for this module""" @@ -152,7 +152,7 @@ class Weather(inkycal_module): 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}') + logger.debug(f'Image size: {im_size}') # Create an image for black pixels and one for coloured pixels im_black = Image.new('RGB', size=im_size, color='white') @@ -160,8 +160,9 @@ class Weather(inkycal_module): # Check if internet is available if internet_available(): - logger.info('Connection test passed') + logger.debug('Connection test passed') else: + logger.error("Network not reachable. Please check your connection.") raise NetworkNotReachableError def get_moon_phase(): diff --git a/inkycal/modules/inkycal_webshot.py b/inkycal/modules/inkycal_webshot.py index 50b7fb0..5f72351 100644 --- a/inkycal/modules/inkycal_webshot.py +++ b/inkycal/modules/inkycal_webshot.py @@ -83,7 +83,7 @@ class Webshot(inkycal_module): raise Exception("Rotation must be either 0, 90, 180 or 270") # give an OK message - print(f'Inkycal webshot loaded') + logger.debug(f'Inkycal webshot loaded') def generate_image(self): """Generate image for this module""" @@ -99,7 +99,7 @@ class Webshot(inkycal_module): 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('image size: {} x {} px'.format(im_width, im_height)) + logger.debug('image size: {} x {} px'.format(im_width, im_height)) # Create an image for black pixels and one for coloured pixels (required) im_black = Image.new('RGB', size=im_size, color='white') @@ -109,6 +109,7 @@ class Webshot(inkycal_module): if internet_available(): logger.info('Connection test passed') else: + logger.error("Network not reachable. Please check your connection.") raise Exception('Network could not be reached :/') logger.info( diff --git a/inkycal/modules/inkycal_xkcd.py b/inkycal/modules/inkycal_xkcd.py index b2ce25e..864e15c 100644 --- a/inkycal/modules/inkycal_xkcd.py +++ b/inkycal/modules/inkycal_xkcd.py @@ -11,6 +11,8 @@ from inkycal.modules.template import inkycal_module logger = logging.getLogger(__name__) +settings = Settings() + class Xkcd(inkycal_module): name = "xkcd - Displays comics from xkcd.com by Randall Munroe" @@ -51,13 +53,13 @@ class Xkcd(inkycal_module): self.scale_filter = config['filter'] # give an OK message - print(f'Inkycal XKCD loaded') + logger.debug(f'Inkycal XKCD loaded') def generate_image(self): """Generate image for this module""" # Create tmp path - tmpPath = f"{top_level}/temp" + tmpPath = settings.TEMPORARY_FOLDER if not os.path.exists(tmpPath): os.mkdir(tmpPath) @@ -66,7 +68,7 @@ class Xkcd(inkycal_module): 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('image size: {} x {} px'.format(im_width, im_height)) + logger.debug('image size: {} x {} px'.format(im_width, im_height)) # Create an image for black pixels and one for coloured pixels (required) im_black = Image.new('RGB', size=im_size, color='white') @@ -76,6 +78,7 @@ class Xkcd(inkycal_module): if internet_available(): logger.info('Connection test passed') else: + logger.error("Network not reachable. Please check your connection.") raise Exception('Network could not be reached :/') # Set some parameters for formatting feeds diff --git a/inkycal/settings.py b/inkycal/settings.py new file mode 100644 index 0000000..8f40352 --- /dev/null +++ b/inkycal/settings.py @@ -0,0 +1,20 @@ +"""Settings class +Used to initialize the settings for the application. +""" +import os + +basedir = os.path.abspath(os.path.dirname(__file__)) + + +class Settings: + """Settings class to initialize the settings for the application. + + """ + CACHE_PATH = os.path.join(basedir, "cache") + LOG_PATH = os.path.join(basedir, "logs") + INKYCAL_LOG_PATH = os.path.join(LOG_PATH, "inkycal.log") + FONT_PATH = os.path.join(basedir, "../fonts") + IMAGE_FOLDER = os.path.join(basedir, "../image_folder") + PARALLEL_DRIVER_PATH = os.path.join(basedir, "inkycal", "display", "drivers", "parallel_drivers") + TEMPORARY_FOLDER = os.path.join(basedir, "tmp") + VCOM = "2.0" diff --git a/inkycal/utils/__init__.py b/inkycal/utils/__init__.py new file mode 100644 index 0000000..e0a85ab --- /dev/null +++ b/inkycal/utils/__init__.py @@ -0,0 +1,2 @@ +from .pisugar import PiSugar +from .json_cache import JSONCache \ No newline at end of file diff --git a/inkycal/utils/json_cache.py b/inkycal/utils/json_cache.py new file mode 100644 index 0000000..76e8e19 --- /dev/null +++ b/inkycal/utils/json_cache.py @@ -0,0 +1,28 @@ +"""JSON Cache +Can be used to cache JSON data to disk. This is useful for caching data to survive reboots. +""" +import json +import os + +from inkycal.settings import Settings + +settings = Settings() + + +class JSONCache: + def __init__(self, name: str, create_if_not_exists: bool = True): + self.path = os.path.join(settings.CACHE_PATH,f"{name}.json") + if create_if_not_exists and not os.path.exists(self.path): + with open(self.path, "w", encoding="utf-8") as file: + json.dump({}, file) + + def read(self): + try: + with open(self.path, "r", encoding="utf-8") as file: + return json.load(file) + except FileNotFoundError: + return {} + + def write(self, data: dict): + with open(self.path, "w", encoding="utf-8") as file: + json.dump(data, file, indent=4, sort_keys=True) diff --git a/inkycal/utils/pisugar.py b/inkycal/utils/pisugar.py new file mode 100644 index 0000000..0c89911 --- /dev/null +++ b/inkycal/utils/pisugar.py @@ -0,0 +1,147 @@ +"""PiSugar helper class for Inkycal.""" + +import logging +import subprocess + +from inkycal.settings import Settings +import arrow + +settings = Settings() + +logger = logging.getLogger(__name__) + + +class PiSugar: + + def __init__(self): + # replace "command" with actual command + self.command_template = 'echo "command" | nc -q 0 127.0.0.1 8423' + self.allowed_commands = ["get battery", "get model", "get rtc_time", "get rtc_alarm_enabled", + "get rtc_alarm_time", "get alarm_repeat", "rtc_pi2rtc", "rtc_alarm_set"] + + def _get_output(self, command, param=None): + if command not in self.allowed_commands: + logger.error(f"Command {command} not allowed") + return None + if param: + cmd = self.command_template.replace("command", f"{command} {param}") + else: + cmd = self.command_template.replace("command", command) + try: + result = subprocess.run(cmd, shell=True, text=True, capture_output=True) + if result.returncode != 0: + print(f"Command failed with {result.stderr}") + return None + output = result.stdout.strip() + return output + except Exception as e: + logger.error(f"Error executing command: {e}") + return None + + def get_battery(self) -> float or None: + """Get the battery level in percentage. + + Returns: + int or None: The battery level in percentage or None if the command fails. + """ + battery_output = self._get_output("get battery") + if battery_output: + for line in battery_output.splitlines(): + if 'battery:' in line: + return float(line.split(':')[1].strip()) + return None + + def get_model(self) -> str or None: + """Get the PiSugar model.""" + model_output = self._get_output("get model") + if model_output: + for line in model_output.splitlines(): + if 'model:' in line: + return line.split(':')[1].strip() + return None + + def get_rtc_time(self) -> arrow.arrow or None: + """Get the RTC time.""" + result = self._get_output("get rtc_time") + if result: + rtc_time = result.split("rtc_time: ")[1].strip() + return arrow.get(rtc_time) + return None + + def get_rtc_alarm_enabled(self) -> str or None: + """Get the RTC alarm enabled status.""" + result = self._get_output("get rtc_alarm_enabled") + if result: + second_line = result.splitlines()[1] + output = second_line.split('rtc_alarm_enabled: ')[1].strip() + return True if output == "true" else False + return None + + def get_rtc_alarm_time(self) -> arrow.arrow or None: + """Get the RTC alarm time.""" + result = self._get_output("get rtc_alarm_time") + if result: + alarm_time = result.split('rtc_alarm_time: ')[1].strip() + return arrow.get(alarm_time) + return None + + def get_alarm_repeat(self) -> dict or None: + """Get the alarm repeat status. + + Returns: + dict or None: A dictionary with the alarm repeating days or None if the command fails. + """ + result = self._get_output("get alarm_repeat") + if result: + repeating_days = f"{int(result.split('alarm_repeat: ')[1].strip()):8b}".strip() + data = {"Monday": False, "Tuesday": False, "Wednesday": False, "Thursday": False, "Friday": False, + "Saturday": False, "Sunday": False} + if repeating_days[0] == "1": + data["Monday"] = True + if repeating_days[1] == "1": + data["Tuesday"] = True + if repeating_days[2] == "1": + data["Wednesday"] = True + if repeating_days[3] == "1": + data["Thursday"] = True + if repeating_days[4] == "1": + data["Friday"] = True + if repeating_days[5] == "1": + data["Saturday"] = True + if repeating_days[6] == "1": + data["Sunday"] = True + return data + return None + + def rtc_pi2rtc(self) -> bool: + """Sync the Pi time to RTC. + + Returns: + bool: True if the sync was successful, False otherwise. + """ + result = self._get_output("rtc_pi2rtc") + if result: + status = result.split('rtc_pi2rtc: ')[1].strip() + if status == "done": + return True + return False + + def rtc_alarm_set(self, time: arrow.arrow, repeat:int=127) -> bool: + """Set the RTC alarm time. + + Args: + time (arrow.arrow): The alarm time in ISO 8601 format. + repeat: int representing 7-bit binary number of repeating days. e.g. 127 = 1111111 = repeat every day + + Returns: + bool: True if the alarm was set successfully, False otherwise. + """ + iso_format = time.isoformat() + result = self._get_output("rtc_alarm_set", f"{iso_format } {repeat}") + if result: + status = result.split('rtc_alarm_set: ')[1].strip() + if status == "done": + return True + return False + + diff --git a/requirements.txt b/requirements.txt index 24d970a..75ea61b 100644 --- a/requirements.txt +++ b/requirements.txt @@ -51,4 +51,4 @@ virtualenv==20.25.0 webencodings==0.5.1 x-wr-timezone==0.0.6 xkcd==2.4.2 -yfinance==0.2.36 +yfinance==0.2.40 diff --git a/tests/test_inkycal_agenda.py b/tests/test_inkycal_agenda.py index cc4903a..685bb86 100755 --- a/tests/test_inkycal_agenda.py +++ b/tests/test_inkycal_agenda.py @@ -28,7 +28,7 @@ tests = [ "padding_x": 10, "padding_y": 10, "fontsize": 12, - "language": "en" + "language": "de" } }, { diff --git a/tests/test_inkycal_webshot.py b/tests/test_inkycal_webshot.py index d8e1b12..073a437 100755 --- a/tests/test_inkycal_webshot.py +++ b/tests/test_inkycal_webshot.py @@ -6,10 +6,15 @@ import logging import unittest from inkycal.modules import Webshot +from inkycal.modules.inky_image import Inkyimage +from tests import Config logger = logging.getLogger(__name__) logging.basicConfig(level=logging.DEBUG) +preview = Inkyimage.preview +merge = Inkyimage.merge + tests = [ { "position": 1, @@ -63,6 +68,7 @@ class TestWebshot(unittest.TestCase): for test in tests: logger.info(f'test {tests.index(test) + 1} generating image..') module = Webshot(test) - module.generate_image() + im_black, im_colour = module.generate_image() + if Config.USE_PREVIEW: + preview(merge(im_black, im_colour)) logger.info('OK') - diff --git a/tests/test_main.py b/tests/test_main.py index ceb834c..e8c30af 100644 --- a/tests/test_main.py +++ b/tests/test_main.py @@ -21,9 +21,9 @@ class TestMain(unittest.TestCase): assert inkycal.settings["info_section_height"] == 70 assert inkycal.settings["border_around_modules"] is True - def test_run(self): + def test_dry_run(self): inkycal = Inkycal(self.settings_path, render=False) - inkycal.test() + inkycal.dry_run() def test_countdown(self): inkycal = Inkycal(self.settings_path, render=False)