Inkycal/inkycal/main.py

431 lines
14 KiB
Python
Raw Normal View History

#!/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
2020-06-12 18:16:19 +02:00
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
2020-06-18 16:24:27 +02:00
# init calibration state
2020-06-18 16:24:27 +02:00
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'))