Merge branch 'feature/venv'

# Conflicts:
#	.github/workflows/tests.yml
#	README.md
#	inkycal/modules/ical_parser.py
#	inkycal/modules/inkycal_agenda.py
#	inkycal/modules/inkycal_feeds.py
#	inkycal/modules/inkycal_todoist.py
#	inkycal/modules/inkycal_weather.py
#	requirements.txt
This commit is contained in:
aceisace
2023-01-13 01:47:18 +01:00
196 changed files with 56819 additions and 6340 deletions

2
inkycal/modules/__init__.py Normal file → Executable file
View File

@@ -7,4 +7,4 @@ from .inkycal_image import Inkyimage
from .inkycal_jokes import Jokes
from .inkycal_stocks import Stocks
from .inkycal_slideshow import Slideshow
#from .inkycal_server import Inkyserver
from .inkycal_textfile_to_display import TextToDisplay

292
inkycal/modules/dev_module.py Normal file → Executable file
View File

@@ -1,5 +1,5 @@
#!/usr/bin/python3
# -*- coding: utf-8 -*-
#!python3
"""
Third party module template (inkycal-compatible module)
@@ -13,7 +13,6 @@ Copyright by aceisace
from inkycal.modules.template import inkycal_module
from inkycal.custom import *
#############################################################################
# Built-in library imports (change as desired)
#############################################################################
@@ -21,7 +20,6 @@ from inkycal.custom import *
# Built-in libraries go here
from random import shuffle
#############################################################################
# External library imports (always use try-except)
#############################################################################
@@ -30,19 +28,18 @@ from random import shuffle
# use try...except ImportError to check if it has been installed
# If it is not found, print a short message on how to install this dependency
try:
import feedparser
import feedparser
except ImportError:
print('feedparser is not installed! Please install with:')
print('pip3 install feedparser')
print('feedparser is not installed! Please install with:')
print('pip3 install feedparser')
#############################################################################
# Filename + logging (do not remove)
#############################################################################
# Get the name of this file, set up logging for this filename
filename = os.path.basename(__file__).split('.py')[0]
logger = logging.getLogger(filename)
logger = logging.getLogger(__name__)
#############################################################################
# Class setup
@@ -52,181 +49,177 @@ logger = logging.getLogger(filename)
# Avoid naming the class with too long names
class Simple(inkycal_module):
""" Simple Class
Once sentence describing what this module does,
e.g. Display hello world with your name!
"""
""" Simple Class
Once sentence describing what this module does,
e.g. Display hello world with your name!
"""
# 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 = "Simple - say hello world"
# 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 = "Simple - say hello world"
# create a dictionary containing variables which your module must have
# to run correctly, e.g. if your module needs an 'api-key' and a 'name':
requires = {
# A simple text input; users can choose what to enter by keyboard
'api_key': {"label" : "Please enter your api-key from some-website"},
# create a dictionary containing variables which your module must have
# to run correctly, e.g. if your module needs an 'api-key' and a 'name':
requires = {
# A simple text input; users can choose what to enter by keyboard
'api_key': {"label": "Please enter your api-key from some-website"},
# A simple text input; users can choose what to enter by keyboard
'username': {"label": "Please enter a username"},
}
# The format for the above is: |"key_name": {"Desription what this means"},|
# A simple text input; users can choose what to enter by keyboard
'username': {"label": "Please enter a username"},
}
# The format for the above is: |"key_name": {"Desription what this means"},|
# create a dictionary containing variables which your module optionally
# can have to run correctly, e.g. if your module needs has optional
# parameters like: 'api-key' and a 'name':
# create a dictionary containing variables which your module optionally
# can have to run correctly, e.g. if your module needs has optional
# parameters like: 'api-key' and a 'name':
#########################################################################
optional = {
#########################################################################
optional = {
# A simple text input with multiple values separated by a comma
'hobbies': {"label": "What is/are your hobbies? Separate multiple ones "
"with a comma"},
# A simple text input with multiple values separated by a comma
'hobbies': {"label": "What is/are your hobbies? Separate multiple ones "
"with a comma"},
# A simple text input which should be a number
'age': {"label": "What is your age? Please enter a number"},
# A dropdown list variable. This will allow users to select something
# from the list in options. Instead of True/False, you can have
# strings, numbers and other datatypes. Add as many options as you need
'likes_inkycal': {
"label": "Do you like Inkycal?",
"options": [True, False],
},
# A simple text input which should be a number
'age': {"label": "What is your age? Please enter a number"},
# A dropdown list with a fallback value in case the user didn't select
# anything
'show_smiley': {
"label": "Show a smiley next to your name?",
"options": [True, False],
"default": True,
},
}
########################################################################
# A dropdown list variable. This will allow users to select something
# from the list in options. Instead of True/False, you can have
# strings, numbers and other datatypes. Add as many options as you need
'likes_inkycal': {
"label": "Do you like Inkycal?",
"options": [True, False],
},
# Initialise the class (do not remove)
def __init__(self, config):
"""Initialize your module module"""
# A dropdown list with a fallback value in case the user didn't select
# anything
'show_smiley': {
"label": "Show a smiley next to your name?",
"options": [True, False],
"default": True,
},
}
# Initialise this module via the inkycal_module template (required)
super().__init__(config)
########################################################################
config = config['config']
# Initialise the class (do not remove)
def __init__(self, config):
"""Initialize your module module"""
# Check if all required parameters are present
# remove this if your module has no required parameters
for param in self.requires:
if not param in config:
raise Exception('config is missing {}'.format(param))
# Initialise this module via the inkycal_module template (required)
super().__init__(config)
# the web-UI removes any blank space from the input
# It can only output strings or booleans, integers and lists need to be
# converted manually, e.g.
config = config['config']
# if you need a boolean (True/False), no conversion is needed:
self.show_smiley = config['show_smiley']
# Check if all required parameters are present
# remove this if your module has no required parameters
for param in self.requires:
if param not in config:
raise Exception('config is missing {}'.format(param))
# if you need a single word input, like the api-ley, no conversion is needed
self.api_key = config['api_key']
# the web-UI removes any blank space from the input
# It can only output strings or booleans, integers and lists need to be
# converted manually, e.g.
# if you need a integer (number) input, you have to convert this to a int
#-----------------------------------------------------------------------#
# bad example :/
self.age = int( config["age"] )
# Remember age was a optional parameter? What if no age was entered
# and there is no fallback value? Then the age would be None.
# This would cause crashing right here
# good example :)
if config["age"] and isinstance(config["age"], str):
self.age = int( config["age"] )
else:
self.age = 10 # just a joke, no offense
# -> Check if age was entered and if it's a string (entered via web-UI)
# If something was entered for age, convert it to a number
# The else statement is executed when nothing was entered for age
# You could assign a custom value now or print something.
#-----------------------------------------------------------------------#
# if you need a boolean (True/False), no conversion is needed:
self.show_smiley = config['show_smiley']
# if you need a list of words, you have to convert the string to a list
#-----------------------------------------------------------------------#
# good example :)
if config["hobbies"] and isinstance(config["hobbies"], str):
self.hobbies = config["age"].split(",")
# split splits the string on each comma -> gives a list
# even if a single value was entered, it will be converted to a list
else:
self.hobbies = [] # empty list if nothing was entered by user
#-----------------------------------------------------------------------#
# if you need a single word input, like the api-ley, no conversion is needed
self.api_key = config['api_key']
# give an OK message
print(f'{filename} loaded')
# if you need a integer (number) input, you have to convert this to a int
# -----------------------------------------------------------------------#
# bad example :/
self.age = int(config["age"])
# Remember age was a optional parameter? What if no age was entered
# and there is no fallback value? Then the age would be None.
# This would cause crashing right here
#############################################################################
# Validation of module specific parameters (optional) #
#############################################################################
# good example :)
if config["age"] and isinstance(config["age"], str):
self.age = int(config["age"])
else:
self.age = 10 # just a joke, no offense
# -> Check if age was entered and if it's a string (entered via web-UI)
# If something was entered for age, convert it to a number
# The else statement is executed when nothing was entered for age
# You could assign a custom value now or print something.
# -----------------------------------------------------------------------#
def _validate(self):
"""Validate module-specific parameters"""
# Check the type of module-specific parameters
# This function is optional, but useful for debugging.
# if you need a list of words, you have to convert the string to a list
# -----------------------------------------------------------------------#
# good example :)
if config["hobbies"] and isinstance(config["hobbies"], str):
self.hobbies = config["age"].split(",")
# split splits the string on each comma -> gives a list
# even if a single value was entered, it will be converted to a list
else:
self.hobbies = [] # empty list if nothing was entered by user
# -----------------------------------------------------------------------#
# Here, we are checking if do_something (from init) is True/False
if not isinstance(self.age, int):
print(f"age has to be a number, but given value is {self.age}")
# give an OK message
print(f'{__name__} loaded')
#############################################################################
# Validation of module specific parameters (optional) #
#############################################################################
#############################################################################
# Generating the image #
#############################################################################
def _validate(self):
"""Validate module-specific parameters"""
# Check the type of module-specific parameters
# This function is optional, but useful for debugging.
def generate_image(self):
"""Generate image for this module"""
# Here, we are checking if do_something (from init) is True/False
if not isinstance(self.age, int):
print(f"age has to be a number, but given value is {self.age}")
# Define new image size with respect to padding
im_width = int(self.width - (2 * self.padding_left))
im_height = int(self.height - (2 * self.padding_top))
im_size = im_width, im_height
logger.info('image size: {} x {} px'.format(im_width, im_height))
#############################################################################
# Generating the image #
#############################################################################
# Use logger.info(), logger.debug(), logger.warning() to display
# useful information for the developer
logger.info('image size: {} x {} px'.format(im_width, im_height))
def generate_image(self):
"""Generate image for this module"""
# Create an image for black pixels and one for coloured pixels (required)
im_black = Image.new('RGB', size = im_size, color = 'white')
im_colour = Image.new('RGB', size = im_size, color = 'white')
# Define new image size with respect to padding
im_width = int(self.width - (2 * self.padding_left))
im_height = int(self.height - (2 * self.padding_top))
im_size = im_width, im_height
logger.info('image size: {} x {} px'.format(im_width, im_height))
#################################################################
# Use logger.info(), logger.debug(), logger.warning() to display
# useful information for the developer
logger.info('image size: {} x {} px'.format(im_width, im_height))
# Your code goes here #
# Write/Draw something on the image
# Create an image for black pixels and one for coloured pixels (required)
im_black = Image.new('RGB', size=im_size, color='white')
im_colour = Image.new('RGB', size=im_size, color='white')
# You can use these custom functions to help you create the image:
# - write() -> write text on the image
# - get_fonts() -> see which fonts are available
# - get_system_tz() -> Get the system's current timezone
# - auto_fontsize() -> Scale the fontsize to the provided height
# - textwrap() -> Split a paragraph into smaller lines
# - internet_available() -> Check if internet is available
# - draw_border() -> Draw a border around the specified area
#################################################################
# If these aren't enough, take a look at python Pillow (imaging library)'s
# documentation.
# Your code goes here #
#################################################################
# Write/Draw something on the image
# return the images ready for the display
return im_black, im_colour
# You can use these custom functions to help you create the image:
# - write() -> write text on the image
# - get_fonts() -> see which fonts are available
# - get_system_tz() -> Get the system's current timezone
# - auto_fontsize() -> Scale the fontsize to the provided height
# - textwrap() -> Split a paragraph into smaller lines
# - internet_available() -> Check if internet is available
# - draw_border() -> Draw a border around the specified area
# If these aren't enough, take a look at python Pillow (imaging library)'s
# documentation.
#################################################################
# return the images ready for the display
return im_black, im_colour
if __name__ == '__main__':
print('running {0} in standalone mode'.format(filename))
print('running {0} in standalone mode'.format(__name__))
################################################################################
# Last steps
@@ -248,4 +241,3 @@ if __name__ == '__main__':
# How do I now import my module?
# from inkycal.modules import Class
# Where Class is the name of the class inside your module (e.g. Simple)

323
inkycal/modules/ical_parser.py Normal file → Executable file
View File

@@ -1,9 +1,17 @@
#!/usr/bin/python3
# -*- coding: utf-8 -*-
#!python3
"""
iCalendar (parsing) module for Inky-Calendar Project
Inkycal iCalendar parsing module
Copyright by aceisace
"""
import urllib
import arrow
from urllib.request import urlopen
import logging
import time
import recurring_ical_events
from icalendar import Calendar
""" ---info about iCalendars---
• all day events start at midnight, ending at midnight of the next day
@@ -13,201 +21,184 @@ Copyright by aceisace
local timezone. Converting all-day events to local timezone is a problem!
"""
import arrow
from urllib.request import urlopen
import logging
import time
import os
logger = logging.getLogger(__name__)
try:
import recurring_ical_events
except ModuleNotFoundError:
print('recurring-ical-events library could not be found.')
print('Please install this with: pip3 install recurring-ical-events')
try:
from icalendar import Calendar, Event
except ModuleNotFoundError:
print('icalendar library could not be found. Please install this with:')
print('pip3 install icalendar')
filename = os.path.basename(__file__).split('.py')[0]
logger = logging.getLogger(filename)
class iCalendar:
"""iCalendar parsing moudule for inkycal.
Parses events from given iCalendar URLs / paths"""
"""iCalendar parsing moudule for inkycal.
Parses events from given iCalendar URLs / paths"""
def __init__(self):
self.icalendars = []
self.parsed_events = []
def __init__(self):
self.icalendars = []
self.parsed_events = []
def load_url(self, url, username=None, password=None):
"""Input a string or list of strings containing valid iCalendar URLs
example: 'URL1' (single url) OR ['URL1', 'URL2'] (multiple URLs)
add username and password to access protected files
"""
def load_url(self, url, username=None, password=None):
"""Input a string or list of strings containing valid iCalendar URLs
example: 'URL1' (single url) OR ['URL1', 'URL2'] (multiple URLs)
add username and password to access protected files
"""
if type(url) == list:
if (username == None) and (password == None):
ical = [Calendar.from_ical(str(urlopen(_).read().decode()))
for _ in url]
else:
ical = [auth_ical(each_url, username, password) for each_url in url]
elif type(url) == str:
if (username == None) and (password == None):
ical = [Calendar.from_ical(str(urlopen(url).read().decode()))]
else:
ical = [auth_ical(url, username, password)]
else:
raise Exception (f"Input: '{url}' is not a string or list!")
def auth_ical(url, uname, passwd):
"""Authorisation helper for protected ical files"""
# Credit to Joshka
password_mgr = urllib.request.HTTPPasswordMgrWithDefaultRealm()
password_mgr.add_password(None, url, username, password)
handler = urllib.request.HTTPBasicAuthHandler(password_mgr)
opener = urllib.request.build_opener(handler)
ical = Calendar.from_ical(str(opener.open(url).read().decode()))
return ical
def auth_ical(url, uname, passwd):
"""Authorisation helper for protected ical files"""
if type(url) == list:
if (username is None) and (password is None):
ical = [Calendar.from_ical(str(urlopen(_).read().decode()))
for _ in url]
else:
ical = [auth_ical(each_url, username, password) for each_url in url]
elif type(url) == str:
if (username is None) and (password is None):
ical = [Calendar.from_ical(str(urlopen(url).read().decode()))]
else:
ical = [auth_ical(url, username, password)]
else:
raise Exception(f"Input: '{url}' is not a string or list!")
# Credit to Joshka
password_mgr = urllib.request.HTTPPasswordMgrWithDefaultRealm()
password_mgr.add_password(None, url, username, password)
handler = urllib.request.HTTPBasicAuthHandler(password_mgr)
opener = urllib.request.build_opener(handler)
ical = Calendar.from_ical(str(opener.open(url).read().decode()))
return ical
# Add the parsed icalendar/s to the self.icalendars list
if ical: self.icalendars += ical
logger.info('loaded iCalendars from URLs')
# Add the parsed icalendar/s to the self.icalendars list
if ical: self.icalendars += ical
logger.info('loaded iCalendars from URLs')
def load_from_file(self, filepath):
"""Input a string or list of strings containing valid iCalendar filepaths
example: 'path1' (single file) OR ['path1', 'path2'] (multiple files)
returns a list of iCalendars as string (raw)
"""
if isinstance(filepath, list):
for path in filepath:
with open(path, mode='r') as ical_file:
ical = (Calendar.from_ical(ical_file.read()))
self.icalendars.append(ical)
def load_from_file(self, filepath):
"""Input a string or list of strings containing valid iCalendar filepaths
example: 'path1' (single file) OR ['path1', 'path2'] (multiple files)
returns a list of iCalendars as string (raw)
"""
if type(filepath) == list:
ical = [Calendar.from_ical(str(open(path).read())) for path in filepath]
elif type(filepath) == str:
ical = [Calendar.from_ical(str(open(filepath).read()))]
else:
raise Exception (f"Input: '{filepath}' is not a string or list!")
elif isinstance(filepath, str):
with open(filepath, mode='r') as ical_file:
ical = (Calendar.from_ical(ical_file.read()))
self.icalendars.append(ical)
else:
raise Exception(f"Input: '{filepath}' is not a string or list!")
self.icalendars += ical
logger.info('loaded iCalendars from filepaths')
logger.info('loaded iCalendars from filepaths')
def get_events(self, timeline_start, timeline_end, timezone=None):
"""Input an arrow (time) object for:
* the beginning of timeline (events have to end after this time)
* the end of the timeline (events have to begin before this time)
* timezone if events should be formatted to local time
Returns a list of events sorted by date
"""
if type(timeline_start) == arrow.arrow.Arrow:
if timezone == None:
timezone = 'UTC'
t_start = timeline_start
t_end = timeline_end
else:
raise Exception('Please input a valid arrow (time) object!')
def get_events(self, timeline_start, timeline_end, timezone=None):
"""Input an arrow (time) object for:
* the beginning of timeline (events have to end after this time)
* the end of the timeline (events have to begin before this time)
* timezone if events should be formatted to local time
Returns a list of events sorted by date
"""
if type(timeline_start) == arrow.arrow.Arrow:
if timezone is None:
timezone = 'UTC'
t_start = timeline_start
t_end = timeline_end
else:
raise Exception('Please input a valid arrow (time) object!')
# parse non-recurring events
# parse non-recurring events
# Recurring events time-span has to be in this format:
# "%Y%m%dT%H%M%SZ" (python strftime)
fmt = lambda date: (date.year, date.month, date.day, date.hour,
date.minute, date.second)
# Recurring events time-span has to be in this format:
# "%Y%m%dT%H%M%SZ" (python strftime)
fmt = lambda date: (date.year, date.month, date.day, date.hour,
date.minute, date.second)
t_start_recurring = fmt(t_start)
t_end_recurring = fmt(t_end)
t_start_recurring = fmt(t_start)
t_end_recurring = fmt(t_end)
# Fetch recurring events
recurring_events = (recurring_ical_events.of(ical).between(
t_start_recurring, t_end_recurring)
for ical in self.icalendars)
# Fetch recurring events
recurring_events = (recurring_ical_events.of(ical).between(
t_start_recurring, t_end_recurring)
for ical in self.icalendars)
events = (
{
'title': events.get('SUMMARY').lstrip(),
events = (
{
'title': events.get('SUMMARY').lstrip(),
'begin': arrow.get(events.get('DTSTART').dt).to(timezone) if (
arrow.get(events.get('dtstart').dt).format('HH:mm') != '00:00')
else arrow.get(events.get('DTSTART').dt).replace(tzinfo=timezone),
'begin': arrow.get(events.get('DTSTART').dt).to(timezone) if (
arrow.get(events.get('dtstart').dt).format('HH:mm') != '00:00')
else arrow.get(events.get('DTSTART').dt).replace(tzinfo=timezone),
'end':arrow.get(events.get("DTEND").dt).to(timezone) if (
arrow.get(events.get('dtstart').dt).format('HH:mm') != '00:00')
else arrow.get(events.get('DTEND').dt).replace(tzinfo=timezone)
'end': arrow.get(events.get("DTEND").dt).to(timezone) if (
arrow.get(events.get('dtstart').dt).format('HH:mm') != '00:00')
else arrow.get(events.get('DTEND').dt).replace(tzinfo=timezone)
} for ical in recurring_events for events in ical)
} for ical in recurring_events for events in ical)
# if any recurring events were found, add them to parsed_events
if events: self.parsed_events += list(events)
# if any recurring events were found, add them to parsed_events
if events: self.parsed_events += list(events)
# Sort events by their beginning date
self.sort()
# Sort events by their beginning date
self.sort()
return self.parsed_events
return self.parsed_events
def sort(self):
"""Sort all parsed events in order of beginning time"""
if not self.parsed_events:
logger.debug('no events found to be sorted')
else:
# sort events by date
by_date = lambda event: event['begin']
self.parsed_events.sort(key=by_date)
def sort(self):
"""Sort all parsed events in order of beginning time"""
if not self.parsed_events:
logger.debug('no events found to be sorted')
else:
# sort events by date
by_date = lambda event: event['begin']
self.parsed_events.sort(key=by_date)
def clear_events(self):
"""clear previously parsed events"""
self.parsed_events = []
def clear_events(self):
"""clear previously parsed events"""
@staticmethod
def all_day(event):
"""Check if an event is an all day event.
Returns True if event is all day, else False
"""
if not ('end' and 'begin') in event:
print('Events must have a starting and ending time')
raise Exception('This event is not valid!')
else:
begin, end = event['begin'], event['end']
duration = end - begin
if (begin.format('HH:mm') == '00:00' and end.format('HH:mm') == '00:00'
and duration.days >= 1):
return True
else:
return False
self.parsed_events = []
@staticmethod
def get_system_tz():
"""Get the timezone set by the system"""
@staticmethod
def all_day(event):
"""Check if an event is an all day event.
Returns True if event is all day, else False
"""
if not ('end' and 'begin') in event:
print('Events must have a starting and ending time')
raise Exception('This event is not valid!')
else:
begin, end = event['begin'], event['end']
duration = end - begin
if (begin.format('HH:mm') == '00:00' and end.format('HH:mm') == '00:00'
and duration.days >= 1):
return True
else:
return False
try:
local_tz = time.tzname[1]
except:
print('System timezone could not be parsed!')
print('Please set timezone manually!. Setting timezone to None...')
local_tz = None
return local_tz
@staticmethod
def get_system_tz():
"""Get the timezone set by the system"""
def show_events(self, fmt='DD MMM YY HH:mm'):
"""print all parsed events in a more readable way
use the format (fmt) parameter to specify the date format
see https://arrow.readthedocs.io/en/latest/#supported-tokens
for more info tokens
"""
try:
local_tz = time.tzname[1]
except:
print('System timezone could not be parsed!')
print('Please set timezone manually!. Setting timezone to None...')
local_tz = None
return local_tz
def show_events(self, fmt='DD MMM YY HH:mm'):
"""print all parsed events in a more readable way
use the format (fmt) parameter to specify the date format
see https://arrow.readthedocs.io/en/latest/#supported-tokens
for more info tokens
"""
if not self.parsed_events:
logger.debug('no events found to be shown')
else:
line_width = max(len(_['title']) for _ in self.parsed_events)
for events in self.parsed_events:
title = events['title']
begin, end = events['begin'].format(fmt), events['end'].format(fmt)
print('{0} {1} | {2} | {3}'.format(
title, ' ' * (line_width - len(title)), begin, end))
if not self.parsed_events:
logger.debug('no events found to be shown')
else:
line_width = max(len(_['title']) for _ in self.parsed_events)
for events in self.parsed_events:
title = events['title']
begin, end = events['begin'].format(fmt), events['end'].format(fmt)
print('{0} {1} | {2} | {3}'.format(
title, ' ' * (line_width - len(title)), begin, end))
if __name__ == '__main__':
print(f'running {filename} in standalone mode')
print(f'running {__name__} in standalone mode')

546
inkycal/modules/inky_image.py Normal file → Executable file
View File

@@ -1,5 +1,5 @@
#!/usr/bin/python3
# -*- coding: utf-8 -*-
#!python3
"""
Custom image class for Inkycal Project
@@ -8,327 +8,325 @@ images.
Copyright by aceisace
"""
from PIL import Image, ImageOps, ImageColor
import requests
import numpy
import os
import logging
import os
import numpy
import requests
from PIL import Image
logger = logging.getLogger(__name__)
filename = os.path.basename(__file__).split('.py')[0]
logger = logging.getLogger(filename)
class Inkyimage:
"""Custom Imge class written for commonly used image operations.
"""
def __init__(self, image=None):
"""Initialize Inkyimage module"""
# no image initially
self.image = image
# give an OK message
logger.info(f'{filename} loaded')
def load(self, path):
"""loads an image from a URL or filepath.
Args:
- path:The full path or url of the image file
e.g. `https://sample.com/logo.png` or `/home/pi/Downloads/nice_pic.png`
Raises:
- FileNotFoundError: This Exception is raised when the file could not be
found.
- OSError: A OSError is raised when the URL doesn't point to the correct
file-format, i.e. is not an image
- TypeError: if the URLS doesn't start with htpp
"""Custom Imge class written for commonly used image operations.
"""
# Try to open the image if it exists and is an image file
try:
if path.startswith('http'):
logger.info('loading image from URL')
image = Image.open(requests.get(path, stream=True).raw)
else:
logger.info('loading image from local path')
image = Image.open(path)
except FileNotFoundError:
logger.error('No image file found', exc_info=True)
raise Exception('Your file could not be found. Please check the filepath')
except OSError:
logger.error('Invalid Image file provided', exc_info=True)
raise Exception('Please check if the path points to an image file.')
logger.info(f'width: {image.width}, height: {image.height}')
def __init__(self, image=None):
"""Initialize Inkyimage module"""
image.convert(mode='RGBA') #convert to a more suitable format
self.image = image
logger.info('loaded Image')
def clear(self):
"""Removes currently saved image if present."""
if self.image:
self.image = None
logger.info('cleared previous image')
def _preview(self):
"""Preview the image on gpicview (only works on Rapsbian with Desktop)"""
if self._image_loaded():
path = '/home/pi/Desktop/'
self.image.save(path+'temp.png')
os.system("gpicview "+path+'temp.png')
os.system('rm '+path+'temp.png')
@staticmethod
def preview(image):
""""Previews an 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')
def _image_loaded(self):
"""returns True if image was loaded"""
if self.image:
return True
else:
logger.error('image not loaded')
return False
def flip(self, angle):
"""Flips the image by the given angle.
Args:
- angle:->int. A multiple of 90, e.g. 90, 180, 270, 360.
"""
if self._image_loaded():
image = self.image
if not angle % 90 == 0:
logger.error('Angle must be a multiple of 90')
return
image = image.rotate(angle, expand = True)
self.image = image
logger.info(f'flipped image by {angle} degrees')
def autoflip(self, layout):
"""flips the image automatically to the given layout.
Args:
- layout:-> str. Choose `horizontal` or `vertical`.
Checks the image's width and height.
In horizontal mode, the image is flipped if the image height is greater
than the image width.
In vertical mode, the image is flipped if the image width is greater
than the image height.
"""
if self._image_loaded():
image = self.image
if layout == 'horizontal':
if (image.height > image.width):
logger.info('image width greater than image height, flipping')
image = image.rotate(90, expand=True)
elif layout == 'vertical':
if (image.width > image.height):
logger.info('image width greater than image height, flipping')
image = image.rotate(90, expand=True)
else:
logger.error('layout not supported')
return
self.image = image
def remove_alpha(self):
"""Removes transparency if image has transparency.
Checks if an image has an alpha band and replaces the transparency with
white pixels.
"""
if self._image_loaded():
image = self.image
if len(image.getbands()) == 4:
logger.info('removing alpha channel')
bg = Image.new('RGBA', (image.width, image.height), 'white')
im = Image.alpha_composite(bg, image)
self.image.paste(im, (0,0))
logger.info('removed transparency')
def resize(self, width=None, height=None):
"""Resize an image to desired width or height"""
if self._image_loaded():
if width == None and height == None:
logger.error('no height of width specified')
return
image = self.image
if width:
initial_width = image.width
wpercent = (width/float(image.width))
hsize = int((float(image.height)*float(wpercent)))
image = image.resize((width, hsize), Image.ANTIALIAS)
logger.info(f"resized image from {initial_width} to {image.width}")
# no image initially
self.image = image
if height:
initial_height = image.height
hpercent = (height / float(image.height))
wsize = int(float(image.width) * float(hpercent))
image = image.resize((wsize, height), Image.ANTIALIAS)
logger.info(f"resized image from {initial_height} to {image.height}")
# give an OK message
logger.info(f'{__name__} loaded')
def load(self, path):
"""loads an image from a URL or filepath.
Args:
- path:The full path or url of the image file
e.g. `https://sample.com/logo.png` or `/home/pi/Downloads/nice_pic.png`
Raises:
- FileNotFoundError: This Exception is raised when the file could not be
found.
- OSError: A OSError is raised when the URL doesn't point to the correct
file-format, i.e. is not an image
- TypeError: if the URLS doesn't start with htpp
"""
# Try to open the image if it exists and is an image file
try:
if path.startswith('http'):
logger.info('loading image from URL')
image = Image.open(requests.get(path, stream=True).raw)
else:
logger.info('loading image from local path')
image = Image.open(path)
except FileNotFoundError:
logger.error('No image file found', exc_info=True)
raise Exception('Your file could not be found. Please check the filepath')
except OSError:
logger.error('Invalid Image file provided', exc_info=True)
raise Exception('Please check if the path points to an image file.')
logger.info(f'width: {image.width}, height: {image.height}')
image.convert(mode='RGBA') # convert to a more suitable format
self.image = image
logger.info('loaded Image')
@staticmethod
def merge(image1, image2):
"""Merges two images into one.
def clear(self):
"""Removes currently saved image if present."""
if self.image:
self.image = None
logger.info('cleared previous image')
Replaces white pixels of the first image with transparent ones. Then pastes
the first image on the second one.
def _preview(self):
"""Preview the image on gpicview (only works on Rapsbian with Desktop)"""
if self._image_loaded():
path = '/home/pi/Desktop/'
self.image.save(path + 'temp.png')
os.system("gpicview " + path + 'temp.png')
os.system('rm ' + path + 'temp.png')
Args:
- image1: A PIL Image object in 'RGBA' mode.
- image2: A PIL Image object in 'RGBA' mode.
@staticmethod
def preview(image):
""""Previews an 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')
Returns:
- A single image.
"""
def _image_loaded(self):
"""returns True if image was loaded"""
if self.image:
return True
else:
logger.error('image not loaded')
return False
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)
def flip(self, angle):
"""Flips the image by the given angle.
image2 = clear_white(image2)
image1.paste(image2, (0,0), image2)
logger.info('merged given images into one')
Args:
- angle:->int. A multiple of 90, e.g. 90, 180, 270, 360.
"""
if self._image_loaded():
return image1
image = self.image
if not angle % 90 == 0:
logger.error('Angle must be a multiple of 90')
return
image = image.rotate(angle, expand=True)
self.image = image
logger.info(f'flipped image by {angle} degrees')
def to_palette(self, palette, dither=True):
"""Maps an image to a given colour palette.
def autoflip(self, layout):
"""flips the image automatically to the given layout.
Maps each pixel from the image to a colour from the palette.
Args:
- layout:-> str. Choose `horizontal` or `vertical`.
Args:
- palette: A supported token. (see below)
- dither:->bool. Use dithering? Set to `False` for solid colour fills.
Checks the image's width and height.
Returns:
- two images: one for the coloured band and one for the black band.
In horizontal mode, the image is flipped if the image height is greater
than the image width.
Raises:
- ValueError if palette token is not supported
In vertical mode, the image is flipped if the image width is greater
than the image height.
"""
if self._image_loaded():
Supported palette tokens:
image = self.image
if layout == 'horizontal':
if image.height > image.width:
logger.info('image width greater than image height, flipping')
image = image.rotate(90, expand=True)
>>> 'bwr' # black-white-red
>>> 'bwy' # black-white-yellow
>>> 'bw' # black-white
"""
# Check if an image is loaded
if self._image_loaded():
image = self.image.convert('RGB')
else:
logger.error('No image loaded')
elif layout == 'vertical':
if image.width > image.height:
logger.info('image width greater than image height, flipping')
image = image.rotate(90, expand=True)
else:
logger.error('layout not supported')
return
self.image = image
if palette == 'bwr':
# black-white-red palette
pal = [255,255,255, 0,0,0, 255,0,0]
def remove_alpha(self):
"""Removes transparency if image has transparency.
elif palette == 'bwy':
# black-white-yellow palette
pal = [255,255,255, 0,0,0, 255,255,0]
Checks if an image has an alpha band and replaces the transparency with
white pixels.
"""
if self._image_loaded():
image = self.image
elif palette == 'bw':
pal = None
if len(image.getbands()) == 4:
logger.info('removing alpha channel')
bg = Image.new('RGBA', (image.width, image.height), 'white')
im = Image.alpha_composite(bg, image)
else:
logger.error('The given palette is unsupported.')
raise ValueError('The given palette is not supported.')
self.image.paste(im, (0, 0))
logger.info('removed transparency')
if pal:
# The palette needs to have 256 colors, for this, the black-colour
# is added until the
colours = len(pal) // 3
#print(f'The palette has {colours} colours')
def resize(self, width=None, height=None):
"""Resize an image to desired width or height"""
if self._image_loaded():
if 256 % colours != 0:
#print('Filling palette with black')
pal += (256 % colours) * [0,0,0]
if not width and not height:
logger.error('no height of width specified')
return
#print(pal)
colours = len(pal) // 3
#print(f'The palette now has {colours} colours')
image = self.image
# Create a dummy image to be used as a palette
palette_im = Image.new('P', (1,1))
if width:
initial_width = image.width
wpercent = (width / float(image.width))
hsize = int((float(image.height) * float(wpercent)))
image = image.resize((width, hsize), Image.LANCZOS)
logger.info(f"resized image from {initial_width} to {image.width}")
self.image = image
# Attach the created palette. The palette should have 256 colours
# equivalent to 768 integers
palette_im.putpalette(pal* (256//colours))
if height:
initial_height = image.height
hpercent = (height / float(image.height))
wsize = int(float(image.width) * float(hpercent))
image = image.resize((wsize, height), Image.LANCZOS)
logger.info(f"resized image from {initial_height} to {image.height}")
self.image = image
# Quantize the image to given palette
quantized_im = image.quantize(palette=palette_im, dither=dither)
quantized_im = quantized_im.convert('RGB')
@staticmethod
def merge(image1, image2):
"""Merges two images into one.
# get rgb of the non-black-white colour from the palette
rgb = [pal[x:x+3] for x in range(0, len(pal),3)]
rgb = [col for col in rgb if col != [0,0,0] and col != [255,255,255]][0]
r_col, g_col, b_col = rgb
#print(f'r:{r_col} g:{g_col} b:{b_col}')
Replaces white pixels of the first image with transparent ones. Then pastes
the first image on the second one.
# Create an image buffer for black pixels
buffer1 = numpy.array(quantized_im)
Args:
- image1: A PIL Image object in 'RGBA' mode.
- image2: A PIL Image object in 'RGBA' mode.
# Get RGB values of each pixel
r,g,b = buffer1[:, :, 0], buffer1[:, :, 1], buffer1[:, :, 2]
Returns:
- A single image.
"""
# convert coloured pixels to white
buffer1[numpy.logical_and(r==r_col, g==g_col)] = [255,255,255]
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)
# reconstruct image for black-band
im_black = Image.fromarray(buffer1)
image2 = clear_white(image2)
image1.paste(image2, (0, 0), image2)
logger.info('merged given images into one')
# Create a buffer for coloured pixels
buffer2 = numpy.array(quantized_im)
return image1
# Get RGB values of each pixel
r,g,b = buffer2[:, :, 0], buffer2[:, :, 1], buffer2[:, :, 2]
def to_palette(self, palette, dither=True):
"""Maps an image to a given colour palette.
# convert black pixels to white
buffer2[numpy.logical_and(r==0, g==0)] = [255,255,255]
Maps each pixel from the image to a colour from the palette.
# convert non-white pixels to black
buffer2[numpy.logical_and(g==g_col, b==0)] = [0,0,0]
Args:
- palette: A supported token. (see below)
- dither:->bool. Use dithering? Set to `False` for solid colour fills.
# reconstruct image for colour-band
im_colour = Image.fromarray(buffer2)
Returns:
- two images: one for the coloured band and one for the black band.
#self.preview(im_black)
#self.preview(im_colour)
Raises:
- ValueError if palette token is not supported
else:
im_black = image.convert('1', dither=dither)
im_colour = Image.new(mode='RGB', size=im_black.size, color='white')
Supported palette tokens:
logger.info('mapped image to specified palette')
>>> 'bwr' # black-white-red
>>> 'bwy' # black-white-yellow
>>> 'bw' # black-white
"""
# Check if an image is loaded
if self._image_loaded():
image = self.image.convert('RGB')
else:
raise FileNotFoundError
return im_black, im_colour
if palette == 'bwr':
# black-white-red palette
pal = [255, 255, 255, 0, 0, 0, 255, 0, 0]
elif palette == 'bwy':
# black-white-yellow palette
pal = [255, 255, 255, 0, 0, 0, 255, 255, 0]
elif palette == 'bw':
pal = None
else:
logger.error('The given palette is unsupported.')
raise ValueError('The given palette is not supported.')
if pal:
# The palette needs to have 256 colors, for this, the black-colour
# is added until the
colours = len(pal) // 3
# print(f'The palette has {colours} colours')
if 256 % colours != 0:
# print('Filling palette with black')
pal += (256 % colours) * [0, 0, 0]
# print(pal)
colours = len(pal) // 3
# print(f'The palette now has {colours} colours')
# Create a dummy image to be used as a palette
palette_im = Image.new('P', (1, 1))
# Attach the created palette. The palette should have 256 colours
# equivalent to 768 integers
palette_im.putpalette(pal * (256 // colours))
# Quantize the image to given palette
quantized_im = image.quantize(palette=palette_im, dither=dither)
quantized_im = quantized_im.convert('RGB')
# get rgb of the non-black-white colour from the palette
rgb = [pal[x:x + 3] for x in range(0, len(pal), 3)]
rgb = [col for col in rgb if col != [0, 0, 0] and col != [255, 255, 255]][0]
r_col, g_col, b_col = rgb
# print(f'r:{r_col} g:{g_col} b:{b_col}')
# Create an image buffer for black pixels
buffer1 = numpy.array(quantized_im)
# Get RGB values of each pixel
r, g, b = buffer1[:, :, 0], buffer1[:, :, 1], buffer1[:, :, 2]
# convert coloured pixels to white
buffer1[numpy.logical_and(r == r_col, g == g_col)] = [255, 255, 255]
# reconstruct image for black-band
im_black = Image.fromarray(buffer1)
# Create a buffer for coloured pixels
buffer2 = numpy.array(quantized_im)
# Get RGB values of each pixel
r, g, b = buffer2[:, :, 0], buffer2[:, :, 1], buffer2[:, :, 2]
# convert black pixels to white
buffer2[numpy.logical_and(r == 0, g == 0)] = [255, 255, 255]
# convert non-white pixels to black
buffer2[numpy.logical_and(g == g_col, b == 0)] = [0, 0, 0]
# reconstruct image for colour-band
im_colour = Image.fromarray(buffer2)
# self.preview(im_black)
# self.preview(im_colour)
else:
im_black = image.convert('1', dither=dither)
im_colour = Image.new(mode='RGB', size=im_black.size, color='white')
logger.info('mapped image to specified palette')
return im_black, im_colour
if __name__ == '__main__':
print(f'running {filename} in standalone/debug mode')
print(f'running {__name__} in standalone/debug mode')

335
inkycal/modules/inkycal_agenda.py Normal file → Executable file
View File

@@ -1,233 +1,230 @@
#!/usr/bin/python3
# -*- coding: utf-8 -*-
#!python3
"""
Agenda module for Inky-Calendar Project
Inkycal Agenda Module
Copyright by aceisace
"""
from inkycal.modules.template import inkycal_module
from inkycal.custom import *
from inkycal.modules.ical_parser import iCalendar
import calendar as cal
import arrow
filename = os.path.basename(__file__).split('.py')[0]
logger = logging.getLogger(filename)
from inkycal.custom import *
from inkycal.modules.ical_parser import iCalendar
from inkycal.modules.template import inkycal_module
logger = logging.getLogger(__name__)
class Agenda(inkycal_module):
"""Agenda class
Create agenda and show events from given icalendars
"""
"""Agenda class
Create agenda and show events from given icalendars
"""
name = "Agenda - Display upcoming events from given iCalendars"
name = "Agenda - Display upcoming events from given iCalendars"
requires = {
"ical_urls" : {
"label":"iCalendar URL/s, separate multiple ones with a comma",
},
requires = {
"ical_urls": {
"label": "iCalendar URL/s, separate multiple ones with a comma",
},
}
optional = {
"ical_files" : {
"label":"iCalendar filepaths, separated with a comma",
},
optional = {
"ical_files": {
"label": "iCalendar filepaths, separated with a comma",
},
"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",
"default":"HH:mm",
},
"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",
"default": "HH:mm",
},
}
def __init__(self, config):
"""Initialize inkycal_agenda module"""
def __init__(self, config):
"""Initialize inkycal_agenda module"""
super().__init__(config)
super().__init__(config)
config = config['config']
config = config['config']
# Check if all required parameters are present
for param in self.requires:
if not param in config:
raise Exception(f'config is missing {param}')
logger.exception(f'config is missing "{param}"')
# Check if all required parameters are present
for param in self.requires:
if param not in config:
raise Exception(f'config is missing {param}')
# module specific parameters
self.date_format = config['date_format']
self.time_format = config['time_format']
self.language = config['language']
# module specific parameters
self.date_format = config['date_format']
self.time_format = config['time_format']
self.language = config['language']
# Check if ical_files is an empty string
if config['ical_urls'] and isinstance(config['ical_urls'], str):
self.ical_urls = config['ical_urls'].split(',')
else:
self.ical_urls = config['ical_urls']
# Check if ical_files is an empty string
if config['ical_urls'] and isinstance(config['ical_urls'], str):
self.ical_urls = config['ical_urls'].split(',')
else:
self.ical_urls = config['ical_urls']
# Check if ical_files is an empty string
if config['ical_files'] and isinstance(config['ical_files'], str):
self.ical_files = config['ical_files'].split(',')
else:
self.ical_files = config['ical_files']
# Check if ical_files is an empty string
if config['ical_files'] and isinstance(config['ical_files'], str):
self.ical_files = config['ical_files'].split(',')
else:
self.ical_files = config['ical_files']
# Additional config
self.timezone = get_system_tz()
# Additional config
self.timezone = get_system_tz()
# give an OK message
print(f'{filename} loaded')
# give an OK message
print(f'{__name__} loaded')
def generate_image(self):
"""Generate image for this module"""
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_left))
im_height = int(self.height - (2 * self.padding_top))
im_size = im_width, im_height
# Define new image size with respect to padding
im_width = int(self.width - (2 * self.padding_left))
im_height = int(self.height - (2 * self.padding_top))
im_size = im_width, im_height
logger.info(f'Image size: {im_size}')
logger.info(f'Image size: {im_size}')
# 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')
# 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')
# Calculate the max number of lines that can fit on the image
line_spacing = 1
line_height = int(self.font.getsize('hg')[1]) + line_spacing
line_width = im_width
max_lines = im_height // line_height
logger.debug(f'max lines: {max_lines}')
# Calculate the max number of lines that can fit on the image
line_spacing = 1
line_height = int(self.font.getsize('hg')[1]) + line_spacing
line_width = im_width
max_lines = im_height // line_height
logger.debug(f'max lines: {max_lines}')
# Create timeline for agenda
now = arrow.now()
today = now.floor('day')
# Create timeline for agenda
now = arrow.now()
today = now.floor('day')
# Create a list of dates for the next days
agenda_events = [
{'begin':today.shift(days=+_),
'title': today.shift(days=+_).format(
self.date_format,locale=self.language)}
for _ in range(max_lines)]
# Create a list of dates for the next days
agenda_events = [
{'begin': today.shift(days=+_),
'title': today.shift(days=+_).format(
self.date_format, locale=self.language)}
for _ in range(max_lines)]
# Load icalendar from config
self.ical = iCalendar()
parser = self.ical
# Load icalendar from config
self.ical = iCalendar()
parser = self.ical
if self.ical_urls:
parser.load_url(self.ical_urls)
if self.ical_urls:
parser.load_url(self.ical_urls)
if self.ical_files:
parser.load_from_file(self.ical_files)
if self.ical_files:
parser.load_from_file(self.ical_files)
# Load events from all icalendar in timerange
upcoming_events = parser.get_events(today, agenda_events[-1]['begin'],
self.timezone)
# Load events from all icalendar in timerange
upcoming_events = parser.get_events(today, agenda_events[-1]['begin'],
self.timezone)
# Sort events by beginning time
parser.sort()
#parser.show_events()
# Sort events by beginning time
parser.sort()
# parser.show_events()
# Set the width for date, time and event titles
date_width = int(max([self.font.getsize(
dates['begin'].format(self.date_format, locale=self.language))[0]
for dates in agenda_events]) * 1.2)
logger.debug(f'date_width: {date_width}')
# Set the width for date, time and event titles
date_width = int(max([self.font.getsize(
dates['begin'].format(self.date_format, locale=self.language))[0]
for dates in agenda_events]) * 1.2)
logger.debug(f'date_width: {date_width}')
# Calculate positions for each line
line_pos = [(0, int(line * line_height)) for line in range(max_lines)]
logger.debug(f'line_pos: {line_pos}')
# Calculate positions for each line
line_pos = [(0, int(line * line_height)) for line in range(max_lines)]
logger.debug(f'line_pos: {line_pos}')
# Check if any events were filtered
if upcoming_events:
logger.info('Managed to parse events from urls')
# Check if any events were filtered
if upcoming_events:
logger.info('Managed to parse events from urls')
# Find out how much space the event times take
time_width = int(max([self.font.getsize(
events['begin'].format(self.time_format, locale=self.language))[0]
for events in upcoming_events]) * 1.2)
logger.debug(f'time_width: {time_width}')
# Find out how much space the event times take
time_width = int(max([self.font.getsize(
events['begin'].format(self.time_format, locale=self.language))[0]
for events in upcoming_events]) * 1.2)
logger.debug(f'time_width: {time_width}')
# Calculate x-pos for time
x_time = date_width
logger.debug(f'x-time: {x_time}')
# Calculate x-pos for time
x_time = date_width
logger.debug(f'x-time: {x_time}')
# Find out how much space is left for event titles
event_width = im_width - time_width - date_width
logger.debug(f'width for events: {event_width}')
# Find out how much space is left for event titles
event_width = im_width - time_width - date_width
logger.debug(f'width for events: {event_width}')
# Calculate x-pos for event titles
x_event = date_width + time_width
logger.debug(f'x-event: {x_event}')
# Calculate x-pos for event titles
x_event = date_width + time_width
logger.debug(f'x-event: {x_event}')
# Merge list of dates and list of events
agenda_events += upcoming_events
# Merge list of dates and list of events
agenda_events += upcoming_events
# Sort the combined list in chronological order of dates
by_date = lambda event: event['begin']
agenda_events.sort(key = by_date)
# Sort the combined list in chronological order of dates
by_date = lambda event: event['begin']
agenda_events.sort(key=by_date)
# Delete more entries than can be displayed (max lines)
del agenda_events[max_lines:]
# Delete more entries than can be displayed (max lines)
del agenda_events[max_lines:]
self._agenda_events = agenda_events
self._agenda_events = agenda_events
cursor = 0
for _ in agenda_events:
title = _['title']
cursor = 0
for _ in agenda_events:
title = _['title']
# Check if item is a date
if not 'end' in _:
ImageDraw.Draw(im_colour).line(
(0, line_pos[cursor][1], im_width, line_pos[cursor][1]),
fill = 'black')
# Check if item is a date
if 'end' not in _:
ImageDraw.Draw(im_colour).line(
(0, line_pos[cursor][1], im_width, line_pos[cursor][1]),
fill='black')
write(im_black, line_pos[cursor], (date_width, line_height),
title, font = self.font, alignment='left')
write(im_black, line_pos[cursor], (date_width, line_height),
title, font=self.font, alignment='left')
cursor += 1
cursor += 1
# Check if item is an event
if 'end' in _:
time = _['begin'].format(self.time_format, locale=self.language)
# Check if item is an event
if 'end' in _:
time = _['begin'].format(self.time_format, locale=self.language)
# Check if event is all day, if not, add the time
if parser.all_day(_) == False:
write(im_black, (x_time, line_pos[cursor][1]),
(time_width, line_height), time,
font = self.font, alignment='left')
# Check if event is all day, if not, add the time
if not parser.all_day(_):
write(im_black, (x_time, line_pos[cursor][1]),
(time_width, line_height), time,
font=self.font, alignment='left')
write(im_black, (x_event, line_pos[cursor][1]),
(event_width, line_height),
''+title, font = self.font, alignment='left')
cursor += 1
write(im_black, (x_event, line_pos[cursor][1]),
(event_width, line_height),
'' + title, font=self.font, alignment='left')
cursor += 1
# If no events were found, write only dates and lines
else:
logger.info('no events found')
# If no events were found, write only dates and lines
else:
logger.info('no events found')
cursor = 0
for _ in agenda_events:
title = _['title']
ImageDraw.Draw(im_colour).line(
(0, line_pos[cursor][1], im_width, line_pos[cursor][1]),
fill = 'black')
cursor = 0
for _ in agenda_events:
title = _['title']
ImageDraw.Draw(im_colour).line(
(0, line_pos[cursor][1], im_width, line_pos[cursor][1]),
fill='black')
write(im_black, line_pos[cursor], (date_width, line_height),
title, font = self.font, alignment='left')
write(im_black, line_pos[cursor], (date_width, line_height),
title, font=self.font, alignment='left')
cursor += 1
cursor += 1
# return the images ready for the display
return im_black, im_colour
# return the images ready for the display
return im_black, im_colour
if __name__ == '__main__':
print(f'running {filename} in standalone mode')
print(f'running {__name__} in standalone mode')

561
inkycal/modules/inkycal_calendar.py Normal file → Executable file
View File

@@ -1,7 +1,7 @@
#!/usr/bin/python3
# -*- coding: utf-8 -*-
#!python3
"""
Calendar module for Inky-Calendar Project
Inkycal Calendar Module
Copyright by aceisace
"""
from inkycal.modules.template import inkycal_module
@@ -10,337 +10,334 @@ from inkycal.custom import *
import calendar as cal
import arrow
filename = os.path.basename(__file__).split('.py')[0]
logger = logging.getLogger(filename)
logger = logging.getLogger(__name__)
class Calendar(inkycal_module):
"""Calendar class
Create monthly calendar and show events from given icalendars
"""
"""Calendar class
Create monthly calendar and show events from given icalendars
"""
name = "Calendar - Show monthly calendar with events from iCalendars"
name = "Calendar - Show monthly calendar with events from iCalendars"
optional = {
optional = {
"week_starts_on" : {
"label":"When does your week start? (default=Monday)",
"options": ["Monday", "Sunday"],
"default": "Monday"
},
"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
},
"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",
},
"ical_urls": {
"label": "iCalendar URL/s, separate multiple ones with a comma",
},
"ical_files" : {
"label":"iCalendar filepaths, separated with a comma",
},
"ical_files": {
"label": "iCalendar filepaths, separated with a comma",
},
"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",
},
"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"
},
"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, config):
"""Initialize inkycal_calendar module"""
def __init__(self, config):
"""Initialize inkycal_calendar module"""
super().__init__(config)
config = config['config']
super().__init__(config)
config = config['config']
# optional parameters
self.weekstart = config['week_starts_on']
self.show_events = config['show_events']
self.date_format = config["date_format"]
self.time_format = config['time_format']
self.language = config['language']
# optional parameters
self.weekstart = config['week_starts_on']
self.show_events = config['show_events']
self.date_format = config["date_format"]
self.time_format = config['time_format']
self.language = config['language']
if config['ical_urls'] and isinstance(config['ical_urls'], str):
self.ical_urls = config['ical_urls'].split(',')
else:
self.ical_urls = config['ical_urls']
if config['ical_urls'] and isinstance(config['ical_urls'], str):
self.ical_urls = config['ical_urls'].split(',')
else:
self.ical_urls = config['ical_urls']
if config['ical_files'] and isinstance(config['ical_files'], str):
self.ical_files = config['ical_files'].split(',')
else:
self.ical_files = config['ical_files']
if config['ical_files'] and isinstance(config['ical_files'], str):
self.ical_files = config['ical_files'].split(',')
else:
self.ical_files = config['ical_files']
# additional configuration
self.timezone = get_system_tz()
self.num_font = ImageFont.truetype(
fonts['NotoSans-SemiCondensed'], size = self.fontsize)
# additional configuration
self.timezone = get_system_tz()
self.num_font = ImageFont.truetype(
fonts['NotoSans-SemiCondensed'], size=self.fontsize)
# give an OK message
print(f'{filename} loaded')
# give an OK message
print(f'{__name__} loaded')
def generate_image(self):
"""Generate image for this module"""
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_left))
im_height = int(self.height - (2 * self.padding_top))
im_size = im_width, im_height
# Define new image size with respect to padding
im_width = int(self.width - (2 * self.padding_left))
im_height = int(self.height - (2 * self.padding_top))
im_size = im_width, im_height
logger.info(f'Image size: {im_size}')
logger.info(f'Image size: {im_size}')
# 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')
# 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')
# Allocate space for month-names, weekdays etc.
month_name_height = int(im_height * 0.10)
weekdays_height = int(self.font.getsize('hg')[1] * 1.25)
logger.debug(f"month_name_height: {month_name_height}")
logger.debug(f"weekdays_height: {weekdays_height}")
# Allocate space for month-names, weekdays etc.
month_name_height = int(im_height * 0.10)
weekdays_height = int(self.font.getsize('hg')[1] * 1.25)
logger.debug(f"month_name_height: {month_name_height}")
logger.debug(f"weekdays_height: {weekdays_height}")
if self.show_events == True:
logger.debug("Allocating space for events")
calendar_height = int(im_height * 0.6)
events_height = im_height - month_name_height - weekdays_height - calendar_height
logger.debug(f'calendar-section size: {im_width} x {calendar_height} px')
logger.debug(f'events-section size: {im_width} x {events_height} px')
else:
logger.debug("Not allocating space for events")
calendar_height = im_height - month_name_height - weekdays_height
logger.debug(f'calendar-section size: {im_width} x {calendar_height} px')
if self.show_events:
logger.debug("Allocating space for events")
calendar_height = int(im_height * 0.6)
events_height = im_height - month_name_height - weekdays_height - calendar_height
logger.debug(f'calendar-section size: {im_width} x {calendar_height} px')
logger.debug(f'events-section size: {im_width} x {events_height} px')
else:
logger.debug("Not allocating space for events")
calendar_height = im_height - month_name_height - weekdays_height
logger.debug(f'calendar-section size: {im_width} x {calendar_height} px')
# Create a 7x6 grid and calculate icon sizes
calendar_rows, calendar_cols = 6, 7
icon_width = im_width // calendar_cols
icon_height = calendar_height // calendar_rows
logger.debug(f"icon_size: {icon_width}x{icon_height}px")
# Create a 7x6 grid and calculate icon sizes
calendar_rows, calendar_cols = 6, 7
icon_width = im_width // calendar_cols
icon_height = calendar_height // calendar_rows
logger.debug(f"icon_size: {icon_width}x{icon_height}px")
# Calculate spacings for calendar area
x_spacing_calendar = int((im_width % calendar_cols) / 2)
y_spacing_calendar = int((im_height % calendar_rows) / 2)
# Calculate spacings for calendar area
x_spacing_calendar = int((im_width % calendar_cols) / 2)
y_spacing_calendar = int((im_height % calendar_rows) / 2)
logger.debug(f"x_spacing_calendar: {x_spacing_calendar}")
logger.debug(f"y_spacing_calendar :{y_spacing_calendar}")
logger.debug(f"x_spacing_calendar: {x_spacing_calendar}")
logger.debug(f"y_spacing_calendar :{y_spacing_calendar}")
# Calculate positions for days of month
grid_start_y = (month_name_height + weekdays_height + y_spacing_calendar)
grid_start_x = x_spacing_calendar
# Calculate positions for days of month
grid_start_y = (month_name_height + weekdays_height + y_spacing_calendar)
grid_start_x = x_spacing_calendar
grid_coordinates = [(grid_start_x + icon_width*x, grid_start_y + icon_height*y)
for y in range(calendar_rows) for x in range(calendar_cols)]
grid_coordinates = [(grid_start_x + icon_width * x, grid_start_y + icon_height * y)
for y in range(calendar_rows) for x in range(calendar_cols)]
weekday_pos = [(grid_start_x + icon_width*_, month_name_height) for _ in
range(calendar_cols)]
weekday_pos = [(grid_start_x + icon_width * _, month_name_height) for _ in
range(calendar_cols)]
now = arrow.now(tz = self.timezone)
now = arrow.now(tz=self.timezone)
# Set weekstart of calendar to specified weekstart
if self.weekstart == "Monday":
cal.setfirstweekday(cal.MONDAY)
weekstart = now.shift(days = - now.weekday())
else:
cal.setfirstweekday(cal.SUNDAY)
weekstart = now.shift(days = - now.isoweekday())
# Set weekstart of calendar to specified weekstart
if self.weekstart == "Monday":
cal.setfirstweekday(cal.MONDAY)
weekstart = now.shift(days=- now.weekday())
else:
cal.setfirstweekday(cal.SUNDAY)
weekstart = now.shift(days=- now.isoweekday())
# Write the name of current month
write(im_black, (0,0),(im_width, month_name_height),
str(now.format('MMMM',locale=self.language)), font = self.font,
autofit = True)
# Write the name of current month
write(im_black, (0, 0), (im_width, month_name_height),
str(now.format('MMMM', locale=self.language)), font=self.font,
autofit=True)
# Set up weeknames in local language and add to main section
weekday_names = [weekstart.shift(days=+_).format('ddd',locale=self.language)
for _ in range(7)]
logger.debug(f'weekday names: {weekday_names}')
# Set up weeknames in local language and add to main section
weekday_names = [weekstart.shift(days=+_).format('ddd', locale=self.language)
for _ in range(7)]
logger.debug(f'weekday names: {weekday_names}')
for _ in range(len(weekday_pos)):
write(
im_black,
weekday_pos[_],
(icon_width, weekdays_height),
weekday_names[_],
font = self.font,
autofit = True,
fill_height=1.0
)
# Create a calendar template and flatten (remove nestings)
flatten = lambda z: [x for y in z for x in y]
calendar_flat = flatten(cal.monthcalendar(now.year, now.month))
#logger.debug(f" calendar_flat: {calendar_flat}")
# Map days of month to co-ordinates of grid -> 3: (row2_x,col3_y)
grid = {}
for i in calendar_flat:
if i != 0:
grid[i] = grid_coordinates[calendar_flat.index(i)]
#logger.debug(f"grid:{grid}")
# remove zeros from calendar since they are not required
calendar_flat = [num for num in calendar_flat if num != 0]
# Add the numbers on the correct positions
for number in calendar_flat:
if number != int(now.day):
write(im_black, grid[number], (icon_width, icon_height),
str(number), font = self.num_font, fill_height = 0.5, fill_width=0.5)
# Draw a red/black circle with the current day of month in white
icon = Image.new('RGBA', (icon_width, icon_height))
current_day_pos = grid[int(now.day)]
x_circle,y_circle = int(icon_width/2), int(icon_height/2)
radius = int(icon_width * 0.2)
ImageDraw.Draw(icon).ellipse(
(x_circle-radius, y_circle-radius, x_circle+radius, y_circle+radius),
fill= 'black', outline=None)
write(icon, (0,0), (icon_width, icon_height), str(now.day),
font=self.num_font, fill_height = 0.5, colour='white')
im_colour.paste(icon, current_day_pos, icon)
# If events should be loaded and shown...
if self.show_events == True:
# If this month requires 5 instead of 6 rows, increase event section height
if len(cal.monthcalendar(now.year, now.month)) == 5:
events_height += icon_height
# If this month requires 4 instead of 6 rows, increase event section height
elif len(cal.monthcalendar(now.year, now.month)) == 4:
events_height += icon_height * 2
# import the ical-parser
from inkycal.modules.ical_parser import iCalendar
# find out how many lines can fit at max in the event section
line_spacing = 0
max_event_lines = events_height // (self.font.getsize('hg')[1] +
line_spacing)
# generate list of coordinates for each line
events_offset = im_height - events_height
event_lines = [(0, events_offset + int(events_height/max_event_lines*_))
for _ in range(max_event_lines)]
#logger.debug(f"event_lines {event_lines}")
# timeline for filtering events within this month
month_start = arrow.get(now.floor('month'))
month_end = arrow.get(now.ceil('month'))
# fetch events from given icalendars
self.ical = iCalendar()
parser = self.ical
if self.ical_urls:
parser.load_url(self.ical_urls)
if self.ical_files:
parser.load_from_file(self.ical_files)
# Filter events for full month (even past ones) for drawing event icons
month_events = parser.get_events(month_start, month_end, self.timezone)
parser.sort()
self.month_events = month_events
# find out on which days of this month events are taking place
days_with_events = [int(events['begin'].format('D')) for events in
month_events]
# remove duplicates (more than one event in a single day)
list(set(days_with_events)).sort()
self._days_with_events = days_with_events
# Draw a border with specified parameters around days with events
for days in days_with_events:
if days in grid:
draw_border(
im_colour,
grid[days],
(icon_width, icon_height),
radius = 6,
thickness= 1,
shrinkage = (0.4, 0.2)
for _ in range(len(weekday_pos)):
write(
im_black,
weekday_pos[_],
(icon_width, weekdays_height),
weekday_names[_],
font=self.font,
autofit=True,
fill_height=1.0
)
# Filter upcoming events until 4 weeks in the future
parser.clear_events()
upcoming_events = parser.get_events(now, now.shift(weeks=4),
self.timezone)
self._upcoming_events = upcoming_events
# Create a calendar template and flatten (remove nestings)
flatten = lambda z: [x for y in z for x in y]
calendar_flat = flatten(cal.monthcalendar(now.year, now.month))
# logger.debug(f" calendar_flat: {calendar_flat}")
# delete events which won't be able to fit (more events than lines)
upcoming_events[:max_event_lines]
# Map days of month to co-ordinates of grid -> 3: (row2_x,col3_y)
grid = {}
for i in calendar_flat:
if i != 0:
grid[i] = grid_coordinates[calendar_flat.index(i)]
# logger.debug(f"grid:{grid}")
# remove zeros from calendar since they are not required
calendar_flat = [num for num in calendar_flat if num != 0]
# Check if any events were found in the given timerange
if upcoming_events:
# Add the numbers on the correct positions
for number in calendar_flat:
if number != int(now.day):
write(im_black, grid[number], (icon_width, icon_height),
str(number), font=self.num_font, fill_height=0.5, fill_width=0.5)
# Find out how much space (width) the date format requires
lang = self.language
# Draw a red/black circle with the current day of month in white
icon = Image.new('RGBA', (icon_width, icon_height))
current_day_pos = grid[int(now.day)]
x_circle, y_circle = int(icon_width / 2), int(icon_height / 2)
radius = int(icon_width * 0.2)
ImageDraw.Draw(icon).ellipse(
(x_circle - radius, y_circle - radius, x_circle + radius, y_circle + radius),
fill='black', outline=None)
write(icon, (0, 0), (icon_width, icon_height), str(now.day),
font=self.num_font, fill_height=0.5, colour='white')
im_colour.paste(icon, current_day_pos, icon)
date_width = int(max([self.font.getsize(
events['begin'].format(self.date_format,locale=lang))[0]
for events in upcoming_events]) * 1.1)
# If events should be loaded and shown...
if self.show_events:
time_width = int(max([self.font.getsize(
events['begin'].format(self.time_format, locale=lang))[0]
for events in upcoming_events]) * 1.1)
# If this month requires 5 instead of 6 rows, increase event section height
if len(cal.monthcalendar(now.year, now.month)) == 5:
events_height += icon_height
line_height = self.font.getsize('hg')[1] + line_spacing
# If this month requires 4 instead of 6 rows, increase event section height
elif len(cal.monthcalendar(now.year, now.month)) == 4:
events_height += icon_height * 2
event_width_s = im_width - date_width - time_width
event_width_l = im_width - date_width
# import the ical-parser
from inkycal.modules.ical_parser import iCalendar
# Display upcoming events below calendar
tomorrow = now.shift(days=1).floor('day')
in_two_days = now.shift(days=2).floor('day')
# find out how many lines can fit at max in the event section
line_spacing = 0
max_event_lines = events_height // (self.font.getsize('hg')[1] +
line_spacing)
cursor = 0
for event in upcoming_events:
if cursor < len(event_lines):
name = event['title']
date = event['begin'].format(self.date_format, locale=lang)
time = event['begin'].format(self.time_format, locale=lang)
#logger.debug(f"name:{name} date:{date} time:{time}")
# generate list of coordinates for each line
events_offset = im_height - events_height
event_lines = [(0, events_offset + int(events_height / max_event_lines * _))
for _ in range(max_event_lines)]
if now < event['end']:
write(im_colour, event_lines[cursor], (date_width, line_height),
date, font=self.font, alignment = 'left')
# logger.debug(f"event_lines {event_lines}")
# Check if event is all day
if parser.all_day(event) == True:
write(im_black, (date_width, event_lines[cursor][1]),
(event_width_l, line_height), name, font=self.font,
alignment = 'left')
else:
write(im_black, (date_width, event_lines[cursor][1]),
(time_width, line_height), time, font=self.font,
alignment = 'left')
# timeline for filtering events within this month
month_start = arrow.get(now.floor('month'))
month_end = arrow.get(now.ceil('month'))
write(im_black, (date_width+time_width,event_lines[cursor][1]),
(event_width_s, line_height), name, font=self.font,
alignment = 'left')
cursor += 1
else:
symbol = '- '
while self.font.getsize(symbol)[0] < im_width*0.9:
symbol += ' -'
write(im_black, event_lines[0],
(im_width, self.font.getsize(symbol)[1]), symbol,
font = self.font)
# fetch events from given icalendars
self.ical = iCalendar()
parser = self.ical
if self.ical_urls:
parser.load_url(self.ical_urls)
if self.ical_files:
parser.load_from_file(self.ical_files)
# Filter events for full month (even past ones) for drawing event icons
month_events = parser.get_events(month_start, month_end, self.timezone)
parser.sort()
self.month_events = month_events
# find out on which days of this month events are taking place
days_with_events = [int(events['begin'].format('D')) for events in
month_events]
# remove duplicates (more than one event in a single day)
list(set(days_with_events)).sort()
self._days_with_events = days_with_events
# Draw a border with specified parameters around days with events
for days in days_with_events:
if days in grid:
draw_border(
im_colour,
grid[days],
(icon_width, icon_height),
radius=6,
thickness=1,
shrinkage=(0.4, 0.2)
)
# Filter upcoming events until 4 weeks in the future
parser.clear_events()
upcoming_events = parser.get_events(now, now.shift(weeks=4),
self.timezone)
self._upcoming_events = upcoming_events
# delete events which won't be able to fit (more events than lines)
upcoming_events = upcoming_events[:max_event_lines]
# Check if any events were found in the given timerange
if upcoming_events:
# Find out how much space (width) the date format requires
lang = self.language
date_width = int(max([self.font.getsize(
events['begin'].format(self.date_format, locale=lang))[0]
for events in upcoming_events]) * 1.1)
time_width = int(max([self.font.getsize(
events['begin'].format(self.time_format, locale=lang))[0]
for events in upcoming_events]) * 1.1)
line_height = self.font.getsize('hg')[1] + line_spacing
event_width_s = im_width - date_width - time_width
event_width_l = im_width - date_width
# Display upcoming events below calendar
tomorrow = now.shift(days=1).floor('day')
in_two_days = now.shift(days=2).floor('day')
cursor = 0
for event in upcoming_events:
if cursor < len(event_lines):
name = event['title']
date = event['begin'].format(self.date_format, locale=lang)
time = event['begin'].format(self.time_format, locale=lang)
# logger.debug(f"name:{name} date:{date} time:{time}")
if now < event['end']:
write(im_colour, event_lines[cursor], (date_width, line_height),
date, font=self.font, alignment='left')
# Check if event is all day
if parser.all_day(event):
write(im_black, (date_width, event_lines[cursor][1]),
(event_width_l, line_height), name, font=self.font,
alignment='left')
else:
write(im_black, (date_width, event_lines[cursor][1]),
(time_width, line_height), time, font=self.font,
alignment='left')
write(im_black, (date_width + time_width, event_lines[cursor][1]),
(event_width_s, line_height), name, font=self.font,
alignment='left')
cursor += 1
else:
symbol = '- '
while self.font.getsize(symbol)[0] < im_width * 0.9:
symbol += ' -'
write(im_black, event_lines[0],
(im_width, self.font.getsize(symbol)[1]), symbol,
font=self.font)
# return the images ready for the display
return im_black, im_colour
# return the images ready for the display
return im_black, im_colour
if __name__ == '__main__':
print(f'running {filename} in standalone mode')
print(f'running {__name__} in standalone mode')

View File

@@ -1,5 +1,4 @@
#!/usr/bin/python3
# -*- coding: utf-8 -*-
#!python3
"""
Feeds module for InkyCal Project
@@ -11,147 +10,146 @@ from inkycal.modules.template import inkycal_module
from inkycal.custom import *
from random import shuffle
try:
import feedparser
except ImportError:
print('feedparser is not installed! Please install with:')
print('pip3 install feedparser')
filename = os.path.basename(__file__).split('.py')[0]
logger = logging.getLogger(filename)
import feedparser
logger = logging.getLogger(__name__)
class Feeds(inkycal_module):
"""RSS class
parses rss/atom feeds from given urls
"""
"""RSS class
parses rss/atom feeds from given urls
"""
name = "RSS / Atom - Display feeds from given RSS/ATOM feeds"
name = "RSS / Atom - Display feeds from given RSS/ATOM feeds"
requires = {
"feed_urls" : {
"label":"Please enter ATOM or RSS feed URL/s, separated by a comma",
},
requires = {
"feed_urls": {
"label": "Please enter ATOM or RSS feed URL/s, separated by a comma",
},
}
optional = {
optional = {
"shuffle_feeds": {
"label": "Should the parsed RSS feeds be shuffled? (default=True)",
"options": [True, False],
"default": True
},
"shuffle_feeds": {
"label": "Should the parsed RSS feeds be shuffled? (default=True)",
"options": [True, False],
"default": True
},
}
def __init__(self, config):
"""Initialize inkycal_feeds module"""
def __init__(self, config):
"""Initialize inkycal_feeds module"""
super().__init__(config)
super().__init__(config)
config = config['config']
config = config['config']
# Check if all required parameters are present
for param in self.requires:
if not param in config:
raise Exception(f'config is missing {param}')
# Check if all required parameters are present
for param in self.requires:
if param not in config:
raise Exception(f'config is missing {param}')
# required parameters
if config["feed_urls"] and isinstance(config['feed_urls'], str):
self.feed_urls = config["feed_urls"].split(",")
else:
self.feed_urls = config["feed_urls"]
# required parameters
if config["feed_urls"] and isinstance(config['feed_urls'], str):
self.feed_urls = config["feed_urls"].split(",")
else:
self.feed_urls = config["feed_urls"]
# optional parameters
self.shuffle_feeds = config["shuffle_feeds"]
# optional parameters
self.shuffle_feeds = config["shuffle_feeds"]
# give an OK message
print(f'{filename} loaded')
# give an OK message
print(f'{__name__} loaded')
def _validate(self):
"""Validate module-specific parameters"""
def _validate(self):
"""Validate module-specific parameters"""
if not isinstance(self.shuffle_feeds, bool):
print('shuffle_feeds has to be a boolean: True/False')
if not isinstance(self.shuffle_feeds, bool):
print('shuffle_feeds has to be a boolean: True/False')
def generate_image(self):
"""Generate image for this module"""
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_left))
im_height = int(self.height - (2 * self.padding_top))
im_size = im_width, im_height
logger.info(f'Image size: {im_size}')
# Define new image size with respect to padding
im_width = int(self.width - (2 * self.padding_left))
im_height = int(self.height - (2 * self.padding_top))
im_size = im_width, im_height
logger.info(f'Image size: {im_size}')
# 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')
# 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():
logger.info('Connection test passed')
else:
raise NetworkNotReachableError
# 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 feeds
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))
# Set some parameters for formatting feeds
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 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)]
# Calculate line_positions
line_positions = [
(0, spacing_top + _ * line_height ) for _ in range(max_lines)]
# Create list containing all feeds from all urls
parsed_feeds = []
for feeds in self.feed_urls:
text = feedparser.parse(feeds)
for posts in text.entries:
if "summary" in posts:
summary = posts["summary"]
parsed_feeds.append(f"{posts.title}: {re.sub('<[^<]+?>', '', posts.summary)}")
# if "description" in posts:
# Create list containing all feeds from all urls
parsed_feeds = []
for feeds in self.feed_urls:
text = feedparser.parse(feeds)
for posts in text.entries:
if 'summary' in posts:
summary = posts.summary
parsed_feeds.append(f"{posts.title}: {re.sub('<[^<]+?>', '', summary)}")
if parsed_feeds:
parsed_feeds = [i.split("\n") for i in parsed_feeds][0]
parsed_feeds = [i for i in parsed_feeds if i]
self._parsed_feeds = parsed_feeds
# Shuffle the list to prevent showing the same content
if self.shuffle_feeds:
shuffle(parsed_feeds)
# Shuffle the list to prevent showing the same content
if self.shuffle_feeds == True:
shuffle(parsed_feeds)
# Trim down the list to the max number of lines
del parsed_feeds[max_lines:]
# Trim down the list to the max number of lines
del parsed_feeds[max_lines:]
# Wrap long text from feeds (line-breaking)
flatten = lambda z: [x for y in z for x in y]
filtered_feeds, counter = [], 0
# Wrap long text from feeds (line-breaking)
flatten = lambda z: [x for y in z for x in y]
filtered_feeds, counter = [], 0
for posts in parsed_feeds:
wrapped = text_wrap(posts, font=self.font, max_width=line_width)
counter += len(wrapped)
if counter < max_lines:
filtered_feeds.append(wrapped)
filtered_feeds = flatten(filtered_feeds)
for posts in parsed_feeds:
wrapped = text_wrap(posts, font = self.font, max_width = line_width)
counter += len(wrapped)
if counter < max_lines:
filtered_feeds.append(wrapped)
filtered_feeds = flatten(filtered_feeds)
self._filtered_feeds = filtered_feeds
logger.debug(f'filtered feeds -> {filtered_feeds}')
logger.debug(f'filtered feeds -> {filtered_feeds}')
# Check if feeds could be parsed and can be displayed
if len(filtered_feeds) == 0 and len(parsed_feeds) > 0:
print('Feeds could be parsed, but the text is too long to be displayed:/')
elif len(filtered_feeds) == 0 and len(parsed_feeds) == 0:
print('No feeds could be parsed :/')
else:
# Write 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')
# Check if feeds could be parsed and can be displayed
if len(filtered_feeds) == 0 and len(parsed_feeds) > 0:
print('Feeds could be parsed, but the text is too long to be displayed:/')
elif len(filtered_feeds) == 0 and len(parsed_feeds) == 0:
print('No feeds could be parsed :/')
else:
# Write 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')
# return images
return im_black, im_colour
# return images
return im_black, im_colour
if __name__ == '__main__':
print(f'running {filename} in standalone/debug mode')
print(f'running {__name__} in standalone/debug mode')

139
inkycal/modules/inkycal_image.py Normal file → Executable file
View File

@@ -1,8 +1,7 @@
#!/usr/bin/python3
# -*- coding: utf-8 -*-
#!python3
"""
Image module for Inkycal Project
Inkycal Image Module
Copyright by aceisace
"""
@@ -11,98 +10,98 @@ from inkycal.custom import *
from inkycal.modules.inky_image import Inkyimage as Images
filename = os.path.basename(__file__).split('.py')[0]
logger = logging.getLogger(filename)
logger = logging.getLogger(__name__)
class Inkyimage(inkycal_module):
"""Displays an image from URL or local path
"""
"""Displays an image from URL or local path
"""
name = "Inkycal Image - show an image from a URL or local path"
name = "Inkycal Image - show an image from a URL or local path"
requires = {
"path":{
"label":"Path to a local folder, e.g. /home/pi/Desktop/images. "
"Only PNG and JPG/JPEG images are used for the slideshow."
},
requires = {
"palette": {
"label":"Which palette should be used for converting images?",
"options": ["bw", "bwr", "bwy"]
}
}
optional = {
"autoflip":{
"label":"Should the image be flipped automatically?",
"options": [True, False]
"path": {
"label": "Path to a local folder, e.g. /home/pi/Desktop/images. "
"Only PNG and JPG/JPEG images are used for the slideshow."
},
"orientation":{
"label": "Please select the desired orientation",
"options": ["vertical", "horizontal"]
}
"palette": {
"label": "Which palette should be used for converting images?",
"options": ["bw", "bwr", "bwy"]
}
}
def __init__(self, config):
"""Initialize module"""
optional = {
super().__init__(config)
"autoflip": {
"label": "Should the image be flipped automatically?",
"options": [True, False]
},
config = config['config']
"orientation": {
"label": "Please select the desired orientation",
"options": ["vertical", "horizontal"]
}
}
# required parameters
for param in self.requires:
if not param in config:
raise Exception(f'config is missing {param}')
def __init__(self, config):
"""Initialize module"""
# optional parameters
self.path = config['path']
self.palette = config['palette']
self.autoflip = config['autoflip']
self.orientation = config['orientation']
super().__init__(config)
# give an OK message
print(f'{filename} loaded')
config = config['config']
# required parameters
for param in self.requires:
if not param in config:
raise Exception(f'config is missing {param}')
def generate_image(self):
"""Generate image for this module"""
# optional parameters
self.path = config['path']
self.palette = config['palette']
self.autoflip = config['autoflip']
self.orientation = config['orientation']
# Define new image size with respect to padding
im_width = int(self.width - (2 * self.padding_left))
im_height = int(self.height - (2 * self.padding_top))
im_size = im_width, im_height
# give an OK message
print(f'{__name__} loaded')
logger.info(f'Image size: {im_size}')
def generate_image(self):
"""Generate image for this module"""
# initialize custom image class
im = Images()
# Define new image size with respect to padding
im_width = int(self.width - (2 * self.padding_left))
im_height = int(self.height - (2 * self.padding_top))
im_size = im_width, im_height
# use the image at the first index
im.load(self.path)
logger.info(f'Image size: {im_size}')
# Remove background if present
im.remove_alpha()
# initialize custom image class
im = Images()
# if autoflip was enabled, flip the image
if self.autoflip == True:
im.autoflip(self.orientation)
# use the image at the first index
im.load(self.path)
# resize the image so it can fit on the epaper
im.resize( width=im_width, height=im_height )
# Remove background if present
im.remove_alpha()
# convert images according to specified palette
im_black, im_colour = im.to_palette(self.palette)
# if autoflip was enabled, flip the image
if self.autoflip:
im.autoflip(self.orientation)
# with the images now send, clear the current image
im.clear()
# resize the image so it can fit on the epaper
im.resize(width=im_width, height=im_height)
# convert images according to specified palette
im_black, im_colour = im.to_palette(self.palette)
# with the images now send, clear the current image
im.clear()
# return images
return im_black, im_colour
# return images
return im_black, im_colour
if __name__ == '__main__':
print(f'running {filename} in standalone/debug mode')
print(f'running {__name__} in standalone/debug mode')

131
inkycal/modules/inkycal_jokes.py Normal file → Executable file
View File

@@ -1,5 +1,4 @@
#!/usr/bin/python3
# -*- coding: utf-8 -*-
#!python3
"""
iCanHazDadJoke module for InkyCal Project
@@ -11,94 +10,94 @@ from inkycal.modules.template import inkycal_module
from inkycal.custom import *
import requests
# Show less logging for request module
logging.getLogger("urllib3").setLevel(logging.WARNING)
filename = os.path.basename(__file__).split('.py')[0]
logger = logging.getLogger(filename)
logger = logging.getLogger(__name__)
class Jokes(inkycal_module):
"""Icanhazdad-api class
parses rss/atom feeds from given urls
"""
"""Icanhazdad-api class
parses rss/atom feeds from given urls
"""
name = "iCanHazDad API - grab a random joke from icanhazdad api"
name = "iCanHazDad API - grab a random joke from icanhazdad api"
def __init__(self, config):
"""Initialize inkycal_feeds module"""
def __init__(self, config):
"""Initialize inkycal_feeds module"""
super().__init__(config)
super().__init__(config)
config = config['config']
config = config['config']
# give an OK message
print(f'{__name__} loaded')
# give an OK message
print(f'{filename} loaded')
def generate_image(self):
"""Generate image for this module"""
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_left))
im_height = int(self.height - (2 * self.padding_top))
im_size = im_width, im_height
logger.info(f'image size: {im_width} x {im_height} px')
# Define new image size with respect to padding
im_width = int(self.width - (2 * self.padding_left))
im_height = int(self.height - (2 * self.padding_top))
im_size = im_width, im_height
logger.info(f'image size: {im_width} x {im_height} px')
# 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')
# 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():
logger.info('Connection test passed')
else:
raise NetworkNotReachableError
# 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 feeds
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))
# Set some parameters for formatting feeds
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))
logger.debug(f"max_lines: {max_lines}")
logger.debug(f"max_lines: {max_lines}")
# Calculate padding from top so the lines look centralised
spacing_top = int(im_height % line_height / 2)
# 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)]
# Calculate line_positions
line_positions = [
(0, spacing_top + _ * line_height ) for _ in range(max_lines)]
logger.debug(f'line positions: {line_positions}')
logger.debug(f'line positions: {line_positions}')
# Get the actual joke
url = "https://icanhazdadjoke.com"
header = {"accept": "text/plain"}
response = requests.get(url, headers=header)
response.encoding = 'utf-8' # Change encoding to UTF-8
joke = response.text.rstrip() # use to remove newlines
logger.debug(f"joke: {joke}")
# Get the actual joke
url = "https://icanhazdadjoke.com"
header = {"accept": "text/plain"}
response = requests.get(url, headers=header)
response.encoding = 'utf-8' # Change encoding to UTF-8
joke = response.text.rstrip() # use to remove newlines
logger.debug(f"joke: {joke}")
# wrap text in case joke is too large
wrapped = text_wrap(joke, font=self.font, max_width=line_width)
logger.debug(f"wrapped: {wrapped}")
# wrap text in case joke is too large
wrapped = text_wrap(joke, font = self.font, max_width = line_width)
logger.debug(f"wrapped: {wrapped}")
# Check if joke can actually fit on the provided space
if len(wrapped) > max_lines:
logger.error("Ohoh, Joke is too large for given space, please consider "
"increasing the size for this module")
# Check if joke can actually fit on the provided space
if len(wrapped) > max_lines:
logger.error("Ohoh, Joke is too large for given space, please consider "
"increasing the size for this module")
# Write the joke on the image
for _ in range(len(wrapped)):
if _ + 1 > max_lines:
logger.error('Ran out of lines for this joke :/')
break
write(im_black, line_positions[_], (line_width, line_height),
wrapped[_], font=self.font, alignment='left')
# Write the joke on the image
for _ in range(len(wrapped)):
if _+1 > max_lines:
logger.error('Ran out of lines for this joke :/')
break
write(im_black, line_positions[_], (line_width, line_height),
wrapped[_], font = self.font, alignment= 'left')
# Return images for black and colour channels
return im_black, im_colour
# Return images for black and colour channels
return im_black, im_colour
if __name__ == '__main__':
print(f'running {filename} in standalone/debug mode')
print(f'running {__name__} in standalone/debug mode')

160
inkycal/modules/inkycal_server.py Normal file → Executable file
View File

@@ -1,5 +1,4 @@
#!/usr/bin/python3
# -*- coding: utf-8 -*-
#!python3
"""
Inkycal-server module for Inkycal Project
@@ -14,115 +13,115 @@ from inkycal.custom import *
from inkycal.modules.inky_image import Inkyimage as Images
filename = os.path.basename(__file__).split('.py')[0]
logger = logging.getLogger(filename)
logger = logging.getLogger(__name__)
class Inkyserver(inkycal_module):
"""Displays an image from URL or local path
"""
"""Displays an image from URL or local path
"""
name = "Inykcal Server - fetches an image from Inkycal-server - (https://inkycal.robertsirre.nl/)"
name = "Inykcal Server - fetches an image from Inkycal-server - (https://inkycal.robertsirre.nl/)"
requires = {
requires = {
"path":{
"label": "Which URL should be used to get the image?"
},
"palette": {
"label":"Which palette should be used to convert the images?",
"options": ['bw', 'bwr', 'bwy']
}
}
optional = {
"path_body":{
"label":"Send this data to the server via POST. Use a comma to "
"separate multiple items",
"path": {
"label": "Which URL should be used to get the image?"
},
"dither":{
"label": "Dither images before sending to E-Paper? Default is False.",
"options": [False, True],
}
"palette": {
"label": "Which palette should be used to convert the images?",
"options": ['bw', 'bwr', 'bwy']
}
}
def __init__(self, config):
"""Initialize module"""
optional = {
super().__init__(config)
"path_body": {
"label": "Send this data to the server via POST. Use a comma to "
"separate multiple items",
},
"dither": {
"label": "Dither images before sending to E-Paper? Default is False.",
"options": [False, True],
}
config = config['config']
}
# required parameters
for param in self.requires:
if not param in config:
raise Exception(f'config is missing {param}')
def __init__(self, config):
"""Initialize module"""
# optional parameters
self.path = config['path']
self.palette = config['palette']
self.dither = config['dither']
super().__init__(config)
# convert path_body to list, if not already
if config['path_body'] and isinstance(config['path_body'], str):
self.path_body = config['path_body'].split(',')
else:
self.path_body = config['path_body']
config = config['config']
# give an OK message
print(f'{filename} loaded')
# required parameters
for param in self.requires:
if param not in config:
raise Exception(f'config is missing {param}')
# optional parameters
self.path = config['path']
self.palette = config['palette']
self.dither = config['dither']
def generate_image(self):
"""Generate image for this module"""
# convert path_body to list, if not already
if config['path_body'] and isinstance(config['path_body'], str):
self.path_body = config['path_body'].split(',')
else:
self.path_body = config['path_body']
# Define new image size with respect to padding
im_width = int(self.width - (2 * self.padding_left))
im_height = int(self.height - (2 * self.padding_top))
im_size = im_width, im_height
# give an OK message
print(f'{__name__} loaded')
logger.info(f'Image size: {im_size}')
def generate_image(self):
"""Generate image for this module"""
# replace width and height of url
print(self.path)
self.path = self.path.format(width=im_width, height=im_height)
print(f"modified path: {self.path}")
# Define new image size with respect to padding
im_width = int(self.width - (2 * self.padding_left))
im_height = int(self.height - (2 * self.padding_top))
im_size = im_width, im_height
# initialize custom image class
im = Images()
logger.info(f'Image size: {im_size}')
# when no path_body is provided, use plain GET
if not self.path_body:
# replace width and height of url
print(self.path)
self.path = self.path.format(width=im_width, height=im_height)
print(f"modified path: {self.path}")
# use the image at the first index
im.load(self.path)
# initialize custom image class
im = Images()
# else use POST request
else:
# Get the response image
response = Image.open(requests.post(
self.path, json=self.path_body, stream=True).raw)
# when no path_body is provided, use plain GET
if not self.path_body:
# initialize custom image class with response
im = Images(response)
# use the image at the first index
im.load(self.path)
# resize the image to respect padding
im.resize( width=im_width, height=im_height )
# else use POST request
else:
# Get the response image
response = Image.open(requests.post(
self.path, json=self.path_body, stream=True).raw)
# convert image to given palette
im_black, im_colour = im.to_palette(self.palette, dither=self.dither)
# initialize custom image class with response
im = Images(response)
# with the images now send, clear the current image
im.clear()
# resize the image to respect padding
im.resize(width=im_width, height=im_height)
# convert image to given palette
im_black, im_colour = im.to_palette(self.palette, dither=self.dither)
# with the images now send, clear the current image
im.clear()
# return images
return im_black, im_colour
# return images
return im_black, im_colour
if __name__ == '__main__':
print(f'running {filename} in standalone/debug mode')
print(f'running {__name__} in standalone/debug mode')
## 'https://inkycal.robertsirre.nl/panel/calendar/{model}?width={width}&height={height}'
##path = path.replace('{model}', model).replace('{width}',str(display_width)).replace('{height}',str(display_height))
@@ -131,4 +130,3 @@ if __name__ == '__main__':
##inkycal_image_path_body = [
## 'https://calendar.google.com/calendar/ical/en.usa%23holiday%40group.v.calendar.google.com/public/basic.ics',
## 'https

180
inkycal/modules/inkycal_slideshow.py Normal file → Executable file
View File

@@ -1,8 +1,7 @@
#!/usr/bin/python3
# -*- coding: utf-8 -*-
#!python3
"""
Image module for Inkycal Project
Inkycal Slideshow Module
Copyright by aceisace
"""
import glob
@@ -13,122 +12,123 @@ from inkycal.custom import *
# PIL has a class named Image, use alias for Inkyimage -> Images
from inkycal.modules.inky_image import Inkyimage as Images
filename = os.path.basename(__file__).split('.py')[0]
logger = logging.getLogger(filename)
logger = logging.getLogger(__name__)
class Slideshow(inkycal_module):
"""Cycles through images in a local image folder
"""
name = "Slideshow - cycle through images from a local folder"
"""Cycles through images in a local image folder
"""
name = "Slideshow - cycle through images from a local folder"
requires = {
"path":{
"label":"Path to a local folder, e.g. /home/pi/Desktop/images. "
"Only PNG and JPG/JPEG images are used for the slideshow."
},
requires = {
"palette": {
"label":"Which palette should be used for converting images?",
"options": ["bw", "bwr", "bwy"]
}
}
optional = {
"autoflip":{
"label":"Should the image be flipped automatically? Default is False",
"options": [False, True]
"path": {
"label": "Path to a local folder, e.g. /home/pi/Desktop/images. "
"Only PNG and JPG/JPEG images are used for the slideshow."
},
"orientation":{
"label": "Please select the desired orientation",
"options": ["vertical", "horizontal"]
}
"palette": {
"label": "Which palette should be used for converting images?",
"options": ["bw", "bwr", "bwy"]
}
}
def __init__(self, config):
"""Initialize module"""
optional = {
super().__init__(config)
"autoflip": {
"label": "Should the image be flipped automatically? Default is False",
"options": [False, True]
},
config = config['config']
"orientation": {
"label": "Please select the desired orientation",
"options": ["vertical", "horizontal"]
}
}
# required parameters
for param in self.requires:
if not param in config:
raise Exception(f'config is missing {param}')
def __init__(self, config):
"""Initialize module"""
# optional parameters
self.path = config['path']
self.palette = config['palette']
self.autoflip = config['autoflip']
self.orientation = config['orientation']
super().__init__(config)
# Get the full path of all png/jpg/jpeg images in the given folder
all_files = glob.glob(f'{self.path}/*')
self.images = [i for i in all_files
if i.split('.')[-1].lower() in ('jpg', 'jpeg', 'png')]
config = config['config']
if not self.images:
logger.error('No images found in the given folder, please '
'double check your path!')
raise Exception('No images found in the given folder path :/')
# required parameters
for param in self.requires:
if not param in config:
raise Exception(f'config is missing {param}')
# set a 'first run' signal
self._first_run = True
# optional parameters
self.path = config['path']
self.palette = config['palette']
self.autoflip = config['autoflip']
self.orientation = config['orientation']
# give an OK message
print(f'{filename} loaded')
# Get the full path of all png/jpg/jpeg images in the given folder
all_files = glob.glob(f'{self.path}/*')
self.images = [i for i in all_files
if i.split('.')[-1].lower() in ('jpg', 'jpeg', 'png')]
def generate_image(self):
"""Generate image for this module"""
if not self.images:
logger.error('No images found in the given folder, please '
'double check your path!')
raise Exception('No images found in the given folder path :/')
# Define new image size with respect to padding
im_width = int(self.width - (2 * self.padding_left))
im_height = int(self.height - (2 * self.padding_top))
im_size = im_width, im_height
# set a 'first run' signal
self._first_run = True
logger.info(f'Image size: {im_size}')
# give an OK message
print(f'{__name__} loaded')
# rotates list items by 1 index
def rotate(somelist):
return somelist[1:] + somelist[:1]
def generate_image(self):
"""Generate image for this module"""
# Switch to the next image if this is not the first run
if self._first_run == True:
self._first_run = False
else:
self.images = rotate(self.images)
# Define new image size with respect to padding
im_width = int(self.width - (2 * self.padding_left))
im_height = int(self.height - (2 * self.padding_top))
im_size = im_width, im_height
# initialize custom image class
im = Images()
logger.info(f'Image size: {im_size}')
# temporary print method, prints current filename
print(f'slideshow - current image name: {self.images[0].split("/")[-1]}')
# rotates list items by 1 index
def rotate(somelist):
return somelist[1:] + somelist[:1]
# use the image at the first index
im.load(self.images[0])
# Switch to the next image if this is not the first run
if self._first_run:
self._first_run = False
else:
self.images = rotate(self.images)
# Remove background if present
im.remove_alpha()
# initialize custom image class
im = Images()
# if autoflip was enabled, flip the image
if self.autoflip == True:
im.autoflip(self.orientation)
# temporary print method, prints current filename
print(f'slideshow - current image name: {self.images[0].split("/")[-1]}')
# resize the image so it can fit on the epaper
im.resize( width=im_width, height=im_height )
# use the image at the first index
im.load(self.images[0])
# convert images according to specified palette
im_black, im_colour = im.to_palette(self.palette)
# Remove background if present
im.remove_alpha()
# with the images now send, clear the current image
im.clear()
# if autoflip was enabled, flip the image
if self.autoflip:
im.autoflip(self.orientation)
# resize the image so it can fit on the epaper
im.resize(width=im_width, height=im_height)
# convert images according to specified palette
im_black, im_colour = im.to_palette(self.palette)
# with the images now send, clear the current image
im.clear()
# return images
return im_black, im_colour
# return images
return im_black, im_colour
if __name__ == '__main__':
print(f'running {filename} in standalone/debug mode')
print(f'running {__name__} in standalone/debug mode')

396
inkycal/modules/inkycal_stocks.py Normal file → Executable file
View File

@@ -1,5 +1,4 @@
#!/usr/bin/python3
# -*- coding: utf-8 -*-
#!python3
"""
Stocks Module for Inkycal Project
@@ -20,249 +19,252 @@ from inkycal.custom import write, internet_available
from PIL import Image
try:
import yfinance as yf
import yfinance as yf
except ImportError:
print('yfinance is not installed! Please install with:')
print('pip3 install yfinance')
print('yfinance is not installed! Please install with:')
print('pip3 install yfinance')
try:
import matplotlib.pyplot as plt
import matplotlib.image as mpimg
import matplotlib.pyplot as plt
import matplotlib.image as mpimg
except ImportError:
print('matplotlib is not installed! Please install with:')
print('pip3 install matplotlib')
print('matplotlib is not installed! Please install with:')
print('pip3 install matplotlib')
logger = logging.getLogger(__name__)
class Stocks(inkycal_module):
name = "Stocks - Displays stock market infos from Yahoo finance"
name = "Stocks - Displays stock market infos from Yahoo finance"
# required parameters
requires = {
# required parameters
requires = {
"tickers": {
"tickers": {
"label": "You can display any information by using "
"the respective symbols that are used by Yahoo! Finance. "
"Separate multiple symbols with a comma sign e.g. "
"TSLA, U, NVDA, EURUSD=X"
}
"label": "You can display any information by using "
"the respective symbols that are used by Yahoo! Finance. "
"Separate multiple symbols with a comma sign e.g. "
"TSLA, U, NVDA, EURUSD=X"
}
}
def __init__(self, config):
def __init__(self, config):
super().__init__(config)
super().__init__(config)
config = config['config']
config = config['config']
# If tickers is a string from web-ui, convert to a list, else use
# tickers as-is i.e. for tests
if config['tickers'] and isinstance(config['tickers'], str):
self.tickers = config['tickers'].replace(" ", "").split(',') #returns list
else:
self.tickers = config['tickers']
# If tickers is a string from web-ui, convert to a list, else use
# tickers as-is i.e. for tests
if config['tickers'] and isinstance(config['tickers'], str):
self.tickers = config['tickers'].replace(" ", "").split(',') # returns list
else:
self.tickers = config['tickers']
# give an OK message
print(f'{__name__} loaded')
# give an OK message
print(f'{__name__} loaded')
def generate_image(self):
"""Generate image for this module"""
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_left))
im_height = int(self.height - (2 * self.padding_top))
im_size = im_width, im_height
logger.info(f'image size: {im_width} x {im_height} px')
# Define new image size with respect to padding
im_width = int(self.width - (2 * self.padding_left))
im_height = int(self.height - (2 * self.padding_top))
im_size = im_width, im_height
logger.info(f'image size: {im_width} x {im_height} px')
# Create an image for black pixels and one for coloured pixels (required)
im_black = Image.new('RGB', size = im_size, color = 'white')
im_colour = Image.new('RGB', size = im_size, color = 'white')
# Create an image for black pixels and one for coloured pixels (required)
im_black = Image.new('RGB', size=im_size, color='white')
im_colour = Image.new('RGB', size=im_size, color='white')
# Create tmp path
tmpPath = '/tmp/inkycal_stocks/'
# Create tmp path
tmpPath = '/tmp/inkycal_stocks/'
try:
os.mkdir(tmpPath)
except OSError:
print (f"Creation of tmp directory {tmpPath} failed")
else:
print (f"Successfully created tmp directory {tmpPath} ")
try:
os.mkdir(tmpPath)
except OSError:
print(f"Creation of tmp directory {tmpPath} failed")
else:
print(f"Successfully created tmp directory {tmpPath} ")
# Check if internet is available
if internet_available() == True:
logger.info('Connection test passed')
else:
raise Exception('Network could not be reached :/')
# 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 feeds
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))
# Set some parameters for formatting feeds
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))
logger.debug(f"max_lines: {max_lines}")
logger.debug(f"max_lines: {max_lines}")
# Calculate padding from top so the lines look centralised
spacing_top = int( im_height % line_height / 2 )
# 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)]
# Calculate line_positions
line_positions = [
(0, spacing_top + _ * line_height) for _ in range(max_lines)]
logger.debug(f'line positions: {line_positions}')
logger.debug(f'line positions: {line_positions}')
parsed_tickers = []
parsed_tickers_colour = []
chartSpace = Image.new('RGBA', (im_width, im_height), "white")
chartSpace_colour = Image.new('RGBA', (im_width, im_height), "white")
parsed_tickers = []
parsed_tickers_colour = []
chartSpace = Image.new('RGBA', (im_width, im_height), "white")
chartSpace_colour = Image.new('RGBA', (im_width, im_height), "white")
tickerCount = range(len(self.tickers))
tickerCount = range(len(self.tickers))
for _ in tickerCount:
ticker = self.tickers[_]
logger.info(f'preparing data for {ticker}...')
for _ in tickerCount:
ticker = self.tickers[_]
logger.info(f'preparing data for {ticker}...')
yfTicker = yf.Ticker(ticker)
yfTicker = yf.Ticker(ticker)
try:
stockInfo = yfTicker.info
except Exception as exceptionMessage:
logger.warning(f"Failed to get '{ticker}' ticker info: {exceptionMessage}")
try:
stockInfo = yfTicker.info
except Exception as exceptionMessage:
logger.warning(f"Failed to get '{ticker}' ticker info: {exceptionMessage}")
try:
stockName = stockInfo['shortName']
except Exception:
stockName = ticker
logger.warning(f"Failed to get '{stockName}' ticker name! Using "
"the ticker symbol as name instead.")
try:
stockName = stockInfo['shortName']
except Exception:
stockName = ticker
logger.warning(f"Failed to get '{stockName}' ticker name! Using "
"the ticker symbol as name instead.")
try:
stockCurrency = stockInfo['currency']
if stockCurrency == 'USD':
stockCurrency = '$'
elif stockCurrency == 'EUR':
stockCurrency = ''
except Exception:
stockCurrency = ''
logger.warning(f"Failed to get ticker currency!")
try:
precision = stockInfo['priceHint']
except Exception:
precision = 2
logger.warning(f"Failed to get '{stockName}' ticker price hint! Using "
"default precision of 2 instead.")
try:
stockCurrency = stockInfo['currency']
if stockCurrency == 'USD':
stockCurrency = '$'
elif stockCurrency == 'EUR':
stockCurrency = ''
except Exception:
stockCurrency = ''
logger.warning(f"Failed to get ticker currency!")
stockHistory = yfTicker.history("30d")
stockHistoryLen = len(stockHistory)
logger.info(f'fetched {stockHistoryLen} datapoints ...')
previousQuote = (stockHistory.tail(2)['Close'].iloc[0])
currentQuote = (stockHistory.tail(1)['Close'].iloc[0])
currentHigh = (stockHistory.tail(1)['High'].iloc[0])
currentLow = (stockHistory.tail(1)['Low'].iloc[0])
currentOpen = (stockHistory.tail(1)['Open'].iloc[0])
currentGain = currentQuote-previousQuote
currentGainPercentage = (1-currentQuote/previousQuote)*-100
firstQuote = stockHistory.tail(stockHistoryLen)['Close'].iloc[0]
logger.info(f'firstQuote {firstQuote} ...')
def floatStr(precision, number):
return "%0.*f" % (precision, number)
def percentageStr(number):
return '({:+.2f}%)'.format(number)
def gainStr(precision, number):
return "%+.*f" % (precision, number)
try:
precision = stockInfo['priceHint']
except Exception:
precision = 2
logger.warning(f"Failed to get '{stockName}' ticker price hint! Using "
"default precision of 2 instead.")
stockNameLine = '{} ({})'.format(stockName, stockCurrency)
stockCurrentValueLine = '{} {} {}'.format(
floatStr(precision, currentQuote), gainStr(precision, currentGain), percentageStr(currentGainPercentage))
stockDayValueLine = '1d OHL: {}/{}/{}'.format(
floatStr(precision, currentOpen), floatStr(precision, currentHigh), floatStr(precision, currentLow))
maxQuote = max(stockHistory.High)
minQuote = min(stockHistory.Low)
logger.info(f'high {maxQuote} low {minQuote} ...')
stockMonthValueLine = '{}d OHL: {}/{}/{}'.format(
stockHistoryLen,floatStr(precision, firstQuote),floatStr(precision, maxQuote),floatStr(precision, minQuote))
stockHistory = yfTicker.history("30d")
stockHistoryLen = len(stockHistory)
logger.info(f'fetched {stockHistoryLen} datapoints ...')
previousQuote = (stockHistory.tail(2)['Close'].iloc[0])
currentQuote = (stockHistory.tail(1)['Close'].iloc[0])
currentHigh = (stockHistory.tail(1)['High'].iloc[0])
currentLow = (stockHistory.tail(1)['Low'].iloc[0])
currentOpen = (stockHistory.tail(1)['Open'].iloc[0])
currentGain = currentQuote - previousQuote
currentGainPercentage = (1 - currentQuote / previousQuote) * -100
firstQuote = stockHistory.tail(stockHistoryLen)['Close'].iloc[0]
logger.info(f'firstQuote {firstQuote} ...')
logger.info(stockNameLine)
logger.info(stockCurrentValueLine)
logger.info(stockDayValueLine)
logger.info(stockMonthValueLine)
parsed_tickers.append(stockNameLine)
parsed_tickers.append(stockCurrentValueLine)
parsed_tickers.append(stockDayValueLine)
parsed_tickers.append(stockMonthValueLine)
def floatStr(precision, number):
return "%0.*f" % (precision, number)
parsed_tickers_colour.append("")
if currentGain < 0:
parsed_tickers_colour.append(stockCurrentValueLine)
else:
parsed_tickers_colour.append("")
if currentOpen > currentQuote:
parsed_tickers_colour.append(stockDayValueLine)
else:
parsed_tickers_colour.append("")
if firstQuote > currentQuote:
parsed_tickers_colour.append(stockMonthValueLine)
else:
parsed_tickers_colour.append("")
def percentageStr(number):
return '({:+.2f}%)'.format(number)
if (_ < len(tickerCount)):
parsed_tickers.append("")
parsed_tickers_colour.append("")
def gainStr(precision, number):
return "%+.*f" % (precision, number)
logger.info(f'creating chart data...')
chartData = stockHistory.reset_index()
chartCloseData = chartData.loc[:,'Close']
chartTimeData = chartData.loc[:,'Date']
stockNameLine = '{} ({})'.format(stockName, stockCurrency)
stockCurrentValueLine = '{} {} {}'.format(
floatStr(precision, currentQuote), gainStr(precision, currentGain),
percentageStr(currentGainPercentage))
stockDayValueLine = '1d OHL: {}/{}/{}'.format(
floatStr(precision, currentOpen), floatStr(precision, currentHigh), floatStr(precision, currentLow))
maxQuote = max(stockHistory.High)
minQuote = min(stockHistory.Low)
logger.info(f'high {maxQuote} low {minQuote} ...')
stockMonthValueLine = '{}d OHL: {}/{}/{}'.format(
stockHistoryLen, floatStr(precision, firstQuote), floatStr(precision, maxQuote),
floatStr(precision, minQuote))
logger.info(f'creating chart plot...')
fig, ax = plt.subplots() # Create a figure containing a single axes.
ax.plot(chartTimeData, chartCloseData, linewidth=8) # Plot some data on the axes.
ax.set_xticklabels([])
ax.set_yticklabels([])
chartPath = tmpPath+ticker+'.png'
logger.info(f'saving chart image to {chartPath}...')
plt.savefig(chartPath)
logger.info(stockNameLine)
logger.info(stockCurrentValueLine)
logger.info(stockDayValueLine)
logger.info(stockMonthValueLine)
parsed_tickers.append(stockNameLine)
parsed_tickers.append(stockCurrentValueLine)
parsed_tickers.append(stockDayValueLine)
parsed_tickers.append(stockMonthValueLine)
logger.info(f'chartSpace is...{im_width} {im_height}')
logger.info(f'open chart ...{chartPath}')
chartImage = Image.open(chartPath)
chartImage.thumbnail((im_width/4,line_height*4), Image.BICUBIC)
parsed_tickers_colour.append("")
if currentGain < 0:
parsed_tickers_colour.append(stockCurrentValueLine)
else:
parsed_tickers_colour.append("")
if currentOpen > currentQuote:
parsed_tickers_colour.append(stockDayValueLine)
else:
parsed_tickers_colour.append("")
if firstQuote > currentQuote:
parsed_tickers_colour.append(stockMonthValueLine)
else:
parsed_tickers_colour.append("")
chartPasteX = im_width-(chartImage.width)
chartPasteY = line_height*5*_
logger.info(f'pasting chart image with index {_} to...{chartPasteX} {chartPasteY}')
if (_ < len(tickerCount)):
parsed_tickers.append("")
parsed_tickers_colour.append("")
if firstQuote > currentQuote:
chartSpace_colour.paste(chartImage, (chartPasteX, chartPasteY))
else:
chartSpace.paste(chartImage, (chartPasteX, chartPasteY))
logger.info(f'creating chart data...')
chartData = stockHistory.reset_index()
chartCloseData = chartData.loc[:, 'Close']
chartTimeData = chartData.loc[:, 'Date']
im_black.paste(chartSpace)
im_colour.paste(chartSpace_colour)
logger.info(f'creating chart plot...')
fig, ax = plt.subplots() # Create a figure containing a single axes.
ax.plot(chartTimeData, chartCloseData, linewidth=8) # Plot some data on the axes.
ax.set_xticklabels([])
ax.set_yticklabels([])
chartPath = tmpPath + ticker + '.png'
logger.info(f'saving chart image to {chartPath}...')
plt.savefig(chartPath)
# Write/Draw something on the black image
for _ in range(len(parsed_tickers)):
if _+1 > max_lines:
logger.error('Ran out of lines for parsed_ticker_colour')
break
write(im_black, line_positions[_], (line_width, line_height),
parsed_tickers[_], font = self.font, alignment= 'left')
logger.info(f'chartSpace is...{im_width} {im_height}')
logger.info(f'open chart ...{chartPath}')
chartImage = Image.open(chartPath)
chartImage.thumbnail((im_width / 4, line_height * 4), Image.BICUBIC)
# Write/Draw something on the colour image
for _ in range(len(parsed_tickers_colour)):
if _+1 > max_lines:
logger.error('Ran out of lines for parsed_tickers_colour')
break
write(im_colour, line_positions[_], (line_width, line_height),
parsed_tickers_colour[_], font = self.font, alignment= 'left')
chartPasteX = im_width - (chartImage.width)
chartPasteY = line_height * 5 * _
logger.info(f'pasting chart image with index {_} to...{chartPasteX} {chartPasteY}')
if firstQuote > currentQuote:
chartSpace_colour.paste(chartImage, (chartPasteX, chartPasteY))
else:
chartSpace.paste(chartImage, (chartPasteX, chartPasteY))
im_black.paste(chartSpace)
im_colour.paste(chartSpace_colour)
# Write/Draw something on the black image
for _ in range(len(parsed_tickers)):
if _ + 1 > max_lines:
logger.error('Ran out of lines for parsed_ticker_colour')
break
write(im_black, line_positions[_], (line_width, line_height),
parsed_tickers[_], font=self.font, alignment='left')
# Write/Draw something on the colour image
for _ in range(len(parsed_tickers_colour)):
if _ + 1 > max_lines:
logger.error('Ran out of lines for parsed_tickers_colour')
break
write(im_colour, line_positions[_], (line_width, line_height),
parsed_tickers_colour[_], font=self.font, alignment='left')
# Save image of black and colour channel in image-folder
return im_black, im_colour
# Save image of black and colour channel in image-folder
return im_black, im_colour
if __name__ == '__main__':
print('running module in standalone/debug mode')
print('running module in standalone/debug mode')

View File

@@ -0,0 +1,117 @@
#!python3
"""
Textfile module for InkyCal Project
Reads data from a plain .txt file and renders it on the display.
If the content is too long, it will be truncated from the back until it fits
Copyright by aceisace
"""
from inkycal.modules.template import inkycal_module
from inkycal.custom import *
from urllib.request import urlopen
logger = logging.getLogger(__name__)
class TextToDisplay(inkycal_module):
"""TextToDisplay module
"""
name = "Text module - Display text from a local file on the display"
requires = {
"filepath": {
"label": "Please enter a filepath or URL pointing to a .txt file",
},
}
def __init__(self, config):
"""Initialize inkycal_textfile_to_display module"""
super().__init__(config)
config = config['config']
# Check if all required parameters are present
for param in self.requires:
if param not in config:
raise Exception(f'config is missing {param}')
# required parameters
self.filepath = config["filepath"]
self.make_request = True if self.filepath.startswith("https://") else False
# give an OK message
print(f'{__name__} loaded')
def _validate(self):
"""Validate module-specific parameters"""
# ensure we only use a single file
assert (self.filepath and len(self.filepath) == 1)
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_left))
im_height = int(self.height - (2 * self.padding_top))
im_size = im_width, im_height
logger.info(f'Image size: {im_size}')
# 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():
logger.info('Connection test passed')
else:
raise NetworkNotReachableError
# Set some parameters for formatting feeds
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)]
if self.make_request:
logger.info("Detected http path, making request")
file_content = urlopen(self.filepath).read().decode('utf-8')
else:
# Create list containing all lines
with open(self.filepath, 'r') as file:
file_content = file.read()
fitted_content = text_wrap(file_content, font=self.font, max_width=im_width)
# Trim down the list to the max number of lines
del fitted_content[max_lines:]
# Write feeds on image
for index, line in enumerate(fitted_content):
write(
im_black,
line_positions[index],
(line_width, line_height),
line,
font=self.font,
alignment='left'
)
# return images
return im_black, im_colour
if __name__ == '__main__':
print(f'running {__name__} in standalone/debug mode')

View File

@@ -1,206 +1,203 @@
#!/usr/bin/python3
# -*- coding: utf-8 -*-
#!python3
"""
todoist module for Inky-Calendar Project
Inkycal Todoist Module
Copyright by aceisace
"""
import arrow
from inkycal.modules.template import inkycal_module
from inkycal.custom import *
try:
from todoist_api_python.api import TodoistAPI
except ImportError:
print('todoist is not installed! Please install with:')
print('pip3 install todoist-python')
from todoist_api_python.api import TodoistAPI
logger = logging.getLogger(__name__)
filename = os.path.basename(__file__).split('.py')[0]
logger = logging.getLogger(filename)
class Todoist(inkycal_module):
"""Todoist api class
parses todo's from api-key
"""
"""Todoist api class
parses todos from the todoist api.
"""
name = "Todoist API - show your todos from todoist"
name = "Todoist API - show your todos from 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",
requires = {
'api_key': {
"label": "Please enter your Todoist API-key",
},
}
}
def __init__(self, config):
"""Initialize inkycal_rss module"""
super().__init__(config)
config = config['config']
# Check if all required parameters are present
for param in self.requires:
if not param in config:
raise Exception(f'config is missing {param}')
# module specific parameters
self.api_key = config['api_key']
# if project filter is set, initialize it
if config['project_filter'] and isinstance(config['project_filter'], str):
self.project_filter = config['project_filter'].split(',')
else:
self.project_filter = config['project_filter']
self._api = TodoistAPI(config['api_key'])
# give an OK message
print(f'{filename} loaded')
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_left))
im_height = int(self.height - (2 * self.padding_top))
im_size = im_width, im_height
logger.info(f'Image size: {im_size}')
# 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['id']: project['name']
# for project in self._api.get_projects()}
all_projects = {}
for project in self._api.get_projects():
all_projects[project.id] = project.name
logger.debug(f"all_projects: {all_projects}")
# Filter entries in all_projects if filter was given
if self.project_filter:
for project_id in list(all_projects):
if all_projects[project_id] not in self.project_filter:
del all_projects[project_id]
logger.debug(f"all_project: {all_projects}")
# If filter was activated and no roject was found with that name,
# raise an exception to avoid showing a blank image
if all_projects == {}:
logger.error('No project found from project filter!')
logger.error('Please double check spellings in project_filter')
raise Exception('No matching project found in filter. Please '
'double check spellings in project_filter or leave'
'empty')
tasks = (task for task in self._api.get_tasks() if
task.is_completed == False)
# Simplify the tasks for faster processing
simplified = [
{
'name':task.content,
'due':task.due.string if task.due != None else "",
'priority':task.priority,
'project':all_projects[ task.project_id ] if task.project_id in all_projects else "deleted"
optional = {
'project_filter': {
"label": "Show Todos only from following project (separated by a comma). Leave empty to show " +
"todos from all projects",
}
for task in tasks]
}
# remove groups that have been deleted
# - not sure if this is needed anymore or exactly how to do it --dealyllama
#simplified = [task for task in simplified if task.project != "deleted"]
def __init__(self, config):
"""Initialize inkycal_rss module"""
logger.debug(f'simplified: {simplified}')
super().__init__(config)
# Get maximum width of project names for selected font
project_width = int(max([
self.font.getsize(task['project'])[0] for task in simplified ]) * 1.1)
config = config['config']
# Get maximum width of project dues for selected font
due_width = int(max([
self.font.getsize(task['due'])[0] for task in simplified ]) * 1.1)
# Check if all required parameters are present
for param in self.requires:
if param not in config:
raise Exception(f'config is missing {param}')
# Group tasks by project name
grouped = {name: [] for id_, name in all_projects.items()}
# module specific parameters
self.api_key = config['api_key']
for task in simplified:
if task['project'] in grouped:
grouped[task['project']].append(task)
logger.debug(f"grouped: {grouped}")
# Add the parsed todos on the image
cursor = 0
for name, todos in grouped.items():
if todos:
for todo in todos:
if cursor < len(line_positions):
line_x, line_y = line_positions[cursor]
# Add todo project name
write(
im_colour, line_positions[cursor],
(project_width, line_height),
todo['project'], font=self.font, alignment='left')
# Add todo due if not empty
if todo['due'] != "":
write(
im_black,
(line_x + project_width, line_y),
(due_width, line_height),
todo['due'], font=self.font, alignment='left')
# Add todo name
write(
im_black,
(line_x+project_width+due_width, line_y),
(im_width-project_width-due_width, line_height),
todo['name'], font=self.font, alignment='left')
cursor += 1
# if project filter is set, initialize it
if config['project_filter'] and isinstance(config['project_filter'], str):
self.project_filter = config['project_filter'].split(',')
else:
logger.error('More todos than available lines')
break
self.project_filter = config['project_filter']
self._api = TodoistAPI(config['api_key'])
# give an OK message
print(f'{__name__} loaded')
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_left))
im_height = int(self.height - (2 * self.padding_top))
im_size = im_width, im_height
logger.info(f'Image size: {im_size}')
# 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():
logger.info('Connection test passed')
else:
raise NetworkNotReachableError
# 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 = self._api.get_projects()
filtered_project_ids_and_names = {project.id: project.name for project in all_projects}
all_active_tasks = self._api.get_tasks()
logger.debug(f"all_projects: {all_projects}")
# Filter entries in all_projects if filter was given
if self.project_filter:
filtered_projects = [project for project in all_projects if project.name in self.project_filter]
filtered_project_ids_and_names = {project.id: project.name for project in filtered_projects}
filtered_project_ids = [project for project in filtered_project_ids_and_names]
logger.debug(f"filtered projects: {filtered_projects}")
# If filter was activated and no project was found with that name,
# raise an exception to avoid showing a blank image
if not filtered_projects:
logger.error('No project found from project filter!')
logger.error('Please double check spellings in project_filter')
raise Exception('No matching project found in filter. Please '
'double check spellings in project_filter or leave'
'empty')
# filtered version of all active tasks
all_active_tasks = [task for task in all_active_tasks if task.project_id in filtered_project_ids]
# Simplify the tasks for faster processing
simplified = [
{
'name': task.content,
'due': arrow.get(task.due.date, "YYYY-MM-DD").format("D-MMM-YY") if task.due else "",
'priority': task.priority,
'project': filtered_project_ids_and_names[task.project_id]
}
for task in all_active_tasks
]
logger.debug(f'simplified: {simplified}')
project_lengths = []
due_lengths = []
for task in simplified:
if task["project"]:
project_lengths.append(int(self.font.getlength(task['project']) * 1.1))
if task["due"]:
due_lengths.append(int(self.font.getlength(task['due']) * 1.1))
# Get maximum width of project names for selected font
project_offset = int(max(project_lengths)) if project_lengths else 0
# Get maximum width of project dues for selected font
due_offset = int(max(due_lengths)) if due_lengths else 0
# create a dict with names of filtered groups
groups = {group_name:[] for group_name in filtered_project_ids_and_names.values()}
for task in simplified:
group_of_current_task = task["project"]
if group_of_current_task in groups:
groups[group_of_current_task].append(task)
logger.debug(f"grouped: {groups}")
# Add the parsed todos on the image
cursor = 0
for name, todos in groups.items():
if todos:
for todo in todos:
if cursor < max_lines:
line_x, line_y = line_positions[cursor]
if todo['project']:
# Add todos project name
write(
im_colour, line_positions[cursor],
(project_offset, line_height),
todo['project'], font=self.font, alignment='left')
# Add todos due if not empty
if todo['due']:
write(
im_black,
(line_x + project_offset, line_y),
(due_offset, line_height),
todo['due'], font=self.font, alignment='left')
if todo['name']:
# Add todos name
write(
im_black,
(line_x + project_offset + due_offset, line_y),
(im_width - project_offset - due_offset, line_height),
todo['name'], font=self.font, alignment='left')
cursor += 1
else:
logger.error('More todos than available lines')
break
# return the images ready for the display
return im_black, im_colour
# return the images ready for the display
return im_black, im_colour
if __name__ == '__main__':
print(f'running {filename} in standalone/debug mode')
print(f'running {__name__} in standalone/debug mode')

926
inkycal/modules/inkycal_weather.py Normal file → Executable file
View File

@@ -1,522 +1,508 @@
#!/usr/bin/python3
# -*- coding: utf-8 -*-
#!python3
"""
Weather module for Inky-Calendar software.
Inkycal weather module
Copyright by aceisace
"""
from inkycal.modules.template import inkycal_module
from inkycal.custom import *
import math, decimal
import math
import decimal
import arrow
from locale import getdefaultlocale as sys_locale
try:
from pyowm.owm import OWM
except ImportError:
print('pyowm is not installed! Please install with:')
print('pip3 install pyowm')
from pyowm.owm import OWM
logger = logging.getLogger(__name__)
filename = os.path.basename(__file__).split('.py')[0]
logger = logging.getLogger(filename)
class Weather(inkycal_module):
"""Weather class
parses weather details from openweathermap
"""
name = "Weather (openweathermap) - Get weather forecasts from openweathermap"
"""Weather class
parses weather details from openweathermap
"""
name = "Weather (openweathermap) - Get weather forecasts from openweathermap"
requires = {
requires = {
"api_key" : {
"label":"Please enter openweathermap api-key. You can create one for free on openweathermap",
},
"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. "+
"You can also enter the location ID found in the url "+
"e.g. https://openweathermap.org/city/4893171 -> ID is 4893171"
}
"location": {
"label": "Please enter your location in the following format: City, Country-Code. " +
"You can also enter the location ID found in the url " +
"e.g. https://openweathermap.org/city/4893171 -> ID is 4893171"
}
}
optional = {
optional = {
"round_temperature": {
"label":"Round temperature to the nearest degree?",
"options": [True, False],
},
"round_temperature": {
"label": "Round temperature to the nearest degree?",
"options": [True, False],
},
"round_windspeed": {
"label":"Round windspeed?",
"options": [True, False],
},
"round_windspeed": {
"label": "Round windspeed?",
"options": [True, False],
},
"forecast_interval": {
"label":"Please select the forecast interval",
"options": ["daily", "hourly"],
},
"forecast_interval": {
"label": "Please select the forecast interval",
"options": ["daily", "hourly"],
},
"units": {
"label": "Which units should be used?",
"options": ["metric", "imperial"],
},
"units": {
"label": "Which units should be used?",
"options": ["metric", "imperial"],
},
"hour_format": {
"label": "Which hour format do you prefer?",
"options": [24, 12],
},
"hour_format": {
"label": "Which hour format do you prefer?",
"options": [24, 12],
},
"use_beaufort": {
"label": "Use beaufort scale for windspeed?",
"options": [True, False],
},
"use_beaufort": {
"label": "Use beaufort scale for windspeed?",
"options": [True, False],
},
}
def __init__(self, config):
"""Initialize inkycal_weather module"""
super().__init__(config)
config = config['config']
# Check if all required parameters are present
for param in self.requires:
if not param in config:
raise Exception(f'config is missing {param}')
# required parameters
self.api_key = config['api_key']
self.location = config['location']
# optional parameters
self.round_temperature = config['round_temperature']
self.round_windspeed = config['round_windspeed']
self.forecast_interval = config['forecast_interval']
self.units = config['units']
self.hour_format = int(config['hour_format'])
self.use_beaufort = config['use_beaufort']
# additional configuration
self.owm = OWM(self.api_key).weather_manager()
self.timezone = get_system_tz()
self.locale = config['language']
self.weatherfont = ImageFont.truetype(
fonts['weathericons-regular-webfont'], size = self.fontsize)
# give an OK message
print(f"{filename} loaded")
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_left))
im_height = int(self.height - (2 * self.padding_top))
im_size = im_width, im_height
logger.info(f'Image size: {im_size}')
# 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:
logger.exception('Network could not be reached :(')
raise
def get_moon_phase():
"""Calculate the current (approximate) moon phase"""
dec = decimal.Decimal
diff = now - arrow.get(2001, 1, 1)
days = dec(diff.days) + (dec(diff.seconds) / dec(86400))
lunations = dec("0.20439731") + (days * dec("0.03386319269"))
position = lunations % dec(1)
index = math.floor((position * dec(8)) + dec("0.5"))
return {0: '\uf095',1: '\uf099',2: '\uf09c',3: '\uf0a0',
4: '\uf0a3',5: '\uf0a7',6: '\uf0aa',7: '\uf0ae' }[int(index) & 7]
def is_negative(temp):
"""Check if temp is below freezing point of water (0°C/30°F)
returns True if temp below freezing point, else False"""
answer = False
if temp_unit == 'celsius' and round(float(temp.split('°')[0])) <= 0:
answer = True
elif temp_unit == 'fahrenheit' and round(float(temp.split('°')[0])) <= 0:
answer = True
return answer
# Lookup-table for weather icons and weather codes
weathericons = {
'01d': '\uf00d', '02d': '\uf002', '03d': '\uf013',
'04d': '\uf012', '09d': '\uf01a ', '10d': '\uf019',
'11d': '\uf01e', '13d': '\uf01b', '50d': '\uf014',
'01n': '\uf02e', '02n': '\uf013', '03n': '\uf013',
'04n': '\uf013', '09n': '\uf037', '10n': '\uf036',
'11n': '\uf03b', '13n': '\uf038', '50n': '\uf023'
}
def draw_icon(image, xy, box_size, icon, rotation = None):
"""Custom function to add icons of weather font on image
image = on which image should the text be added?
xy = xy-coordinates as tuple -> (x,y)
box_size = size of text-box -> (width,height)
icon = icon-unicode, looks this up in weathericons dictionary
"""
x,y = xy
box_width, box_height = box_size
text = icon
font = self.weatherfont
# Increase fontsize to fit specified height and width of text box
size = 8
font = ImageFont.truetype(font.path, size)
text_width, text_height = font.getsize(text)
while (text_width < int(box_width * 0.9) and
text_height < int(box_height * 0.9)):
size += 1
font = ImageFont.truetype(font.path, size)
text_width, text_height = font.getsize(text)
text_width, text_height = font.getsize(text)
# Align text to desired position
x = int((box_width / 2) - (text_width / 2))
y = int((box_height / 2) - (text_height / 2))
# Draw the text in the text-box
draw = ImageDraw.Draw(image)
space = Image.new('RGBA', (box_width, box_height))
ImageDraw.Draw(space).text((x, y), text, fill='black', font=font)
if rotation != None:
space.rotate(rotation, expand = True)
# Update only region with text (add text with transparent background)
image.paste(space, xy, space)
# column1 column2 column3 column4 column5 column6 column7
# |----------|----------|----------|----------|----------|----------|----------|
# | time | temperat.| moonphase| forecast1| forecast2| forecast3| forecast4|
# | current |----------|----------|----------|----------|----------|----------|
# | weather | humidity | sunrise | icon1 | icon2 | icon3 | icon4 |
# | icon |----------|----------|----------|----------|----------|----------|
# | | windspeed| sunset | temperat.| temperat.| temperat.| temperat.|
# |----------|----------|----------|----------|----------|----------|----------|
# Calculate size rows and columns
col_width = im_width // 7
# Ratio width height
image_ratio = im_width / im_height
if image_ratio >= 4:
row_height = im_height // 3
else:
logger.info('Please consider decreasing the height.')
row_height = int( (im_height* (1-im_height/im_width)) / 3 )
logger.debug(f"row_height: {row_height} | col_width: {col_width}")
# Calculate spacings for better centering
spacing_top = int( (im_width % col_width) / 2 )
spacing_left = int( (im_height % row_height) / 2 )
# Define sizes for weather icons
icon_small = int(col_width / 3)
icon_medium = icon_small * 2
icon_large = icon_small * 3
# Calculate the x-axis position of each col
col1 = spacing_top
col2 = col1 + col_width
col3 = col2 + col_width
col4 = col3 + col_width
col5 = col4 + col_width
col6 = col5 + col_width
col7 = col6 + col_width
# Calculate the y-axis position of each row
line_gap = int((im_height - spacing_top - 3*row_height) // 4)
row1 = line_gap
row2 = row1 + line_gap + row_height
row3 = row2+ line_gap + row_height
# Draw lines on each row and border
############################################################################
## draw = ImageDraw.Draw(im_black)
## draw.line((0, 0, im_width, 0), fill='red')
## draw.line((0, im_height-1, im_width, im_height-1), fill='red')
## draw.line((0, row1, im_width, row1), fill='black')
## draw.line((0, row1+row_height, im_width, row1+row_height), fill='black')
## draw.line((0, row2, im_width, row2), fill='black')
## draw.line((0, row2+row_height, im_width, row2+row_height), fill='black')
## draw.line((0, row3, im_width, row3), fill='black')
## draw.line((0, row3+row_height, im_width, row3+row_height), fill='black')
############################################################################
# Positions for current weather details
weather_icon_pos = (col1, 0)
temperature_icon_pos = (col2, row1)
temperature_pos = (col2+icon_small, row1)
humidity_icon_pos = (col2, row2)
humidity_pos = (col2+icon_small, row2)
windspeed_icon_pos = (col2, row3)
windspeed_pos = (col2+icon_small, row3)
# Positions for sunrise, sunset, moonphase
moonphase_pos = (col3, row1)
sunrise_icon_pos = (col3, row2)
sunrise_time_pos = (col3+icon_small, row2)
sunset_icon_pos = (col3, row3)
sunset_time_pos = (col3+ icon_small, row3)
# Positions for forecast 1
stamp_fc1 = (col4, row1)
icon_fc1 = (col4, row1+row_height)
temp_fc1 = (col4, row3)
# Positions for forecast 2
stamp_fc2 = (col5, row1)
icon_fc2 = (col5, row1+row_height)
temp_fc2 = (col5, row3)
# Positions for forecast 3
stamp_fc3 = (col6, row1)
icon_fc3 = (col6, row1+row_height)
temp_fc3 = (col6, row3)
# Positions for forecast 4
stamp_fc4 = (col7, row1)
icon_fc4 = (col7, row1+row_height)
temp_fc4 = (col7, row3)
# Create current-weather and weather-forecast objects
if self.location.isdigit():
logging.debug('looking up location by ID')
weather = self.owm.weather_at_id(int(self.location)).weather
forecast = self.owm.forecast_at_id(int(self.location), '3h')
else:
logging.debug('looking up location by string')
weather = self.owm.weather_at_place(self.location).weather
forecast = self.owm.forecast_at_place(self.location, '3h')
# Set decimals
dec_temp = None if self.round_temperature == True else 1
dec_wind = None if self.round_windspeed == True else 1
# Set correct temperature units
if self.units == 'metric':
temp_unit = 'celsius'
elif self.units == 'imperial':
temp_unit = 'fahrenheit'
logging.debug(f'temperature unit: {temp_unit}')
logging.debug(f'decimals temperature: {dec_temp} | decimals wind: {dec_wind}')
# Get current time
now = arrow.utcnow()
if self.forecast_interval == 'hourly':
logger.debug("getting hourly forecasts")
# Forecasts are provided for every 3rd full hour
# find out how many hours there are until the next 3rd full hour
if (now.hour % 3) != 0:
hour_gap = 3 - (now.hour % 3)
else:
hour_gap = 3
# Create timings for hourly forcasts
forecast_timings = [now.shift(hours = + hour_gap + _).floor('hour')
for _ in range(0,12,3)]
# Create forecast objects for given timings
forecasts = [forecast.get_weather_at(forecast_time.datetime) for
forecast_time in forecast_timings]
# Add forecast-data to fc_data dictionary
fc_data = {}
for forecast in forecasts:
temp = '{}°'.format(round(
forecast.temperature(unit=temp_unit)['temp'], ndigits=dec_temp))
icon = forecast.weather_icon_name
fc_data['fc'+str(forecasts.index(forecast)+1)] = {
'temp':temp,
'icon':icon,
'stamp': forecast_timings[forecasts.index(forecast)].to(
get_system_tz()).format('H.00' if self.hour_format == 24 else 'h a')
}
elif self.forecast_interval == 'daily':
logger.debug("getting daily forecasts")
def calculate_forecast(days_from_today):
"""Get temperature range and most frequent icon code for forecast
days_from_today should be int from 1-4: e.g. 2 -> 2 days from today
"""
# Create a list containing time-objects for every 3rd hour of the day
time_range = list(arrow.Arrow.range('hour',
now.shift(days=days_from_today).floor('day'),
now.shift(days=days_from_today).ceil('day')
))[::3]
# Get forecasts for each time-object
forecasts = [forecast.get_weather_at(_.datetime) for _ in time_range]
# Get all temperatures for this day
daily_temp = [round(_.temperature(unit=temp_unit)['temp'],
ndigits=dec_temp) for _ in forecasts]
# Calculate min. and max. temp for this day
temp_range = f'{max(daily_temp)}°/{min(daily_temp)}°'
# Get all weather icon codes for this day
daily_icons = [_.weather_icon_name for _ in forecasts]
# Find most common element from all weather icon codes
status = max(set(daily_icons), key=daily_icons.count)
weekday = now.shift(days=days_from_today).format('ddd', locale=
self.locale)
return {'temp':temp_range, 'icon':status, 'stamp': weekday}
forecasts = [calculate_forecast(days) for days in range (1,5)]
fc_data = {}
for forecast in forecasts:
fc_data['fc'+str(forecasts.index(forecast)+1)] = {
'temp':forecast['temp'],
'icon':forecast['icon'],
'stamp': forecast['stamp']
}
def __init__(self, config):
"""Initialize inkycal_weather module"""
super().__init__(config)
config = config['config']
# Check if all required parameters are present
for param in self.requires:
if not param in config:
raise Exception(f'config is missing {param}')
# required parameters
self.api_key = config['api_key']
self.location = config['location']
# optional parameters
self.round_temperature = config['round_temperature']
self.round_windspeed = config['round_windspeed']
self.forecast_interval = config['forecast_interval']
self.units = config['units']
self.hour_format = int(config['hour_format'])
self.use_beaufort = config['use_beaufort']
# additional configuration
self.owm = OWM(self.api_key).weather_manager()
self.timezone = get_system_tz()
self.locale = config['language']
self.weatherfont = ImageFont.truetype(
fonts['weathericons-regular-webfont'], size=self.fontsize)
# give an OK message
print(f"{__name__} loaded")
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_left))
im_height = int(self.height - (2 * self.padding_top))
im_size = im_width, im_height
logger.info(f'Image size: {im_size}')
# 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():
logger.info('Connection test passed')
else:
raise NetworkNotReachableError
def get_moon_phase():
"""Calculate the current (approximate) moon phase"""
dec = decimal.Decimal
diff = now - arrow.get(2001, 1, 1)
days = dec(diff.days) + (dec(diff.seconds) / dec(86400))
lunations = dec("0.20439731") + (days * dec("0.03386319269"))
position = lunations % dec(1)
index = math.floor((position * dec(8)) + dec("0.5"))
return {0: '\uf095', 1: '\uf099', 2: '\uf09c', 3: '\uf0a0',
4: '\uf0a3', 5: '\uf0a7', 6: '\uf0aa', 7: '\uf0ae'}[int(index) & 7]
def is_negative(temp):
"""Check if temp is below freezing point of water (0°C/30°F)
returns True if temp below freezing point, else False"""
answer = False
if temp_unit == 'celsius' and round(float(temp.split('°')[0])) <= 0:
answer = True
elif temp_unit == 'fahrenheit' and round(float(temp.split('°')[0])) <= 0:
answer = True
return answer
# Lookup-table for weather icons and weather codes
weathericons = {
'01d': '\uf00d', '02d': '\uf002', '03d': '\uf013',
'04d': '\uf012', '09d': '\uf01a ', '10d': '\uf019',
'11d': '\uf01e', '13d': '\uf01b', '50d': '\uf014',
'01n': '\uf02e', '02n': '\uf013', '03n': '\uf013',
'04n': '\uf013', '09n': '\uf037', '10n': '\uf036',
'11n': '\uf03b', '13n': '\uf038', '50n': '\uf023'
}
def draw_icon(image, xy, box_size, icon, rotation=None):
"""Custom function to add icons of weather font on image
image = on which image should the text be added?
xy = xy-coordinates as tuple -> (x,y)
box_size = size of text-box -> (width,height)
icon = icon-unicode, looks this up in weathericons dictionary
"""
x, y = xy
box_width, box_height = box_size
text = icon
font = self.weatherfont
# Increase fontsize to fit specified height and width of text box
size = 8
font = ImageFont.truetype(font.path, size)
text_width, text_height = font.getsize(text)
while (text_width < int(box_width * 0.9) and
text_height < int(box_height * 0.9)):
size += 1
font = ImageFont.truetype(font.path, size)
text_width, text_height = font.getsize(text)
text_width, text_height = font.getsize(text)
# Align text to desired position
x = int((box_width / 2) - (text_width / 2))
y = int((box_height / 2) - (text_height / 2))
# Draw the text in the text-box
draw = ImageDraw.Draw(image)
space = Image.new('RGBA', (box_width, box_height))
ImageDraw.Draw(space).text((x, y), text, fill='black', font=font)
if rotation != None:
space.rotate(rotation, expand=True)
# Update only region with text (add text with transparent background)
image.paste(space, xy, space)
# column1 column2 column3 column4 column5 column6 column7
# |----------|----------|----------|----------|----------|----------|----------|
# | time | temperat.| moonphase| forecast1| forecast2| forecast3| forecast4|
# | current |----------|----------|----------|----------|----------|----------|
# | weather | humidity | sunrise | icon1 | icon2 | icon3 | icon4 |
# | icon |----------|----------|----------|----------|----------|----------|
# | | windspeed| sunset | temperat.| temperat.| temperat.| temperat.|
# |----------|----------|----------|----------|----------|----------|----------|
# Calculate size rows and columns
col_width = im_width // 7
# Ratio width height
image_ratio = im_width / im_height
if image_ratio >= 4:
row_height = im_height // 3
else:
logger.info('Please consider decreasing the height.')
row_height = int((im_height * (1 - im_height / im_width)) / 3)
logger.debug(f"row_height: {row_height} | col_width: {col_width}")
# Calculate spacings for better centering
spacing_top = int((im_width % col_width) / 2)
spacing_left = int((im_height % row_height) / 2)
# Define sizes for weather icons
icon_small = int(col_width / 3)
icon_medium = icon_small * 2
icon_large = icon_small * 3
# Calculate the x-axis position of each col
col1 = spacing_top
col2 = col1 + col_width
col3 = col2 + col_width
col4 = col3 + col_width
col5 = col4 + col_width
col6 = col5 + col_width
col7 = col6 + col_width
# Calculate the y-axis position of each row
line_gap = int((im_height - spacing_top - 3 * row_height) // 4)
row1 = line_gap
row2 = row1 + line_gap + row_height
row3 = row2 + line_gap + row_height
# Draw lines on each row and border
############################################################################
## draw = ImageDraw.Draw(im_black)
## draw.line((0, 0, im_width, 0), fill='red')
## draw.line((0, im_height-1, im_width, im_height-1), fill='red')
## draw.line((0, row1, im_width, row1), fill='black')
## draw.line((0, row1+row_height, im_width, row1+row_height), fill='black')
## draw.line((0, row2, im_width, row2), fill='black')
## draw.line((0, row2+row_height, im_width, row2+row_height), fill='black')
## draw.line((0, row3, im_width, row3), fill='black')
## draw.line((0, row3+row_height, im_width, row3+row_height), fill='black')
############################################################################
# Positions for current weather details
weather_icon_pos = (col1, 0)
temperature_icon_pos = (col2, row1)
temperature_pos = (col2 + icon_small, row1)
humidity_icon_pos = (col2, row2)
humidity_pos = (col2 + icon_small, row2)
windspeed_icon_pos = (col2, row3)
windspeed_pos = (col2 + icon_small, row3)
# Positions for sunrise, sunset, moonphase
moonphase_pos = (col3, row1)
sunrise_icon_pos = (col3, row2)
sunrise_time_pos = (col3 + icon_small, row2)
sunset_icon_pos = (col3, row3)
sunset_time_pos = (col3 + icon_small, row3)
# Positions for forecast 1
stamp_fc1 = (col4, row1)
icon_fc1 = (col4, row1 + row_height)
temp_fc1 = (col4, row3)
# Positions for forecast 2
stamp_fc2 = (col5, row1)
icon_fc2 = (col5, row1 + row_height)
temp_fc2 = (col5, row3)
# Positions for forecast 3
stamp_fc3 = (col6, row1)
icon_fc3 = (col6, row1 + row_height)
temp_fc3 = (col6, row3)
# Positions for forecast 4
stamp_fc4 = (col7, row1)
icon_fc4 = (col7, row1 + row_height)
temp_fc4 = (col7, row3)
# Create current-weather and weather-forecast objects
if self.location.isdigit():
logging.debug('looking up location by ID')
weather = self.owm.weather_at_id(int(self.location)).weather
forecast = self.owm.forecast_at_id(int(self.location), '3h')
else:
logging.debug('looking up location by string')
weather = self.owm.weather_at_place(self.location).weather
forecast = self.owm.forecast_at_place(self.location, '3h')
# Set decimals
dec_temp = None if self.round_temperature == True else 1
dec_wind = None if self.round_windspeed == True else 1
# Set correct temperature units
if self.units == 'metric':
temp_unit = 'celsius'
elif self.units == 'imperial':
temp_unit = 'fahrenheit'
logging.debug(f'temperature unit: {temp_unit}')
logging.debug(f'decimals temperature: {dec_temp} | decimals wind: {dec_wind}')
# Get current time
now = arrow.utcnow()
if self.forecast_interval == 'hourly':
logger.debug("getting hourly forecasts")
# Forecasts are provided for every 3rd full hour
# find out how many hours there are until the next 3rd full hour
if (now.hour % 3) != 0:
hour_gap = 3 - (now.hour % 3)
else:
hour_gap = 3
# Create timings for hourly forcasts
forecast_timings = [now.shift(hours=+ hour_gap + _).floor('hour')
for _ in range(0, 12, 3)]
# Create forecast objects for given timings
forecasts = [forecast.get_weather_at(forecast_time.datetime) for
forecast_time in forecast_timings]
# Add forecast-data to fc_data dictionary
fc_data = {}
for forecast in forecasts:
temp = '{}°'.format(round(
forecast.temperature(unit=temp_unit)['temp'], ndigits=dec_temp))
icon = forecast.weather_icon_name
fc_data['fc' + str(forecasts.index(forecast) + 1)] = {
'temp': temp,
'icon': icon,
'stamp': forecast_timings[forecasts.index(forecast)].to(
get_system_tz()).format('H.00' if self.hour_format == 24 else 'h a')
}
elif self.forecast_interval == 'daily':
logger.debug("getting daily forecasts")
def calculate_forecast(days_from_today):
"""Get temperature range and most frequent icon code for forecast
days_from_today should be int from 1-4: e.g. 2 -> 2 days from today
"""
# Create a list containing time-objects for every 3rd hour of the day
time_range = list(arrow.Arrow.range('hour',
now.shift(days=days_from_today).floor('day'),
now.shift(days=days_from_today).ceil('day')
))[::3]
# Get forecasts for each time-object
forecasts = [forecast.get_weather_at(_.datetime) for _ in time_range]
# Get all temperatures for this day
daily_temp = [round(_.temperature(unit=temp_unit)['temp'],
ndigits=dec_temp) for _ in forecasts]
# Calculate min. and max. temp for this day
temp_range = f'{max(daily_temp)}°/{min(daily_temp)}°'
# Get all weather icon codes for this day
daily_icons = [_.weather_icon_name for _ in forecasts]
# Find most common element from all weather icon codes
status = max(set(daily_icons), key=daily_icons.count)
weekday = now.shift(days=days_from_today).format('ddd', locale=
self.locale)
return {'temp': temp_range, 'icon': status, 'stamp': weekday}
forecasts = [calculate_forecast(days) for days in range(1, 5)]
fc_data = {}
for forecast in forecasts:
fc_data['fc' + str(forecasts.index(forecast) + 1)] = {
'temp': forecast['temp'],
'icon': forecast['icon'],
'stamp': forecast['stamp']
}
for key, val in fc_data.items():
logger.debug((key, val))
# Get some current weather details
temperature = '{}°'.format(round(
weather.temperature(unit=temp_unit)['temp'], ndigits=dec_temp))
weather_icon = weather.weather_icon_name
humidity = str(weather.humidity)
sunrise_raw = arrow.get(weather.sunrise_time()).to(self.timezone)
sunset_raw = arrow.get(weather.sunset_time()).to(self.timezone)
for key,val in fc_data.items():
logger.debug((key,val))
logger.debug(f'weather_icon: {weather_icon}')
# Get some current weather details
temperature = '{}°'.format(round(
weather.temperature(unit=temp_unit)['temp'], ndigits=dec_temp))
if self.hour_format == 12:
logger.debug('using 12 hour format for sunrise/sunset')
sunrise = sunrise_raw.format('h:mm a')
sunset = sunset_raw.format('h:mm a')
weather_icon = weather.weather_icon_name
humidity = str(weather.humidity)
sunrise_raw = arrow.get(weather.sunrise_time()).to(self.timezone)
sunset_raw = arrow.get(weather.sunset_time()).to(self.timezone)
elif self.hour_format == 24:
logger.debug('using 24 hour format for sunrise/sunset')
sunrise = sunrise_raw.format('H:mm')
sunset = sunset_raw.format('H:mm')
logger.debug(f'weather_icon: {weather_icon}')
# Format the windspeed to user preference
if self.use_beaufort:
logger.debug("using beaufort for wind")
wind = str(weather.wind(unit='beaufort')['speed'])
else:
if self.units == 'metric':
logging.debug('getting windspeed in metric unit')
wind = str(weather.wind(unit='meters_sec')['speed']) + 'm/s'
if self.hour_format == 12:
logger.debug('using 12 hour format for sunrise/sunset')
sunrise = sunrise_raw.format('h:mm a')
sunset = sunset_raw.format('h:mm a')
elif self.units == 'imperial':
logging.debug('getting windspeed in imperial unit')
wind = str(weather.wind(unit='miles_hour')['speed']) + 'miles/h'
elif self.hour_format == 24:
logger.debug('using 24 hour format for sunrise/sunset')
sunrise = sunrise_raw.format('H:mm')
sunset = sunset_raw.format('H:mm')
dec = decimal.Decimal
moonphase = get_moon_phase()
# Format the windspeed to user preference
if self.use_beaufort == True:
logger.debug("using beaufort for wind")
wind = str(weather.wind(unit='beaufort')['speed'])
# Fill weather details in col 1 (current weather icon)
draw_icon(im_colour, weather_icon_pos, (col_width, im_height),
weathericons[weather_icon])
elif self.use_beaufort == False:
# Fill weather details in col 2 (temp, humidity, wind)
draw_icon(im_colour, temperature_icon_pos, (icon_small, row_height),
'\uf053')
if self.units == 'metric':
logging.debug('getting windspeed in metric unit')
wind = str(round(weather.wind(unit='meters_sec')['speed'], ndigits=dec_wind)) + ' m/s'
if is_negative(temperature):
write(im_black, temperature_pos, (col_width - icon_small, row_height),
temperature, font=self.font)
else:
write(im_black, temperature_pos, (col_width - icon_small, row_height),
temperature, font=self.font)
elif self.units == 'imperial':
logging.debug('getting windspeed in imperial unit')
wind = str(round(weather.wind(unit='miles_hour')['speed'], ndigits=dec_wind)) + ' mph'
draw_icon(im_colour, humidity_icon_pos, (icon_small, row_height),
'\uf07a')
dec = decimal.Decimal
moonphase = get_moon_phase()
write(im_black, humidity_pos, (col_width - icon_small, row_height),
humidity + '%', font=self.font)
# Fill weather details in col 1 (current weather icon)
draw_icon(im_colour, weather_icon_pos, (col_width, im_height),
weathericons[weather_icon])
draw_icon(im_colour, windspeed_icon_pos, (icon_small, icon_small),
'\uf050')
# Fill weather details in col 2 (temp, humidity, wind)
draw_icon(im_colour, temperature_icon_pos, (icon_small, row_height),
'\uf053')
write(im_black, windspeed_pos, (col_width - icon_small, row_height),
wind, font=self.font)
if is_negative(temperature):
write(im_black, temperature_pos, (col_width-icon_small, row_height),
temperature, font = self.font)
else:
write(im_black, temperature_pos, (col_width-icon_small, row_height),
temperature, font = self.font)
# Fill weather details in col 3 (moonphase, sunrise, sunset)
draw_icon(im_colour, moonphase_pos, (col_width, row_height), moonphase)
draw_icon(im_colour, humidity_icon_pos, (icon_small, row_height),
'\uf07a')
draw_icon(im_colour, sunrise_icon_pos, (icon_small, icon_small), '\uf051')
write(im_black, sunrise_time_pos, (col_width - icon_small, row_height),
sunrise, font=self.font)
write(im_black, humidity_pos, (col_width-icon_small, row_height),
humidity+'%', font = self.font)
draw_icon(im_colour, sunset_icon_pos, (icon_small, icon_small), '\uf052')
write(im_black, sunset_time_pos, (col_width - icon_small, row_height), sunset,
font=self.font)
draw_icon(im_colour, windspeed_icon_pos, (icon_small, icon_small),
'\uf050')
# Add the forecast data to the correct places
for pos in range(1, len(fc_data) + 1):
stamp = fc_data[f'fc{pos}']['stamp']
write(im_black, windspeed_pos, (col_width-icon_small, row_height),
wind, font=self.font)
icon = weathericons[fc_data[f'fc{pos}']['icon']]
temp = fc_data[f'fc{pos}']['temp']
# Fill weather details in col 3 (moonphase, sunrise, sunset)
draw_icon(im_colour, moonphase_pos, (col_width, row_height), moonphase)
write(im_black, eval(f'stamp_fc{pos}'), (col_width, row_height),
stamp, font=self.font)
draw_icon(im_colour, eval(f'icon_fc{pos}'), (col_width, row_height + line_gap * 2),
icon)
write(im_black, eval(f'temp_fc{pos}'), (col_width, row_height),
temp, font=self.font)
draw_icon(im_colour, sunrise_icon_pos, (icon_small, icon_small), '\uf051')
write(im_black, sunrise_time_pos, (col_width-icon_small, row_height),
sunrise, font = self.font)
border_h = row3 + row_height
border_w = col_width - 3 # leave 3 pixels gap
draw_icon(im_colour, sunset_icon_pos, (icon_small, icon_small), '\uf052')
write(im_black, sunset_time_pos, (col_width-icon_small, row_height), sunset,
font = self.font)
# Add borders around each sub-section
draw_border(im_black, (col1, row1), (col_width * 3 - 3, border_h),
shrinkage=(0, 0))
# Add the forecast data to the correct places
for pos in range(1, len(fc_data)+1):
stamp = fc_data[f'fc{pos}']['stamp']
for _ in range(4, 8):
draw_border(im_black, (eval(f'col{_}'), row1), (border_w, border_h),
shrinkage=(0, 0))
icon = weathericons[fc_data[f'fc{pos}']['icon']]
temp = fc_data[f'fc{pos}']['temp']
# return the images ready for the display
return im_black, im_colour
write(im_black, eval(f'stamp_fc{pos}'), (col_width, row_height),
stamp, font = self.font)
draw_icon(im_colour, eval(f'icon_fc{pos}'), (col_width, row_height+line_gap*2),
icon)
write(im_black, eval(f'temp_fc{pos}'), (col_width, row_height),
temp, font = self.font)
border_h = row3 + row_height
border_w = col_width - 3 #leave 3 pixels gap
# Add borders around each sub-section
draw_border(im_black, (col1, row1), (col_width*3 - 3, border_h),
shrinkage=(0,0))
for _ in range(4,8):
draw_border(im_black, (eval(f'col{_}'), row1), (border_w, border_h),
shrinkage=(0,0))
# return the images ready for the display
return im_black, im_colour
if __name__ == '__main__':
print(f'running {filename} in standalone mode')
print(f'running {__name__} in standalone mode')

148
inkycal/modules/template.py Normal file → Executable file
View File

@@ -1,92 +1,92 @@
#!python3
import abc
from inkycal.custom import *
class inkycal_module(metaclass=abc.ABCMeta):
"""Generic base class for inkycal modules"""
"""Generic base class for inkycal modules"""
@classmethod
def __subclasshook__(cls, subclass):
return (hasattr(subclass, 'generate_image') and
callable(subclass.generate_image) or
NotImplemented)
@classmethod
def __subclasshook__(cls, subclass):
return (hasattr(subclass, 'generate_image') and
callable(subclass.generate_image) or
NotImplemented)
def __init__(self, config):
"""Initialize module with given config"""
def __init__(self, config):
"""Initialize module with given config"""
# Initializes base module
# sets properties shared amongst all sections
self.config = conf = config['config']
self.width, self.height = conf['size']
# Initializes base module
# sets properties shared amongst all sections
self.config = conf = config['config']
self.width, self.height = conf['size']
self.padding_left = self.padding_right = conf["padding_x"]
self.padding_top = self.padding_bottom = conf['padding_y']
self.padding_left = self.padding_right = conf["padding_x"]
self.padding_top = self.padding_bottom = conf['padding_y']
self.fontsize = conf["fontsize"]
self.font = ImageFont.truetype(
fonts['NotoSansUI-Regular'], size = self.fontsize)
self.fontsize = conf["fontsize"]
self.font = ImageFont.truetype(
fonts['NotoSansUI-Regular'], size=self.fontsize)
def set(self, help=False, **kwargs):
"""Set attributes of class, e.g. class.set(key=value)
see that can be changed by setting help to True
"""
lst = dir(self).copy()
options = [_ for _ in lst if not _.startswith('_')]
if 'logger' in options: options.remove('logger')
def set(self, help=False, **kwargs):
"""Set attributes of class, e.g. class.set(key=value)
see that can be changed by setting help to True
"""
lst = dir(self).copy()
options = [_ for _ in lst if not _.startswith('_')]
if 'logger' in options: options.remove('logger')
if help == True:
print('The following can be configured:')
print(options)
if help == True:
print('The following can be configured:')
print(options)
for key, value in kwargs.items():
if key in options:
if key == 'fontsize':
self.font = ImageFont.truetype(self.font.path, value)
self.fontsize = value
else:
setattr(self, key, value)
print(f"set '{key}' to '{value}'")
else:
print(f'{key} does not exist')
pass
for key, value in kwargs.items():
if key in options:
if key == 'fontsize':
self.font = ImageFont.truetype(self.font.path, value)
self.fontsize = value
else:
setattr(self, key, value)
print(f"set '{key}' to '{value}'")
else:
print(f'{key} does not exist')
pass
# Check if validation has been implemented
try:
self._validate()
except AttributeError:
print('no validation implemented')
# Check if validation has been implemented
try:
self._validate()
except AttributeError:
print('no validation implemented')
@abc.abstractmethod
def generate_image(self):
# Generate image for this module with specified parameters
raise NotImplementedError(
'The developers were too lazy to implement this function')
@abc.abstractmethod
def generate_image(self):
# Generate image for this module with specified parameters
raise NotImplementedError(
'The developers were too lazy to implement this function')
@classmethod
def get_config(cls):
# Do not change
# Get the config of this module for the web-ui
try:
if hasattr(cls, 'requires'):
for each in cls.requires:
if not "label" in cls.requires[each]:
raise Exception(f"no label found for {each}")
if hasattr(cls, 'optional'):
for each in cls.optional:
if not "label" in cls.optional[each]:
raise Exception(f"no label found for {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')
@classmethod
def get_config(cls):
# Do not change
# Get the config of this module for the web-ui
try:
if hasattr(cls, 'requires'):
for each in cls.requires:
if not "label" in cls.requires[each]:
raise Exception(f"no label found for {each}")
if hasattr(cls, 'optional'):
for each in cls.optional:
if not "label" in cls.optional[each]:
raise Exception(f"no label found for {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')