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