From 49b0d7cc652b506caecad6b1055fd614c6d4ac66 Mon Sep 17 00:00:00 2001 From: Ace Date: Sat, 21 Nov 2020 16:31:00 +0100 Subject: [PATCH] Adapted main file to support new web-ui A lot of work behind the scenes. Logging is now handled by main file now. Improved logging, support for logging from modules, improved support for info section, added support for info section height, slight improvements in printing output --- inkycal/main.py | 372 ++++++++++++++++++++++++++++++++++++++++++------ 1 file changed, 325 insertions(+), 47 deletions(-) diff --git a/inkycal/main.py b/inkycal/main.py index 1270c8f..504d423 100644 --- a/inkycal/main.py +++ b/inkycal/main.py @@ -27,18 +27,22 @@ except ImportError: print('numpy is not installed! Please install with:') print('pip3 install numpy') -filename = os.path.basename(__file__).split('.py')[0] -logger = logging.getLogger(filename) -logger.setLevel(level=logging.ERROR) +logging.basicConfig( + level = logging.INFO, #DEBUG > #INFO > #ERROR > #WARNING > #CRITICAL + format='%(name)s -> %(levelname)s -> %(asctime)s -> %(message)s', + datefmt='%d-%m-%Y %H:%M') +logger = logging.getLogger('inykcal main') class Inkycal: """Inkycal main class""" - def __init__(self, settings_path, render=True): + def __init__(self, settings_path=None, render=True): """Initialise Inkycal - settings_path = str -> location/folder of settings file - render = bool -> show something on the ePaper? + settings_path = str -> the full path to your settings.json file + if no path is given, try looking for settings file in /boot folder + + render = bool (True/False) -> show the image on the epaper display? """ self._release = '2.0.0' @@ -48,75 +52,82 @@ class Inkycal: self.render = render # load settings file - throw an error if file could not be found - try: - with open(settings_path) as file: - settings = json.load(file) - self.settings = settings + if settings_path: + try: + with open(settings_path) as settings_file: + settings = json.load(settings_file) + self.settings = settings - except FileNotFoundError: - print('No settings file found in specified location') - print('Please double check your path') + except FileNotFoundError: + print('No settings file found in given path\n' + 'Please double check your settings_path') + return - # Option to use epaper image optimisation + else: + try: + with open('/boot/settings.json') as settings_file: + settings = json.load(settings_file) + self.settings = settings + + except FileNotFoundError: + print('No settings file found in /boot') + return + + + # Option to use epaper image optimisation, reduces colours self.optimize = True + # Init Display class with model in settings file + from inkycal.display import Display + self.Display = Display(settings["model"]) + # Load drivers if image should be rendered if self.render == True: - # Init Display class with model in settings file - from inkycal.display import Display - self.Display = Display(settings["model"]) - # check if colours can be rendered self.supports_colour = True if 'colour' in settings['model'] else False # init calibration state self._calibration_state = False - - - # WIP + # Load and intialize modules specified in the settings file + self._module_number = 1 for module in settings['modules']: + module_name = module['name'] try: - loader = f'from inkycal.modules import {module["name"]}' - print(loader) - conf = module["config"] - #size, conf = module_data['size'], module_data['config'] - setup = f'self.{module} = {module}(size, conf)' + loader = f'from inkycal.modules import {module_name}' + # print(loader) exec(loader) + setup = f'self.module_{self._module_number} = {module_name}({module})' + # print(setup) exec(setup) - logger.debug(('{}: size: {}, config: {}'.format(module, size, conf))) + logger.info(('name : {name} size : {width}x{height} px'.format( + name = module_name, + width = module['config']['size'][0], + height = module['config']['size'][1]))) + + self._module_number += 1 # If a module was not found, print an error message except ImportError: - print( - 'Could not find module: "{}". Please try to import manually.'.format( - module)) + print('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)) + # Path to store images + self.image_folder = top_level+'/images' + # Give an OK message print('loaded inkycal') def countdown(self, interval_mins=None): """Returns the remaining time in seconds until next display update""" - # Validate update interval - allowed_intervals = [10, 15, 20, 30, 60] - # Check if empty, if empty, use value from settings file if interval_mins == None: - interval_mins = self.settings.update_interval - - # Check if integer - if not isinstance(interval_mins, int): - raise Exception('Update interval must be an integer -> 60') - - # Check if value is supported - if interval_mins not in allowed_intervals: - raise Exception('Update interval is {}, but should be one of: {}'.format( - interval_mins, allowed_intervals)) + interval_mins = self.settings["update_interval"] # Find out at which minutes the update should happen now = arrow.now() @@ -127,7 +138,7 @@ class Inkycal: minutes = [_ for _ in update_timings if _>= now.minute][0] - now.minute # Print the remaining time in mins until next update - print('{0} Minutes left until next refresh'.format(minutes)) + print(f'{minutes} Minutes left until next refresh') # Calculate time in seconds until next update remaining_time = minutes*60 + (60 - now.second) @@ -136,17 +147,284 @@ class Inkycal: return remaining_time + def test(self): + """Inkycal test run + Generates images for each module, one by one and prints OK if no + problems were found.""" + + print(f'Inkycal version: v{self._release}') + print(f'Selected E-paper display: {self.settings["model"]}') + + # store module numbers in here + errors = [] + + for number in range(1, self._module_number): + name = eval(f"self.module_{number}.name") + generate_im = f'black,colour=self.module_{number}.generate_image()' + save_black = f'black.save("{self.image_folder}/module{number}_black.png", "PNG")' + save_colour = f'colour.save("{self.image_folder}/module{number}_colour.png", "PNG")' + full_command = generate_im+'\n'+save_black+'\n'+save_colour + #print(full_command) + + print(f'generating image(s) for {name}...') + try: + exec(full_command) + except Exception as Error: + errors.append(number) + print('Error!') + print(traceback.format_exc()) + + if errors: + print('Error/s in modules:',*errors) + del errors + + def run(self): + """Runs the main inykcal program nonstop (cannot be stopped anymore!) + Will show something on the display if render was set to True""" + + # 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"]}') + + while True: + print(f"Date: {runtime.format('D MMM YY')} | Time: {runtime.format('HH:mm')}") + print('Generating images for all modules...') + + errors = [] # store module numbers in here + + # short info for info-section + self.info = f"{runtime.format('D MMM @ HH:mm')} " + + for number in range(1, self._module_number): + name = eval(f"self.module_{number}.name") + generate_im = f'black,colour=self.module_{number}.generate_image()' + save_black = f'black.save("{self.image_folder}/module{number}_black.png", "PNG")' + save_colour = f'colour.save("{self.image_folder}/module{number}_colour.png", "PNG")' + full_command = generate_im+'\n'+save_black+'\n'+save_colour + + try: + exec(full_command) + print('OK!') + self.info += f"module {number}: OK " + except Exception as Error: + errors.append(number) + print('Error!') + print(traceback.format_exc()) + self.info += f"module {number}: Error! " + + if errors: + print('Error/s in modules:',*errors) + counter = 0 + else: + counter += 1 + print('successful') + del errors + + # Assemble image from each module - add info section if specified + self._assemble() + + # Check if image should be rendered + if self.render == True: + Display = self.Display + + self._calibration_check() + + if self.supports_colour == True: + im_black = Image.open(f"{self.image_folder}/canvas.png") + im_colour = Image.open(f"{self.image_folder}/canvas_colour.png") + + # Flip the image by 180° if required + if self.settings['orientaton'] == 180: + im_black = upside_down(im_black) + im_colour = upside_down(im_colour) + + # render the image on the display + Display.render(im_black, im_colour) + + # Part for black-white ePapers + elif self.supports_colour == False: + + im_black = self._merge_bands() + + # Flip the image by 180° if required + if self.settings['orientaton'] == 180: + im_black = upside_down(im_black) + + Display.render(im_black) + + print('\ninkycal has been running without any errors for ' + f"{counter} display updates \n" + f'Programm started {runtime.humanize()}') + + sleep_time = self.countdown() + time.sleep(sleep_time) + + def _merge_bands(self): + """Merges black and coloured bands for black-white ePapers + returns the merged image + """ + + im_path = images + + im1_path, im2_path = images+'canvas.png', images+'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): + + im1 = Image.open(im1_path).convert('RGBA') + im2 = Image.open(im2_path).convert('RGBA') + + def clear_white(img): + """Replace all white pixels from image with transparent pixels + """ + x = numpy.asarray(img.convert('RGBA')).copy() + x[:, :, 3] = (255 * (x[:, :, :3] != 255).any(axis=2)).astype(numpy.uint8) + return Image.fromarray(x) + + im2 = clear_white(im2) + im1.paste(im2, (0,0), im2) + + # If there is no image for the coloured-band, return the bw-image + elif os.path.exists(im1_path) and not os.path.exists(im2_path): + im1 = Image.open(im1_name).convert('RGBA') + + return im1 + def _assemble(self): + """Assembles all sub-images to a single image""" + + # Create 2 blank images with the same resolution as the display + width, height = self.Display.get_display_size(self.settings["model"]) + + # Since Inkycal runs in vertical mode, switch the height and width + width, height = height, width + + im_black = Image.new('RGB', (width, height), color = 'white') + im_colour = Image.new('RGB', (width ,height), color = 'white') + + # Set cursor for y-axis + im1_cursor = 0 + im2_cursor = 0 + + for number in range(1, self._module_number): + + # get the path of the current module's generated images + im1_path = f"{self.image_folder}/module{number}_black.png" + im2_path = f"{self.image_folder}/module{number}_colour.png" + + # Check if there is an image for the black band + if os.path.exists(im1_path): + + # Get actual size of image + im1 = Image.open(im1_path).convert('RGBA') + im1_size = im1.size + + # Get the size of the section + section_size = [i for i in self.settings['modules'] if \ + i['position'] == number][0]['config']['size'] + + # Calculate coordinates to center the image + x = int( (section_size[0] - im1_size[0]) /2) + + # If this is the first module, use the y-offset + if im1_cursor == 0: + y = int( (section_size[1]-im1_size[1]) /2) + else: + y = im1_cursor + int( (section_size[1]-im1_size[1]) /2) + + # center the image in the section space + im_black.paste(im1, (x,y), im1) + + # Shift the y-axis cursor at the beginning of next section + im1_cursor += section_size[1] + + # Check if there is an image for the coloured band + if os.path.exists(im2_path): + + # Get actual size of image + im2 = Image.open(im2_path).convert('RGBA') + im2_size = im2.size + + # Get the size of the section + section_size = [i for i in self.settings['modules'] if \ + i['position'] == number][0]['config']['size'] + + # Calculate coordinates to center the image + x = int( (section_size[0]-im2_size[0]) /2) + + # If this is the first module, use the y-offset + if im2_cursor == 0: + y = int( (section_size[1]-im2_size[1]) /2) + else: + y = im2_cursor + int( (section_size[1]-im2_size[1]) /2) + + # center the image in the section space + im_colour.paste(im2, (x,y), im2) + + # Shift the y-axis cursor at the beginning of next section + im2_cursor += section_size[1] + # Add info-section if specified -- + # Calculate the max. fontsize for info-section + if self.settings['info_section'] == True: + info_height = self.settings["info_section_height"] + info_width = width + font = self.font = ImageFont.truetype( + fonts['NotoSansUI-Regular'], size = 14) + info_x = im_black.size[1] - info_height + write(im_black, (0, info_x), (info_width, info_height), + self.info, font = font) + # optimize the image by mapping colours to pure black and white + if self.optimize == True: + im_black = self._optimize_im(im_black) + im_colour = self._optimize_im(im_colour) + im_black.save(self.image_folder+'/canvas.png', 'PNG') + im_colour.save(self.image_folder+'/canvas_colour.png', 'PNG') + def _optimize_im(self, image, threshold=220): + """Optimize the image for rendering on ePaper displays""" + + buffer = numpy.array(image.convert('RGB')) + red, green = buffer[:, :, 0], buffer[:, :, 1] + + # grey->black + buffer[numpy.logical_and(red <= threshold, green <= threshold)] = [0,0,0] + image = Image.fromarray(buffer) + return image + + def calibrate(self): + """Calibrate the ePaper display to prevent burn-ins (ghosting) + use this command to manually calibrate the display""" + + self.Display.calibrate() + + def _calibration_check(self): + """Calibration sheduler + uses calibration hours from settings file to check if calibration is due""" + now = arrow.now() + # print('hour:', now.hour, 'hours:', self._calibration_hours) + # print('state:', self._calibration_state) + if now.hour in self._calibration_hours and self._calibration_state == False: + self.calibrate() + self._calibration_state = True + else: + self._calibration_state = False + + # Work in progress : Adding and removing modules - Please stand by if __name__ == '__main__': - print('running {0} in standalone/debug mode'.format(filename)) - - + print('running {0} in standalone/debug mode'.format('inkycal main'))