├── .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 |
4 |
5 |
6 |
--------------------------------------------------------------------------------
/.idea/discord.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
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 |
5 |
6 |
7 |
8 |
9 |
10 |
11 |
12 |
13 |
14 |
15 |
16 |
17 |
18 |
19 |
20 |
21 |
22 |
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 | 
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 | 
16 |
17 | * `uptime` - a system info viewer
18 |
19 | 
20 |
21 | * `affirmations` - display positive affirmations (or whatever kind you want, really)
22 |
23 | 
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 | 
29 | * `calendar` and `tasks` - shows a list of upcoming events or todos from your calendars (see `local_settings.py.example`)
30 |
31 | 
32 | 
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 |
--------------------------------------------------------------------------------