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
431 lines
14 KiB
Python
431 lines
14 KiB
Python
#!/usr/bin/python3
|
|
# -*- coding: utf-8 -*-
|
|
|
|
"""
|
|
Main class for inkycal Project
|
|
Copyright by aceisace
|
|
"""
|
|
|
|
from inkycal.display import Display
|
|
from inkycal.custom import *
|
|
import os
|
|
import traceback
|
|
import logging
|
|
import arrow
|
|
import time
|
|
import json
|
|
|
|
try:
|
|
from PIL import Image
|
|
except ImportError:
|
|
print('Pillow is not installed! Please install with:')
|
|
print('pip3 install Pillow')
|
|
|
|
try:
|
|
import numpy
|
|
except ImportError:
|
|
print('numpy is not installed! Please install with:')
|
|
print('pip3 install numpy')
|
|
|
|
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=None, render=True):
|
|
"""Initialise Inkycal
|
|
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'
|
|
|
|
# Check if render was set correctly
|
|
if render not in [True, False]:
|
|
raise Exception('render must be True or False, not "{}"'.format(render))
|
|
self.render = render
|
|
|
|
# load settings file - throw an error if file could not be found
|
|
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 given path\n'
|
|
'Please double check your settings_path')
|
|
return
|
|
|
|
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:
|
|
|
|
# check if colours can be rendered
|
|
self.supports_colour = True if 'colour' in settings['model'] else False
|
|
|
|
# init calibration state
|
|
self._calibration_state = False
|
|
|
|
# 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)
|
|
exec(loader)
|
|
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])))
|
|
|
|
self._module_number += 1
|
|
|
|
# If a module was not found, print an error message
|
|
except ImportError:
|
|
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"""
|
|
|
|
# Check if empty, if empty, use value from settings file
|
|
if interval_mins == None:
|
|
interval_mins = self.settings["update_interval"]
|
|
|
|
# Find out at which minutes the update should happen
|
|
now = arrow.now()
|
|
update_timings = [(60 - int(interval_mins)*updates) for updates in
|
|
range(60//int(interval_mins))][::-1]
|
|
|
|
# Calculate time in mins until next update
|
|
minutes = [_ for _ in update_timings if _>= now.minute][0] - now.minute
|
|
|
|
# Print the remaining time in mins until next update
|
|
print(f'{minutes} Minutes left until next refresh')
|
|
|
|
# Calculate time in seconds until next update
|
|
remaining_time = minutes*60 + (60 - now.second)
|
|
|
|
# Return seconds until next update
|
|
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('inkycal main'))
|