Initial commit for release v2.0.0

A  lot of work-in-progress and far from complete.
Lots of improvements related to user-friendliness, fully new web-UI. Better infrastructure....
more coming soon
This commit is contained in:
Ace
2020-11-09 17:51:15 +01:00
parent b6c2534644
commit 29788f0313
33 changed files with 2587 additions and 1765 deletions

View File

@@ -1,15 +1,17 @@
# Settings and Layout
from inkycal.config.layout import Layout
from inkycal.config.settings_parser import Settings
#from inkycal.config.layout import Layout
#from inkycal.config.settings_parser import Settings
from inkycal.display import Display
# All supported inkycal_modules
import inkycal.modules.inkycal_agenda
import inkycal.modules.inkycal_calendar
import inkycal.modules.inkycal_weather
import inkycal.modules.inkycal_rss
# import inkycal.modules.inkycal_image
#import inkycal.modules.inkycal_image
# import inkycal.modules.inkycal_server
# Main file
from inkycal.main import Inkycal
# Added by module adder

474
inkycal/backup.py Normal file
View File

@@ -0,0 +1,474 @@
from inkycal import Settings, Layout
from inkycal.custom import *
#from os.path import exists
import os
import traceback
import logging
import arrow
import time
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')
logger = logging.getLogger('inkycal')
logger.setLevel(level=logging.ERROR)
class Inkycal:
"""Inkycal main class"""
def __init__(self, settings_path, render=True):
"""Initialise Inkycal
settings_path = str -> location/folder of settings file
render = bool -> show something on the ePaper?
"""
self._release = '2.0.0beta'
# Check if render is boolean
if not isinstance(render, bool):
raise Exception('render must be True or False, not "{}"'.format(render))
self.render = render
# Init settings class
self.Settings = Settings(settings_path)
# Check if display support colour
self.supports_colour = self.Settings.Layout.supports_colour
# Option to flip image upside down
if self.Settings.display_orientation == 'normal':
self.upside_down = False
elif self.Settings.display_orientation == 'upside_down':
self.upside_down = True
# Option to use epaper image optimisation
self.optimize = True
# Load drivers if image should be rendered
if self.render == True:
# Get model and check if colour can be rendered
model= self.Settings.model
# Init Display class
from inkycal.display import Display
self.Display = Display(model)
# get calibration hours
self._calibration_hours = self.Settings.calibration_hours
# set a check for calibration
self._calibration_state = False
# load+validate settings file. Import and setup specified modules
self.active_modules = self.Settings.active_modules()
for module in self.active_modules:
try:
loader = 'from inkycal.modules import {0}'.format(module)
module_data = self.Settings.get_config(module)
size, conf = module_data['size'], module_data['config']
setup = 'self.{} = {}(size, conf)'.format(module, module)
exec(loader)
exec(setup)
logger.debug(('{}: size: {}, config: {}'.format(module, size, conf)))
# If a module was not found, print an error message
except ImportError:
print(
'Could not find module: "{}". Please try to import manually.'.format(
module))
# 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))
# 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('{0} Minutes left until next refresh'.format(minutes))
# 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('You are running inkycal v{}'.format(self._release))
print('Running inkycal test-run for {} ePaper'.format(
self.Settings.model))
if self.upside_down == True:
print('upside-down mode active')
for module in self.active_modules:
generate_im = 'self.{0}.generate_image()'.format(module)
print('generating image for {} module...'.format(module), end = '')
try:
exec(generate_im)
print('OK!')
except Exception as Error:
print('Error!')
print(traceback.format_exc())
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"""
# TODO: printing traceback on display (or at least a smaller message?)
# Calibration
# 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 = 1
# Calculate the max. fontsize for info-section
if self.Settings.info_section == True:
info_section_height = round(self.Settings.Layout.display_height* (1/95) )
self.font = auto_fontsize(ImageFont.truetype(
fonts['NotoSans-SemiCondensed']), info_section_height)
while True:
print('Generating images for all modules...')
for module in self.active_modules:
generate_im = 'self.{0}.generate_image()'.format(module)
try:
exec(generate_im)
except Exception as Error:
print('Error!')
message = traceback.format_exc()
print(message)
counter = 0
print('OK')
# Assemble image from each module
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(images+'canvas.png')
im_colour = Image.open(images+'canvas_colour.png')
# Flip the image by 180° if required
if self.upside_down == True:
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.upside_down == True:
im_black = upside_down(im_black)
Display.render(im_black)
print('\ninkycal has been running without any errors for', end = ' ')
print('{} display updates'.format(counter))
print('Programm started {}'.format(runtime.humanize()))
counter += 1
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):
"""Assmebles all sub-images to a single image"""
# Create an empty canvas with the size of the display
width, height = self.Settings.Layout.display_size
if self.Settings.info_section == True:
height = round(height * ((1/95)*100) )
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 module in self.active_modules:
im1_path = images+module+'.png'
im2_path = images+module+'_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 = self.Settings.get_config(module)['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 = self.Settings.get_config(module)['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]
# Show an info section if specified by the settings file
now = arrow.now()
stamp = 'last update: {}'.format(now.format('D MMM @ HH:mm', locale =
self.Settings.language))
if self.Settings.info_section == True:
write(im_black, (0, im1_cursor), (width, height-im1_cursor),
stamp, font = self.font)
# optimize the image by mapping colours to pure black and white
if self.optimize == True:
self._optimize_im(im_black).save(images+'canvas.png', 'PNG')
self._optimize_im(im_colour).save(images+'canvas_colour.png', 'PNG')
else:
im_black.save(images+'canvas.png', 'PNG')
im_colour.save(images+'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
def _check_for_updates(self):
"""Check if a new update is available for inkycal"""
raise NotImplementedError('Tha developer were too lazy to implement this..')
@staticmethod
def _add_module(filepath_module, classname):
"""Add a third party module to inkycal
filepath_module = the full path of your module. The file should be in /modules!
classname = the name of your class inside the module
"""
# Path for modules
_module_path = 'inkycal/modules/'
# Check if the filepath is a string
if not isinstance(filepath_module, str):
raise ValueError('filepath has to be a string!')
# Check if the classname is a string
if not isinstance(classname, str):
raise ValueError('classname has to be a string!')
# TODO:
# Ensure only third-party modules are deleted as built-in modules
# should not be deleted
# Check if module is inside the modules folder
if not _module_path in filepath_module:
raise Exception('Your module should be in', _module_path)
# Get the name of the third-party module file without extension (.py)
filename = filepath_module.split('.py')[0].split('/')[-1]
# Check if filename or classname is in the current module init file
with open('modules/__init__.py', mode ='r') as module_init:
content = module_init.read().splitlines()
for line in content:
if (filename or clasname) in line:
raise Exception(
'A module with this filename or classname already exists')
# Check if filename or classname is in the current inkycal init file
with open('__init__.py', mode ='r') as inkycal_init:
content = inkycal_init.read().splitlines()
for line in content:
if (filename or clasname) in line:
raise Exception(
'A module with this filename or classname already exists')
# If all checks have passed, add the module in the module init file
with open('modules/__init__.py', mode='a') as module_init:
module_init.write('from .{} import {}'.format(filename, classname))
# If all checks have passed, add the module in the inkycal init file
with open('__init__.py', mode ='a') as inkycal_init:
inkycal_init.write('# Added by module adder \n')
inkycal_init.write('import inkycal.modules.{}'.format(filename))
print('Your module {} has been added successfully! Hooray!'.format(
classname))
@staticmethod
def _remove_module(classname, remove_file = True):
"""Removes a third-party module from inkycal
Input the classname of the file you want to remove
"""
# Check if filename or classname is in the current module init file
with open('modules/__init__.py', mode ='r') as module_init:
content = module_init.read().splitlines()
with open('modules/__init__.py', mode ='w') as module_init:
for line in content:
if not classname in line:
module_init.write(line+'\n')
else:
filename = line.split(' ')[1].split('.')[1]
# Check if filename or classname is in the current inkycal init file
with open('__init__.py', mode ='r') as inkycal_init:
content = inkycal_init.read().splitlines()
with open('__init__.py', mode ='w') as inkycal_init:
for line in content:
if not filename in line:
inkycal_init.write(line+'\n')
# remove the file of the third party module if it exists and remove_file
# was set to True (default)
if os.path.exists('modules/{}.py'.format(filename)) and remove_file == True:
os.remove('modules/{}.py'.format(filename))
print('The module {} has been removed successfully'.format(classname))

View File

@@ -24,8 +24,6 @@ class Layout:
if (model != None) and (width == None) and (height == None):
display_dimensions = {
'9_in_7': (1200, 825),
'epd_7_in_5_v3_colour': (880, 528),
'epd_7_in_5_v3': (880, 528),
'epd_7_in_5_v2_colour': (800, 480),
'epd_7_in_5_v2': (800, 480),
'epd_7_in_5_colour': (640, 384),
@@ -116,6 +114,14 @@ class Layout:
size = (self.bottom_section_width, self.bottom_section_height)
return size
## def set_info_section(self, value):
## """Should a small info section be showed """
## if not isinstance(value, bool):
## raise ValueError('value has to bee a boolean: True/False')
## self.info_section = value
## logger.info(('show info section: {}').format(value))
if __name__ == '__main__':
print('running {0} in standalone/debug mode'.format(
os.path.basename(__file__).split('.py')[0]))

View File

@@ -23,7 +23,6 @@ class Settings:
_supported_update_interval = [10, 15, 20, 30, 60]
_supported_display_orientation = ['normal', 'upside_down']
_supported_models = [
'epd_7_in_5_v3_colour', 'epd_7_in_5_v3',
'epd_7_in_5_v2_colour', 'epd_7_in_5_v2',
'epd_7_in_5_colour', 'epd_7_in_5',
'epd_5_in_83_colour','epd_5_in_83',

View File

@@ -67,7 +67,7 @@ def auto_fontsize(font, max_height):
def write(image, xy, box_size, text, font=None, **kwargs):
"""Write text on specified image
image = on which image should the text be added?
xy = xy-coordinates as tuple -> (x,y)
xy = (x,y) coordinates as tuple -> (x,y)
box_size = size of text-box -> (width,height)
text = string (what to write)
font = which font to use

View File

@@ -1 +1 @@
from .epaper import Display
from .display import Display

130
inkycal/display/display.py Normal file
View File

@@ -0,0 +1,130 @@
#!/usr/bin/python3
# -*- coding: utf-8 -*-
"""
Inky-Calendar epaper functions
Copyright by aceisace
"""
from importlib import import_module
from PIL import Image
from inkycal.custom import top_level
import glob
class Display:
"""Display class for inkycal
Handles rendering on display"""
def __init__(self, epaper_model):
"""Load the drivers for this epaper model"""
if 'colour' in epaper_model:
self.supports_colour = True
else:
self.supports_colour = False
try:
driver_path = f'inkycal.display.drivers.{epaper_model}'
driver = import_module(driver_path)
self._epaper = driver.EPD()
self.model_name = epaper_model
#self.height = driver.EPD_HEIGHT
#self.width = driver.EPD_WIDTH
except ImportError:
raise Exception('This module is not supported. Check your spellings?')
except FileNotFoundError:
raise Exception('SPI could not be found. Please check if SPI is enabled')
def render(self, im_black, im_colour = None):
"""Render an image on the epaper
im_colour is required for three-colour epapers"""
epaper = self._epaper
if self.supports_colour == False:
print('Initialising..', end = '')
epaper.init()
# For the 9.7" ePaper, the image needs to be flipped by 90 deg first
# The other displays flip the image automatically
if self.model_name == "9_in_7":
im_black.rotate(90, expand=True)
print('Updating display......', end = '')
epaper.display(epaper.getbuffer(im_black))
print('Done')
elif self.supports_colour == True:
if not im_colour:
raise Exception('im_colour is required for coloured epaper displays')
print('Initialising..', end = '')
epaper.init()
print('Updating display......', end = '')
epaper.display(epaper.getbuffer(im_black), epaper.getbuffer(im_colour))
print('Done')
print('Sending E-Paper to deep sleep...', end = '')
epaper.sleep()
print('Done')
def calibrate(self, cycles=3):
"""Flush display with single colour to prevent burn-ins (ghosting)
cycles -> int. How many times should each colour be flushed?
recommended cycles = 3"""
epaper = self._epaper
epaper.init()
white = Image.new('1', (epaper.width, epaper.height), 'white')
black = Image.new('1', (epaper.width, epaper.height), 'black')
print('----------Started calibration of ePaper display----------')
if self.supports_colour == True:
for _ in range(cycles):
print('Calibrating...', end= ' ')
print('black...', end= ' ')
epaper.display(epaper.getbuffer(black), epaper.getbuffer(white))
print('colour...', end = ' ')
epaper.display(epaper.getbuffer(white), epaper.getbuffer(black))
print('white...')
epaper.display(epaper.getbuffer(white), epaper.getbuffer(white))
print('Cycle {0} of {1} complete'.format(_+1, cycles))
if self.supports_colour == False:
for _ in range(cycles):
print('Calibrating...', end= ' ')
print('black...', end = ' ')
epaper.display(epaper.getbuffer(black))
print('white...')
epaper.display(epaper.getbuffer(white)),
print('Cycle {0} of {1} complete'.format(_+1, cycles))
print('-----------Calibration complete----------')
epaper.sleep()
@classmethod
def get_display_size(cls, model_name):
"returns (width, height) of given display"
if not isinstance(model_name, str):
print('model_name should be a string')
return
else:
driver_files = top_level+'/inkycal/display/drivers/*.py'
drivers = glob.glob(driver_files)
drivers = [i.split('/')[-1].split('.')[0] for i in drivers]
if model_name not in drivers:
print('This model name was not found. Please double check your spellings')
return
else:
with open(top_level+'/inkycal/display/drivers/'+model_name+'.py') as file:
for line in file:
if 'EPD_WIDTH=' in line.replace(" ", ""):
width = int(line.rstrip().replace(" ", "").split('=')[-1])
if 'EPD_HEIGHT=' in line.replace(" ", ""):
height = int(line.rstrip().replace(" ", "").split('=')[-1])
return width, height
if __name__ == '__main__':
print("Running Display class in standalone mode")

View File

@@ -1,12 +1,19 @@
from inkycal import Settings, Layout
from inkycal.custom import *
#!/usr/bin/python3
# -*- coding: utf-8 -*-
#from os.path import exists
"""
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
@@ -20,36 +27,36 @@ except ImportError:
print('numpy is not installed! Please install with:')
print('pip3 install numpy')
logger = logging.getLogger('inkycal')
filename = os.path.basename(__file__).split('.py')[0]
logger = logging.getLogger(filename)
logger.setLevel(level=logging.ERROR)
class Inkycal:
"""Inkycal main class"""
def __init__(self, settings_path, render=True):
"""initialise class
"""Initialise Inkycal
settings_path = str -> location/folder of settings file
render = bool -> show something on the ePaper?
"""
self._release = '2.0.0beta'
self._release = '2.0.0'
# Check if render is boolean
if not isinstance(render, bool):
# 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
# Init settings class
self.Settings = Settings(settings_path)
# 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
#print(self.settings)
# Check if display support colour
self.supports_colour = self.Settings.Layout.supports_colour
# Option to flip image upside down
if self.Settings.display_orientation == 'normal':
self.upside_down = False
elif self.Settings.display_orientation == 'upside_down':
self.upside_down = True
except FileNotFoundError:
print('No settings file found in specified location')
print('Please double check your path')
# Option to use epaper image optimisation
self.optimize = True
@@ -57,27 +64,26 @@ class Inkycal:
# Load drivers if image should be rendered
if self.render == True:
# Get model and check if colour can be rendered
model= self.Settings.model
# Init Display class
# Init Display class with model in settings file
from inkycal.display import Display
self.Display = Display(model)
self.Display = Display(settings["model"])
# get calibration hours
self._calibration_hours = self.Settings.calibration_hours
# check if colours can be rendered
self.supports_colour = True if 'colour' in settings['model'] else False
# set a check for calibration
# init calibration state
self._calibration_state = False
# load+validate settings file. Import and setup specified modules
self.active_modules = self.Settings.active_modules()
for module in self.active_modules:
# WIP
for module in settings['modules']:
try:
loader = 'from inkycal.modules import {0}'.format(module)
module_data = self.Settings.get_config(module)
size, conf = module_data['size'], module_data['config']
setup = 'self.{} = {}(size, conf)'.format(module, module)
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)'
exec(loader)
exec(setup)
logger.debug(('{}: size: {}, config: {}'.format(module, size, conf)))
@@ -88,6 +94,9 @@ class Inkycal:
'Could not find module: "{}". Please try to import manually.'.format(
module))
except Exception as e:
print(str(e))
# Give an OK message
print('loaded inkycal')
@@ -99,7 +108,7 @@ class Inkycal:
# Check if empty, if empty, use value from settings file
if interval_mins == None:
interval_mins = self.Settings.update_interval
interval_mins = self.settings.update_interval
# Check if integer
if not isinstance(interval_mins, int):
@@ -127,348 +136,18 @@ class Inkycal:
# 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('You are running inkycal v{}'.format(self._release))
print('Running inkycal test-run for {} ePaper'.format(
self.Settings.model))
if self.upside_down == True:
print('upside-down mode active')
for module in self.active_modules:
generate_im = 'self.{0}.generate_image()'.format(module)
print('generating image for {} module...'.format(module), end = '')
try:
exec(generate_im)
print('OK!')
except Exception as Error:
print('Error!')
print(traceback.format_exc())
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"""
# TODO: printing traceback on display (or at least a smaller message?)
# Calibration
# 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 = 1
# Calculate the max. fontsize for info-section
if self.Settings.info_section == True:
info_section_height = round(self.Settings.Layout.display_height* (1/95) )
self.font = auto_fontsize(ImageFont.truetype(
fonts['NotoSans-SemiCondensed']), info_section_height)
while True:
print('Generating images for all modules...')
for module in self.active_modules:
generate_im = 'self.{0}.generate_image()'.format(module)
try:
exec(generate_im)
except Exception as Error:
print('Error!')
message = traceback.format_exc()
print(message)
counter = 0
print('OK')
if __name__ == '__main__':
print('running {0} in standalone/debug mode'.format(filename))
# Assemble image from each module
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(images+'canvas.png')
im_colour = Image.open(images+'canvas_colour.png')
# Flip the image by 180° if required
if self.upside_down == True:
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.upside_down == True:
im_black = upside_down(im_black)
Display.render(im_black)
print('\ninkycal has been running without any errors for', end = ' ')
print('{} display updates'.format(counter))
print('Programm started {}'.format(runtime.humanize()))
counter += 1
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):
"""Assmebles all sub-images to a single image"""
# Create an empty canvas with the size of the display
width, height = self.Settings.Layout.display_size
if self.Settings.info_section == True:
height = round(height * ((1/95)*100) )
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 module in self.active_modules:
im1_path = images+module+'.png'
im2_path = images+module+'_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 = self.Settings.get_config(module)['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 = self.Settings.get_config(module)['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]
# Show an info section if specified by the settings file
now = arrow.now()
stamp = 'last update: {}'.format(now.format('D MMM @ HH:mm', locale =
self.Settings.language))
if self.Settings.info_section == True:
write(im_black, (0, im1_cursor), (width, height-im1_cursor),
stamp, font = self.font)
# optimize the image by mapping colours to pure black and white
if self.optimize == True:
self._optimize_im(im_black).save(images+'canvas.png', 'PNG')
self._optimize_im(im_colour).save(images+'canvas_colour.png', 'PNG')
else:
im_black.save(images+'canvas.png', 'PNG')
im_colour.save(images+'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
def _check_for_updates(self):
"""Check if a new update is available for inkycal"""
raise NotImplementedError('Tha developer were too lazy to implement this..')
@staticmethod
def _add_module(filepath_module, classname):
"""Add a third party module to inkycal
filepath_module = the full path of your module. The file should be in /modules!
classname = the name of your class inside the module
"""
# Path for modules
_module_path = 'inkycal/modules/'
# Check if the filepath is a string
if not isinstance(filepath_module, str):
raise ValueError('filepath has to be a string!')
# Check if the classname is a string
if not isinstance(classname, str):
raise ValueError('classname has to be a string!')
# TODO:
# Ensure only third-party modules are deleted as built-in modules
# should not be deleted
# Check if module is inside the modules folder
if not _module_path in filepath_module:
raise Exception('Your module should be in', _module_path)
# Get the name of the third-party module file without extension (.py)
filename = filepath_module.split('.py')[0].split('/')[-1]
# Check if filename or classname is in the current module init file
with open('modules/__init__.py', mode ='r') as module_init:
content = module_init.read().splitlines()
for line in content:
if (filename or clasname) in line:
raise Exception(
'A module with this filename or classname already exists')
# Check if filename or classname is in the current inkycal init file
with open('__init__.py', mode ='r') as inkycal_init:
content = inkycal_init.read().splitlines()
for line in content:
if (filename or clasname) in line:
raise Exception(
'A module with this filename or classname already exists')
# If all checks have passed, add the module in the module init file
with open('modules/__init__.py', mode='a') as module_init:
module_init.write('from .{} import {}'.format(filename, classname))
# If all checks have passed, add the module in the inkycal init file
with open('__init__.py', mode ='a') as inkycal_init:
inkycal_init.write('# Added by module adder \n')
inkycal_init.write('import inkycal.modules.{}'.format(filename))
print('Your module {} has been added successfully! Hooray!'.format(
classname))
@staticmethod
def _remove_module(classname, remove_file = True):
"""Removes a third-party module from inkycal
Input the classname of the file you want to remove
"""
# Check if filename or classname is in the current module init file
with open('modules/__init__.py', mode ='r') as module_init:
content = module_init.read().splitlines()
with open('modules/__init__.py', mode ='w') as module_init:
for line in content:
if not classname in line:
module_init.write(line+'\n')
else:
filename = line.split(' ')[1].split('.')[1]
# Check if filename or classname is in the current inkycal init file
with open('__init__.py', mode ='r') as inkycal_init:
content = inkycal_init.read().splitlines()
with open('__init__.py', mode ='w') as inkycal_init:
for line in content:
if not filename in line:
inkycal_init.write(line+'\n')
# remove the file of the third party module if it exists and remove_file
# was set to True (default)
if os.path.exists('modules/{}.py'.format(filename)) and remove_file == True:
os.remove('modules/{}.py'.format(filename))
print('The module {} has been removed successfully'.format(classname))

View File

@@ -16,35 +16,60 @@ filename = os.path.basename(__file__).split('.py')[0]
logger = logging.getLogger(filename)
logger.setLevel(level=logging.ERROR)
class Agenda(inkycal_module):
"""Agenda class
Create agenda and show events from given icalendars
"""
name = "Inkycal Agenda"
requires = {
"ical_urls" : {
"label":"iCalendar URL/s, separate multiple ones with a comma",
},
}
optional = {
"ical_files" : {
"label":"iCalendar filepaths, separated with a comma",
"default":[]
},
"date_format":{
"label":"Use an arrow-supported token for custom date formatting "+
"see https://arrow.readthedocs.io/en/stable/#supported-tokens, e.g. ddd D MMM",
"default": "ddd D MMM",
},
"time_format":{
"label":"Use an arrow-supported token for custom time formatting "+
"see https://arrow.readthedocs.io/en/stable/#supported-tokens, e.g. HH:mm",
},
}
def __init__(self, section_size, section_config):
"""Initialize inkycal_agenda module"""
super().__init__(section_size, section_config)
# Module specific parameters
required = ['week_starts_on', 'ical_urls']
for param in required:
for param in self.equires:
if not param in section_config:
raise Exception('config is missing {}'.format(param))
# class name
self.name = self.__class__.__name__
# module specific parameters
self.date_format = 'ddd D MMM'
self.time_format = "HH:mm"
self.date_format = self.config['date_format']
self.time_format = self.config['time_format']
self.language = self.config['language']
self.timezone = get_system_tz()
self.ical_urls = self.config['ical_urls']
self.ical_files = []
self.ical_files = self.config['ical_files']
self.timezone = get_system_tz()
# give an OK message
print('{0} loaded'.format(self.name))
print('{0} loaded'.format(filename))
def _validate(self):
"""Validate module-specific parameters"""
@@ -191,7 +216,6 @@ class Agenda(inkycal_module):
# If no events were found, write only dates and lines
else:
line_pos = [(0, int(line * line_height)) for line in range(max_lines)]
cursor = 0
for _ in agenda_events:
title = _['title']
@@ -206,9 +230,8 @@ class Agenda(inkycal_module):
logger.info('no events found')
# Save image of black and colour channel in image-folder
im_black.save(images+self.name+'.png')
im_colour.save(images+self.name+'_colour.png')
# return the images ready for the display
return im_black, im_colour
if __name__ == '__main__':
print('running {0} in standalone mode'.format(filename))

View File

@@ -19,42 +19,74 @@ class Calendar(inkycal_module):
Create monthly calendar and show events from given icalendars
"""
name = "Inkycal Calendar"
optional = {
"week_starts_on" : {
"label":"When does your week start? (default=Monday)",
"options": ["Monday", "Sunday"],
"default": "Monday"
},
"show_events" : {
"label":"Show parsed events? (default = True)",
"options": [True, False],
"default": True
},
"ical_urls" : {
"label":"iCalendar URL/s, separate multiple ones with a comma",
"default":[]
},
"ical_files" : {
"label":"iCalendar filepaths, separated with a comma",
"default":[]
},
"date_format":{
"label":"Use an arrow-supported token for custom date formatting "+
"see https://arrow.readthedocs.io/en/stable/#supported-tokens, e.g. D MMM",
"default": "D MMM",
},
"time_format":{
"label":"Use an arrow-supported token for custom time formatting "+
"see https://arrow.readthedocs.io/en/stable/#supported-tokens, e.g. HH:mm",
"default": "HH:mm"
},
}
def __init__(self, section_size, section_config):
"""Initialize inkycal_calendar module"""
super().__init__(section_size, section_config)
# Module specific parameters
required = ['week_starts_on']
for param in required:
if not param in section_config:
raise Exception('config is missing {}'.format(param))
# module name
self.name = self.__class__.__name__
# module specific parameters
self.num_font = ImageFont.truetype(
fonts['NotoSans-SemiCondensed'], size = self.fontsize)
self.weekstart = self.config['week_starts_on']
self.show_events = True
self.date_format = 'D MMM'
self.time_format = "HH:mm"
self.show_events = self.config['show_events']
self.date_format = self.config["date_format"]
self.time_format = self.config['time_format']
self.language = self.config['language']
self.timezone = get_system_tz()
self.ical_urls = self.config['ical_urls']
self.ical_files = []
self.ical_files = self.config['ical_files']
# give an OK message
print('{0} loaded'.format(self.name))
print('{0} loaded'.format(filename))
def generate_image(self):
"""Generate image for this module"""
# Define new image size with respect to padding
im_width = int(self.width - (self.width * 2 * self.margin_x))
im_height = int(self.height - (self.height * 2 * self.margin_y))
im_width = int(self.width - (2 * self.padding_x))
im_height = int(self.height - (2 * self.padding_y))
im_size = im_width, im_height
logger.info('Image size: {0}'.format(im_size))
@@ -80,15 +112,7 @@ class Calendar(inkycal_module):
im_width, calendar_height))
# Create grid and calculate icon sizes
now = arrow.now(tz = self.timezone)
monthstart = now.span('month')[0].weekday()
monthdays = now.ceil('month').day
if monthstart > 4 and monthdays == 31:
calendar_rows, calendar_cols = 7, 7
else:
calendar_rows, calendar_cols = 6, 7
calendar_rows, calendar_cols = 6, 7
icon_width = im_width // calendar_cols
icon_height = calendar_height // calendar_rows
@@ -106,6 +130,8 @@ class Calendar(inkycal_module):
weekday_pos = [(grid_start_x + icon_width*_, month_name_height) for _ in
range(calendar_cols)]
now = arrow.now(tz = self.timezone)
# Set weekstart of calendar to specified weekstart
if self.weekstart == "Monday":
cal.setfirstweekday(cal.MONDAY)
@@ -283,9 +309,8 @@ class Calendar(inkycal_module):
(im_width, self.font.getsize(symbol)[1]), symbol,
font = self.font)
# Save image of black and colour channel in image-folder
im_black.save(images+self.name+'.png')
im_colour.save(images+self.name+'_colour.png')
# return the images ready for the display
return im_black, im_colour
if __name__ == '__main__':
print('running {0} in standalone mode'.format(filename))

View File

@@ -1,305 +1,32 @@
#!/usr/bin/python3
# -*- coding: utf-8 -*-
"""
Image module for Inkycal Project
Image module for inkycal Project
Copyright by aceisace
Development satge: Beta
"""
from inkycal.modules.template import inkycal_module
from inkycal.custom import *
from os import path
from PIL import ImageOps
import requests
import numpy
filename = os.path.basename(__file__).split('.py')[0]
logger = logging.getLogger(filename)
logger.setLevel(level=logging.DEBUG)
"""----------------------------------------------------------------"""
#path = 'https://github.com/aceisace/Inky-Calendar/raw/master/Gallery/Inky-Calendar-logo.png'
#path ='/home/pi/Inky-Calendar/images/canvas.png'
path = inkycal_image_path
path_body = inkycal_image_path_body
mode = 'auto' # 'horizontal' # 'vertical' # 'auto'
upside_down = False # Flip image by 180 deg (upside-down)
alignment = 'center' # top_center, top_left, center_left, bottom_right etc.
colours = 'bwr' # bwr # bwy # bw
render = True # show image on E-Paper?
"""----------------------------------------------------------------"""
class Inkyimage(inkycal_module):
"""Image class
display an image from a given path or URL
"""
_allowed_layout = ['fill', 'center', 'fit', 'auto']
_allowed_rotation = [0, 90, 180, 270, 360, 'auto']
_allowed_colours = ['bw', 'bwr', 'bwy']
# First determine dimensions
if mode == 'horizontal':
display_width, display_height == display_height, display_width
def __init__(self, section_size, section_config):
"""Initialize inkycal_rss module"""
super().__init__(section_size, section_config)
# Module specific parameters
required = ['path']
for param in required:
if not param in section_config:
raise Exception('config is missing {}'.format(param))
# module name
self.name = self.__class__.__name__
# module specific parameters
self.image_path = self.config['path']
self.rotation = 0 #0 #90 # 180 # 270 # auto
self.layout = 'fill' # centre # fit # auto
self.colours = 'bw' #grab from settings file?
# give an OK message
print('{0} loaded'.format(self.name))
def _validate(self):
"""Validate module-specific parameters"""
# Validate image_path
if not isinstance(self.image_path, str):
print(
'image_path has to be a string: "URL1" or "/home/pi/Desktop/im.png"')
# Validate layout
if not isinstance(self.layout, str) or (
self.layout not in self._allowed_layout):
print('layout has to be one of the following:', self._allowed_layout)
# Validate rotation angle
if self.rotation not in self._allowed_rotation:
print('rotation has to be one of the following:', self._allowed_rotation)
# Validate colours
if not isinstance(self.colours, str) or (
self.colours not in self._allowed_colours):
print('colour has to be one of the following:', self._allowed_colours)
def generate_image(self):
"""Generate image for this module"""
# Define new image size with respect to padding
im_width = self.width
im_height = self.height
im_size = im_width, im_height
logger.info('image size: {} x {} px'.format(im_width, im_height))
# Try to open the image if it exists and is an image file
try:
if self.image_path.startswith('http'):
logger.debug('identified url')
self.image = Image.open(requests.get(self.image_path, stream=True).raw)
else:
logger.info('identified local path')
self.image = Image.open(self.image_path)
except FileNotFoundError:
raise ('Your file could not be found. Please check the filepath')
except OSError:
raise ('Please check if the path points to an image file.')
logger.debug(('image-width:', self.image.width))
logger.debug(('image-height:', self.image.height))
# Create an image for black pixels and one for coloured pixels
im_black = Image.new('RGB', size = im_size, color = 'white')
im_colour = Image.new('RGB', size = im_size, color = 'white')
# do the required operations
self._remove_alpha()
self._to_layout()
black, colour = self._map_colours()
# paste the imaeges on the canvas
im_black.paste(black, (self.x, self.y))
if colour != None:
im_colour.paste(colour, (self.x, self.y))
# Save image of black and colour channel in image-folder
im_black.save(images+self.name+'.png', 'PNG')
if colour != None:
im_colour.save(images+self.name+'_colour.png', 'PNG')
def _rotate(self, angle=None):
"""Rotate the image to a given angle
angle must be one of :[0, 90, 180, 270, 360, 'auto']
"""
im = self.image
if angle == None:
angle = self.rotation
# Check if angle is supported
if angle not in self._allowed_rotation:
print('invalid angle provided, setting to fallback: 0 deg')
angle = 0
# Autoflip the image if angle == 'auto'
if angle == 'auto':
if (im.width > self.height) and (im.width < self.height):
print('display vertical, image horizontal -> flipping image')
image = im.rotate(90, expand=True)
if (im.width < self.height) and (im.width > self.height):
print('display horizontal, image vertical -> flipping image')
image = im.rotate(90, expand=True)
# if not auto, flip to specified angle
else:
image = im.rotate(angle, expand = True)
self.image = image
def _fit_width(self, width=None):
"""Resize an image to desired width"""
im = self.image
if width == None: width = self.width
logger.debug(('resizing width from', im.width, 'to'))
wpercent = (width/float(im.width))
hsize = int((float(im.height)*float(wpercent)))
image = im.resize((width, hsize), Image.ANTIALIAS)
logger.debug(image.width)
self.image = image
def _fit_height(self, height=None):
"""Resize an image to desired height"""
im = self.image
if height == None: height = self.height
logger.debug(('resizing height from', im.height, 'to'))
hpercent = (height / float(im.height))
wsize = int(float(im.width) * float(hpercent))
image = im.resize((wsize, height), Image.ANTIALIAS)
logger.debug(image.height)
self.image = image
def _to_layout(self, mode=None):
"""Adjust the image to suit the layout
mode can be center, fit or fill"""
im = self.image
if mode == None: mode = self.layout
if mode not in self._allowed_layout:
print('{} is not supported. Should be one of {}'.format(
mode, self._allowed_layout))
print('setting layout to fallback: centre')
mode = 'center'
# If mode is center, just center the image
if mode == 'center':
pass
# if mode is fit, adjust height of the image while keeping ascept-ratio
if mode == 'fit':
self._fit_height()
# if mode is fill, enlargen or shrink the image to fit width
if mode == 'fill':
self._fit_width()
# in auto mode, flip image automatically and fit both height and width
if mode == 'auto':
# Check if width is bigger than height and rotate by 90 deg if true
if im.width > im.height:
self._rotate(90)
# fit both height and width
self._fit_height()
self._fit_width()
if self.image.width > self.width:
x = int( (self.image.width - self.width) / 2)
else:
x = int( (self.width - self.image.width) / 2)
if self.image.height > self.height:
y = int( (self.image.height - self.height) / 2)
else:
y = int( (self.height - self.image.height) / 2)
self.x, self.y = x, y
def _remove_alpha(self):
im = self.image
if len(im.getbands()) == 4:
logger.debug('removing transparency')
bg = Image.new('RGBA', (im.width, im.height), 'white')
im = Image.alpha_composite(bg, im)
self.image.paste(im, (0,0))
def _map_colours(self, colours = None):
"""Map image colours to display-supported colours """
im = self.image.convert('RGB')
if colours == None: colours = self.colours
if colours not in self._allowed_colours:
print('invalid colour: "{}", has to be one of: {}'.format(
colours, self._allowed_colours))
print('setting to fallback: bw')
colours = 'bw'
if colours == 'bw':
# For black-white images, use monochrome dithering
im_black = im.convert('1', dither=True)
im_colour = None
elif colours == 'bwr':
# For black-white-red images, create corresponding palette
pal = [255,255,255, 0,0,0, 255,0,0, 255,255,255]
elif colours == 'bwy':
# For black-white-yellow images, create corresponding palette"""
pal = [255,255,255, 0,0,0, 255,255,0, 255,255,255]
# Map each pixel of the opened image to the Palette
if colours == 'bwr' or colours == 'bwy':
palette_im = Image.new('P', (3,1))
palette_im.putpalette(pal * 64)
quantized_im = im.quantize(palette=palette_im)
quantized_im.convert('RGB')
# Create buffer for coloured pixels
buffer1 = numpy.array(quantized_im.convert('RGB'))
r1,g1,b1 = buffer1[:, :, 0], buffer1[:, :, 1], buffer1[:, :, 2]
# Create buffer for black pixels
buffer2 = numpy.array(quantized_im.convert('RGB'))
r2,g2,b2 = buffer2[:, :, 0], buffer2[:, :, 1], buffer2[:, :, 2]
if colours == 'bwr':
# Create image for only red pixels
buffer2[numpy.logical_and(r2 == 0, b2 == 0)] = [255,255,255] # black->white
buffer2[numpy.logical_and(r2 == 255, b2 == 0)] = [0,0,0] #red->black
im_colour = Image.fromarray(buffer2)
# Create image for only black pixels
buffer1[numpy.logical_and(r1 == 255, b1 == 0)] = [255,255,255]
im_black = Image.fromarray(buffer1)
if colours == 'bwy':
# Create image for only yellow pixels
buffer2[numpy.logical_and(r2 == 0, b2 == 0)] = [255,255,255] # black->white
buffer2[numpy.logical_and(g2 == 255, b2 == 0)] = [0,0,0] #yellow -> black
im_colour = Image.fromarray(buffer2)
# Create image for only black pixels
buffer1[numpy.logical_and(g1 == 255, b1 == 0)] = [255,255,255]
im_black = Image.fromarray(buffer1)
return im_black, im_colour
@staticmethod
def save(image):
im = self.image
im.save('/home/pi/Desktop/test.png', 'PNG')
@staticmethod
def _show(image):
"""Preview the image on gpicview (only works on Rapsbian with Desktop)"""
path = '/home/pi/Desktop/'
image.save(path+'temp.png')
os.system("gpicview "+path+'temp.png')
os.system('rm '+path+'temp.png')
if __name__ == '__main__':
print('running {0} in standalone/debug mode'.format(filename))
## a = Inkyimage((480,800), {'path': "https://raw.githubusercontent.com/aceisace/Inky-Calendar/dev_ver2_0/Gallery/logo.png"})
## a.generate_image()
print('Done')

View File

@@ -0,0 +1,305 @@
#!/usr/bin/python3
# -*- coding: utf-8 -*-
"""
Image module for Inkycal Project
Copyright by aceisace
"""
from inkycal.modules.template import inkycal_module
from inkycal.custom import *
from PIL import ImageOps
import requests
import numpy
filename = os.path.basename(__file__).split('.py')[0]
logger = logging.getLogger(filename)
logger.setLevel(level=logging.ERROR)
class Inkyimage(inkycal_module):
"""Image class
display an image from a given path or URL
"""
name = "Inykcal Image - show an image from a URL or local path"
requires = {
'path': {
"label":"Please enter the path of the image file (local or URL)",
}
}
optional = {
'rotation':{
"label":"Specify the angle to rotate the image. Default is 0",
"options": [0, 90, 180, 270, 360, "auto"],
"default":0,
},
'layout':{
"label":"How should the image be displayed on the display? Default is auto",
"options": ['fill', 'center', 'fit', 'auto'],
"default": "auto"
}
}
def __init__(self, section_size, section_config):
"""Initialize inkycal_rss module"""
super().__init__(section_size, section_config)
# required parameters
for param in self.requires:
if not param in section_config:
raise Exception('config is missing {}'.format(param))
# optional parameters
self.image_path = self.config['path']
self.rotation = self.config['rotation']
self.layout = self.config['layout']
# give an OK message
print('{0} loaded'.format(self.name))
def _validate(self):
"""Validate module-specific parameters"""
# Validate image_path
if not isinstance(self.image_path, str):
print(
'image_path has to be a string: "URL1" or "/home/pi/Desktop/im.png"')
# Validate layout
if not isinstance(self.layout, str):
print('layout has to be a string')
def generate_image(self):
"""Generate image for this module"""
# Define new image size with respect to padding
im_width = self.width
im_height = self.height
im_size = im_width, im_height
logger.info('image size: {} x {} px'.format(im_width, im_height))
# Try to open the image if it exists and is an image file
try:
if self.image_path.startswith('http'):
logger.debug('identified url')
self.image = Image.open(requests.get(self.image_path, stream=True).raw)
else:
logger.info('identified local path')
self.image = Image.open(self.image_path)
except FileNotFoundError:
raise ('Your file could not be found. Please check the filepath')
except OSError:
raise ('Please check if the path points to an image file.')
logger.debug(('image-width:', self.image.width))
logger.debug(('image-height:', self.image.height))
# Create an image for black pixels and one for coloured pixels
im_black = Image.new('RGB', size = im_size, color = 'white')
im_colour = Image.new('RGB', size = im_size, color = 'white')
# do the required operations
self._remove_alpha()
self._to_layout()
black, colour = self._map_colours()
# paste the images on the canvas
im_black.paste(black, (self.x, self.y))
im_colour.paste(colour, (self.x, self.y))
# Save images of black and colour channel in image-folder
im_black.save(images+self.name+'.png', 'PNG')
im_colour.save(images+self.name+'_colour.png', 'PNG')
def _rotate(self, angle=None):
"""Rotate the image to a given angle
angle must be one of :[0, 90, 180, 270, 360, 'auto']
"""
im = self.image
if angle == None:
angle = self.rotation
# Check if angle is supported
if angle not in self._allowed_rotation:
print('invalid angle provided, setting to fallback: 0 deg')
angle = 0
# Autoflip the image if angle == 'auto'
if angle == 'auto':
if (im.width > self.height) and (im.width < self.height):
print('display vertical, image horizontal -> flipping image')
image = im.rotate(90, expand=True)
if (im.width < self.height) and (im.width > self.height):
print('display horizontal, image vertical -> flipping image')
image = im.rotate(90, expand=True)
# if not auto, flip to specified angle
else:
image = im.rotate(angle, expand = True)
self.image = image
def _fit_width(self, width=None):
"""Resize an image to desired width"""
im = self.image
if width == None: width = self.width
logger.debug(('resizing width from', im.width, 'to'))
wpercent = (width/float(im.width))
hsize = int((float(im.height)*float(wpercent)))
image = im.resize((width, hsize), Image.ANTIALIAS)
logger.debug(image.width)
self.image = image
def _fit_height(self, height=None):
"""Resize an image to desired height"""
im = self.image
if height == None: height = self.height
logger.debug(('resizing height from', im.height, 'to'))
hpercent = (height / float(im.height))
wsize = int(float(im.width) * float(hpercent))
image = im.resize((wsize, height), Image.ANTIALIAS)
logger.debug(image.height)
self.image = image
def _to_layout(self, mode=None):
"""Adjust the image to suit the layout
mode can be center, fit or fill"""
im = self.image
if mode == None: mode = self.layout
if mode not in self._allowed_layout:
print('{} is not supported. Should be one of {}'.format(
mode, self._allowed_layout))
print('setting layout to fallback: centre')
mode = 'center'
# If mode is center, just center the image
if mode == 'center':
pass
# if mode is fit, adjust height of the image while keeping ascept-ratio
if mode == 'fit':
self._fit_height()
# if mode is fill, enlargen or shrink the image to fit width
if mode == 'fill':
self._fit_width()
# in auto mode, flip image automatically and fit both height and width
if mode == 'auto':
# Check if width is bigger than height and rotate by 90 deg if true
if im.width > im.height:
self._rotate(90)
# fit both height and width
self._fit_height()
self._fit_width()
if self.image.width > self.width:
x = int( (self.image.width - self.width) / 2)
else:
x = int( (self.width - self.image.width) / 2)
if self.image.height > self.height:
y = int( (self.image.height - self.height) / 2)
else:
y = int( (self.height - self.image.height) / 2)
self.x, self.y = x, y
def _remove_alpha(self):
im = self.image
if len(im.getbands()) == 4:
logger.debug('removing transparency')
bg = Image.new('RGBA', (im.width, im.height), 'white')
im = Image.alpha_composite(bg, im)
self.image.paste(im, (0,0))
def _map_colours(self, colours = None):
"""Map image colours to display-supported colours """
im = self.image.convert('RGB')
if colours == 'bw':
# For black-white images, use monochrome dithering
im_black = im.convert('1', dither=True)
im_colour = None
elif colours == 'bwr':
# For black-white-red images, create corresponding palette
pal = [255,255,255, 0,0,0, 255,0,0, 255,255,255]
elif colours == 'bwy':
# For black-white-yellow images, create corresponding palette"""
pal = [255,255,255, 0,0,0, 255,255,0, 255,255,255]
# Map each pixel of the opened image to the Palette
if colours == 'bwr' or colours == 'bwy':
palette_im = Image.new('P', (3,1))
palette_im.putpalette(pal * 64)
quantized_im = im.quantize(palette=palette_im)
quantized_im.convert('RGB')
# Create buffer for coloured pixels
buffer1 = numpy.array(quantized_im.convert('RGB'))
r1,g1,b1 = buffer1[:, :, 0], buffer1[:, :, 1], buffer1[:, :, 2]
# Create buffer for black pixels
buffer2 = numpy.array(quantized_im.convert('RGB'))
r2,g2,b2 = buffer2[:, :, 0], buffer2[:, :, 1], buffer2[:, :, 2]
if colours == 'bwr':
# Create image for only red pixels
buffer2[numpy.logical_and(r2 == 0, b2 == 0)] = [255,255,255] # black->white
buffer2[numpy.logical_and(r2 == 255, b2 == 0)] = [0,0,0] #red->black
im_colour = Image.fromarray(buffer2)
# Create image for only black pixels
buffer1[numpy.logical_and(r1 == 255, b1 == 0)] = [255,255,255]
im_black = Image.fromarray(buffer1)
if colours == 'bwy':
# Create image for only yellow pixels
buffer2[numpy.logical_and(r2 == 0, b2 == 0)] = [255,255,255] # black->white
buffer2[numpy.logical_and(g2 == 255, b2 == 0)] = [0,0,0] #yellow -> black
im_colour = Image.fromarray(buffer2)
# Create image for only black pixels
buffer1[numpy.logical_and(g1 == 255, b1 == 0)] = [255,255,255]
im_black = Image.fromarray(buffer1)
return im_black, im_colour
@staticmethod
def save(image, path):
im = self.image
im.save(path, 'PNG')
@staticmethod
def _show(image):
"""Preview the image on gpicview (only works on Rapsbian with Desktop)"""
path = '/home/pi/Desktop/'
image.save(path+'temp.png')
os.system("gpicview "+path+'temp.png')
os.system('rm '+path+'temp.png')
if __name__ == '__main__':
print('running {0} in standalone/debug mode'.format(filename))
#a = Inkyimage((480,800), {'path': "https://raw.githubusercontent.com/aceisace/Inky-Calendar/dev_ver2_0/Gallery/logo.png"})
#a = Inkyimage((480,800), {'path': "https://raw.githubusercontent.com/aceisace/Inky-Calendar/dev_ver2_0/Gallery/logo.png"})
a = Inkyimage((480, 800), {'path': "/home/pi/Desktop/im/IMG_0475.JPG"})
a.generate_image()

View File

@@ -2,7 +2,7 @@
# -*- coding: utf-8 -*-
"""
RSS module for Inky-Calendar Project
RSS module for inkyCal Project
Copyright by aceisace
"""
@@ -25,28 +25,48 @@ class RSS(inkycal_module):
parses rss/atom feeds from given urls
"""
name = "Inkycal RSS / Atom"
requires = {
"rss_urls" : {
"label":"Please enter ATOM or RSS feed URL/s, separated by a comma",
},
}
optional = {
"shuffle_feeds": {
"label": "Should the parsed RSS feeds be shuffled? (default=True)",
"options": [True, False],
"default": True
},
}
def __init__(self, section_size, section_config):
"""Initialize inkycal_rss module"""
super().__init__(section_size, section_config)
# Module specific parameters
required = ['rss_urls']
for param in required:
# Check if required parameters are available in config
for param in self.requires:
if not param in section_config:
raise Exception('config is missing {}'.format(param))
# module name
self.name = self.__class__.__name__
# parse required config
self.rss_urls = self.config["rss_urls"].split(",")
# module specific parameters
self.shuffle_feeds = True
# parse optional config
self.shuffle_feeds = self.config["shuffle_feeds"]
# give an OK message
print('{0} loaded'.format(self.name))
print('{0} loaded'.format(filename))
def _validate(self):
"""Validate module-specific parameters"""
if not isinstance(self.shuffle_feeds, bool):
print('shuffle_feeds has to be a boolean: True/False')
@@ -55,8 +75,8 @@ class RSS(inkycal_module):
"""Generate image for this module"""
# Define new image size with respect to padding
im_width = int(self.width - (self.width * 2 * self.margin_x))
im_height = int(self.height - (self.height * 2 * self.margin_y))
im_width = int(self.width - ( 2 * self.padding_x))
im_height = int(self.height - (2 * self.padding_y))
im_size = im_width, im_height
logger.info('image size: {} x {} px'.format(im_width, im_height))
@@ -70,7 +90,6 @@ class RSS(inkycal_module):
else:
raise Exception('Network could not be reached :/')
# Set some parameters for formatting rss feeds
line_spacing = 1
line_height = self.font.getsize('hg')[1] + line_spacing
@@ -86,7 +105,7 @@ class RSS(inkycal_module):
# Create list containing all rss-feeds from all rss-feed urls
parsed_feeds = []
for feeds in self.config['rss_urls']:
for feeds in self.rss_urls:
text = feedparser.parse(feeds)
for posts in text.entries:
parsed_feeds.append('{0}: {1}'.format(posts.title, posts.summary))
@@ -127,8 +146,8 @@ class RSS(inkycal_module):
del filtered_feeds, parsed_feeds, wrapped, counter, text
# Save image of black and colour channel in image-folder
im_black.save(images+self.name+'.png', 'PNG')
im_colour.save(images+self.name+'_colour.png', 'PNG')
return im_black, im_colour
if __name__ == '__main__':
print('running {0} in standalone/debug mode'.format(filename))
print(RSS.get_config())

View File

@@ -0,0 +1,41 @@
#!/usr/bin/python3
# -*- coding: utf-8 -*-
"""
Image Server module for Inkycal project
For use with Robert Sierre's inkycal web-service
Copyright by aceisace
"""
from os import path
from PIL import ImageOps
import requests
import numpy
"""----------------------------------------------------------------"""
#path = 'https://github.com/aceisace/Inky-Calendar/raw/master/Gallery/Inky-Calendar-logo.png'
#path ='/home/pi/Inky-Calendar/images/canvas.png'
path = inkycal_image_path
path_body = inkycal_image_path_body
mode = 'auto' # 'horizontal' # 'vertical' # 'auto'
upside_down = False # Flip image by 180 deg (upside-down)
alignment = 'center' # top_center, top_left, center_left, bottom_right etc.
colours = 'bwr' # bwr # bwy # bw
render = True # show image on E-Paper?
"""----------------------------------------------------------------"""
path = path.replace('{model}', model).replace('{width}',str(display_width)).replace('{height}',str(display_height))
print(path)
try:
# POST request, passing path_body in the body
im = Image.open(requests.post(path, json=path_body, stream=True).raw)
except FileNotFoundError:
raise Exception('Your file could not be found. Please check the path to your file.')
except OSError:
raise Exception('Please check if the path points to an image file.')

View File

@@ -20,14 +20,157 @@ logger = logging.getLogger(filename)
logger.setLevel(level=logging.ERROR)
api = todoist.TodoistAPI('your api key')
api.sync()
class Todoist(inkycal_module):
"""Todoist api class
parses todo's from api-key
"""
# Print name of author
print(api.state['user']['full_name']+'\n')
name = "Inkycal Todoist"
requires = {
'api_key': {
"label":"Please enter your Todoist API-key",
},
}
optional = {
'project_filter': {
"label":"Show Todos only from following project (separated by a comma). Leave empty to show "+
"todos from all projects",
"default": []
}
}
def __init__(self, section_size, section_config):
"""Initialize inkycal_rss module"""
super().__init__(section_size, section_config)
# Module specific parameters
for param in self.requires:
if not param in section_config:
raise Exception('config is missing {}'.format(param))
tasks = (task.data for task in api.state['items'])
# module specific parameters
self.api_key = self.config['api_key']
self.project_filter = self.config['project_filter']# only show todos from these projects
for _ in tasks:
print('task: {} is {}'.format(_['content'], 'done' if _['checked'] == 1 else 'not done'))
self._api = todoist.TodoistAPI(self.config['api_key'])
self._api.sync()
# give an OK message
print('{0} loaded'.format(self.name))
def _validate(self):
"""Validate module-specific parameters"""
if not isinstance(self.api_key, str):
print('api_key has to be a string: "Yourtopsecretkey123" ')
def generate_image(self):
"""Generate image for this module"""
# Define new image size with respect to padding
im_width = int(self.width - (2 * self.padding_x))
im_height = int(self.height - (2 * self.padding_y))
im_size = im_width, im_height
logger.info('image size: {} x {} px'.format(im_width, im_height))
# Create an image for black pixels and one for coloured pixels
im_black = Image.new('RGB', size = im_size, color = 'white')
im_colour = Image.new('RGB', size = im_size, color = 'white')
# Check if internet is available
if internet_available() == True:
logger.info('Connection test passed')
else:
raise Exception('Network could not be reached :/')
# Set some parameters for formatting todos
line_spacing = 1
line_height = self.font.getsize('hg')[1] + line_spacing
line_width = im_width
max_lines = (im_height // (self.font.getsize('hg')[1] + line_spacing))
# Calculate padding from top so the lines look centralised
spacing_top = int( im_height % line_height / 2 )
# Calculate line_positions
line_positions = [
(0, spacing_top + _ * line_height ) for _ in range(max_lines)]
#------------------------------------------------------------------------##
# Get all projects by name and id
all_projects = {project['name']: project['id']
for project in self._api.projects.all()}
# Check if project from filter could be found
if self.project_filter:
for project in self.project_filter:
if project not in all_projects:
print('Could not find a project named {}'.format(project))
self.project_filter.remove(project)
# function for extracting project names from tasks
get_project_name = lambda task: (self._api.projects.get_data(
task['project_id'])['project']['name'])
# If the filter is empty, parse all tasks which are not yet done
if self.project_filter:
tasks = (task.data for task in self._api.state['items']
if (task['checked'] == 0) and
(get_project_name(task) in self.project_filter))
# If filter is not empty, parse undone tasks in only those projects
else:
tasks = (task.data for task in self._api.state['items'] if
(task['checked'] == 0))
# Simplify the tasks for faster processing
simplified = [{'name':task['content'],
'due':task['due'],
'priority':task['priority'],
'project_id':task['project_id']}
for task in tasks]
# Group tasks by project name
grouped = {}
if self.project_filter:
for project in self.project_filter:
project_id = all_projects[project]
grouped[ project ] = [
task for task in simplified if task['project_id'] == project_id]
else:
for project in all_projects:
project_id = all_projects[project]
grouped[ project ] = [
task for task in simplified if task['project_id'] == project_id]
# Print tasks sorted by groups
for project, tasks in grouped.items():
print('*', project)
for task in tasks:
print('{} {}'.format(
task['due']['string'] if task['due'] != None else '', task['name']))
## # Write rss-feeds on image
## for _ in range(len(filtered_feeds)):
## write(im_black, line_positions[_], (line_width, line_height),
## filtered_feeds[_], font = self.font, alignment= 'left')
# Cleanup ---------------------------
# del grouped, parsed_feeds, wrapped, counter, text
# return the images ready for the display
return im_black, im_colour
if __name__ == '__main__':
print('running {0} in standalone/debug mode'.format(filename))
config = {'api_key':'4e166367dcafdd60e6a9f4cbed598d578bf2c359'}
size = (480, 100)
a = Todoist(size, config)
b,c = a.generate_image()

View File

@@ -26,6 +26,60 @@ class Weather(inkycal_module):
"""Weather class
parses weather details from openweathermap
"""
#TODO: automatic setup of pyowm by location id if location is numeric
name = "Inkycal Weather (openweathermap)"
requires = {
"api_key" : {
"label":"Please enter openweathermap api-key. You can create one for free on openweathermap",
},
"location": {
"label":"Please enter your location in the following format: City, Country-Code"
}
}
optional = {
"round_temperature": {
"label":"Round temperature to the nearest degree?",
"options": [True, False],
"default" : True
},
"round_windspeed": {
"label":"Round windspeed?",
"options": [True, False],
"default": True
},
"forecast_interval": {
"label":"Please select the forecast interval",
"options": ["daily", "hourly"],
"default": "daily"
},
"units": {
"label": "Which units should be used?",
"options": ["metric", "imperial"],
"default": "metric"
},
"hour_format": {
"label": "Which hour format do you prefer?",
"options": [12, 24],
"default": 24
},
"use_beaufort": {
"label": "Use beaufort scale for windspeed?",
"options": [True, False],
"default": True
},
}
def __init__(self, section_size, section_config):
"""Initialize inkycal_weather module"""
@@ -33,35 +87,36 @@ class Weather(inkycal_module):
super().__init__(section_size, section_config)
# Module specific parameters
required = ['api_key','location']
for param in required:
for param in self.requires:
if not param in section_config:
raise Exception('config is missing {}'.format(param))
# module name
self.name = self.__class__.__name__
# required parameters
self.location = self.config['location']
self.api_key = self.config['api_key']
# module specific parameters
self.owm = pyowm.OWM(self.config['api_key'])
# optional parameters
self.round_temperature = self.config['round_temperature']
self.round_windspeed = self.config['round_windspeed']
self.forecast_interval = self.config['forecast_interval']
self.units = self.config['units']
self.hour_format = self.config['hours']
self.hour_format = self.config['hour_format']
self.use_beaufort = self.config['use_beaufort']
self.timezone = get_system_tz()
self.round_temperature = True
self.round_windspeed = True
self.use_beaufort = True
self.forecast_interval = 'daily' # daily # hourly
self.locale = sys_locale()[0]
self.weatherfont = ImageFont.truetype(fonts['weathericons-regular-webfont'],
size = self.fontsize)
#self.owm = pyowm.OWM(self.config['api_key'])
# give an OK message
print('{0} loaded'.format(self.name))
print('{0} loaded'.format(filename))
def generate_image(self):
"""Generate image for this module"""
# Define new image size with respect to padding
im_width = int(self.width - (self.width * 2 * self.margin_x))
im_height = int(self.height - (self.height * 2 * self.margin_y))
im_width = int(self.width - (2 * self.padding_x))
im_height = int(self.height - (2 * self.padding_y))
im_size = im_width, im_height
logger.info('image size: {} x {} px'.format(im_width, im_height))
@@ -422,9 +477,8 @@ class Weather(inkycal_module):
draw_border(im_black, (col6, row1), (col_width, im_height))
draw_border(im_black, (col7, row1), (col_width, im_height))
# Save image of black and colour channel in image-folder
im_black.save(images+self.name+'.png', "PNG")
im_colour.save(images+self.name+'_colour.png', "PNG")
# return the images ready for the display
return im_black, im_colour
if __name__ == '__main__':
print('running {0} in standalone mode'.format(filename))

View File

@@ -10,14 +10,16 @@ class inkycal_module(metaclass=abc.ABCMeta):
callable(subclass.generate_image) or
NotImplemented)
def __init__(self, section_size, section_config):
def __init__(self, section_config):
# Initializes base module
# sets properties shared amongst all sections
self.config = section_config
self.width, self.height = section_size
self.fontsize = 12
self.margin_x = 0.02
self.margin_y = 0.05
self.width, self.height = section_config['size']
self.padding_left = self.padding_right = self.config["padding_x"]
self.padding_top = self.padding_bottom = self.config["padding_y"]
self.fontsize = self.config["fontsize"]
self.font = ImageFont.truetype(
fonts['NotoSans-SemiCondensed'], size = self.fontsize)
@@ -56,3 +58,33 @@ class inkycal_module(metaclass=abc.ABCMeta):
# Generate image for this module with specified parameters
raise NotImplementedError(
'The developers were too lazy to implement this function')
@classmethod
def get_config(cls):
# Get the config of this module for the web-ui
# Do not change
try:
if hasattr(cls, 'requires'):
for each in cls.requires:
if not "label" in cls.requires[each]:
raise Exception("no label found for {}".format(each))
if hasattr(cls, 'optional'):
for each in cls.optional:
if not "label" in cls.optional[each]:
raise Exception("no label found for {}".format(each))
conf = {
"name": cls.__name__,
"name_str": cls.name,
"requires": cls.requires if hasattr(cls, 'requires') else {},
"optional": cls.optional if hasattr(cls, 'optional') else {},
}
return conf
except:
raise Exception(
'Ohoh, something went wrong while trying to get the config of this module')

View File

@@ -60,9 +60,21 @@ class Simple(inkycal_module):
Explain what this module does...
"""
# name is the name that will be shown on the web-ui
# may be same or different to the class name (Do not remove this)
name = "My own module"
# create a dictionary that specifies what your module absolutely needs
# to run correctly
# Use the following format -> "key" : "info about this key for web-ui"
# You can add as many required entries as you like
requires = {
"module_parameter" : "Short info about this parameter, shown on the web-ui",
}
# Initialise the class (do not remove)
def __init__(self, section_size, section_config):
"""Initialize inkycal_rss module"""
"""Initialize your module module"""
# Initialise this module via the inkycal_module template (required)
super().__init__(section_size, section_config)

474
inkycal/old.py Normal file
View File

@@ -0,0 +1,474 @@
from inkycal import Settings, Layout
from inkycal.custom import *
#from os.path import exists
import os
import traceback
import logging
import arrow
import time
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')
logger = logging.getLogger('inkycal')
logger.setLevel(level=logging.ERROR)
class Inkycal:
"""Inkycal main class"""
def __init__(self, settings_path, render=True):
"""initialise class
settings_path = str -> location/folder of settings file
render = bool -> show something on the ePaper?
"""
self._release = '2.0.0beta'
# Check if render is boolean
if not isinstance(render, bool):
raise Exception('render must be True or False, not "{}"'.format(render))
self.render = render
# Init settings class
self.Settings = Settings(settings_path)
# Check if display support colour
self.supports_colour = self.Settings.Layout.supports_colour
# Option to flip image upside down
if self.Settings.display_orientation == 'normal':
self.upside_down = False
elif self.Settings.display_orientation == 'upside_down':
self.upside_down = True
# Option to use epaper image optimisation
self.optimize = True
# Load drivers if image should be rendered
if self.render == True:
# Get model and check if colour can be rendered
model= self.Settings.model
# Init Display class
from inkycal.display import Display
self.Display = Display(model)
# get calibration hours
self._calibration_hours = self.Settings.calibration_hours
# set a check for calibration
self._calibration_state = False
# load+validate settings file. Import and setup specified modules
self.active_modules = self.Settings.active_modules()
for module in self.active_modules:
try:
loader = 'from inkycal.modules import {0}'.format(module)
module_data = self.Settings.get_config(module)
size, conf = module_data['size'], module_data['config']
setup = 'self.{} = {}(size, conf)'.format(module, module)
exec(loader)
exec(setup)
logger.debug(('{}: size: {}, config: {}'.format(module, size, conf)))
# If a module was not found, print an error message
except ImportError:
print(
'Could not find module: "{}". Please try to import manually.'.format(
module))
# 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))
# 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('{0} Minutes left until next refresh'.format(minutes))
# 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('You are running inkycal v{}'.format(self._release))
print('Running inkycal test-run for {} ePaper'.format(
self.Settings.model))
if self.upside_down == True:
print('upside-down mode active')
for module in self.active_modules:
generate_im = 'self.{0}.generate_image()'.format(module)
print('generating image for {} module...'.format(module), end = '')
try:
exec(generate_im)
print('OK!')
except Exception as Error:
print('Error!')
print(traceback.format_exc())
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"""
# TODO: printing traceback on display (or at least a smaller message?)
# Calibration
# 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 = 1
# Calculate the max. fontsize for info-section
if self.Settings.info_section == True:
info_section_height = round(self.Settings.Layout.display_height* (1/95) )
self.font = auto_fontsize(ImageFont.truetype(
fonts['NotoSans-SemiCondensed']), info_section_height)
while True:
print('Generating images for all modules...')
for module in self.active_modules:
generate_im = 'self.{0}.generate_image()'.format(module)
try:
exec(generate_im)
except Exception as Error:
print('Error!')
message = traceback.format_exc()
print(message)
counter = 0
print('OK')
# Assemble image from each module
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(images+'canvas.png')
im_colour = Image.open(images+'canvas_colour.png')
# Flip the image by 180° if required
if self.upside_down == True:
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.upside_down == True:
im_black = upside_down(im_black)
Display.render(im_black)
print('\ninkycal has been running without any errors for', end = ' ')
print('{} display updates'.format(counter))
print('Programm started {}'.format(runtime.humanize()))
counter += 1
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):
"""Assmebles all sub-images to a single image"""
# Create an empty canvas with the size of the display
width, height = self.Settings.Layout.display_size
if self.Settings.info_section == True:
height = round(height * ((1/95)*100) )
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 module in self.active_modules:
im1_path = images+module+'.png'
im2_path = images+module+'_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 = self.Settings.get_config(module)['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 = self.Settings.get_config(module)['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]
# Show an info section if specified by the settings file
now = arrow.now()
stamp = 'last update: {}'.format(now.format('D MMM @ HH:mm', locale =
self.Settings.language))
if self.Settings.info_section == True:
write(im_black, (0, im1_cursor), (width, height-im1_cursor),
stamp, font = self.font)
# optimize the image by mapping colours to pure black and white
if self.optimize == True:
self._optimize_im(im_black).save(images+'canvas.png', 'PNG')
self._optimize_im(im_colour).save(images+'canvas_colour.png', 'PNG')
else:
im_black.save(images+'canvas.png', 'PNG')
im_colour.save(images+'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
def _check_for_updates(self):
"""Check if a new update is available for inkycal"""
raise NotImplementedError('Tha developer were too lazy to implement this..')
@staticmethod
def _add_module(filepath_module, classname):
"""Add a third party module to inkycal
filepath_module = the full path of your module. The file should be in /modules!
classname = the name of your class inside the module
"""
# Path for modules
_module_path = 'inkycal/modules/'
# Check if the filepath is a string
if not isinstance(filepath_module, str):
raise ValueError('filepath has to be a string!')
# Check if the classname is a string
if not isinstance(classname, str):
raise ValueError('classname has to be a string!')
# TODO:
# Ensure only third-party modules are deleted as built-in modules
# should not be deleted
# Check if module is inside the modules folder
if not _module_path in filepath_module:
raise Exception('Your module should be in', _module_path)
# Get the name of the third-party module file without extension (.py)
filename = filepath_module.split('.py')[0].split('/')[-1]
# Check if filename or classname is in the current module init file
with open('modules/__init__.py', mode ='r') as module_init:
content = module_init.read().splitlines()
for line in content:
if (filename or clasname) in line:
raise Exception(
'A module with this filename or classname already exists')
# Check if filename or classname is in the current inkycal init file
with open('__init__.py', mode ='r') as inkycal_init:
content = inkycal_init.read().splitlines()
for line in content:
if (filename or clasname) in line:
raise Exception(
'A module with this filename or classname already exists')
# If all checks have passed, add the module in the module init file
with open('modules/__init__.py', mode='a') as module_init:
module_init.write('from .{} import {}'.format(filename, classname))
# If all checks have passed, add the module in the inkycal init file
with open('__init__.py', mode ='a') as inkycal_init:
inkycal_init.write('# Added by module adder \n')
inkycal_init.write('import inkycal.modules.{}'.format(filename))
print('Your module {} has been added successfully! Hooray!'.format(
classname))
@staticmethod
def _remove_module(classname, remove_file = True):
"""Removes a third-party module from inkycal
Input the classname of the file you want to remove
"""
# Check if filename or classname is in the current module init file
with open('modules/__init__.py', mode ='r') as module_init:
content = module_init.read().splitlines()
with open('modules/__init__.py', mode ='w') as module_init:
for line in content:
if not classname in line:
module_init.write(line+'\n')
else:
filename = line.split(' ')[1].split('.')[1]
# Check if filename or classname is in the current inkycal init file
with open('__init__.py', mode ='r') as inkycal_init:
content = inkycal_init.read().splitlines()
with open('__init__.py', mode ='w') as inkycal_init:
for line in content:
if not filename in line:
inkycal_init.write(line+'\n')
# remove the file of the third party module if it exists and remove_file
# was set to True (default)
if os.path.exists('modules/{}.py'.format(filename)) and remove_file == True:
os.remove('modules/{}.py'.format(filename))
print('The module {} has been removed successfully'.format(classname))