├── .gitignore ├── .github └── FUNDING.yml ├── images ├── arch.png ├── sun.png ├── cloud.png ├── manjaro.png ├── cloud_sun.png └── raspberry-pi.png ├── screenshots ├── fortune.png ├── picture.jpg ├── system.png ├── tasks.png ├── calendar.png ├── dashboard.png └── affirmations.png ├── .idea ├── vcs.xml ├── .gitignore ├── inspectionProfiles │ └── profiles_settings.xml ├── discord.xml ├── misc.xml ├── modules.xml ├── epdtext.iml └── deployment.xml ├── epdtext.service ├── requirements.txt ├── cli.py ├── screens ├── network.py ├── affirmations.py ├── fortune.py ├── tasks.py ├── sensors.py ├── calendar.py ├── webview.py ├── weather.py ├── system.py ├── example.py ├── dashboard.py └── __init__.py ├── libs ├── __init__.py ├── system.py ├── epd.py ├── weather.py └── calendar.py ├── local_settings.py.example ├── settings.py ├── README.md └── app.py /.gitignore: -------------------------------------------------------------------------------- 1 | *.pyc 2 | /local_settings.py 3 | /.cache 4 | -------------------------------------------------------------------------------- /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | github: tsbarnes 2 | liberapay: tsbarnes 3 | -------------------------------------------------------------------------------- /images/arch.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tsbarnes/epdtext/HEAD/images/arch.png -------------------------------------------------------------------------------- /images/sun.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tsbarnes/epdtext/HEAD/images/sun.png -------------------------------------------------------------------------------- /images/cloud.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tsbarnes/epdtext/HEAD/images/cloud.png -------------------------------------------------------------------------------- /images/manjaro.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tsbarnes/epdtext/HEAD/images/manjaro.png -------------------------------------------------------------------------------- /images/cloud_sun.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tsbarnes/epdtext/HEAD/images/cloud_sun.png -------------------------------------------------------------------------------- /images/raspberry-pi.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tsbarnes/epdtext/HEAD/images/raspberry-pi.png -------------------------------------------------------------------------------- /screenshots/fortune.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tsbarnes/epdtext/HEAD/screenshots/fortune.png -------------------------------------------------------------------------------- /screenshots/picture.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tsbarnes/epdtext/HEAD/screenshots/picture.jpg -------------------------------------------------------------------------------- /screenshots/system.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tsbarnes/epdtext/HEAD/screenshots/system.png -------------------------------------------------------------------------------- /screenshots/tasks.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tsbarnes/epdtext/HEAD/screenshots/tasks.png -------------------------------------------------------------------------------- /screenshots/calendar.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tsbarnes/epdtext/HEAD/screenshots/calendar.png -------------------------------------------------------------------------------- /screenshots/dashboard.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tsbarnes/epdtext/HEAD/screenshots/dashboard.png -------------------------------------------------------------------------------- /screenshots/affirmations.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tsbarnes/epdtext/HEAD/screenshots/affirmations.png -------------------------------------------------------------------------------- /.idea/vcs.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | -------------------------------------------------------------------------------- /.idea/.gitignore: -------------------------------------------------------------------------------- 1 | # Default ignored files 2 | /shelf/ 3 | /workspace.xml 4 | # Editor-based HTTP Client requests 5 | /httpRequests/ 6 | # Datasource local storage ignored files 7 | /dataSources/ 8 | /dataSources.local.xml 9 | -------------------------------------------------------------------------------- /.idea/inspectionProfiles/profiles_settings.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 6 | -------------------------------------------------------------------------------- /.idea/discord.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 7 | -------------------------------------------------------------------------------- /.idea/misc.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | -------------------------------------------------------------------------------- /.idea/modules.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | -------------------------------------------------------------------------------- /epdtext.service: -------------------------------------------------------------------------------- 1 | [Unit] 2 | Description=e-Paper Display 3 | After=local-fs.target network.target 4 | 5 | [Service] 6 | Type=simple 7 | ExecStart=/usr/bin/python3 /home/pi/epdtext/app.py 8 | WorkingDirectory=/home/pi/epdtext 9 | User=pi 10 | Restart=on-failure 11 | 12 | [Install] 13 | WantedBy=default.target 14 | 15 | -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | Pillow>=5.4.1 2 | gpiozero>=1.5.1 3 | caldav>=0.8.0 4 | icalendar>=4.0.7 5 | icalevents==0.1.24 6 | humanize>=3.11.0 7 | posix_ipc>=1.0.5 8 | pytz>=2021.1 9 | requests>=2.21.0 10 | distro>=1.6.0 11 | python_weather>=0.3.6 12 | asyncio>=3.4.3 13 | tzlocal>=3.0.0 14 | PySensors>=0.0.4 15 | htmlwebshot>=0.1.2 16 | psutil>=5.8.0 17 | DateTime>=4.3 18 | httplib2>=0.19.1 19 | urllib3>=1.24.1 20 | -------------------------------------------------------------------------------- /cli.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | 3 | import logging 4 | import sys 5 | 6 | import posix_ipc 7 | 8 | logger = logging.getLogger('epdtext.cli') 9 | 10 | if len(sys.argv) < 2: 11 | logger.error("No command specified") 12 | exit(1) 13 | 14 | try: 15 | mq = posix_ipc.MessageQueue("/epdtext_ipc") 16 | mq.block = False 17 | except posix_ipc.PermissionsError: 18 | logger.error("couldn't open message queue") 19 | exit(1) 20 | 21 | command_line = " ".join(sys.argv[1:]) 22 | 23 | mq.send(command_line, timeout=10) 24 | -------------------------------------------------------------------------------- /.idea/epdtext.iml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | -------------------------------------------------------------------------------- /screens/network.py: -------------------------------------------------------------------------------- 1 | from libs.system import System, get_system 2 | from screens import AbstractScreen 3 | 4 | 5 | class Screen(AbstractScreen): 6 | system: System = get_system() 7 | 8 | def reload(self) -> None: 9 | self.blank() 10 | self.draw_titlebar("Network") 11 | 12 | text = "Local IP: {}".format(self.system.local_ipv4_address) + '\n' 13 | text += "Total upload: {}".format(self.system.network_total_sent) + '\n' 14 | text += "Total download: {}".format(self.system.network_total_received) 15 | self.text(text, font_size=16, position=(5, 30)) 16 | -------------------------------------------------------------------------------- /libs/__init__.py: -------------------------------------------------------------------------------- 1 | import inspect 2 | import logging 3 | import os 4 | import pathlib 5 | from collections.abc import Generator 6 | 7 | 8 | logger = logging.getLogger('epdtext.libs') 9 | 10 | 11 | def get_libs(): 12 | libs: list = [] 13 | 14 | path: str = os.path.dirname(os.path.abspath(inspect.getfile(inspect.currentframe()))) 15 | lib_directory: Generator[pathlib.Path, None, None] = pathlib.Path(path).rglob("*.py") 16 | 17 | for file in lib_directory: 18 | if file.name == "__init__.py": 19 | continue 20 | module_name = file.name.split(".")[0] 21 | logger.debug("Found '{0}' in '{1}'".format(module_name, path)) 22 | libs.append(module_name) 23 | 24 | return libs 25 | -------------------------------------------------------------------------------- /.idea/deployment.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 23 | -------------------------------------------------------------------------------- /screens/affirmations.py: -------------------------------------------------------------------------------- 1 | import random 2 | 3 | import settings 4 | from screens import AbstractScreen 5 | 6 | 7 | class Screen(AbstractScreen): 8 | affirmations = settings.AFFIRMATIONS 9 | current_affirmation = affirmations[0] 10 | 11 | def get_random_affirmation(self): 12 | affirmation = random.choice(self.affirmations) 13 | while affirmation == self.current_affirmation: 14 | affirmation = random.choice(self.affirmations) 15 | self.current_affirmation = affirmation 16 | return affirmation 17 | 18 | def reload(self): 19 | self.blank() 20 | self.draw_titlebar("Affirmations") 21 | text = self.get_random_affirmation() 22 | self.text(text, font_size=25, position=(5, 30)) 23 | 24 | def handle_btn_press(self, button_number=1): 25 | if button_number == 1: 26 | self.reload() 27 | self.show() 28 | elif button_number == 2: 29 | pass 30 | -------------------------------------------------------------------------------- /screens/fortune.py: -------------------------------------------------------------------------------- 1 | import logging 2 | import subprocess 3 | 4 | from screens import AbstractScreen 5 | 6 | try: 7 | from local_settings import FORTUNE_PATH 8 | except ImportError: 9 | FORTUNE_PATH = "fortune" 10 | 11 | 12 | class Screen(AbstractScreen): 13 | def reload(self): 14 | try: 15 | child = subprocess.Popen([FORTUNE_PATH], stdout=subprocess.PIPE, stderr=subprocess.STDOUT) 16 | string = child.stdout.read().decode().replace('\n', ' ') 17 | except OSError: 18 | logging.error("couldn't run application 'fortune'") 19 | string = "Couldn't run 'fortune'" 20 | self.blank() 21 | self.draw_titlebar("Fortune") 22 | self.text(string, font_size=14, position=(5, 25)) 23 | 24 | def handle_btn_press(self, button_number=1): 25 | if button_number == 1: 26 | self.reload() 27 | self.show() 28 | elif button_number == 2: 29 | pass 30 | -------------------------------------------------------------------------------- /screens/tasks.py: -------------------------------------------------------------------------------- 1 | import humanize 2 | 3 | from libs.calendar import Calendar, get_calendar 4 | from screens import AbstractScreen 5 | 6 | 7 | class Screen(AbstractScreen): 8 | calendar: Calendar = get_calendar() 9 | 10 | def reload(self): 11 | self.blank() 12 | 13 | self.draw_titlebar("Tasks") 14 | 15 | text = '' 16 | 17 | for obj in self.calendar.tasks: 18 | text += "* " + obj["summary"].replace('\n', ' ') + '\n' 19 | if obj["due"]: 20 | text += " - Due: " + humanize.naturalday(obj["due"]) + "\n" 21 | 22 | if text != '': 23 | self.text(text, font_size=16, position=(5, 25)) 24 | else: 25 | self.text('No tasks', font_size=30, position=(5, 25)) 26 | 27 | def handle_btn_press(self, button_number=1): 28 | if button_number == 1: 29 | self.reload() 30 | self.show() 31 | elif button_number == 2: 32 | self.show() 33 | -------------------------------------------------------------------------------- /screens/sensors.py: -------------------------------------------------------------------------------- 1 | """Sensors screen""" 2 | from libs import system 3 | from screens import AbstractScreen 4 | 5 | 6 | class Screen(AbstractScreen): 7 | """ 8 | This class provides the screen methods needed by epdtext 9 | """ 10 | system = system.get_system() 11 | 12 | def handle_btn_press(self, button_number: int = 1): 13 | """ 14 | This method receives the button presses 15 | """ 16 | 17 | # Buttons 0 and 3 are used to switch screens 18 | if button_number == 1: 19 | pass 20 | elif button_number == 2: 21 | pass 22 | 23 | def reload(self): 24 | """ 25 | This method should draw the contents of the screen to self.image 26 | """ 27 | 28 | self.blank() 29 | 30 | self.draw_titlebar("Sensors") 31 | 32 | text = "Temperature:\t" + str(round(self.system.temperature)) + '°\n' 33 | text += "Voltage: \t" + str(self.system.voltage) 34 | self.text(text, font_size=16, position=(5, 30)) 35 | 36 | def iterate_loop(self): 37 | """ 38 | This method is optional, and will be run once per cycle 39 | """ 40 | # Do whatever you need to do, but try to make sure it doesn't take too long 41 | 42 | # This line is very important, it keeps the auto reload working 43 | super().iterate_loop() 44 | -------------------------------------------------------------------------------- /screens/calendar.py: -------------------------------------------------------------------------------- 1 | import logging 2 | 3 | from libs.calendar import Calendar, get_calendar, update_calendar 4 | from screens import AbstractScreen 5 | 6 | 7 | class Screen(AbstractScreen): 8 | calendar: Calendar = get_calendar() 9 | 10 | def reload(self): 11 | self.blank() 12 | 13 | self.draw_titlebar("Calendar") 14 | 15 | if len(self.calendar.events) < 1: 16 | self.text('No current events', font_size=25) 17 | return 18 | 19 | current_line = 0 20 | for event in self.calendar.events: 21 | text = ' -- ' + self.calendar.humanized_datetime(event["start"]) + ' -- ' 22 | current_line += self.centered_text(text, y=(25 + current_line * 20), font_size=15) 23 | text = event["summary"].strip('\n') 24 | current_line += self.text(text, (5, 25 + current_line * 20), font_size=15) 25 | 26 | def handle_btn_press(self, button_number=1): 27 | if button_number == 0: 28 | pass 29 | elif button_number == 1: 30 | self.reload() 31 | self.show() 32 | elif button_number == 2: 33 | update_calendar() 34 | self.reload() 35 | self.show() 36 | elif button_number == 3: 37 | pass 38 | else: 39 | logging.error("Unknown button pressed: KEY{}".format(button_number + 1)) 40 | -------------------------------------------------------------------------------- /screens/webview.py: -------------------------------------------------------------------------------- 1 | from PIL import Image 2 | from htmlwebshot import WebShot 3 | 4 | from screens import AbstractScreen 5 | 6 | try: 7 | from local_settings import WEBVIEW_URL 8 | except ImportError: 9 | WEBVIEW_URL = "http://tsbarnes.com/" 10 | 11 | 12 | class Screen(AbstractScreen): 13 | """ 14 | This class provides the screen methods needed by epdtext 15 | """ 16 | 17 | # Add an instance of our Example class 18 | webshot: WebShot = WebShot() 19 | 20 | def handle_btn_press(self, button_number: int = 1): 21 | """ 22 | This method receives the button presses 23 | """ 24 | 25 | # Buttons 0 and 3 are used to switch screens 26 | if button_number == 1: 27 | pass 28 | elif button_number == 2: 29 | pass 30 | 31 | def reload(self): 32 | """ 33 | This method should draw the contents of the screen to self.image 34 | """ 35 | size = self.display.get_size() 36 | self.image = Image.open(self.webshot.create_pic(url=WEBVIEW_URL, size=(size[1], size[0]))) 37 | 38 | def iterate_loop(self): 39 | """ 40 | This method is optional, and will be run once per cycle 41 | """ 42 | # Do whatever you need to do, but try to make sure it doesn't take too long 43 | 44 | # This line is very important, it keeps the auto reload working 45 | super().iterate_loop() 46 | -------------------------------------------------------------------------------- /screens/weather.py: -------------------------------------------------------------------------------- 1 | import logging 2 | 3 | from libs.weather import Weather, get_weather, update_weather 4 | from screens import AbstractScreen 5 | 6 | 7 | class Screen(AbstractScreen): 8 | weather: Weather = get_weather() 9 | 10 | def handle_btn_press(self, button_number: int = 1): 11 | if button_number == 0: 12 | pass 13 | elif button_number == 1: 14 | self.reload() 15 | self.show() 16 | elif button_number == 2: 17 | update_weather() 18 | self.reload() 19 | self.show() 20 | elif button_number == 3: 21 | pass 22 | else: 23 | logging.error("Unknown button pressed: KEY{}".format(button_number + 1)) 24 | 25 | def reload(self): 26 | self.blank() 27 | 28 | self.draw_titlebar("Weather") 29 | 30 | logo = self.weather.get_icon() 31 | self.image.paste(logo, (30, 60)) 32 | 33 | if self.weather.weather: 34 | text = str(self.weather.get_temperature()) + '°' 35 | self.centered_text(text, 40, 60) 36 | 37 | text = str(self.weather.get_sky_text()) 38 | self.centered_text(text, 105, 30) 39 | 40 | text = str(self.weather.get_location_name()) 41 | self.centered_text(text, 140, 20) 42 | 43 | logging.debug("Sky Code: " + str(self.weather.get_sky_code())) 44 | else: 45 | self.centered_text("No data", 105, 30) 46 | -------------------------------------------------------------------------------- /screens/system.py: -------------------------------------------------------------------------------- 1 | import logging 2 | import os 3 | 4 | import humanize 5 | from PIL import Image 6 | 7 | import settings 8 | from libs import system 9 | from screens import AbstractScreen 10 | from settings import LOGO 11 | 12 | 13 | class Screen(AbstractScreen): 14 | system = system.get_system() 15 | 16 | def reload(self): 17 | self.blank() 18 | self.draw_titlebar("System") 19 | 20 | if LOGO: 21 | logo = Image.open(LOGO) 22 | else: 23 | logo = Image.open(self.system.icon) 24 | self.image.paste(logo, (100, 25)) 25 | 26 | string = self.system.model + '\n' 27 | self.text(string, font_size=14, font_name=settings.BOLD_FONT, position=(5, 75), wrap=False) 28 | 29 | string = '' 30 | 31 | string += 'OS: ' + self.system.dist + '\n' 32 | 33 | string += 'Local IP: ' + self.system.local_ipv4_address + '\n' 34 | string += 'Node: ' + self.system.node + '\n' 35 | 36 | string += 'CPU Temp: ' + str(round(self.system.temperature)) + '°\n' 37 | string += 'Uptime: ' + humanize.naturaldelta(self.system.uptime) 38 | 39 | self.text(string, font_size=14, font_name=settings.MONOSPACE_FONT, position=(5, 90), wrap=False) 40 | 41 | def handle_btn_press(self, button_number=1): 42 | if button_number == 1: 43 | self.reload() 44 | self.show() 45 | elif button_number == 2: 46 | logging.info("Rebooting...") 47 | self.blank() 48 | self.text("Rebooting...", font_size=30) 49 | self.show() 50 | os.system("sudo systemctl reboot") 51 | -------------------------------------------------------------------------------- /local_settings.py.example: -------------------------------------------------------------------------------- 1 | import python_weather 2 | 3 | # Set to the appropriate driver for your Waveshare e-paper display 4 | DRIVER = "epd2in7b" 5 | 6 | # Set to True to enable debug messages 7 | DEBUG = False 8 | 9 | # ** Leave commented to log to console ** 10 | # LOGFILE = '/home/pi/epd.log' 11 | 12 | # Path to the logo to print on the `uptime` screen 13 | # ** Must be a black and white 45x45 image file ** 14 | LOGO = '/home/pi/epdtext/logo.png' 15 | 16 | # Monospace fonts are recommended, as the text wrapping is based on character length 17 | FONT = '/usr/share/fonts/truetype/dejavu/DejaVuSansMono.ttf' 18 | 19 | # How long to wait (in seconds) before automatically redrawing the screen 20 | TIME = 900 21 | 22 | # Note: you *must* define at least one calendar to use the 'calendar' screen, 23 | # and at least one caldav calendar to use the 'tasks' screen. 24 | CALENDAR_URLS = [ 25 | { type: 'webcal', url: '' }, 26 | { type: 'caldav', url: '', username: '', password: '', 46 | ] 47 | 48 | # City to display weather for 49 | WEATHER_CITY = "Ladysmith, VA" 50 | # Format for weather output 51 | WEATHER_FORMAT = python_weather.IMPERIAL 52 | # Refresh interval in seconds for o 53 | WEATHER_REFRESH = 900 54 | -------------------------------------------------------------------------------- /screens/example.py: -------------------------------------------------------------------------------- 1 | """Example screen to show how to make them""" 2 | from screens import AbstractScreen 3 | 4 | 5 | # Example of how to include a user setting 6 | try: 7 | from local_settings import HELLO 8 | except ImportError: 9 | HELLO = "Hello World!" 10 | 11 | 12 | class Example: 13 | """ 14 | Just another class, feel free to make it do whatever you want 15 | """ 16 | def foobar(self) -> str: 17 | """ 18 | This method just returns some text, yours can do anything you want 19 | :return: str 20 | """ 21 | return HELLO 22 | 23 | 24 | class Screen(AbstractScreen): 25 | """ 26 | This class provides the screen methods needed by epdtext 27 | """ 28 | 29 | # Add an instance of our Example class 30 | example: Example = Example() 31 | 32 | def handle_btn_press(self, button_number: int = 1): 33 | """ 34 | This method receives the button presses 35 | """ 36 | 37 | # Buttons 0 and 3 are used to switch screens 38 | if button_number == 1: 39 | pass 40 | elif button_number == 2: 41 | pass 42 | 43 | def reload(self): 44 | """ 45 | This method should draw the contents of the screen to self.image 46 | """ 47 | 48 | # self.blank() resets self.image to a blank image 49 | self.blank() 50 | # self.draw_titlebar(str) creates the small title at the top of the screen 51 | self.draw_titlebar("Example") 52 | 53 | # self.text(text) draws the text to self.image 54 | # Optional parameters include font, font_size, position, and color 55 | self.text(self.example.foobar(), font_size=40, position=(50, 50)) 56 | 57 | def iterate_loop(self): 58 | """ 59 | This method is optional, and will be run once per cycle 60 | """ 61 | # Do whatever you need to do, but try to make sure it doesn't take too long 62 | 63 | # This line is very important, it keeps the auto reload working 64 | super().iterate_loop() 65 | -------------------------------------------------------------------------------- /settings.py: -------------------------------------------------------------------------------- 1 | import python_weather 2 | import tzlocal 3 | 4 | try: 5 | from local_settings import DRIVER 6 | except ImportError: 7 | DRIVER = "epd2in7b" 8 | 9 | try: 10 | from local_settings import DEBUG 11 | except ImportError: 12 | DEBUG = False 13 | 14 | try: 15 | from local_settings import SAVE_SCREENSHOTS 16 | except ImportError: 17 | SAVE_SCREENSHOTS = False 18 | 19 | try: 20 | from local_settings import LOGFILE 21 | except ImportError: 22 | LOGFILE = None 23 | 24 | try: 25 | from local_settings import PAGE_BUTTONS 26 | except ImportError: 27 | PAGE_BUTTONS = True 28 | 29 | try: 30 | from local_settings import LOGO 31 | except ImportError: 32 | LOGO = None 33 | 34 | try: 35 | from local_settings import FONT 36 | except ImportError: 37 | FONT = '/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf' 38 | 39 | try: 40 | from local_settings import BOLD_FONT 41 | except ImportError: 42 | BOLD_FONT = '/usr/share/fonts/truetype/dejavu/DejaVuSans-Bold.ttf' 43 | 44 | try: 45 | from local_settings import MONOSPACE_FONT 46 | except ImportError: 47 | MONOSPACE_FONT = '/usr/share/fonts/truetype/dejavu/DejaVuSansMono.ttf' 48 | 49 | try: 50 | from local_settings import TIME 51 | except ImportError: 52 | TIME = 900 53 | 54 | try: 55 | from local_settings import CALENDAR_URLS 56 | except ImportError: 57 | CALENDAR_URLS = [] 58 | 59 | try: 60 | from local_settings import CALENDAR_REFRESH 61 | except ImportError: 62 | CALENDAR_REFRESH = 900 63 | 64 | try: 65 | from local_settings import TIMEZONE 66 | except ImportError: 67 | TIMEZONE = tzlocal.get_localzone().key 68 | 69 | try: 70 | from local_settings import SCREENS 71 | except ImportError: 72 | SCREENS = [ 73 | 'system', 74 | 'fortune', 75 | 'affirmations', 76 | ] 77 | 78 | try: 79 | from local_settings import AFFIRMATIONS 80 | except ImportError: 81 | AFFIRMATIONS = [ 82 | "You are enough", 83 | "You are loved", 84 | "You are safe", 85 | "Be yourself", 86 | "They can't hurt you anymore", 87 | "You are beautiful", 88 | "You are strong", 89 | "You have come a long way" 90 | ] 91 | 92 | try: 93 | from local_settings import WEATHER_CITY 94 | except ImportError: 95 | WEATHER_CITY = "Richmond, VA" 96 | 97 | try: 98 | from local_settings import WEATHER_FORMAT 99 | except ImportError: 100 | WEATHER_FORMAT = python_weather.IMPERIAL 101 | 102 | try: 103 | from local_settings import WEATHER_REFRESH 104 | except ImportError: 105 | WEATHER_REFRESH = 900 106 | -------------------------------------------------------------------------------- /screens/dashboard.py: -------------------------------------------------------------------------------- 1 | import logging 2 | import threading 3 | 4 | from libs.calendar import Calendar, get_calendar, update_calendar 5 | from libs.weather import Weather, get_weather, update_weather 6 | from screens import AbstractScreen 7 | 8 | 9 | class Screen(AbstractScreen): 10 | calendar: Calendar = get_calendar() 11 | weather: Weather = get_weather() 12 | 13 | def reload(self): 14 | self.blank() 15 | self.draw_titlebar("Dashboard") 16 | 17 | logo = self.weather.get_icon() 18 | self.image.paste(logo, (15, 30)) 19 | 20 | text = str(self.weather.get_temperature()) + '°' 21 | self.text(text, font_size=35, position=(60, 25)) 22 | 23 | text = str(self.weather.get_sky_text()) 24 | self.text(text, font_size=14, position=(150, 35)) 25 | 26 | self.line((0, 70, self.image.size[0], 70), width=1) 27 | 28 | if len(self.calendar.events) > 0: 29 | start = self.calendar.standardize_date(self.calendar.events[0]["start"]) 30 | text = ' -- ' + self.calendar.humanized_datetime(start) + ' -- ' 31 | self.centered_text(text, font_size=16, y=75) 32 | 33 | text = str(self.calendar.events[0]['summary']) 34 | self.text(text, font_size=14, position=(5, 95), max_lines=2) 35 | else: 36 | text = "No calendar events" 37 | self.centered_text(text, font_size=14, y=85) 38 | 39 | self.line((0, 130, self.image.size[0], 130), width=1) 40 | 41 | if len(self.calendar.tasks) > 0: 42 | text = str(self.calendar.tasks[0]['summary']) 43 | self.text(text, font_size=14, position=(5, 135), max_lines=1) 44 | 45 | if self.calendar.tasks[0].get('due'): 46 | text = ' - Due: ' + self.calendar.humanized_datetime(self.calendar.tasks[0]['due']) 47 | self.text(text, font_size=14, position=(5, 150), max_lines=1) 48 | else: 49 | text = "No current tasks" 50 | self.centered_text(text, font_size=14, y=145) 51 | 52 | def handle_btn_press(self, button_number=1): 53 | thread_lock = threading.Lock() 54 | thread_lock.acquire() 55 | if button_number == 0: 56 | pass 57 | elif button_number == 1: 58 | self.blank() 59 | self.text("Please wait...", font_size=40) 60 | self.show() 61 | update_calendar() 62 | update_weather() 63 | self.reload() 64 | self.show() 65 | elif button_number == 2: 66 | pass 67 | elif button_number == 3: 68 | pass 69 | else: 70 | logging.error("Unknown button pressed: KEY{}".format(button_number + 1)) 71 | thread_lock.release() 72 | -------------------------------------------------------------------------------- /libs/system.py: -------------------------------------------------------------------------------- 1 | import datetime 2 | import logging 3 | import platform 4 | import time 5 | 6 | import distro 7 | import psutil 8 | 9 | try: 10 | # Try to load the network interface setting from local_settings.py 11 | from local_settings import NETWORK_INTERFACE 12 | except ImportError: 13 | # Set the default to wlan0 14 | NETWORK_INTERFACE = "wlan0" 15 | 16 | 17 | logger = logging.getLogger('pitftmanager.libs.system') 18 | 19 | 20 | class System: 21 | """ 22 | This class provides access to system information 23 | """ 24 | 25 | @staticmethod 26 | def get_size(data, suffix="B"): 27 | """ 28 | Scale bytes to its proper format 29 | e.g: 30 | 1253656 => '1.20MB' 31 | 1253656678 => '1.17GB' 32 | :param data: the size in bytes 33 | :param suffix: which suffix to use as a single letter 34 | :return the size converted to the proper suffix 35 | """ 36 | factor = 1024 37 | for unit in ["", "K", "M", "G", "T", "P"]: 38 | if data < factor: 39 | return f"{data:.2f}{unit}{suffix}" 40 | data /= factor 41 | 42 | @property 43 | def temperature(self): 44 | return round(psutil.sensors_temperatures()['cpu_thermal'][0].current) 45 | 46 | @property 47 | def model(self): 48 | with open('/sys/firmware/devicetree/base/model', 'r') as model_file: 49 | return model_file.read() 50 | 51 | @property 52 | def system(self): 53 | return platform.system() 54 | 55 | @property 56 | def dist(self): 57 | return "{0} {1}".format(distro.name(), distro.version()) 58 | 59 | @property 60 | def machine(self): 61 | return platform.machine() 62 | 63 | @property 64 | def node(self): 65 | return platform.node() 66 | 67 | @property 68 | def arch(self): 69 | return platform.architecture()[0] 70 | 71 | @property 72 | def uptime(self): 73 | return datetime.timedelta(seconds=time.clock_gettime(time.CLOCK_BOOTTIME)) 74 | 75 | @property 76 | def network_total_sent(self): 77 | net_io = psutil.net_io_counters() 78 | return self.get_size(net_io.bytes_sent) 79 | 80 | @property 81 | def network_total_received(self): 82 | net_io = psutil.net_io_counters() 83 | return self.get_size(net_io.bytes_recv) 84 | 85 | @property 86 | def local_ipv4_address(self): 87 | for interface_name, interface_addresses in psutil.net_if_addrs().items(): 88 | for address in interface_addresses: 89 | if interface_name == NETWORK_INTERFACE: 90 | if str(address.family) == 'AddressFamily.AF_INET': 91 | return address.address 92 | return None 93 | 94 | @property 95 | def icon(self): 96 | if distro.id() == 'archarm': 97 | return "images/arch.png" 98 | if distro.id() == "manjaro-arm": 99 | return "images/manjaro.png" 100 | return "images/raspberry-pi.png" 101 | 102 | 103 | system = System() 104 | 105 | 106 | def get_system(): 107 | return system 108 | 109 | 110 | if __name__ == '__main__': 111 | logging.basicConfig(level=logging.DEBUG) 112 | logging.info("Local IPv4 address: {}".format(system.local_ipv4_address)) 113 | -------------------------------------------------------------------------------- /libs/epd.py: -------------------------------------------------------------------------------- 1 | import importlib 2 | import time 3 | import logging 4 | import threading 5 | 6 | from PIL import Image 7 | from gpiozero import Button 8 | 9 | from settings import DRIVER 10 | 11 | try: 12 | driver = importlib.import_module("waveshare_epd." + DRIVER) 13 | except ImportError: 14 | logging.error("Driver '{0}' couldn't be loaded".format(DRIVER)) 15 | raise ImportError("Couldn't load driver") 16 | 17 | logger = logging.getLogger("epdtext.libs.epd") 18 | 19 | 20 | class EPD(threading.Thread): 21 | """ 22 | This class provides threaded access to the e-paper display 23 | """ 24 | epd: driver.EPD = driver.EPD() 25 | dirty: bool = False 26 | image: Image = Image.new("1", (driver.EPD_HEIGHT, driver.EPD_WIDTH), 255) 27 | thread_lock = threading.Lock() 28 | shutdown = threading.Event() 29 | 30 | def __init__(self): 31 | """ 32 | Initialize the display and image buffer 33 | """ 34 | super().__init__() 35 | self.image = Image.new("1", self.get_size(), 255) 36 | self.epd.init() # initialize the display 37 | self.buttons = [Button(5), Button(6), Button(13), Button(19)] 38 | self.name = "EPD" 39 | 40 | def run(self): 41 | """ 42 | Creates and starts the thread process, don't call this directly. 43 | Instead use EPD.start(), which will call this method on it's own. 44 | """ 45 | self.epd.Clear() 46 | thread_process = threading.Thread(target=self.process_epd) 47 | # run thread as a daemon so it gets cleaned up on exit. 48 | thread_process.daemon = True 49 | thread_process.start() 50 | self.shutdown.wait() 51 | 52 | def process_epd(self): 53 | """ 54 | Main display loop, handled in a separate thread 55 | """ 56 | while not self.shutdown.is_set(): 57 | time.sleep(1) 58 | if self.dirty and self.image: 59 | self.thread_lock.acquire() 60 | self.dirty = False 61 | logger.debug("Writing image to display") 62 | red_image = Image.new("1", get_size(), 255) 63 | self.epd.display(self.epd.getbuffer(self.image), self.epd.getbuffer(red_image)) 64 | self.thread_lock.release() 65 | 66 | def stop(self): 67 | """ 68 | Stop the display thread 69 | """ 70 | self.shutdown.set() 71 | 72 | def clear(self): 73 | image = Image.new("1", get_size(), 255) 74 | self.show(image) 75 | 76 | def show(self, image: Image): 77 | """ 78 | Draws an image to the image buffer 79 | :param image: image to be displayed 80 | """ 81 | logger.debug("Image sent to EPD") 82 | self.image = image 83 | self.dirty = True 84 | 85 | def get_size(self): 86 | """ 87 | Get EPD size 88 | :return: tuple of height and width 89 | """ 90 | return driver.EPD_HEIGHT, driver.EPD_WIDTH 91 | 92 | 93 | epd = EPD() 94 | 95 | 96 | def get_epd(): 97 | """ 98 | Get the EPD instance 99 | :return: EPD instance 100 | """ 101 | return epd 102 | 103 | 104 | def get_size(): 105 | """ 106 | Get the EPD size 107 | :return: tuple of height and width 108 | """ 109 | return driver.EPD_HEIGHT, driver.EPD_WIDTH 110 | 111 | 112 | def get_buttons(): 113 | """ 114 | Gets the buttons from the EPD 115 | :return: list of buttons 116 | """ 117 | return epd.buttons 118 | -------------------------------------------------------------------------------- /libs/weather.py: -------------------------------------------------------------------------------- 1 | import threading 2 | import time 3 | import python_weather 4 | import asyncio 5 | import xml 6 | import logging 7 | from PIL import Image 8 | 9 | 10 | try: 11 | from local_settings import WEATHER_FORMAT 12 | except ImportError: 13 | WEATHER_FORMAT = python_weather.IMPERIAL 14 | 15 | try: 16 | from local_settings import WEATHER_CITY 17 | except ImportError: 18 | WEATHER_CITY = "Richmond, VA" 19 | 20 | try: 21 | from local_settings import WEATHER_REFRESH 22 | except ImportError: 23 | WEATHER_REFRESH = 900 24 | 25 | 26 | logger = logging.getLogger("pitftmanager.libs.weather") 27 | 28 | 29 | class Weather(threading.Thread): 30 | """ 31 | This class provides access to the weather info 32 | """ 33 | weather = None 34 | refresh_interval: int = WEATHER_REFRESH 35 | loop = asyncio.get_event_loop() 36 | thread_lock = threading.Lock() 37 | 38 | def __init__(self): 39 | super().__init__() 40 | self.name = "Weather" 41 | self.shutdown = threading.Event() 42 | 43 | def run(self) -> None: 44 | thread_process = threading.Thread(target=self.weather_loop) 45 | # run thread as a daemon so it gets cleaned up on exit. 46 | thread_process.daemon = True 47 | thread_process.start() 48 | self.shutdown.wait() 49 | 50 | def weather_loop(self): 51 | while not self.shutdown.is_set(): 52 | self.refresh_interval -= 1 53 | time.sleep(1) 54 | if self.refresh_interval < 1: 55 | try: 56 | self.loop.run_until_complete(self.update()) 57 | except xml.parsers.expat.ExpatError as error: 58 | logger.warning(error) 59 | self.refresh_interval = WEATHER_REFRESH 60 | 61 | def stop(self): 62 | self.shutdown.set() 63 | 64 | async def update(self): 65 | """ 66 | Update the weather info 67 | :return: None 68 | """ 69 | self.thread_lock.acquire() 70 | client = python_weather.Client(format=WEATHER_FORMAT) 71 | self.weather = await client.find(WEATHER_CITY) 72 | await client.close() 73 | self.thread_lock.release() 74 | 75 | def get_temperature(self): 76 | """ 77 | Get the temperature 78 | :return: String of the temperature 79 | """ 80 | if not self.weather: 81 | return "--" 82 | 83 | return self.weather.current.temperature 84 | 85 | def get_sky_code(self): 86 | """ 87 | Get the sky code 88 | :return: String of the sky code 89 | """ 90 | if not self.weather: 91 | return 0 92 | 93 | return self.weather.current.sky_code 94 | 95 | def get_sky_text(self): 96 | """ 97 | Get the sky text 98 | :return: String of the sky text 99 | """ 100 | if not self.weather: 101 | return "--" 102 | 103 | return self.weather.current.sky_text 104 | 105 | def get_location_name(self): 106 | """ 107 | Get the location name 108 | :return: String of the location name 109 | """ 110 | if not self.weather: 111 | return "--" 112 | 113 | return self.weather.location_name 114 | 115 | def get_icon(self): 116 | """ 117 | Get the icon for the current weather 118 | :return: Image of the icon 119 | """ 120 | if not self.weather: 121 | return Image.open("images/sun.png") 122 | 123 | if self.weather.current.sky_code == 0: 124 | image = Image.open("images/sun.png") 125 | return image.resize((32, 32)) 126 | elif self.weather.current.sky_code == 26: 127 | image = Image.open("images/cloud.png") 128 | return image.resize((32, 32)) 129 | elif self.weather.current.sky_code == 28: 130 | image = Image.open("images/cloud.png") 131 | return image.resize((32, 32)) 132 | elif self.weather.current.sky_code == 30: 133 | image = Image.open("images/cloud_sun.png") 134 | return image.resize((32, 32)) 135 | elif self.weather.current.sky_code == 32: 136 | image = Image.open("images/sun.png") 137 | return image.resize((32, 32)) 138 | else: 139 | logger.warning("Unable to find icon for sky code: {}".format(self.weather.current.sky_code)) 140 | image = Image.open("images/sun.png") 141 | return image.resize((32, 32)) 142 | 143 | 144 | weather: Weather = Weather() 145 | 146 | 147 | def get_weather(): 148 | """ 149 | Get the main weather object 150 | :return: Weather 151 | """ 152 | return weather 153 | 154 | 155 | def update_weather(): 156 | """ 157 | Update the weather info 158 | :return: None 159 | """ 160 | loop = asyncio.get_event_loop() 161 | loop.run_until_complete(weather.update()) 162 | 163 | 164 | if __name__ == '__main__': 165 | logging.basicConfig(level=logging.DEBUG) 166 | update_weather() 167 | logger.info(weather.weather.current.sky_text) 168 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # epdtext 2 | 3 | A simple display manager app for the [WaveShare 2.7in e-Paper HAT](https://www.waveshare.com/2.7inch-e-paper-hat.htm) 4 | 5 | ![Picture](/screenshots/picture.jpg) 6 | 7 | ## Screens 8 | 9 | The app provides a number of screens that can be displayed on the e-paper HAT, and allows switching between them with the builtin buttons. 10 | 11 | The included screens are: 12 | 13 | * `dashboard` - a dashboard widget showing the current weather, next calendar event, and next task 14 | 15 | ![Screenshot](/screenshots/dashboard.png) 16 | 17 | * `uptime` - a system info viewer 18 | 19 | ![Screenshot](/screenshots/system.png) 20 | 21 | * `affirmations` - display positive affirmations (or whatever kind you want, really) 22 | 23 | ![Screenshot](/screenshots/affirmations.png) 24 | 25 | * `fortune` - shows a random fortune from the fortune database (requires the `fortune-mod` package) 26 | * Install `fortune-mod` with this command: `sudo apt install fortune-mod` 27 | 28 | ![Screenshot](/screenshots/fortune.png) 29 | * `calendar` and `tasks` - shows a list of upcoming events or todos from your calendars (see `local_settings.py.example`) 30 | 31 | ![Screenshot](/screenshots/calendar.png) 32 | ![Screenshot](/screenshots/tasks.png) 33 | 34 | * `weather` - shows the current weather 35 | 36 | ## Making your own 37 | 38 | The framework is extensible, so you can write your own screens as well, each screen is a Python module providing a `Screen` class that inherits from `AbstractScreen`. 39 | 40 | For more information on how to create your own screens, check the wiki. 41 | 42 | ## Message queue 43 | 44 | There's also a message queue interface to control the screen from other apps. (example command line client available in `cli.py`) 45 | 46 | ## Setup on Raspberry Pi OS 47 | 48 | * First, enable the SPI inferface on the Pi if you haven't already. 49 | * Then, install the Python requirements 50 | 51 | ```shell 52 | sudo apt install python3-pip python3-pil python3-numpy python3-gpiozero 53 | ``` 54 | 55 | * Then install the drivers for Python 56 | 57 | ```shell 58 | git clone https://github.com/waveshare/e-Paper ~/e-Paper 59 | cd ~/e-Paper/RaspberryPi_JetsonNano/python 60 | python3 setup.py install 61 | ``` 62 | 63 | * Check out the code if you haven't already: 64 | 65 | ```shell 66 | git clone https://github.com/tsbarnes/epdtext.git ~/epdtext 67 | ``` 68 | 69 | * Install the remaining Python dependencies 70 | ```shell 71 | cd ~/epdtext 72 | sudo pip3 install -r requirements.txt 73 | ``` 74 | 75 | * Then (optionally) create local_settings.py and add your settings overrides there. 76 | * You can copy `local_settings.py.example` to `local_settings.py` and edit it to configure `epdtext` 77 | * **NOTE**: if you're using a different Waveshare screen, you can use the `DRIVER` setting to configure it 78 | * See the wiki for more configuration help 79 | 80 | * Also optional is installing the systemd unit. 81 | 82 | ```shell 83 | cp ~/epdtext/epdtext.service /etc/systemd/system 84 | sudo systemctl enable epdtext 85 | ``` 86 | 87 | ## Setup on Arch Linux ARM 88 | 89 | * First, enable the SPI inferface on the Pi if you haven't already. 90 | * Then, install the Python requirements 91 | 92 | ```shell 93 | sudo pacman -S python-pip python-pillow python-numpy python-gpiozero 94 | ``` 95 | 96 | * Then install the drivers for Python 97 | 98 | ```shell 99 | git clone https://github.com/waveshare/e-Paper ~/e-Paper 100 | cd ~/e-Paper/RaspberryPi_JetsonNano/python 101 | python3 setup.py install 102 | ``` 103 | 104 | * Check out the code if you haven't already: 105 | 106 | ```shell 107 | git clone https://github.com/tsbarnes/epdtext.git ~/epdtext 108 | ``` 109 | 110 | * Install the remaining Python dependencies 111 | ```shell 112 | cd ~/epdtext 113 | sudo pip install -r requirements.txt 114 | ``` 115 | 116 | * Then (optionally) create local_settings.py and add your settings overrides there. 117 | * You can copy `local_settings.py.example` to `local_settings.py` and edit it to configure `epdtext` 118 | * **NOTE**: if you're using a different Waveshare screen, you can use the `DRIVER` setting to configure it 119 | * If you don't set the `LOGO` setting, it defaults to the Arch logo on Arch Linux ARM 120 | * See the wiki for more configuration help 121 | 122 | * Also optional is installing the systemd unit. 123 | 124 | ```shell 125 | cp ~/epdtext/epdtext.service /etc/systemd/system 126 | ``` 127 | 128 | You'll need to edit the `/etc/systemd/system/epdtext.service` file and change `/home/pi` to `/home/alarm` 129 | (or the home directory of the user you checked it out as) and change the User line to root. 130 | 131 | Also of note, on Arch Linux ARM, epdtext must be run as root. 132 | 133 | ## Usage 134 | 135 | To start up the app without `systemd`, run this command: 136 | ```shell 137 | cd ~/epdtext 138 | python3 app.py 139 | ``` 140 | 141 | To start the app with ´systemd´, run this: 142 | ```shell 143 | sudo systemctl start epdtext 144 | ``` 145 | 146 | To reload using the CLI client: 147 | ```shell 148 | cd ~/epdtext 149 | ./cli.py reload 150 | ``` 151 | 152 | To switch to the uptime screen with the CLI: 153 | ```shell 154 | cd ~/epdtext 155 | ./cli.py screen uptime 156 | ``` 157 | 158 | ## epdtext-web 159 | 160 | There's now a web frontend to epdtext! Check out [epdtext-web](https://github.com/tsbarnes/epdtext-web) 161 | -------------------------------------------------------------------------------- /screens/__init__.py: -------------------------------------------------------------------------------- 1 | import inspect 2 | import logging 3 | import os 4 | import pathlib 5 | import textwrap 6 | import uuid 7 | from collections.abc import Generator 8 | from string import ascii_letters 9 | 10 | from PIL import Image, ImageDraw, ImageFont 11 | 12 | import settings 13 | from libs.epd import EPD, get_epd, get_size 14 | 15 | 16 | class AbstractScreen: 17 | """ 18 | Abstract screen class, screens should inherit from this 19 | """ 20 | display: EPD = get_epd() 21 | image: Image = None 22 | filename: str = None 23 | reload_interval: int = 60 24 | reload_wait: int = 0 25 | show_now: bool = False 26 | 27 | def __init__(self): 28 | """ 29 | This method creates the image for the screen and sets up the class 30 | """ 31 | self.filename = "/tmp/{0}_{1}.png".format(self.__module__, str(uuid.uuid4())) 32 | 33 | def blank(self) -> None: 34 | """ 35 | This method clears the image by recreating it 36 | """ 37 | self.image = Image.new("1", get_size(), 255) 38 | 39 | def draw_titlebar(self, title: str) -> None: 40 | """ 41 | This method draws a titlebar if desired 42 | :param title: 43 | """ 44 | self.centered_text(title, font_size=15, y=0, font_name=settings.BOLD_FONT) 45 | self.line((0, 20, self.display.get_size()[0], 20), width=1) 46 | 47 | def show(self) -> None: 48 | """ 49 | This method copies the image to the display 50 | """ 51 | if not self.image: 52 | logging.error("show() called with no image defined!") 53 | return 54 | 55 | if settings.SAVE_SCREENSHOTS: 56 | self.image.save(self.filename) 57 | 58 | self.display.show(self.image) 59 | 60 | def reload(self) -> None: 61 | """ 62 | This method redraws the contents of the image 63 | """ 64 | raise NotImplementedError() 65 | 66 | def handle_btn_press(self, button_number=1) -> None: 67 | """ 68 | This method handles the button presses. 69 | Buttons 0 and 3 are generally used to switch screens, while buttons 1 and 2 are passed 70 | to this method. If there's only one screen, or if you set the "NO_WRAP_TEXT" setting to True 71 | :param button_number: default is 1 72 | """ 73 | raise NotImplementedError() 74 | 75 | def iterate_loop(self) -> None: 76 | """ 77 | Called once per cycle (roughly every one second). If you need to do something in the main loop, 78 | do it here. If you override this, call super().iterate_loop() 79 | :return: None 80 | """ 81 | if not self.image: 82 | self.reload() 83 | self.reload_wait += 1 84 | if self.reload_wait >= self.reload_interval: 85 | self.reload_wait = 0 86 | self.reload() 87 | 88 | def paste(self, image: Image, position: tuple = (0, 0)) -> None: 89 | """ 90 | Paste an image onto the buffer 91 | :param image: Image to paste 92 | :param position: tuple position to paste at 93 | :return: None 94 | """ 95 | self.image.paste(image, position) 96 | 97 | def line(self, position: tuple, fill: any = "black", width: int = 5) -> None: 98 | """ 99 | Draw a line onto the buffer 100 | :param position: tuple position to draw line 101 | :param fill: color to fill line with 102 | :param width: width of line 103 | :return: None 104 | """ 105 | draw = ImageDraw.Draw(self.image) 106 | draw.line(position, fill, width) 107 | 108 | def text(self, text, position=(5, 5), font_name=None, font_size=20, 109 | color="black", wrap=True, max_lines=None) -> int: 110 | """ 111 | Draws text onto the app's image 112 | :param text: string to draw 113 | :param position: tuple representing where to draw the text 114 | :param font_name: filename of font to use, None for default 115 | :param font_size: integer font size to draw 116 | :param color: color of the text 117 | :param wrap: boolean whether to wrap the text 118 | :param max_lines: number of lines to draw maximum 119 | :return: integer number of lines drawn 120 | """ 121 | if not font_name: 122 | font_name = settings.FONT 123 | if not self.image: 124 | raise ValueError("self.image is None") 125 | 126 | font: ImageFont = ImageFont.truetype(font_name, font_size) 127 | draw: ImageDraw = ImageDraw.Draw(self.image) 128 | number_of_lines: int = 0 129 | scaled_wrapped_text: str = '' 130 | 131 | if wrap: 132 | avg_char_width: int = sum(font.getsize(char)[0] for char in ascii_letters) / len(ascii_letters) 133 | max_char_count: int = int((self.image.size[0] * .95) / avg_char_width) 134 | 135 | for line in str(text).split('\n'): 136 | new_wrapped_text = textwrap.fill(text=line, width=max_char_count) 137 | for wrapped_line in new_wrapped_text.split('\n'): 138 | if not max_lines or number_of_lines < max_lines: 139 | number_of_lines += 1 140 | scaled_wrapped_text += wrapped_line + '\n' 141 | else: 142 | for line in str(text).split('\n'): 143 | if not max_lines or number_of_lines < max_lines: 144 | number_of_lines += 1 145 | scaled_wrapped_text += line + '\n' 146 | 147 | draw.text(position, scaled_wrapped_text, font=font, fill=color) 148 | 149 | return number_of_lines 150 | 151 | def centered_text(self, text: str, y: int, font_size: int = 20, font_name: str = settings.FONT) -> int: 152 | """ 153 | Draws text centered horizontally 154 | :param text: str text to be displayed 155 | :param y: vertical starting position 156 | :param font_size: size of font 157 | :param font_name: name of font 158 | :return: None 159 | """ 160 | font = ImageFont.truetype(font_name, font_size) 161 | avg_char_width: int = sum(font.getsize(char)[0] for char in ascii_letters) / len(ascii_letters) 162 | number_of_lines = 0 163 | for line in text.split('\n'): 164 | centered_position = (self.image.size[0] / 2) - (avg_char_width * len(line) / 2) 165 | position = (centered_position, y + (number_of_lines * font_size)) 166 | self.text(text, font_size=font_size, font_name=font_name, position=position, wrap=False) 167 | number_of_lines += 1 168 | 169 | return number_of_lines 170 | 171 | 172 | def get_screens() -> list: 173 | """ 174 | Gets the full list of screens available in the screens/ directory 175 | :return: list 176 | """ 177 | screens: list = [] 178 | 179 | path: str = os.path.dirname(os.path.abspath(inspect.getfile(inspect.currentframe()))) 180 | screen_directory: Generator[pathlib.Path, None, None] = pathlib.Path(path).rglob("*.py") 181 | 182 | for file in screen_directory: 183 | if file.name == "__init__.py": 184 | continue 185 | module_name = file.name.split(".")[0] 186 | logging.debug("Found '{0}' in '{1}'".format(module_name, path)) 187 | screens.append(module_name) 188 | 189 | return screens 190 | 191 | 192 | # When run as main, this module gets the available screens and exits 193 | if __name__ == '__main__': 194 | logging.basicConfig(level=logging.DEBUG) 195 | get_screens() 196 | -------------------------------------------------------------------------------- /app.py: -------------------------------------------------------------------------------- 1 | import asyncio 2 | import importlib 3 | import logging 4 | import signal 5 | import time 6 | import sys 7 | import os 8 | 9 | import posix_ipc 10 | 11 | import settings 12 | from libs import epd 13 | from libs.calendar import Calendar, get_calendar 14 | from libs.epd import EPD, get_epd 15 | from libs.weather import Weather, get_weather 16 | from settings import TIME, SCREENS, DEBUG, LOGFILE 17 | 18 | 19 | class App: 20 | logger = logging.getLogger("epdtext.app") 21 | current_screen_index: int = 0 22 | screen_modules: list = [] 23 | screens: list = [] 24 | calendar: Calendar = get_calendar() 25 | weather: Weather = get_weather() 26 | epd: EPD = get_epd() 27 | async_loop: asyncio.AbstractEventLoop = asyncio.new_event_loop() 28 | loop_time: int = 0 29 | 30 | def current_screen(self): 31 | return self.screens[self.current_screen_index] 32 | 33 | def current_screen_module(self): 34 | return self.screen_modules[self.current_screen_index] 35 | 36 | def previous_screen(self): 37 | if self.current_screen_index > 0: 38 | self.current_screen_index -= 1 39 | else: 40 | self.current_screen_index = len(self.screens) - 1 41 | self.logger.debug("Current screen: {0}".format(self.current_screen().__module__)) 42 | self.current_screen().reload() 43 | self.current_screen().show() 44 | 45 | def next_screen(self): 46 | self.current_screen_index += 1 47 | if self.current_screen_index >= len(self.screens): 48 | self.current_screen_index = 0 49 | self.logger.debug("Current screen: {0}".format(self.current_screen().__module__)) 50 | self.current_screen().reload() 51 | self.current_screen().show() 52 | 53 | def handle_btn0_press(self): 54 | if settings.PAGE_BUTTONS: 55 | self.previous_screen() 56 | else: 57 | self.logger.debug("Screen '{0}' handling button 0".format(self.current_screen().__module__)) 58 | self.current_screen().handle_btn_press(button_number=0) 59 | 60 | def handle_btn1_press(self): 61 | self.logger.debug("Screen '{0}' handling button 1".format(self.current_screen().__module__)) 62 | self.current_screen().handle_btn_press(button_number=1) 63 | 64 | def handle_btn2_press(self): 65 | self.logger.debug("Screen '{0}' handling button 2".format(self.current_screen().__module__)) 66 | self.current_screen().handle_btn_press(button_number=2) 67 | 68 | def handle_btn3_press(self): 69 | if settings.PAGE_BUTTONS: 70 | self.next_screen() 71 | else: 72 | self.logger.debug("Screen '{0}' handling button 3".format(self.current_screen().__module__)) 73 | self.current_screen().handle_btn_press(button_number=3) 74 | 75 | def add_screen(self, screen_name): 76 | try: 77 | screen_module = importlib.import_module("screens." + screen_name) 78 | except ImportError as error: 79 | self.logger.error(error) 80 | try: 81 | screen_module = importlib.import_module(screen_name) 82 | except ImportError: 83 | screen_module = None 84 | if screen_module: 85 | try: 86 | new_screen = screen_module.Screen() 87 | self.screens.append(new_screen) 88 | self.screen_modules.append(screen_module) 89 | except AttributeError: 90 | self.logger.error("Screen '{0}' has no Screen class".format(screen_name)) 91 | else: 92 | self.logger.error("Failed to load app: {}".format(screen_name)) 93 | 94 | def find_screen_index_by_name(self, screen_name): 95 | for index in range(0, len(self.screens)): 96 | name = self.screens[index].__module__ 97 | if name == screen_name or name.split('.')[-1] == screen_name: 98 | return index 99 | self.logger.error("Screen '{0}' doesn't exist".format(screen_name)) 100 | return -1 101 | 102 | def get_screen_by_name(self, screen_name): 103 | index = self.find_screen_index_by_name(screen_name) 104 | if index >= 0: 105 | return self.screens[index] 106 | else: 107 | self.logger.error("Screen '{0}' not found".format(screen_name)) 108 | return None 109 | 110 | def get_screen_module_by_name(self, screen_name): 111 | index = self.find_screen_index_by_name(screen_name) 112 | if index >= 0: 113 | return self.screen_modules[index] 114 | else: 115 | self.logger.error("Screen '{0}' not found".format(screen_name)) 116 | return None 117 | 118 | def __init__(self): 119 | if DEBUG: 120 | logging.basicConfig(level=logging.DEBUG, filename=LOGFILE) 121 | self.logger.info("Debug messages enabled") 122 | else: 123 | logging.basicConfig(filename=LOGFILE) 124 | 125 | self.logger.info("Starting epdtext...") 126 | self.logger.info("Timezone selected: {}".format(settings.TIMEZONE)) 127 | 128 | signal.signal(signal.SIGINT, self.shutdown) 129 | signal.signal(signal.SIGTERM, self.shutdown) 130 | 131 | self.epd.start() 132 | 133 | self.mq = posix_ipc.MessageQueue("/epdtext_ipc", flags=posix_ipc.O_CREAT) 134 | self.mq.block = False 135 | 136 | self.calendar.get_latest_events() 137 | self.calendar.start() 138 | self.weather.start() 139 | 140 | btns = epd.get_buttons() 141 | btns[0].when_pressed = self.handle_btn0_press 142 | btns[1].when_pressed = self.handle_btn1_press 143 | btns[2].when_pressed = self.handle_btn2_press 144 | btns[3].when_pressed = self.handle_btn3_press 145 | 146 | for module in SCREENS: 147 | self.add_screen(module) 148 | 149 | def shutdown(self, *args): 150 | self.logger.info("epdtext shutting down gracefully...") 151 | self.epd.clear() 152 | while len(self.screens) > 0: 153 | del self.screens[0] 154 | 155 | time.sleep(5) 156 | 157 | self.epd.stop() 158 | self.calendar.stop() 159 | self.weather.stop() 160 | 161 | self.epd.join() 162 | self.calendar.join() 163 | self.weather.join() 164 | 165 | exit(0) 166 | 167 | def process_message(self): 168 | try: 169 | message = self.mq.receive(timeout=10) 170 | except posix_ipc.BusyError: 171 | message = None 172 | 173 | if message: 174 | parts = message[0].decode().split() 175 | 176 | command = parts[0] 177 | self.logger.debug("Received IPC command: " + command) 178 | if command == "button0": 179 | self.handle_btn0_press() 180 | elif command == "button3": 181 | self.handle_btn3_press() 182 | elif command == "button1": 183 | self.handle_btn1_press() 184 | elif command == "button2": 185 | self.handle_btn2_press() 186 | elif command == "previous": 187 | self.previous_screen() 188 | elif command == "next": 189 | self.next_screen() 190 | elif command == "reload": 191 | self.current_screen().reload() 192 | self.current_screen().show() 193 | elif command == "screen": 194 | self.logger.debug("Attempting switch to screen '{0}'".format(parts[1])) 195 | self.current_screen_index = self.find_screen_index_by_name(parts[1]) 196 | if self.current_screen_index < 0: 197 | self.logger.error("Couldn't find screen '{0}'".format(parts[1])) 198 | self.current_screen_index = 0 199 | self.current_screen().reload() 200 | self.current_screen().show() 201 | elif command == "remove_screen": 202 | self.logger.debug("Attempting to remove screen '{0}'".format(parts[1])) 203 | if self.current_screen_index == self.find_screen_index_by_name(parts[1]): 204 | self.current_screen_index = 0 205 | self.current_screen().reload() 206 | self.screens.remove(self.get_screen_by_name(parts[1])) 207 | self.screen_modules.remove(self.get_screen_module_by_name(parts[1])) 208 | elif command == "add_screen": 209 | self.logger.debug("Attempting to add screen '{0}'".format(parts[1])) 210 | if self.get_screen_by_name(parts[1]): 211 | self.logger.error("Screen '{0}' already added".format(parts[1])) 212 | else: 213 | self.add_screen(parts[1]) 214 | 215 | else: 216 | self.logger.error("Command '{0}' not recognized".format(command)) 217 | 218 | def loop(self): 219 | while True: 220 | self.process_message() 221 | 222 | time.sleep(1) 223 | 224 | self.weather.refresh_interval -= 1 225 | if self.weather.refresh_interval < 1: 226 | asyncio.get_event_loop().run_until_complete(self.weather.update()) 227 | self.weather.refresh_interval = settings.WEATHER_REFRESH 228 | 229 | self.current_screen().iterate_loop() 230 | 231 | if self.loop_time >= TIME: 232 | self.loop_time = 0 233 | 234 | self.loop_time += 1 235 | 236 | if self.loop_time == 1: 237 | self.current_screen().show() 238 | 239 | 240 | if __name__ == '__main__': 241 | sys.path.append(os.path.dirname(os.path.abspath(__file__))) 242 | 243 | app = App() 244 | app.loop() 245 | -------------------------------------------------------------------------------- /libs/calendar.py: -------------------------------------------------------------------------------- 1 | import time 2 | import logging 3 | import threading 4 | from datetime import date, datetime, timedelta 5 | 6 | import caldav 7 | import httplib2.error 8 | import humanize 9 | import pytz 10 | import requests.exceptions 11 | import urllib3.exceptions 12 | from icalevents.icalevents import events 13 | from requests.exceptions import SSLError 14 | 15 | import settings 16 | from settings import TIMEZONE 17 | 18 | 19 | try: 20 | from local_settings import CALENDAR_URLS 21 | except ImportError: 22 | CALENDAR_URLS = None 23 | 24 | try: 25 | from local_settings import CALENDAR_REFRESH 26 | except ImportError: 27 | CALENDAR_REFRESH = 900 28 | 29 | timezone = pytz.timezone(TIMEZONE) 30 | logger = logging.getLogger('pitftmanager.libs.calendar') 31 | 32 | 33 | def sort_by_date(obj: dict): 34 | """ 35 | Sort the events or tasks by date 36 | :param obj: dict containing summary and start/due date 37 | :return: the same object, with time added if needed 38 | """ 39 | if obj.get("start"): 40 | if isinstance(obj["start"], date) and not isinstance(obj["start"], datetime): 41 | return datetime.combine(obj["start"], datetime.min.time(), timezone) 42 | if not obj["start"].tzinfo: 43 | return timezone.localize(obj["start"]) 44 | return obj["start"] 45 | elif obj.get("due"): 46 | if not obj["due"]: 47 | return datetime.fromisocalendar(4000, 1, 1) 48 | if isinstance(obj["due"], date) and not isinstance(obj["due"], datetime): 49 | return datetime.combine(obj["due"], datetime.min.time(), timezone) 50 | if not obj["due"].tzinfo: 51 | return timezone.localize(obj["due"]) 52 | return obj["due"] 53 | else: 54 | return timezone.localize(datetime.max) 55 | 56 | 57 | class Calendar(threading.Thread): 58 | """ 59 | This class handles the calendar events and tasks 60 | """ 61 | timezone = None 62 | refresh_interval: int = CALENDAR_REFRESH 63 | events: list = [] 64 | tasks: list = [] 65 | thread_lock: threading.Lock = threading.Lock() 66 | 67 | def __init__(self): 68 | """ 69 | Initialize the timezone 70 | """ 71 | super().__init__() 72 | self.timezone = pytz.timezone(TIMEZONE) 73 | self.name = "Calendar" 74 | self.shutdown = threading.Event() 75 | 76 | def run(self): 77 | thread_process = threading.Thread(target=self.calendar_loop) 78 | # run thread as a daemon so it gets cleaned up on exit. 79 | thread_process.daemon = True 80 | thread_process.start() 81 | self.shutdown.wait() 82 | 83 | def calendar_loop(self): 84 | while not self.shutdown.is_set(): 85 | self.refresh_interval -= 1 86 | time.sleep(1) 87 | if self.refresh_interval < 1: 88 | self.get_latest_events() 89 | self.refresh_interval = CALENDAR_REFRESH 90 | 91 | def stop(self): 92 | self.shutdown.set() 93 | 94 | def standardize_date(self, arg): 95 | """ 96 | Adds time to dates to make datetimes as needed 97 | :param arg: an object containing a summary and date 98 | :return: a new datetime object, or the same object if no changes were needed 99 | """ 100 | if isinstance(arg, datetime) and not arg.tzinfo: 101 | logger.debug("Object has no timezone") 102 | return self.timezone.localize(arg) 103 | elif isinstance(arg, date) and not isinstance(arg, datetime): 104 | logger.debug("Object has no time") 105 | return datetime.combine(arg, datetime.min.time(), self.timezone) 106 | else: 107 | return arg 108 | 109 | def get_events_from_webcal(self, new_events, url): 110 | """ 111 | Retrieve events from webcal and append them to the list 112 | :param new_events: list of new events 113 | :param url: the URL of the webcal 114 | """ 115 | try: 116 | timeline: list = events(url, start=datetime.today(), 117 | end=datetime.today() + timedelta(days=7)) 118 | for event in timeline: 119 | start = event.start 120 | summary = event.summary 121 | 122 | new_events.append({ 123 | 'start': start, 124 | 'summary': summary 125 | }) 126 | except ValueError as error: 127 | logger.error('Error reading calendar "{0}"'.format(url)) 128 | logger.error(error) 129 | pass 130 | except httplib2.error.ServerNotFoundError as error: 131 | logger.error('Error reading calendar "{0}"'.format(url)) 132 | logger.error(error) 133 | pass 134 | 135 | def get_events_from_caldav(self, new_events, new_tasks, url, username, password): 136 | """ 137 | Retrieve events and tasks from CalDAV 138 | :param new_events: list of new events 139 | :param new_tasks: list of new tasks 140 | :param url: URL of CalDAV server 141 | :param username: CalDAV user name 142 | :param password: CalDAV password 143 | :return: the list of events 144 | """ 145 | try: 146 | client = caldav.DAVClient(url=url, username=username, password=password) 147 | principal = client.principal() 148 | except SSLError as error: 149 | logger.error("SSL error connecting to CalDAV server") 150 | logger.error(error) 151 | return 152 | except urllib3.exceptions.NewConnectionError as error: 153 | logger.error("Error establishing connection to '{}'".format(url)) 154 | logger.error(error) 155 | return 156 | except caldav.lib.error.AuthorizationError as error: 157 | logger.error("Authorization error connecting to '{}'".format(url)) 158 | logger.error(error) 159 | return 160 | except requests.exceptions.ConnectionError as error: 161 | logger.error("SSL error connecting to CalDAV server") 162 | logger.error(error) 163 | return 164 | 165 | calendars = principal.calendars() 166 | 167 | for cal in calendars: 168 | calendar_events = cal.date_search(start=datetime.today(), 169 | end=datetime.today() + timedelta(days=7), 170 | expand=True) 171 | for event in calendar_events: 172 | start = self.standardize_date(event.vobject_instance.vevent.dtstart.value) 173 | summary = event.vobject_instance.vevent.summary.value 174 | 175 | new_events.append({ 176 | 'start': start, 177 | 'summary': summary 178 | }) 179 | 180 | todos = cal.todos() 181 | 182 | for todo in todos: 183 | try: 184 | due = self.standardize_date(todo.vobject_instance.vtodo.due.value) 185 | except AttributeError: 186 | due = None 187 | 188 | summary = todo.vobject_instance.vtodo.summary.value 189 | 190 | new_tasks.append({ 191 | 'due': due, 192 | 'summary': summary 193 | }) 194 | 195 | def get_latest_events(self): 196 | """ 197 | Update events and tasks 198 | """ 199 | logger.debug("Started reading calendars...") 200 | self.thread_lock.acquire() 201 | new_events = [] 202 | new_tasks = [] 203 | 204 | for connection in CALENDAR_URLS: 205 | if str(connection["type"]).lower() == 'webcal': 206 | try: 207 | self.get_events_from_webcal(new_events, connection["url"]) 208 | except KeyError as error: 209 | logger.error("No URL specified for calendar") 210 | logger.error(error) 211 | elif str(connection['type']).lower() == 'caldav': 212 | try: 213 | self.get_events_from_caldav(new_events, new_tasks, connection["url"], 214 | connection["username"], connection["password"]) 215 | except KeyError as error: 216 | if connection.get('url'): 217 | logger.error("Error reading calendar: {}".format(connection['url'])) 218 | else: 219 | logger.error("No URL specified for calendar") 220 | logger.error(error) 221 | else: 222 | logger.error("calendar type not recognized: {0}".format(str(connection["type"]))) 223 | 224 | new_events.sort(key=sort_by_date) 225 | new_tasks.sort(key=sort_by_date) 226 | 227 | logger.debug("done!") 228 | 229 | self.events = new_events 230 | self.tasks = new_tasks 231 | 232 | self.thread_lock.release() 233 | 234 | def events_as_string(self): 235 | """ 236 | Get the current events as a string 237 | :return: list of events 238 | """ 239 | text = '' 240 | 241 | for obj in self.events: 242 | text += self.humanized_datetime(obj["start"]) + '\n' 243 | text += obj["summary"].replace('\n', ' ') + '\n' 244 | 245 | return text 246 | 247 | def tasks_as_string(self): 248 | """ 249 | Get the current tasks as a string 250 | :return: list of tasks 251 | """ 252 | text = '' 253 | 254 | for obj in self.tasks: 255 | text += "* " + obj["summary"].replace('\n', ' ') + '\n' 256 | if obj["due"]: 257 | text += " - Due: " + self.humanized_datetime(obj["due"]) + "\n" 258 | 259 | return text 260 | 261 | def humanized_datetime(self, dt: datetime): 262 | """ 263 | Get a human-readable interpretation of a datetime 264 | :param dt: datetime to humanize 265 | :return: str 266 | """ 267 | try: 268 | obj = self.timezone.localize(dt) 269 | except ValueError: 270 | obj = dt 271 | except AttributeError: 272 | obj = dt 273 | if (isinstance(obj, date) and not isinstance(obj, datetime)) or obj.date() > datetime.today().date(): 274 | return humanize.naturaldate(obj) 275 | else: 276 | return humanize.naturaltime(obj, when=datetime.now(self.timezone)) 277 | 278 | 279 | calendar = Calendar() 280 | 281 | 282 | def get_calendar(): 283 | """ 284 | Retrieve main Calendar object 285 | :return: Calendar 286 | """ 287 | return calendar 288 | 289 | 290 | def update_calendar(): 291 | """ 292 | Update calendar events and tasks 293 | :return: None 294 | """ 295 | calendar.refresh_interval = 0 296 | 297 | 298 | if __name__ == '__main__': 299 | logging.basicConfig(level=logging.DEBUG) 300 | calendar.get_latest_events() 301 | for event in calendar.events: 302 | logger.info(event.start) 303 | logger.info(event.summary) 304 | --------------------------------------------------------------------------------