Merge pull request #349 from aceinnolab/feature/#311

Feature/#311
This commit is contained in:
Ace 2024-06-25 19:13:34 +02:00 committed by GitHub
commit f0c3b90f48
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
42 changed files with 543 additions and 267 deletions

View File

@ -1,12 +0,0 @@
"""
Clears the display of any content.
"""
from inkycal import Inkycal
print("loading Inkycal and display driver...")
inky = Inkycal(render=True) # Initialise Inkycal
print("clearing display...")
inky.calibrate(cycles=1) # Calibrate the display
print("clear complete...")
print("finished!")

View File

Before

Width:  |  Height:  |  Size: 4.5 KiB

After

Width:  |  Height:  |  Size: 4.5 KiB

View File

Before

Width:  |  Height:  |  Size: 1.2 KiB

After

Width:  |  Height:  |  Size: 1.2 KiB

View File

Before

Width:  |  Height:  |  Size: 1.2 KiB

After

Width:  |  Height:  |  Size: 1.2 KiB

View File

Before

Width:  |  Height:  |  Size: 12 KiB

After

Width:  |  Height:  |  Size: 12 KiB

View File

Before

Width:  |  Height:  |  Size: 63 KiB

After

Width:  |  Height:  |  Size: 63 KiB

View File

Before

Width:  |  Height:  |  Size: 1.2 KiB

After

Width:  |  Height:  |  Size: 1.2 KiB

View File

@ -1,7 +1,40 @@
"""Basic Inkycal run script.
Assumes that the settings.json file is in the /boot directory.
set render=True to render the display, set render=False to only run the modules.
"""
import asyncio import asyncio
from inkycal import Inkycal from inkycal import Inkycal
inky = Inkycal(render=True) # Initialise Inkycal
# If your settings.json file is not in /boot, use the full path: inky = Inkycal('path/to/settings.json', render=True) async def dry_run():
inky.test() # test if Inkycal can be run correctly, running this will show a bit of info for each module # create an instance of Inkycal
asyncio.run(inky.run()) # If there were no issues, you can run Inkycal nonstop # If your settings.json file is not in /boot, use the full path:
# inky = Inkycal('path/to/settings.json', render=True)
inky = Inkycal(render=False)
await inky.run(run_once=True) # dry-run without rendering anything on the display
async def clear_display():
print("loading Inkycal and display driver...")
inky = Inkycal(render=True) # Initialise Inkycal
print("clearing display...")
inky.calibrate(cycles=1) # Calibrate the display
print("clear complete...")
print("finished!")
async def run():
# create an instance of Inkycal
# If your settings.json file is not in /boot, use the full path:
# inky = Inkycal('path/to/settings.json', render=True)
# when using experimental PiSugar support:
# inky = Inkycal(render=True, use_pi_sugar=True)
inky = Inkycal(render=True)
await inky.run() # If there were no issues, you can run Inkycal nonstop
if __name__ == "__main__":
asyncio.run(run())

View File

@ -1,20 +1,15 @@
# Display class (for driving E-Paper displays)
from inkycal.display import Display
# Default modules # Default modules
import inkycal.modules.inkycal_agenda import inkycal.modules.inkycal_agenda
import inkycal.modules.inkycal_calendar import inkycal.modules.inkycal_calendar
import inkycal.modules.inkycal_weather
import inkycal.modules.inkycal_feeds import inkycal.modules.inkycal_feeds
import inkycal.modules.inkycal_todoist import inkycal.modules.inkycal_fullweather
import inkycal.modules.inkycal_image import inkycal.modules.inkycal_image
import inkycal.modules.inkycal_jokes import inkycal.modules.inkycal_jokes
import inkycal.modules.inkycal_slideshow import inkycal.modules.inkycal_slideshow
import inkycal.modules.inkycal_stocks import inkycal.modules.inkycal_stocks
import inkycal.modules.inkycal_todoist
import inkycal.modules.inkycal_weather
import inkycal.modules.inkycal_webshot import inkycal.modules.inkycal_webshot
import inkycal.modules.inkycal_xkcd import inkycal.modules.inkycal_xkcd
import inkycal.modules.inkycal_fullweather from inkycal.display import Display
# Main file
from inkycal.main import Inkycal from inkycal.main import Inkycal
import inkycal.modules.inkycal_stocks

View File

@ -17,20 +17,16 @@ from PIL import Image
from PIL import ImageDraw from PIL import ImageDraw
from PIL import ImageFont from PIL import ImageFont
from inkycal.settings import Settings
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
logger.setLevel(level=logging.INFO)
# Get the path to the Inkycal folder settings = Settings()
top_level = "/".join(os.path.dirname(os.path.abspath(os.path.dirname(__file__))).split("/")[:-1])
# Get path of 'fonts' and 'images' folders within Inkycal folder
fonts_location = os.path.join(top_level, "fonts/")
image_folder = os.path.join(top_level, "image_folder/")
# Get available fonts within fonts folder # Get available fonts within fonts folder
fonts = {} fonts = {}
for path, dirs, files in os.walk(fonts_location): for path, dirs, files in os.walk(settings.FONT_PATH):
for _ in files: for _ in files:
if _.endswith(".otf"): if _.endswith(".otf"):
name = _.split(".otf")[0] name = _.split(".otf")[0]

View File

@ -2,13 +2,11 @@
Inkycal ePaper driving functions Inkycal ePaper driving functions
Copyright by aceisace Copyright by aceisace
""" """
import os
from importlib import import_module from importlib import import_module
import PIL import PIL
from PIL import Image from PIL import Image
from inkycal.custom import top_level
from inkycal.display.supported_models import supported_models from inkycal.display.supported_models import supported_models
@ -199,9 +197,7 @@ class Display:
>>> Display.get_display_names() >>> Display.get_display_names()
""" """
driver_files = top_level + '/inkycal/display/drivers/' return list(supported_models.keys())
drivers = [i for i in os.listdir(driver_files) if i.endswith(".py") and not i.startswith("__") and "_" in i]
return drivers
if __name__ == '__main__': if __name__ == '__main__':

View File

@ -2,22 +2,18 @@
10.3" driver class 10.3" driver class
Copyright by aceinnolab Copyright by aceinnolab
""" """
import os
from subprocess import run from subprocess import run
from PIL import Image from PIL import Image
from inkycal.custom import image_folder, top_level from inkycal.settings import Settings
# Display resolution # Display resolution
EPD_WIDTH = 1872 EPD_WIDTH = 1872
EPD_HEIGHT = 1404 EPD_HEIGHT = 1404
# Please insert VCOM of your display. The Minus sign before is not required settings = Settings()
VCOM = "2.0"
driver_dir = top_level + '/inkycal/display/drivers/parallel_drivers/'
command = f'sudo {driver_dir}epd -{VCOM} 0 {image_folder + "canvas.bmp"}'
class EPD: class EPD:
@ -40,8 +36,8 @@ class EPD:
def getbuffer(self, image): def getbuffer(self, image):
"""ad-hoc""" """ad-hoc"""
image = image.rotate(90, expand=True).transpose(Image.FLIP_LEFT_RIGHT) image = image.rotate(90, expand=True).transpose(Image.FLIP_LEFT_RIGHT)
image.convert('RGB').save(image_folder + 'canvas.bmp', 'BMP') image.convert('RGB').save(os.path.join(settings.IMAGE_FOLDER, 'canvas.bmp'), 'BMP')
command = f'sudo {driver_dir}epd -{VCOM} 0 {image_folder + "canvas.bmp"}' command = f'sudo {settings.PARALLEL_DRIVER_PATH}/epd -{settings.VCOM} 0 {settings.IMAGE_FOLDER + "canvas.bmp"}'
print(command) print(command)
return command return command

View File

@ -2,20 +2,16 @@
7.8" parallel driver class 7.8" parallel driver class
Copyright by aceinnolab Copyright by aceinnolab
""" """
import os
from subprocess import run from subprocess import run
from inkycal.custom import image_folder, top_level from inkycal.settings import Settings
# Display resolution # Display resolution
EPD_WIDTH = 1872 EPD_WIDTH = 1872
EPD_HEIGHT = 1404 EPD_HEIGHT = 1404
# Please insert VCOM of your display. The Minus sign before is not required settings = Settings()
VCOM = "2.0"
driver_dir = top_level + '/inkycal/display/drivers/parallel_drivers/'
command = f'sudo {driver_dir}epd -{VCOM} 0 {image_folder + "canvas.bmp"}'
class EPD: class EPD:
@ -38,8 +34,8 @@ class EPD:
def getbuffer(self, image): def getbuffer(self, image):
"""ad-hoc""" """ad-hoc"""
image = image.rotate(90, expand=True) image = image.rotate(90, expand=True)
image.convert('RGB').save(image_folder + 'canvas.bmp', 'BMP') image.convert('RGB').save(os.path.join(settings.IMAGE_FOLDER, 'canvas.bmp'), 'BMP')
command = f'sudo {driver_dir}epd -{VCOM} 0 {image_folder + "canvas.bmp"}' command = f'sudo {settings.PARALLEL_DRIVER_PATH}/epd -{settings.VCOM} 0 {settings.IMAGE_FOLDER + "canvas.bmp"}'
print(command) print(command)
return command return command

View File

@ -4,18 +4,16 @@ Copyright by aceinnolab
""" """
from subprocess import run from subprocess import run
from inkycal.custom import image_folder, top_level from inkycal.settings import Settings
# Display resolution # Display resolution
EPD_WIDTH = 1200 EPD_WIDTH = 1200
EPD_HEIGHT = 825 EPD_HEIGHT = 825
# Please insert VCOM of your display. The Minus sign before is not required
VCOM = "2.0"
driver_dir = top_level + '/inkycal/display/drivers/parallel_drivers/' settings = Settings()
command = f'sudo {driver_dir}epd -{VCOM} 0 {image_folder + "canvas.bmp"}' command = f'sudo {settings.PARALLEL_DRIVER_PATH}/epd -{settings.VCOM} 0 {settings.IMAGE_FOLDER + "canvas.bmp"}'
class EPD: class EPD:
@ -38,8 +36,8 @@ class EPD:
def getbuffer(self, image): def getbuffer(self, image):
"""ad-hoc""" """ad-hoc"""
image = image.rotate(90, expand=True) image = image.rotate(90, expand=True)
image.convert('RGB').save(image_folder + 'canvas.bmp', 'BMP') image.convert('RGB').save(settings.IMAGE_FOLDER + 'canvas.bmp', 'BMP')
command = f'sudo {driver_dir}epd -{VCOM} 0 {image_folder + "canvas.bmp"}' command = f'sudo {settings.PARALLEL_DRIVER_PATH}/epd -{settings.VCOM} 0 {settings.IMAGE_FOLDER + "canvas.bmp"}'
print(command) print(command)
return command return command

View File

@ -28,8 +28,6 @@ THE SOFTWARE.
""" """
import logging import logging
import os
import subprocess
import sys import sys
import time import time
@ -128,4 +126,3 @@ implementation = RaspberryPi()
for func in [x for x in dir(implementation) if not x.startswith('_')]: for func in [x for x in dir(implementation) if not x.startswith('_')]:
setattr(sys.modules[__name__], func, getattr(implementation, func)) setattr(sys.modules[__name__], func, getattr(implementation, func))

35
inkycal/loggers.py Normal file
View File

@ -0,0 +1,35 @@
"""Logging configuration for Inkycal."""
import logging
import os
from logging.handlers import RotatingFileHandler
from inkycal.settings import Settings
# On the console, set a logger to show only important logs
# (level ERROR or higher)
stream_handler = logging.StreamHandler()
stream_handler.setLevel(logging.INFO)
settings = Settings()
if not os.path.exists(settings.LOG_PATH):
os.mkdir(settings.LOG_PATH)
# Save all logs to a file, which contains more detailed output
logging.basicConfig(
level=logging.INFO,
format='%(asctime)s | %(name)s | %(levelname)s: %(message)s',
datefmt='%d-%m-%Y %H:%M:%S',
handlers=[
stream_handler, # add stream handler from above
RotatingFileHandler( # log to a file too
settings.INKYCAL_LOG_PATH, # file to log
maxBytes=2*1024*1024, # 2MB max filesize
backupCount=5 # create max 5 log files
)
]
)
# Show less logging for PIL module
logging.getLogger("PIL").setLevel(logging.WARNING)

View File

@ -6,44 +6,21 @@ Copyright by aceinnolab
import asyncio import asyncio
import glob import glob
import hashlib import hashlib
from logging.handlers import RotatingFileHandler
import numpy import numpy
from inkycal import loggers # noqa
from inkycal.custom import * from inkycal.custom import *
from inkycal.display import Display from inkycal.display import Display
from inkycal.modules.inky_image import Inkyimage as Images from inkycal.modules.inky_image import Inkyimage as Images
from inkycal.utils import JSONCache
# On the console, set a logger to show only important logs
# (level ERROR or higher)
stream_handler = logging.StreamHandler()
stream_handler.setLevel(logging.ERROR)
if not os.path.exists(f'{top_level}/logs'):
os.mkdir(f'{top_level}/logs')
# Save all logs to a file, which contains more detailed output
logging.basicConfig(
level=logging.INFO,
format='%(asctime)s | %(name)s | %(levelname)s: %(message)s',
datefmt='%d-%m-%Y %H:%M:%S',
handlers=[
stream_handler, # add stream handler from above
RotatingFileHandler( # log to a file too
f'{top_level}/logs/inkycal.log', # file to log
maxBytes=2097152, # 2MB max filesize
backupCount=5 # create max 5 log files
)
]
)
# Show less logging for PIL module
logging.getLogger("PIL").setLevel(logging.WARNING)
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
settings = Settings()
CACHE_NAME = "inkycal_main"
# TODO: autostart -> supervisor?
class Inkycal: class Inkycal:
"""Inkycal main class """Inkycal main class
@ -60,43 +37,45 @@ class Inkycal:
to improve rendering on E-Papers. Set this to False for 9.7" E-Paper. to improve rendering on E-Papers. Set this to False for 9.7" E-Paper.
""" """
def __init__(self, settings_path: str or None = None, render: bool = True): def __init__(self, settings_path: str or None = None, render: bool = True, use_pi_sugar: bool = False):
"""Initialise Inkycal""" """Initialise Inkycal"""
self._release = "2.0.3"
# Get the release version from setup.py logger.info(f"Inkycal v{self._release} booting up...")
with open(f'{top_level}/setup.py') as setup_file:
for line in setup_file:
if line.startswith('__version__'):
self._release = line.split("=")[-1].replace("'", "").replace('"', "").replace(" ", "")
break
self.render = render self.render = render
self.info = None self.info = None
logger.info("Checking if a settings file is present...")
# load settings file - throw an error if file could not be found # load settings file - throw an error if file could not be found
if settings_path: if settings_path:
logger.info(f"Custom location for settings.json file specified: {settings_path}")
try: try:
with open(settings_path) as settings_file: with open(settings_path) as settings_file:
settings = json.load(settings_file) self.settings = json.load(settings_file)
self.settings = settings
except FileNotFoundError: except FileNotFoundError:
raise FileNotFoundError( raise FileNotFoundError(
f"No settings.json file could be found in the specified location: {settings_path}") f"No settings.json file could be found in the specified location: {settings_path}")
else: else:
logger.info("Looking for settings.json file in /boot folder...")
try: try:
with open('/boot/settings.json') as settings_file: with open('/boot/settings.json', mode="r") as settings_file:
settings = json.load(settings_file) self.settings = json.load(settings_file)
self.settings = settings
except FileNotFoundError: except FileNotFoundError:
raise SettingsFileNotFoundError raise SettingsFileNotFoundError
self.disable_calibration = self.settings.get('disable_calibration', False) self.disable_calibration = self.settings.get('disable_calibration', False)
if self.disable_calibration:
logger.info("Calibration disabled. Please proceed with caution to prevent ghosting.")
if not os.path.exists(image_folder): if not os.path.exists(settings.IMAGE_FOLDER):
os.mkdir(image_folder) os.mkdir(settings.IMAGE_FOLDER)
if not os.path.exists(settings.CACHE_PATH):
os.mkdir(settings.CACHE_PATH)
# Option to use epaper image optimisation, reduces colours # Option to use epaper image optimisation, reduces colours
self.optimize = True self.optimize = True
@ -109,10 +88,10 @@ class Inkycal:
if self.render: if self.render:
# Init Display class with model in settings file # Init Display class with model in settings file
# from inkycal.display import Display # from inkycal.display import Display
self.Display = Display(settings["model"]) self.Display = Display(self.settings["model"])
# check if colours can be rendered # check if colours can be rendered
self.supports_colour = True if 'colour' in settings['model'] else False self.supports_colour = True if 'colour' in self.settings['model'] else False
# get calibration hours # get calibration hours
self._calibration_hours = self.settings['calibration_hours'] self._calibration_hours = self.settings['calibration_hours']
@ -122,7 +101,7 @@ class Inkycal:
# Load and initialise modules specified in the settings file # Load and initialise modules specified in the settings file
self._module_number = 1 self._module_number = 1
for module in settings['modules']: for module in self.settings['modules']:
module_name = module['name'] module_name = module['name']
try: try:
loader = f'from inkycal.modules import {module_name}' loader = f'from inkycal.modules import {module_name}'
@ -131,10 +110,9 @@ class Inkycal:
setup = f'self.module_{self._module_number} = {module_name}({module})' setup = f'self.module_{self._module_number} = {module_name}({module})'
# print(setup) # print(setup)
exec(setup) exec(setup)
logger.info(('name : {name} size : {width}x{height} px'.format( width = module['config']['size'][0]
name=module_name, height = module['config']['size'][1]
width=module['config']['size'][0], logger.info(f'name : {module_name} size : {width}x{height} px')
height=module['config']['size'][1])))
self._module_number += 1 self._module_number += 1
@ -147,57 +125,83 @@ class Inkycal:
logger.exception(f"Exception: {traceback.format_exc()}.") logger.exception(f"Exception: {traceback.format_exc()}.")
# Path to store images # Path to store images
self.image_folder = image_folder self.image_folder = settings.IMAGE_FOLDER
# Remove old hashes # Remove old hashes
self._remove_hashes(self.image_folder) self._remove_hashes(self.image_folder)
# Give an OK message # set up cache
print('loaded inkycal') if not os.path.exists(os.path.join(settings.CACHE_PATH, CACHE_NAME)):
if not os.path.exists(settings.CACHE_PATH):
os.mkdir(settings.CACHE_PATH)
self.cache = JSONCache(CACHE_NAME)
self.cache_data = self.cache.read()
def countdown(self, interval_mins: int or None = None) -> int: self.counter = 0 if "counter" not in self.cache_data else int(self.cache_data["counter"])
"""Returns the remaining time in seconds until next display update.
self.use_pi_sugar = use_pi_sugar
self.battery_capacity = 100
if self.use_pi_sugar:
logger.info("PiSugar support enabled.")
from inkycal.utils import PiSugar
self.pisugar = PiSugar()
self.battery_capacity = self.pisugar.get_battery()
logger.info(f"PiSugar battery capacity: {self.battery_capacity}%")
if self.battery_capacity < 20:
logger.warning("Battery capacity is below 20%!")
logger.info("Setting system time to PiSugar time...")
if self.pisugar.rtc_pi2rtc():
logger.info("RTC time updates successfully")
else:
logger.warning("RTC time could not be set!")
print(
f"Using PiSigar model: {self.pisugar.get_model()}. Current PiSugar time: {self.pisugar.get_rtc_time()}")
# Give an OK message
logger.info('Inkycal initialised successfully!')
def countdown(self, interval_mins: int = None) -> int:
"""Returns the remaining time in seconds until the next display update based on the interval.
Args: Args:
- interval_mins = int -> the interval in minutes for the update interval_mins (int): The interval in minutes for the update. If none is given, the value
if no interval is given, the value from the settings file is used. from the settings file is used.
Returns: Returns:
- int -> the remaining time in seconds until next update int: The remaining time in seconds until the next update.
""" """
# Default to settings if no interval is provided
# Check if empty, if empty, use value from settings file
if interval_mins is None: if interval_mins is None:
interval_mins = self.settings["update_interval"] interval_mins = self.settings["update_interval"]
# Find out at which minutes the update should happen # Get the current time
now = arrow.now() now = arrow.now()
if interval_mins <= 60:
update_timings = [(60 - interval_mins * updates) for updates in range(60 // interval_mins)][::-1]
# Calculate time in minutes until next update # Calculate the next update time
minutes = [_ for _ in update_timings if _ >= now.minute][0] - now.minute # Finding the total minutes from the start of the day
minutes_since_midnight = now.hour * 60 + now.minute
# Print the remaining time in minutes until next update # Finding the next interval point
print(f'{minutes} minutes left until next refresh') minutes_to_next_interval = (
minutes_since_midnight // interval_mins + 1) * interval_mins - minutes_since_midnight
seconds_to_next_interval = minutes_to_next_interval * 60 - now.second
# Calculate time in seconds until next update # Logging the remaining time in appropriate units
remaining_time = minutes * 60 + (60 - now.second) hours_to_next_interval = minutes_to_next_interval // 60
remaining_minutes = minutes_to_next_interval % 60
# Return seconds until next update if hours_to_next_interval > 0:
return remaining_time print(f'{hours_to_next_interval} hours and {remaining_minutes} minutes left until next refresh')
else: else:
# Calculate time in minutes until next update using the range of 24 hours in steps of every full hour print(f'{remaining_minutes} minutes left until next refresh')
update_timings = [(60 * 24 - interval_mins * updates) for updates in range(60 * 24 // interval_mins)][::-1]
minutes = [_ for _ in update_timings if _ >= now.minute][0] - now.minute
remaining_time = minutes * 60 + (60 - now.second)
print(f'{round(minutes / 60, 1)} hours left until next refresh') return seconds_to_next_interval
# Return seconds until next update def dry_run(self):
return remaining_time
def test(self):
"""Tests if Inkycal can run without issues. """Tests if Inkycal can run without issues.
Attempts to import module names from settings file. Loads the config Attempts to import module names from settings file. Loads the config
@ -206,8 +210,6 @@ class Inkycal:
Generated images can be found in the /images folder of Inkycal. Generated images can be found in the /images folder of Inkycal.
""" """
logger.info(f"Inkycal version: v{self._release}")
logger.info(f'Selected E-paper display: {self.settings["model"]}') logger.info(f'Selected E-paper display: {self.settings["model"]}')
# store module numbers in here # store module numbers in here
@ -218,20 +220,13 @@ class Inkycal:
for number in range(1, self._module_number): for number in range(1, self._module_number):
name = eval(f"self.module_{number}.name") name = eval(f"self.module_{number}.name")
module = eval(f'self.module_{number}') success = self.process_module(number)
print(f'generating image(s) for {name}...', end="") if success:
try: logger.debug(f'Image of module {name} generated successfully')
black, colour = module.generate_image() else:
if self.show_border: logger.warning(f'Generating image of module {name} failed!')
draw_border_2(im=black, xy=(1, 1), size=(black.width - 2, black.height - 2), radius=5)
black.save(f"{self.image_folder}module{number}_black.png", "PNG")
colour.save(f"{self.image_folder}module{number}_colour.png", "PNG")
print("OK!")
except Exception:
errors.append(number) errors.append(number)
self.info += f"module {number}: Error! " self.info += f"module {number}: Error! "
logger.exception("Error!")
logger.exception(f"Exception: {traceback.format_exc()}.")
if errors: if errors:
logger.error('Error/s in modules:', *errors) logger.error('Error/s in modules:', *errors)
@ -277,76 +272,69 @@ class Inkycal:
print("Refresh needed: {a}".format(a=res)) print("Refresh needed: {a}".format(a=res))
return res return res
async def run(self): async def run(self, run_once=False):
"""Runs main program in nonstop mode. """Runs main program in nonstop mode or a single iteration based on the run_once flag.
Uses an infinity loop to run Inkycal nonstop. Inkycal generates the image Args:
from all modules, assembles them in one image, refreshed the E-Paper and run_once (bool): If True, runs the updating process once and stops. If False,
then sleeps until the next scheduled update. runs indefinitely.
Uses an infinity loop to run Inkycal nonstop or a single time based on run_once.
Inkycal generates the image from all modules, assembles them in one image,
refreshes the E-Paper and then sleeps until the next scheduled update or exits.
""" """
# Get the time of initial run # Get the time of initial run
runtime = arrow.now() runtime = arrow.now()
# Function to flip images upside down # Function to flip images upside down
upside_down = lambda image: image.rotate(180, expand=True) upside_down = lambda image: image.rotate(180, expand=True)
# Count the number of times without any errors logger.info(f'Inkycal version: v{self._release}')
counter = 0 logger.info(f'Selected E-paper display: {self.settings["model"]}')
print(f'Inkycal version: v{self._release}')
print(f'Selected E-paper display: {self.settings["model"]}')
while True: while True:
logger.info("Starting new cycle...")
current_time = arrow.now(tz=get_system_tz()) current_time = arrow.now(tz=get_system_tz())
print(f"Date: {current_time.format('D MMM YY')} | " logger.info(f"Timestamp: {current_time.format('HH:mm:ss DD.MM.YYYY')}")
f"Time: {current_time.format('HH:mm')}") self.cache_data["counter"] = self.counter
print('Generating images for all modules...', end='')
errors = [] # store module numbers in here errors = [] # Store module numbers in here
# short info for info-section # Short info for info-section
if not self.settings.get('image_hash', False): if not self.settings.get('image_hash', False):
self.info = f"{current_time.format('D MMM @ HH:mm')} " self.info = f"{current_time.format('D MMM @ HH:mm')} "
else: else:
self.info = "" self.info = ""
for number in range(1, self._module_number): for number in range(1, self._module_number):
success = self.process_module(number)
# name = eval(f"self.module_{number}.name") if not success:
module = eval(f'self.module_{number}')
try:
black, colour = module.generate_image()
if self.show_border:
draw_border_2(im=black, xy=(1, 1), size=(black.width - 2, black.height - 2), radius=5)
black.save(f"{self.image_folder}module{number}_black.png", "PNG")
colour.save(f"{self.image_folder}module{number}_colour.png", "PNG")
self.info += f"module {number}: OK "
except Exception as e:
errors.append(number) errors.append(number)
self.info += f"module {number}: Error! " self.info += f"im {number}: X "
logger.exception("Error!")
logger.exception(f"Exception: {traceback.format_exc()}.")
if errors: if errors:
logger.error("Error/s in modules:", *errors) logger.error("Error/s in modules:", *errors)
counter = 0 self.counter = 0
self.cache_data["counter"] = 0
else: else:
counter += 1 self.counter += 1
logger.info("successful") self.cache_data["counter"] += 1
logger.info("All images generated successfully!")
del errors del errors
if self.battery_capacity < 20:
self.info += "Low battery! "
# Assemble image from each module - add info section if specified # Assemble image from each module - add info section if specified
self._assemble() self._assemble()
# Check if image should be rendered # Check if image should be rendered
if self.render: if self.render:
logger.info("Attempting to render image on display...")
display = self.Display display = self.Display
self._calibration_check() self._calibration_check()
if self._calibration_state: if self._calibration_state:
# after calibration, we have to forcefully rewrite the screen # After calibration, we have to forcefully rewrite the screen
self._remove_hashes(self.image_folder) self._remove_hashes(self.image_folder)
if self.supports_colour: if self.supports_colour:
@ -358,17 +346,15 @@ class Inkycal:
im_black = upside_down(im_black) im_black = upside_down(im_black)
im_colour = upside_down(im_colour) im_colour = upside_down(im_colour)
# render the image on the display # Render the image on the display
if not self.settings.get('image_hash', False) or self._needs_image_update([ if not self.settings.get('image_hash', False) or self._needs_image_update([
(f"{self.image_folder}/canvas.png.hash", im_black), (f"{self.image_folder}/canvas.png.hash", im_black),
(f"{self.image_folder}/canvas_colour.png.hash", im_colour) (f"{self.image_folder}/canvas_colour.png.hash", im_colour)
]): ]):
# render the image on the display
display.render(im_black, im_colour) display.render(im_black, im_colour)
# Part for black-white ePapers # Part for black-white ePapers
elif not self.supports_colour: else:
im_black = self._merge_bands() im_black = self._merge_bands()
# Flip the image by 180° if required # Flip the image by 180° if required
@ -376,14 +362,29 @@ class Inkycal:
im_black = upside_down(im_black) im_black = upside_down(im_black)
if not self.settings.get('image_hash', False) or self._needs_image_update([ if not self.settings.get('image_hash', False) or self._needs_image_update([
(f"{self.image_folder}/canvas.png.hash", im_black), (f"{self.image_folder}/canvas.png.hash", im_black), ]):
]):
display.render(im_black) display.render(im_black)
print(f'\nNo errors since {counter} display updates \n' logger.info(f'\nNo errors since {self.counter} display updates')
f'program started {runtime.humanize()}') logger.info(f'program started {runtime.humanize()}')
# store the cache data
self.cache.write(self.cache_data)
# Exit the loop if run_once is True
if run_once:
break # Exit the loop after one full cycle if run_once is True
sleep_time = self.countdown() sleep_time = self.countdown()
if self.use_pi_sugar:
sleep_time_rtc = arrow.now(tz=get_system_tz()).shift(seconds=sleep_time)
result = self.pisugar.rtc_alarm_set(sleep_time_rtc, 127)
if result:
logger.info(f"Alarm set for {sleep_time_rtc.format('HH:mm:ss')}")
else:
logger.warning(f"Failed to set alarm for {sleep_time_rtc.format('HH:mm:ss')}")
await asyncio.sleep(sleep_time) await asyncio.sleep(sleep_time)
@staticmethod @staticmethod
@ -392,7 +393,8 @@ class Inkycal:
returns the merged image returns the merged image
""" """
im1_path, im2_path = image_folder + 'canvas.png', image_folder + 'canvas_colour.png' im1_path = os.path.join(settings.image_folder, "canvas.png")
im2_path = os.path.join(settings.image_folder, "canvas_colour.png")
# If there is an image for black and colour, merge them # If there is an image for black and colour, merge them
if os.path.exists(im1_path) and os.path.exists(im2_path): if os.path.exists(im1_path) and os.path.exists(im2_path):
@ -531,7 +533,7 @@ class Inkycal:
im_colour = black_to_colour(im_colour) im_colour = black_to_colour(im_colour)
im_colour.paste(im_black, (0, 0), im_black) im_colour.paste(im_black, (0, 0), im_black)
im_colour.save(image_folder + 'full-screen.png', 'PNG') im_colour.save(os.path.join(settings.IMAGE_FOLDER, 'full-screen.png'), 'PNG')
@staticmethod @staticmethod
def _optimize_im(image, threshold=220): def _optimize_im(image, threshold=220):
@ -574,13 +576,40 @@ class Inkycal:
@staticmethod @staticmethod
def cleanup(): def cleanup():
# clean up old images in image_folder # clean up old images in image_folder
for _file in glob.glob(f"{image_folder}*.png"): if len(glob.glob(settings.IMAGE_FOLDER)) <= 1:
return
for _file in glob.glob(settings.IMAGE_FOLDER):
try: try:
os.remove(_file) os.remove(_file)
except: except:
logger.error(f"could not remove file: {_file}") logger.error(f"could not remove file: {_file}")
pass pass
def process_module(self, number) -> bool or Exception:
"""Process individual module to generate images and handle exceptions."""
module = eval(f'self.module_{number}')
try:
black, colour = module.generate_image()
if self.show_border:
draw_border_2(im=black, xy=(1, 1), size=(black.width - 2, black.height - 2), radius=5)
black.save(f"{self.image_folder}module{number}_black.png", "PNG")
colour.save(f"{self.image_folder}module{number}_colour.png", "PNG")
return True
except Exception:
logger.exception(f"Error in module {number}!")
return False
def _shutdown_system(self):
"""Shutdown the system"""
import subprocess
from time import sleep
try:
logger.info("Shutting down OS in 5 seconds...")
sleep(5)
subprocess.run(["sudo", "shutdown", "-h", "now"], check=True)
except subprocess.CalledProcessError:
logger.warning("Failed to execute shutdown command.")
if __name__ == '__main__': if __name__ == '__main__':
print(f'running inkycal main in standalone/debug mode') print(f'running inkycal main in standalone/debug mode')

View File

@ -156,7 +156,7 @@ class Simple(inkycal_module):
# -----------------------------------------------------------------------# # -----------------------------------------------------------------------#
# give an OK message # give an OK message
print(f'{__name__} loaded') logger.debug(f'{__name__} loaded')
############################################################################# #############################################################################
# Validation of module specific parameters (optional) # # Validation of module specific parameters (optional) #

View File

@ -27,7 +27,7 @@ class Inkyimage:
self.image = image self.image = image
# give an OK message # give an OK message
logger.info(f"{__name__} loaded") logger.debug(f"{__name__} loaded")
def load(self, path: str) -> None: def load(self, path: str) -> None:
"""loads an image from a URL or filepath. """loads an image from a URL or filepath.
@ -59,7 +59,7 @@ class Inkyimage:
logger.error("Invalid Image file provided", exc_info=True) logger.error("Invalid Image file provided", exc_info=True)
raise Exception("Please check if the path points to an image file.") raise Exception("Please check if the path points to an image file.")
logger.info(f"width: {image.width}, height: {image.height}") logger.debug(f"width: {image.width}, height: {image.height}")
image.convert(mode="RGBA") # convert to a more suitable format image.convert(mode="RGBA") # convert to a more suitable format
self.image = image self.image = image

View File

@ -2,9 +2,7 @@
Inkycal Agenda Module Inkycal Agenda Module
Copyright by aceinnolab Copyright by aceinnolab
""" """
import arrow # noqa
import arrow
from inkycal.custom import * from inkycal.custom import *
from inkycal.modules.ical_parser import iCalendar from inkycal.modules.ical_parser import iCalendar
from inkycal.modules.template import inkycal_module from inkycal.modules.template import inkycal_module
@ -80,7 +78,7 @@ class Agenda(inkycal_module):
self.icon_font = ImageFont.truetype(fonts['MaterialIcons'], size=self.fontsize) self.icon_font = ImageFont.truetype(fonts['MaterialIcons'], size=self.fontsize)
# give an OK message # give an OK message
print(f'{__name__} loaded') logger.debug(f'{__name__} loaded')
def generate_image(self): def generate_image(self):
"""Generate image for this module""" """Generate image for this module"""
@ -90,7 +88,7 @@ class Agenda(inkycal_module):
im_height = int(self.height - (2 * self.padding_top)) im_height = int(self.height - (2 * self.padding_top))
im_size = im_width, im_height im_size = im_width, im_height
logger.info(f'Image size: {im_size}') logger.debug(f'Image size: {im_size}')
# Create an image for black pixels and one for coloured pixels # Create an image for black pixels and one for coloured pixels
im_black = Image.new('RGB', size=im_size, color='white') im_black = Image.new('RGB', size=im_size, color='white')

View File

@ -84,7 +84,7 @@ class Calendar(inkycal_module):
) )
# give an OK message # give an OK message
print(f'{__name__} loaded') logger.debug(f'{__name__} loaded')
@staticmethod @staticmethod
def flatten(values): def flatten(values):
@ -100,7 +100,7 @@ class Calendar(inkycal_module):
im_size = im_width, im_height im_size = im_width, im_height
events_height = 0 events_height = 0
logger.info(f'Image size: {im_size}') logger.debug(f'Image size: {im_size}')
# Create an image for black pixels and one for coloured pixels # Create an image for black pixels and one for coloured pixels
im_black = Image.new('RGB', size=im_size, color='white') im_black = Image.new('RGB', size=im_size, color='white')
@ -265,7 +265,7 @@ class Calendar(inkycal_module):
# find out how many lines can fit at max in the event section # find out how many lines can fit at max in the event section
line_spacing = 2 line_spacing = 2
text_bbox_height = self.font.getbbox("hg") text_bbox_height = self.font.getbbox("hg")
line_height = text_bbox_height[3] + line_spacing line_height = text_bbox_height[3] - text_bbox_height[1] + line_spacing
max_event_lines = events_height // (line_height + line_spacing) max_event_lines = events_height // (line_height + line_spacing)
# generate list of coordinates for each line # generate list of coordinates for each line
@ -322,7 +322,7 @@ class Calendar(inkycal_module):
im_colour, im_colour,
grid[days], grid[days],
(icon_width, icon_height), (icon_width, icon_height),
radius=6, radius=6
) )
# Filter upcoming events until 4 weeks in the future # Filter upcoming events until 4 weeks in the future

View File

@ -60,7 +60,7 @@ class Feeds(inkycal_module):
self.shuffle_feeds = config["shuffle_feeds"] self.shuffle_feeds = config["shuffle_feeds"]
# give an OK message # give an OK message
print(f'{__name__} loaded') logger.debug(f'{__name__} loaded')
def _validate(self): def _validate(self):
"""Validate module-specific parameters""" """Validate module-specific parameters"""
@ -75,7 +75,7 @@ class Feeds(inkycal_module):
im_width = int(self.width - (2 * self.padding_left)) im_width = int(self.width - (2 * self.padding_left))
im_height = int(self.height - (2 * self.padding_top)) im_height = int(self.height - (2 * self.padding_top))
im_size = im_width, im_height im_size = im_width, im_height
logger.info(f'Image size: {im_size}') logger.debug(f'Image size: {im_size}')
# Create an image for black pixels and one for coloured pixels # Create an image for black pixels and one for coloured pixels
im_black = Image.new('RGB', size=im_size, color='white') im_black = Image.new('RGB', size=im_size, color='white')
@ -83,8 +83,9 @@ class Feeds(inkycal_module):
# Check if internet is available # Check if internet is available
if internet_available(): if internet_available():
logger.info('Connection test passed') logger.debug('Connection test passed')
else: else:
logger.error("Network not reachable. Please check your connection.")
raise NetworkNotReachableError raise NetworkNotReachableError
# Set some parameters for formatting feeds # Set some parameters for formatting feeds

View File

@ -23,16 +23,18 @@ from icons.weather_icons.weather_icons import get_weather_icon
from inkycal.custom.functions import fonts from inkycal.custom.functions import fonts
from inkycal.custom.functions import get_system_tz from inkycal.custom.functions import get_system_tz
from inkycal.custom.functions import internet_available from inkycal.custom.functions import internet_available
from inkycal.custom.functions import top_level
from inkycal.custom.inkycal_exceptions import NetworkNotReachableError from inkycal.custom.inkycal_exceptions import NetworkNotReachableError
from inkycal.custom.openweathermap_wrapper import OpenWeatherMap from inkycal.custom.openweathermap_wrapper import OpenWeatherMap
from inkycal.modules.inky_image import image_to_palette from inkycal.modules.inky_image import image_to_palette
from inkycal.modules.template import inkycal_module from inkycal.modules.template import inkycal_module
from inkycal.settings import Settings
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
logger.setLevel(logging.INFO) logger.setLevel(logging.INFO)
icons_dir = os.path.join(top_level, "icons", "ui-icons") settings = Settings()
icons_dir = os.path.join(settings.FONT_PATH, "ui-icons")
def outline(image: Image, size: int, color: tuple) -> Image: def outline(image: Image, size: int, color: tuple) -> Image:
@ -139,7 +141,7 @@ class Fullweather(inkycal_module):
# Check if all required parameters are present # Check if all required parameters are present
for param in self.requires: for param in self.requires:
if not param in config: if param not in config:
raise Exception(f"config is missing {param}") raise Exception(f"config is missing {param}")
# required parameters # required parameters
@ -237,7 +239,7 @@ class Fullweather(inkycal_module):
self.left_section_width = int(self.width / 4) self.left_section_width = int(self.width / 4)
# give an OK message # give an OK message
print(f"{__name__} loaded") logger.debug(f"{__name__} loaded")
def createBaseImage(self): def createBaseImage(self):
""" """

View File

@ -50,7 +50,7 @@ class Inkyimage(inkycal_module):
self.dither = False self.dither = False
# give an OK message # give an OK message
print(f"{__name__} loaded") logger.debug(f"{__name__} loaded")
def generate_image(self): def generate_image(self):
"""Generate image for this module""" """Generate image for this module"""
@ -71,7 +71,7 @@ class Inkyimage(inkycal_module):
# Remove background if present # Remove background if present
im.remove_alpha() im.remove_alpha()
# if autoflip was enabled, flip the image # if auto-flip was enabled, flip the image
if self.autoflip: if self.autoflip:
im.autoflip(self.orientation) im.autoflip(self.orientation)

View File

@ -30,7 +30,7 @@ class Jokes(inkycal_module):
config = config['config'] config = config['config']
# give an OK message # give an OK message
print(f'{__name__} loaded') logger.debug(f'{__name__} loaded')
def generate_image(self): def generate_image(self):
"""Generate image for this module""" """Generate image for this module"""
@ -39,7 +39,7 @@ class Jokes(inkycal_module):
im_width = int(self.width - (2 * self.padding_left)) im_width = int(self.width - (2 * self.padding_left))
im_height = int(self.height - (2 * self.padding_top)) im_height = int(self.height - (2 * self.padding_top))
im_size = im_width, im_height im_size = im_width, im_height
logger.info(f'image size: {im_width} x {im_height} px') logger.debug(f'image size: {im_width} x {im_height} px')
# Create an image for black pixels and one for coloured pixels # Create an image for black pixels and one for coloured pixels
im_black = Image.new('RGB', size=im_size, color='white') im_black = Image.new('RGB', size=im_size, color='white')
@ -47,8 +47,9 @@ class Jokes(inkycal_module):
# Check if internet is available # Check if internet is available
if internet_available(): if internet_available():
logger.info('Connection test passed') logger.debug('Connection test passed')
else: else:
logger.error("Network not reachable. Please check your connection.")
raise NetworkNotReachableError raise NetworkNotReachableError
# Set some parameters for formatting feeds # Set some parameters for formatting feeds

View File

@ -67,7 +67,7 @@ class Inkyserver(inkycal_module):
self.path_body = config['path_body'] self.path_body = config['path_body']
# give an OK message # give an OK message
print(f'{__name__} loaded') logger.debug(f'{__name__} loaded')
def generate_image(self): def generate_image(self):
"""Generate image for this module""" """Generate image for this module"""

View File

@ -8,13 +8,13 @@ from inkycal.custom import *
# PIL has a class named Image, use alias for Inkyimage -> Images # PIL has a class named Image, use alias for Inkyimage -> Images
from inkycal.modules.inky_image import Inkyimage as Images, image_to_palette from inkycal.modules.inky_image import Inkyimage as Images, image_to_palette
from inkycal.modules.template import inkycal_module from inkycal.modules.template import inkycal_module
from inkycal.utils import JSONCache
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
class Slideshow(inkycal_module): class Slideshow(inkycal_module):
"""Cycles through images in a local image folder """Cycles through images in a local image folder"""
"""
name = "Slideshow - cycle through images from a local folder" name = "Slideshow - cycle through images from a local folder"
requires = { requires = {
@ -53,7 +53,7 @@ class Slideshow(inkycal_module):
# required parameters # required parameters
for param in self.requires: for param in self.requires:
if not param in config: if param not in config:
raise Exception(f'config is missing {param}') raise Exception(f'config is missing {param}')
# optional parameters # optional parameters
@ -64,19 +64,20 @@ class Slideshow(inkycal_module):
# Get the full path of all png/jpg/jpeg images in the given folder # Get the full path of all png/jpg/jpeg images in the given folder
all_files = glob.glob(f'{self.path}/*') all_files = glob.glob(f'{self.path}/*')
self.images = [i for i in all_files self.images = [i for i in all_files if i.split('.')[-1].lower() in ('jpg', 'jpeg', 'png')]
if i.split('.')[-1].lower() in ('jpg', 'jpeg', 'png')]
if not self.images: if not self.images:
logger.error('No images found in the given folder, please ' logger.error('No images found in the given folder, please double check your path!')
'double check your path!')
raise Exception('No images found in the given folder path :/') raise Exception('No images found in the given folder path :/')
self.cache = JSONCache('inkycal_slideshow')
self.cache_data = self.cache.read()
# set a 'first run' signal # set a 'first run' signal
self._first_run = True self._first_run = True
# give an OK message # give an OK message
print(f'{__name__} loaded') logger.debug(f'{__name__} loaded')
def generate_image(self): def generate_image(self):
"""Generate image for this module""" """Generate image for this module"""
@ -86,17 +87,19 @@ class Slideshow(inkycal_module):
im_height = int(self.height - (2 * self.padding_top)) im_height = int(self.height - (2 * self.padding_top))
im_size = im_width, im_height im_size = im_width, im_height
logger.info(f'Image size: {im_size}') logger.debug(f'Image size: {im_size}')
# rotates list items by 1 index # rotates list items by 1 index
def rotate(somelist): def rotate(list: list):
return somelist[1:] + somelist[:1] return list[1:] + list[:1]
# Switch to the next image if this is not the first run # Switch to the next image if this is not the first run
if self._first_run: if self._first_run:
self._first_run = False self._first_run = False
self.cache_data["current_index"] = 0
else: else:
self.images = rotate(self.images) self.images = rotate(self.images)
self.cache_data["current_index"] = (self.cache_data["current_index"] + 1) % len(self.images)
# initialize custom image class # initialize custom image class
im = Images() im = Images()
@ -110,7 +113,7 @@ class Slideshow(inkycal_module):
# Remove background if present # Remove background if present
im.remove_alpha() im.remove_alpha()
# if autoflip was enabled, flip the image # if auto-flip was enabled, flip the image
if self.autoflip: if self.autoflip:
im.autoflip(self.orientation) im.autoflip(self.orientation)
@ -123,6 +126,8 @@ class Slideshow(inkycal_module):
# with the images now send, clear the current image # with the images now send, clear the current image
im.clear() im.clear()
self.cache.write(self.cache_data)
# return images # return images
return im_black, im_colour return im_black, im_colour

View File

@ -54,7 +54,7 @@ class Stocks(inkycal_module):
self.tickers = config['tickers'] self.tickers = config['tickers']
# give an OK message # give an OK message
print(f'{__name__} loaded') logger.debug(f'{__name__} loaded')
def generate_image(self): def generate_image(self):
"""Generate image for this module""" """Generate image for this module"""
@ -63,7 +63,7 @@ class Stocks(inkycal_module):
im_width = int(self.width - (2 * self.padding_left)) im_width = int(self.width - (2 * self.padding_left))
im_height = int(self.height - (2 * self.padding_top)) im_height = int(self.height - (2 * self.padding_top))
im_size = im_width, im_height im_size = im_width, im_height
logger.info(f'image size: {im_width} x {im_height} px') logger.debug(f'image size: {im_width} x {im_height} px')
# Create an image for black pixels and one for coloured pixels (required) # Create an image for black pixels and one for coloured pixels (required)
im_black = Image.new('RGB', size=im_size, color='white') im_black = Image.new('RGB', size=im_size, color='white')
@ -142,7 +142,7 @@ class Stocks(inkycal_module):
logger.warning(f"Failed to get '{stockName}' ticker price hint! Using " logger.warning(f"Failed to get '{stockName}' ticker price hint! Using "
"default precision of 2 instead.") "default precision of 2 instead.")
stockHistory = yfTicker.history("30d") stockHistory = yfTicker.history("1mo")
stockHistoryLen = len(stockHistory) stockHistoryLen = len(stockHistory)
logger.info(f'fetched {stockHistoryLen} datapoints ...') logger.info(f'fetched {stockHistoryLen} datapoints ...')
previousQuote = (stockHistory.tail(2)['Close'].iloc[0]) previousQuote = (stockHistory.tail(2)['Close'].iloc[0])

View File

@ -31,7 +31,7 @@ class TextToDisplay(inkycal_module):
self.make_request = True if self.filepath.startswith("https://") else False self.make_request = True if self.filepath.startswith("https://") else False
# give an OK message # give an OK message
print(f'{__name__} loaded') logger.debug(f'{__name__} loaded')
def _validate(self): def _validate(self):
"""Validate module-specific parameters""" """Validate module-specific parameters"""
@ -45,7 +45,7 @@ class TextToDisplay(inkycal_module):
im_width = int(self.width - (2 * self.padding_left)) im_width = int(self.width - (2 * self.padding_left))
im_height = int(self.height - (2 * self.padding_top)) im_height = int(self.height - (2 * self.padding_top))
im_size = im_width, im_height im_size = im_width, im_height
logger.info(f'Image size: {im_size}') logger.debug(f'Image size: {im_size}')
# Create an image for black pixels and one for coloured pixels # Create an image for black pixels and one for coloured pixels
im_black = Image.new('RGB', size=im_size, color='white') im_black = Image.new('RGB', size=im_size, color='white')

View File

@ -32,7 +32,7 @@ class Tindie(inkycal_module):
# self.mode = config['mode'] # unshipped_orders, shipped_orders, all_orders # self.mode = config['mode'] # unshipped_orders, shipped_orders, all_orders
# give an OK message # give an OK message
print(f'{__name__} loaded') logger.debug(f'{__name__} loaded')
def generate_image(self): def generate_image(self):
"""Generate image for this module""" """Generate image for this module"""
@ -40,7 +40,7 @@ class Tindie(inkycal_module):
im_width = int(self.width - (2 * self.padding_left)) im_width = int(self.width - (2 * self.padding_left))
im_height = int(self.height - (2 * self.padding_top)) im_height = int(self.height - (2 * self.padding_top))
im_size = im_width, im_height im_size = im_width, im_height
logger.info(f'image size: {im_width} x {im_height} px') logger.debug(f'image size: {im_width} x {im_height} px')
# Create an image for black pixels and one for coloured pixels # Create an image for black pixels and one for coloured pixels
im_black = Image.new('RGB', size=im_size, color='white') im_black = Image.new('RGB', size=im_size, color='white')
@ -50,6 +50,7 @@ class Tindie(inkycal_module):
if internet_available(): if internet_available():
logger.info('Connection test passed') logger.info('Connection test passed')
else: else:
logger.error("Network not reachable. Please check your connection.")
raise NetworkNotReachableError raise NetworkNotReachableError
# Set some parameters for formatting feeds # Set some parameters for formatting feeds

View File

@ -56,7 +56,7 @@ class Todoist(inkycal_module):
self._api = TodoistAPI(config['api_key']) self._api = TodoistAPI(config['api_key'])
# give an OK message # give an OK message
print(f'{__name__} loaded') logger.debug(f'{__name__} loaded')
def _validate(self): def _validate(self):
"""Validate module-specific parameters""" """Validate module-specific parameters"""
@ -70,7 +70,7 @@ class Todoist(inkycal_module):
im_width = int(self.width - (2 * self.padding_left)) im_width = int(self.width - (2 * self.padding_left))
im_height = int(self.height - (2 * self.padding_top)) im_height = int(self.height - (2 * self.padding_top))
im_size = im_width, im_height im_size = im_width, im_height
logger.info(f'Image size: {im_size}') logger.debug(f'Image size: {im_size}')
# Create an image for black pixels and one for coloured pixels # Create an image for black pixels and one for coloured pixels
im_black = Image.new('RGB', size=im_size, color='white') im_black = Image.new('RGB', size=im_size, color='white')
@ -80,6 +80,7 @@ class Todoist(inkycal_module):
if internet_available(): if internet_available():
logger.info('Connection test passed') logger.info('Connection test passed')
else: else:
logger.error("Network not reachable. Please check your connection.")
raise NetworkNotReachableError raise NetworkNotReachableError
# Set some parameters for formatting todos # Set some parameters for formatting todos

View File

@ -143,7 +143,7 @@ class Weather(inkycal_module):
self.tempDispUnit = "°" self.tempDispUnit = "°"
# give an OK message # give an OK message
print(f"{__name__} loaded") logger.debug(f"{__name__} loaded")
def generate_image(self): def generate_image(self):
"""Generate image for this module""" """Generate image for this module"""
@ -152,7 +152,7 @@ class Weather(inkycal_module):
im_width = int(self.width - (2 * self.padding_left)) im_width = int(self.width - (2 * self.padding_left))
im_height = int(self.height - (2 * self.padding_top)) im_height = int(self.height - (2 * self.padding_top))
im_size = im_width, im_height im_size = im_width, im_height
logger.info(f'Image size: {im_size}') logger.debug(f'Image size: {im_size}')
# Create an image for black pixels and one for coloured pixels # Create an image for black pixels and one for coloured pixels
im_black = Image.new('RGB', size=im_size, color='white') im_black = Image.new('RGB', size=im_size, color='white')
@ -160,8 +160,9 @@ class Weather(inkycal_module):
# Check if internet is available # Check if internet is available
if internet_available(): if internet_available():
logger.info('Connection test passed') logger.debug('Connection test passed')
else: else:
logger.error("Network not reachable. Please check your connection.")
raise NetworkNotReachableError raise NetworkNotReachableError
def get_moon_phase(): def get_moon_phase():

View File

@ -83,7 +83,7 @@ class Webshot(inkycal_module):
raise Exception("Rotation must be either 0, 90, 180 or 270") raise Exception("Rotation must be either 0, 90, 180 or 270")
# give an OK message # give an OK message
print(f'Inkycal webshot loaded') logger.debug(f'Inkycal webshot loaded')
def generate_image(self): def generate_image(self):
"""Generate image for this module""" """Generate image for this module"""
@ -99,7 +99,7 @@ class Webshot(inkycal_module):
im_width = int(self.width - (2 * self.padding_left)) im_width = int(self.width - (2 * self.padding_left))
im_height = int(self.height - (2 * self.padding_top)) im_height = int(self.height - (2 * self.padding_top))
im_size = im_width, im_height im_size = im_width, im_height
logger.info('image size: {} x {} px'.format(im_width, im_height)) logger.debug('image size: {} x {} px'.format(im_width, im_height))
# Create an image for black pixels and one for coloured pixels (required) # Create an image for black pixels and one for coloured pixels (required)
im_black = Image.new('RGB', size=im_size, color='white') im_black = Image.new('RGB', size=im_size, color='white')
@ -109,6 +109,7 @@ class Webshot(inkycal_module):
if internet_available(): if internet_available():
logger.info('Connection test passed') logger.info('Connection test passed')
else: else:
logger.error("Network not reachable. Please check your connection.")
raise Exception('Network could not be reached :/') raise Exception('Network could not be reached :/')
logger.info( logger.info(

View File

@ -11,6 +11,8 @@ from inkycal.modules.template import inkycal_module
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
settings = Settings()
class Xkcd(inkycal_module): class Xkcd(inkycal_module):
name = "xkcd - Displays comics from xkcd.com by Randall Munroe" name = "xkcd - Displays comics from xkcd.com by Randall Munroe"
@ -51,13 +53,13 @@ class Xkcd(inkycal_module):
self.scale_filter = config['filter'] self.scale_filter = config['filter']
# give an OK message # give an OK message
print(f'Inkycal XKCD loaded') logger.debug(f'Inkycal XKCD loaded')
def generate_image(self): def generate_image(self):
"""Generate image for this module""" """Generate image for this module"""
# Create tmp path # Create tmp path
tmpPath = f"{top_level}/temp" tmpPath = settings.TEMPORARY_FOLDER
if not os.path.exists(tmpPath): if not os.path.exists(tmpPath):
os.mkdir(tmpPath) os.mkdir(tmpPath)
@ -66,7 +68,7 @@ class Xkcd(inkycal_module):
im_width = int(self.width - (2 * self.padding_left)) im_width = int(self.width - (2 * self.padding_left))
im_height = int(self.height - (2 * self.padding_top)) im_height = int(self.height - (2 * self.padding_top))
im_size = im_width, im_height im_size = im_width, im_height
logger.info('image size: {} x {} px'.format(im_width, im_height)) logger.debug('image size: {} x {} px'.format(im_width, im_height))
# Create an image for black pixels and one for coloured pixels (required) # Create an image for black pixels and one for coloured pixels (required)
im_black = Image.new('RGB', size=im_size, color='white') im_black = Image.new('RGB', size=im_size, color='white')
@ -76,6 +78,7 @@ class Xkcd(inkycal_module):
if internet_available(): if internet_available():
logger.info('Connection test passed') logger.info('Connection test passed')
else: else:
logger.error("Network not reachable. Please check your connection.")
raise Exception('Network could not be reached :/') raise Exception('Network could not be reached :/')
# Set some parameters for formatting feeds # Set some parameters for formatting feeds

20
inkycal/settings.py Normal file
View File

@ -0,0 +1,20 @@
"""Settings class
Used to initialize the settings for the application.
"""
import os
basedir = os.path.abspath(os.path.dirname(__file__))
class Settings:
"""Settings class to initialize the settings for the application.
"""
CACHE_PATH = os.path.join(basedir, "cache")
LOG_PATH = os.path.join(basedir, "logs")
INKYCAL_LOG_PATH = os.path.join(LOG_PATH, "inkycal.log")
FONT_PATH = os.path.join(basedir, "../fonts")
IMAGE_FOLDER = os.path.join(basedir, "../image_folder")
PARALLEL_DRIVER_PATH = os.path.join(basedir, "inkycal", "display", "drivers", "parallel_drivers")
TEMPORARY_FOLDER = os.path.join(basedir, "tmp")
VCOM = "2.0"

View File

@ -0,0 +1,2 @@
from .pisugar import PiSugar
from .json_cache import JSONCache

View File

@ -0,0 +1,28 @@
"""JSON Cache
Can be used to cache JSON data to disk. This is useful for caching data to survive reboots.
"""
import json
import os
from inkycal.settings import Settings
settings = Settings()
class JSONCache:
def __init__(self, name: str, create_if_not_exists: bool = True):
self.path = os.path.join(settings.CACHE_PATH,f"{name}.json")
if create_if_not_exists and not os.path.exists(self.path):
with open(self.path, "w", encoding="utf-8") as file:
json.dump({}, file)
def read(self):
try:
with open(self.path, "r", encoding="utf-8") as file:
return json.load(file)
except FileNotFoundError:
return {}
def write(self, data: dict):
with open(self.path, "w", encoding="utf-8") as file:
json.dump(data, file, indent=4, sort_keys=True)

147
inkycal/utils/pisugar.py Normal file
View File

@ -0,0 +1,147 @@
"""PiSugar helper class for Inkycal."""
import logging
import subprocess
from inkycal.settings import Settings
import arrow
settings = Settings()
logger = logging.getLogger(__name__)
class PiSugar:
def __init__(self):
# replace "command" with actual command
self.command_template = 'echo "command" | nc -q 0 127.0.0.1 8423'
self.allowed_commands = ["get battery", "get model", "get rtc_time", "get rtc_alarm_enabled",
"get rtc_alarm_time", "get alarm_repeat", "rtc_pi2rtc", "rtc_alarm_set"]
def _get_output(self, command, param=None):
if command not in self.allowed_commands:
logger.error(f"Command {command} not allowed")
return None
if param:
cmd = self.command_template.replace("command", f"{command} {param}")
else:
cmd = self.command_template.replace("command", command)
try:
result = subprocess.run(cmd, shell=True, text=True, capture_output=True)
if result.returncode != 0:
print(f"Command failed with {result.stderr}")
return None
output = result.stdout.strip()
return output
except Exception as e:
logger.error(f"Error executing command: {e}")
return None
def get_battery(self) -> float or None:
"""Get the battery level in percentage.
Returns:
int or None: The battery level in percentage or None if the command fails.
"""
battery_output = self._get_output("get battery")
if battery_output:
for line in battery_output.splitlines():
if 'battery:' in line:
return float(line.split(':')[1].strip())
return None
def get_model(self) -> str or None:
"""Get the PiSugar model."""
model_output = self._get_output("get model")
if model_output:
for line in model_output.splitlines():
if 'model:' in line:
return line.split(':')[1].strip()
return None
def get_rtc_time(self) -> arrow.arrow or None:
"""Get the RTC time."""
result = self._get_output("get rtc_time")
if result:
rtc_time = result.split("rtc_time: ")[1].strip()
return arrow.get(rtc_time)
return None
def get_rtc_alarm_enabled(self) -> str or None:
"""Get the RTC alarm enabled status."""
result = self._get_output("get rtc_alarm_enabled")
if result:
second_line = result.splitlines()[1]
output = second_line.split('rtc_alarm_enabled: ')[1].strip()
return True if output == "true" else False
return None
def get_rtc_alarm_time(self) -> arrow.arrow or None:
"""Get the RTC alarm time."""
result = self._get_output("get rtc_alarm_time")
if result:
alarm_time = result.split('rtc_alarm_time: ')[1].strip()
return arrow.get(alarm_time)
return None
def get_alarm_repeat(self) -> dict or None:
"""Get the alarm repeat status.
Returns:
dict or None: A dictionary with the alarm repeating days or None if the command fails.
"""
result = self._get_output("get alarm_repeat")
if result:
repeating_days = f"{int(result.split('alarm_repeat: ')[1].strip()):8b}".strip()
data = {"Monday": False, "Tuesday": False, "Wednesday": False, "Thursday": False, "Friday": False,
"Saturday": False, "Sunday": False}
if repeating_days[0] == "1":
data["Monday"] = True
if repeating_days[1] == "1":
data["Tuesday"] = True
if repeating_days[2] == "1":
data["Wednesday"] = True
if repeating_days[3] == "1":
data["Thursday"] = True
if repeating_days[4] == "1":
data["Friday"] = True
if repeating_days[5] == "1":
data["Saturday"] = True
if repeating_days[6] == "1":
data["Sunday"] = True
return data
return None
def rtc_pi2rtc(self) -> bool:
"""Sync the Pi time to RTC.
Returns:
bool: True if the sync was successful, False otherwise.
"""
result = self._get_output("rtc_pi2rtc")
if result:
status = result.split('rtc_pi2rtc: ')[1].strip()
if status == "done":
return True
return False
def rtc_alarm_set(self, time: arrow.arrow, repeat:int=127) -> bool:
"""Set the RTC alarm time.
Args:
time (arrow.arrow): The alarm time in ISO 8601 format.
repeat: int representing 7-bit binary number of repeating days. e.g. 127 = 1111111 = repeat every day
Returns:
bool: True if the alarm was set successfully, False otherwise.
"""
iso_format = time.isoformat()
result = self._get_output("rtc_alarm_set", f"{iso_format } {repeat}")
if result:
status = result.split('rtc_alarm_set: ')[1].strip()
if status == "done":
return True
return False

View File

@ -51,4 +51,4 @@ virtualenv==20.25.0
webencodings==0.5.1 webencodings==0.5.1
x-wr-timezone==0.0.6 x-wr-timezone==0.0.6
xkcd==2.4.2 xkcd==2.4.2
yfinance==0.2.36 yfinance==0.2.40

View File

@ -28,7 +28,7 @@ tests = [
"padding_x": 10, "padding_x": 10,
"padding_y": 10, "padding_y": 10,
"fontsize": 12, "fontsize": 12,
"language": "en" "language": "de"
} }
}, },
{ {

View File

@ -6,10 +6,15 @@ import logging
import unittest import unittest
from inkycal.modules import Webshot from inkycal.modules import Webshot
from inkycal.modules.inky_image import Inkyimage
from tests import Config
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
logging.basicConfig(level=logging.DEBUG) logging.basicConfig(level=logging.DEBUG)
preview = Inkyimage.preview
merge = Inkyimage.merge
tests = [ tests = [
{ {
"position": 1, "position": 1,
@ -63,6 +68,7 @@ class TestWebshot(unittest.TestCase):
for test in tests: for test in tests:
logger.info(f'test {tests.index(test) + 1} generating image..') logger.info(f'test {tests.index(test) + 1} generating image..')
module = Webshot(test) module = Webshot(test)
module.generate_image() im_black, im_colour = module.generate_image()
if Config.USE_PREVIEW:
preview(merge(im_black, im_colour))
logger.info('OK') logger.info('OK')

View File

@ -21,9 +21,9 @@ class TestMain(unittest.TestCase):
assert inkycal.settings["info_section_height"] == 70 assert inkycal.settings["info_section_height"] == 70
assert inkycal.settings["border_around_modules"] is True assert inkycal.settings["border_around_modules"] is True
def test_run(self): def test_dry_run(self):
inkycal = Inkycal(self.settings_path, render=False) inkycal = Inkycal(self.settings_path, render=False)
inkycal.test() inkycal.dry_run()
def test_countdown(self): def test_countdown(self):
inkycal = Inkycal(self.settings_path, render=False) inkycal = Inkycal(self.settings_path, render=False)