├── alert_providers ├── __init__.py ├── metofficerssfeed.py ├── base_provider.py ├── weathergovalerts.py └── meteireann.py ├── calendar_providers ├── __init__.py ├── base_provider.py ├── ics.py ├── caldav.py ├── outlook.py └── google.py ├── weather_providers ├── __init__.py ├── base_provider.py ├── visualcrossing.py ├── weathergov.py └── accuweather.py ├── tox.ini ├── screenshots ├── 001.png ├── 002.png ├── 003.png ├── 004.png ├── 005.png ├── display.png ├── pvt_xkcd.png ├── pvt_literature.png └── pvt_literature_irl.png ├── .gitmodules ├── waveshare-epaper-display.timer.example ├── waveshare-epaper-display.service.example ├── .vscode ├── settings.json ├── tasks.json ├── extensions.json └── launch.json ├── .gitignore ├── .editorconfig ├── icons ├── climacell_clear_night.svg ├── overcast.svg ├── thundershower_rain.svg ├── climacell_mostly_cloudy.svg ├── clear_sky_day.svg ├── foggy.svg ├── cold.svg ├── clearnight.svg ├── very_hot.svg ├── wind.svg ├── snow.svg ├── rain_night_light.svg ├── rain_day.svg ├── climacell_partly_cloudy_night.svg ├── partlycloudynight.svg ├── climacell_ice_pellets_light.svg ├── climacell_mostly_clear_night.svg ├── mostly_cloudy.svg ├── rain_night.svg ├── scattered_clouds.svg ├── climacell_cloudy.svg ├── climacell_flurries.svg ├── freezing_rain.svg ├── scattered_thundershowers.svg ├── climacell_ice_pellets.svg ├── fire_smoke.svg ├── climacell_snow_light.svg ├── climacell_freezing_drizzle.svg ├── climacell_drizzle.svg ├── few_clouds.svg ├── scattered_clouds_fog.svg ├── rain_night_heavy.svg ├── rain_snow_mix.svg ├── climacell_ice_pellets_heavy.svg ├── climacell_freezing_rain_light.svg ├── climacell_fog_light.svg ├── shower_spells.svg ├── climacell_fog.svg ├── climacell_rain_light.svg ├── sleet.svg ├── climacell_partly_cloudy_day.svg ├── climacell_clear_day.svg ├── tornado_hurricane.svg ├── drought.svg ├── dust_ash_sand.svg ├── haze.svg ├── climacell_snow.svg ├── climacell_freezing_rain.svg ├── volcano.svg ├── climacell_rain.svg ├── blizzard.svg ├── climacell_freezing_rain_heavy.svg ├── climacell_tstorm.svg ├── night_partly_cloudy_rain.svg ├── climacell_rain_heavy.svg ├── climacell_mostly_clear_day.svg ├── ice_pellets.svg ├── climacell_snow_heavy.svg ├── day_partly_cloudy_rain.svg ├── rain_icepellets_mix.svg └── mostly_cloudy_night.svg ├── screen-custom.svg ├── screen-custom-get.py.sample ├── docs └── privacypolicy.md ├── LICENSE ├── requirements.txt ├── xkcd_get.py ├── outlook_util.py ├── display.py ├── run.sh ├── screen-calendar-month.py ├── env.sh.sample ├── screen-template.2.svg ├── screen-template.1.svg ├── screen-literature-clock-get.py └── screen-calendar-get.py /alert_providers/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /calendar_providers/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /weather_providers/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /tox.ini: -------------------------------------------------------------------------------- 1 | [flake8] 2 | max_line_length = 140 -------------------------------------------------------------------------------- /screenshots/001.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mendhak/waveshare-epaper-display/HEAD/screenshots/001.png -------------------------------------------------------------------------------- /screenshots/002.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mendhak/waveshare-epaper-display/HEAD/screenshots/002.png -------------------------------------------------------------------------------- /screenshots/003.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mendhak/waveshare-epaper-display/HEAD/screenshots/003.png -------------------------------------------------------------------------------- /screenshots/004.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mendhak/waveshare-epaper-display/HEAD/screenshots/004.png -------------------------------------------------------------------------------- /screenshots/005.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mendhak/waveshare-epaper-display/HEAD/screenshots/005.png -------------------------------------------------------------------------------- /screenshots/display.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mendhak/waveshare-epaper-display/HEAD/screenshots/display.png -------------------------------------------------------------------------------- /screenshots/pvt_xkcd.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mendhak/waveshare-epaper-display/HEAD/screenshots/pvt_xkcd.png -------------------------------------------------------------------------------- /screenshots/pvt_literature.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mendhak/waveshare-epaper-display/HEAD/screenshots/pvt_literature.png -------------------------------------------------------------------------------- /screenshots/pvt_literature_irl.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mendhak/waveshare-epaper-display/HEAD/screenshots/pvt_literature_irl.png -------------------------------------------------------------------------------- /.gitmodules: -------------------------------------------------------------------------------- 1 | [submodule "lib/e-Paper"] 2 | path = lib/e-Paper 3 | url = https://github.com/waveshare/e-Paper.git 4 | ignore = dirty 5 | branch = master 6 | -------------------------------------------------------------------------------- /waveshare-epaper-display.timer.example: -------------------------------------------------------------------------------- 1 | [Unit] 2 | Description=Waveshare ePaper display update timer 3 | 4 | [Timer] 5 | OnCalendar=*:0/1 6 | Unit=waveshare-epaper-display.service 7 | 8 | [Install] 9 | WantedBy=timers.target 10 | -------------------------------------------------------------------------------- /waveshare-epaper-display.service.example: -------------------------------------------------------------------------------- 1 | [Unit] 2 | Description=Waveshare ePaper display update service 3 | 4 | [Service] 5 | Type=oneshot 6 | WorkingDirectory=%h/waveshare-epaper-display/ 7 | ExecStart=%h/waveshare-epaper-display/run.sh 8 | 9 | [Install] 10 | WantedBy=default.target 11 | -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "python.defaultInterpreterPath": ".venv/bin/python", 3 | "python.envFile": "${workspaceFolder}/env.sh", 4 | "python.terminal.activateEnvironment": true, 5 | "python.terminal.activateEnvInCurrentTerminal": true, 6 | "markdown.extension.toc.updateOnSave": false 7 | } 8 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | __pycache__ 2 | *.__pycache__ 3 | *.pyc 4 | screen-output* 5 | screen-custom-get.py 6 | env.sh 7 | *.log 8 | *.pickle 9 | credentials* 10 | *.json 11 | !.vscode/* 12 | *.bin 13 | test.svg 14 | *.xml 15 | venv 16 | cache_ 17 | .venv 18 | .env 19 | 20 | xkcd-comic-strip.png 21 | 22 | screen-literature*.svg 23 | screen-literature*.png 24 | litclock_annotated.csv 25 | -------------------------------------------------------------------------------- /.vscode/tasks.json: -------------------------------------------------------------------------------- 1 | { 2 | // See https://go.microsoft.com/fwlink/?LinkId=733558 3 | // for the documentation about the tasks.json format 4 | "version": "2.0.0", 5 | "tasks": [ 6 | { 7 | "label": "Source Shell", 8 | "type": "shell", 9 | "command": "source env.sh; printenv > ${workspaceFolder}/.env;" 10 | } 11 | ] 12 | } -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | # EditorConfig is awesome: http://EditorConfig.org 2 | 3 | # top-most EditorConfig file 4 | root = true 5 | 6 | # Unix-style newlines with a newline ending every file 7 | [*] 8 | end_of_line = lf 9 | insert_final_newline = true 10 | trim_trailing_whitespace = true 11 | charset = utf-8 12 | 13 | # 4 space indentation 14 | [*.{py,java,r,R}] 15 | indent_style = space 16 | indent_size = 4 17 | 18 | [*.md] 19 | trim_trailing_whitespace = false 20 | charset = utf-8 21 | -------------------------------------------------------------------------------- /icons/climacell_clear_night.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 8 | 9 | -------------------------------------------------------------------------------- /screen-custom.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 6 | CUSTOM_DATA_1 7 | -------------------------------------------------------------------------------- /icons/overcast.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | -------------------------------------------------------------------------------- /.vscode/extensions.json: -------------------------------------------------------------------------------- 1 | { 2 | // See https://go.microsoft.com/fwlink/?LinkId=827846 to learn about workspace recommendations. 3 | // Extension identifier format: ${publisher}.${name}. Example: vscode.csharp 4 | 5 | // List of extensions which should be recommended for users of this workspace. 6 | "recommendations": [ 7 | "EditorConfig.EditorConfig", "ms-python.python", "ms-python.flake8" 8 | ], 9 | // List of extensions recommended by VS Code that should not be recommended for users of this workspace. 10 | "unwantedRecommendations": [ 11 | 12 | ] 13 | } 14 | -------------------------------------------------------------------------------- /calendar_providers/base_provider.py: -------------------------------------------------------------------------------- 1 | 2 | from abc import ABC, abstractmethod 3 | import typing 4 | from typing import NamedTuple 5 | 6 | class CalendarEvent(NamedTuple): 7 | summary: str 8 | start: any 9 | end: any 10 | all_day_event: bool 11 | 12 | 13 | class BaseCalendarProvider(ABC): 14 | 15 | @abstractmethod 16 | def get_calendar_events(self) -> list[CalendarEvent]: 17 | """ 18 | Implement this method. 19 | Return a list of `CalendarEvent` which contains summary, start date, end date, and all day event 20 | """ 21 | pass 22 | 23 | 24 | -------------------------------------------------------------------------------- /alert_providers/metofficerssfeed.py: -------------------------------------------------------------------------------- 1 | from alert_providers.base_provider import BaseAlertProvider 2 | import logging 3 | import xml.etree.ElementTree as ET 4 | 5 | class MetOfficeRssFeed(BaseAlertProvider): 6 | def __init__(self, feed_url): 7 | self.feed_url = feed_url 8 | 9 | 10 | def get_alert(self): 11 | severe = self.get_response_xml(self.feed_url) 12 | logging.info("get_alert - {}".format(ET.tostring(severe, encoding='unicode'))) 13 | 14 | for type_tag in severe.findall('channel/item'): 15 | value = type_tag.findtext("title") 16 | return value 17 | return "" 18 | -------------------------------------------------------------------------------- /.vscode/launch.json: -------------------------------------------------------------------------------- 1 | { 2 | // Use IntelliSense to learn about possible attributes. 3 | // Hover to view descriptions of existing attributes. 4 | // For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387 5 | "version": "0.2.0", 6 | "configurations": [ 7 | { 8 | "name": "Python: Current File", 9 | "type": "python", 10 | "request": "launch", 11 | "program": "${file}", 12 | "console": "integratedTerminal", 13 | "justMyCode": true, 14 | "envFile": "${workspaceFolder}/.env", 15 | "preLaunchTask": "Source Shell" 16 | 17 | } 18 | ] 19 | } -------------------------------------------------------------------------------- /icons/thundershower_rain.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | -------------------------------------------------------------------------------- /icons/climacell_mostly_cloudy.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 9 | 10 | -------------------------------------------------------------------------------- /icons/clear_sky_day.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | -------------------------------------------------------------------------------- /screen-custom-get.py.sample: -------------------------------------------------------------------------------- 1 | import logging 2 | from utility import update_svg, configure_logging 3 | 4 | configure_logging() 5 | 6 | def main(): 7 | output_svg_filename = 'screen-custom.svg' 8 | 9 | # If you make changes to this file be sure to make a backup in case you ever update! 10 | 11 | # Add custom code here like getting PiHole Status, car charger status, API calls. 12 | # Assign the value you want to display to custom_value_1, and it will replace CUSTOM_DATA_1 in screen-custom.svg. 13 | # You can edit the screen-custom.svg to change appearance, position, font size, add more custom data. 14 | custom_value_1 = ""; 15 | 16 | logging.info("Updating SVG") 17 | output_dict = { 18 | 'CUSTOM_DATA_1' : custom_value_1 19 | } 20 | update_svg('screen-custom.svg', 'screen-output-custom-temp.svg', output_dict) 21 | 22 | if __name__ == "__main__": 23 | main() 24 | -------------------------------------------------------------------------------- /icons/foggy.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | -------------------------------------------------------------------------------- /icons/cold.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | -------------------------------------------------------------------------------- /icons/clearnight.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | -------------------------------------------------------------------------------- /docs/privacypolicy.md: -------------------------------------------------------------------------------- 1 | ## Privacy Policy for Waveshare Epaper Display 2 | 3 | Last updated: 25th March 2025. 4 | 5 | In short - We do not collect any personal information from you. 6 | 7 | Welcome to Waveshare Epaper Display ("us", "we", "Waveshare Epaper Display" or "our"). 8 | 9 | Waveshare Epaper Display does not collect or send to any third-parties, any personal information, analytics, tracking or profiling data. Read below to understand what happens to the information that you provide to Waveshare Epaper Display. 10 | 11 | When Waveshare Epaper Display connects to your weather, alerts, or calendar providers, it is specifically to fetch the relevant weather, alert, or calendar information, and display the information on the screen. The connection occurs using the configuration that you provide for those respective provider services. This information is not collected in any way by us. 12 | 13 | If you have any questions about this Privacy Policy, please contact us. You can use Github Issues or email gpslogger at mendhak.com. 14 | 15 | -------------------------------------------------------------------------------- /icons/very_hot.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | -------------------------------------------------------------------------------- /icons/wind.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 6 | 9 | 12 | 13 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2019 mendhak 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /icons/snow.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | astral==3.2 2 | Babel==2.11.0 3 | cachetools==5.2.0 4 | cairocffi==1.4.0 5 | CairoSVG==2.5.2 6 | caldav==0.10.0 7 | certifi==2022.9.24 8 | cffi==1.15.1 9 | charset-normalizer==2.1.1 10 | cryptography==38.0.1 11 | cssselect2==0.7.0 12 | DateTime==4.3 13 | defusedxml==0.7.1 14 | drawsvg==2.3.0 15 | emoji==2.11.0 16 | google-api-core==2.10.2 17 | google-api-python-client==2.65.0 18 | google-auth==2.14.1 19 | google-auth-httplib2==0.1.0 20 | google-auth-oauthlib==0.7.1 21 | googleapis-common-protos==1.56.4 22 | httplib2==0.20.4 23 | humanize==4.6.0 24 | icalendar==4.0.9 25 | icalevents==0.1.27 26 | idna==3.4 27 | lxml==4.9.1 28 | msal==1.20.0 29 | oauthlib==3.2.2 30 | Pillow==9.3.0 31 | protobuf==4.21.9 32 | pyasn1==0.4.8 33 | pyasn1-modules==0.2.8 34 | pycparser==2.21 35 | PyJWT==2.6.0 36 | pyparsing==2.4.7 37 | python-dateutil==2.8.2 38 | pytz==2021.3 39 | pytz-deprecation-shim==0.1.0.post0 40 | requests==2.28.1 41 | requests-oauthlib==1.3.1 42 | RPi.GPIO==0.7.1 43 | rsa==4.9 44 | six==1.16.0 45 | spidev==3.6 46 | tinycss2==1.2.1 47 | tzdata==2022.6 48 | tzlocal==4.2 49 | uritemplate==4.1.1 50 | urllib3==1.26.12 51 | vobject==0.9.6.1 52 | webencodings==0.5.1 53 | zope.interface==5.5.1 54 | -------------------------------------------------------------------------------- /icons/rain_night_light.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | -------------------------------------------------------------------------------- /alert_providers/base_provider.py: -------------------------------------------------------------------------------- 1 | import os 2 | from abc import ABC, abstractmethod 3 | from utility import get_xml_from_url, get_json_from_url 4 | 5 | 6 | class BaseAlertProvider(ABC): 7 | 8 | ttl = float(os.getenv("ALERT_TTL", 1 * 60 * 60)) 9 | 10 | @abstractmethod 11 | def get_alert(self): 12 | """ 13 | Implement this method. 14 | Return a string containing the alert message 15 | "Yellow warning of wind..." 16 | """ 17 | pass 18 | 19 | def get_response_json(self, url, headers={}): 20 | """ 21 | Perform an HTTP GET for a `url` with optional `headers`. 22 | Caches the response in `cache_file_name` for ALERT_TTL seconds. 23 | Returns the response as JSON 24 | """ 25 | return get_json_from_url(url, headers, "cache_severe_alert.json", self.ttl) 26 | 27 | def get_response_xml(self, url, headers={}): 28 | """ 29 | Perform an HTTP GET for a `url` with optional `headers`. 30 | Caches the response in `cache_file_name` for ALERT_TTL seconds. 31 | Returns the response as an XML ElementTree 32 | """ 33 | return get_xml_from_url(url, headers, "cache_severe_alert.xml", self.ttl) 34 | 35 | 36 | -------------------------------------------------------------------------------- /alert_providers/weathergovalerts.py: -------------------------------------------------------------------------------- 1 | from alert_providers.base_provider import BaseAlertProvider 2 | import logging 3 | import xml.etree.ElementTree as ET 4 | 5 | class WeatherGovAlerts(BaseAlertProvider): 6 | def __init__(self, location_lat, location_long, weathergov_self_id): 7 | self.location_lat = location_lat 8 | self.location_long = location_long 9 | self.weathergov_self_id = weathergov_self_id 10 | 11 | 12 | def get_alert(self): 13 | try: 14 | severe = self.get_response_json("https://api.weather.gov/alerts?point={},{}".format(self.location_lat, self.location_long), 15 | headers={'User-Agent':'({0})'.format(self.weathergov_self_id)}) 16 | logging.debug("get_alert - {}".format(severe)) 17 | if 'features' in severe: 18 | if 'properties' in severe["features"][0]: 19 | if 'parameters' in severe["features"][0]["properties"]: 20 | if 'NWSheadline' in severe["features"][0]["properties"]["parameters"]: 21 | return severe["features"][0]["properties"]["parameters"]["NWSheadline"][0] 22 | except Exception as error: 23 | pass 24 | return "" 25 | 26 | -------------------------------------------------------------------------------- /icons/rain_day.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | -------------------------------------------------------------------------------- /icons/climacell_partly_cloudy_night.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 7 | 13 | 14 | -------------------------------------------------------------------------------- /icons/partlycloudynight.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | -------------------------------------------------------------------------------- /icons/climacell_ice_pellets_light.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 7 | 14 | 15 | -------------------------------------------------------------------------------- /icons/climacell_mostly_clear_night.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 7 | 13 | 14 | -------------------------------------------------------------------------------- /icons/mostly_cloudy.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | -------------------------------------------------------------------------------- /icons/rain_night.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | -------------------------------------------------------------------------------- /xkcd_get.py: -------------------------------------------------------------------------------- 1 | import requests 2 | import logging 3 | import os 4 | from PIL import Image 5 | from utility import is_stale, configure_logging 6 | import sys 7 | 8 | configure_logging() 9 | 10 | def xkcd_get_img(): 11 | xkcd_file_name = "xkcd-comic-strip.png" 12 | if not is_stale(xkcd_file_name, 3600): 13 | logging.info("xkcd-comic-strip.png is still fresh. Skipping download.") 14 | sys.exit(1) 15 | 16 | logging.info("Downloading xkcd-json") 17 | response = requests.get("https://xkcd.com/info.0.json") 18 | result = response.json() 19 | 20 | logging.info("Downloading xkcd_img") 21 | logging.info(result["img"]) 22 | 23 | path = os.path.dirname(os.path.realpath(__file__)) 24 | filename = path + '/' + os.path.basename(xkcd_file_name) 25 | if os.path.exists(filename): 26 | os.remove(filename) 27 | image_response = requests.get(result["img"]) 28 | open(filename, 'wb').write(image_response.content) 29 | 30 | logging.info("Resizing the image to fit the screen. Disortions can happen.") 31 | 32 | im = Image.open(filename) 33 | logging.debug("PNG size: ",im.size) 34 | 35 | width = int(os.environ.get('WAVESHARE_WIDTH')) 36 | height = int(os.environ.get('WAVESHARE_HEIGHT')) 37 | im = im.resize((width,height)) 38 | im.save(filename, "PNG") 39 | 40 | 41 | def main(): 42 | xkcd_get_img() 43 | 44 | 45 | if __name__ == "__main__": 46 | main() 47 | -------------------------------------------------------------------------------- /icons/scattered_clouds.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | -------------------------------------------------------------------------------- /icons/climacell_cloudy.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 9 | 16 | 17 | -------------------------------------------------------------------------------- /icons/climacell_flurries.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 10 | 11 | 12 | 18 | 19 | 20 | -------------------------------------------------------------------------------- /icons/freezing_rain.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | -------------------------------------------------------------------------------- /icons/scattered_thundershowers.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | -------------------------------------------------------------------------------- /icons/climacell_ice_pellets.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 9 | 18 | 19 | 20 | -------------------------------------------------------------------------------- /icons/fire_smoke.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | -------------------------------------------------------------------------------- /icons/climacell_snow_light.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 11 | 12 | 18 | 19 | -------------------------------------------------------------------------------- /icons/climacell_freezing_drizzle.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 12 | 13 | 19 | 20 | -------------------------------------------------------------------------------- /outlook_util.py: -------------------------------------------------------------------------------- 1 | import logging 2 | import datetime 3 | import requests 4 | from calendar_providers.outlook import OutlookCalendar 5 | from utility import configure_logging 6 | 7 | 8 | configure_logging() 9 | 10 | 11 | def main(): 12 | 13 | access_token = OutlookCalendar(None, None, None, None).get_access_token() 14 | 15 | endpoint_calendar_list = "https://graph.microsoft.com/v1.0/me/calendars" 16 | 17 | if access_token: 18 | 19 | headers = {'Authorization': 'Bearer ' + access_token} 20 | 21 | calendars_data = requests.get(endpoint_calendar_list, headers=headers).json() 22 | 23 | print("") 24 | print("Here are the available Calendar names and IDs. Copy the ID of the Calendar you want into env.sh") 25 | for cal in calendars_data["value"]: 26 | print("============================================") 27 | print("Name : ", cal["name"]) 28 | print("ID : ", cal["id"]) 29 | print("Any upcoming events: ") 30 | 31 | today_start_time = datetime.datetime.utcnow() 32 | oneyearlater_iso = (datetime.datetime.now().astimezone() + datetime.timedelta(days=365)).astimezone() 33 | 34 | logging.debug(today_start_time) 35 | logging.debug(oneyearlater_iso) 36 | 37 | outlook_calendar = OutlookCalendar(cal["id"], 10, today_start_time, oneyearlater_iso) 38 | events_data = outlook_calendar.get_calendar_events(bypass_cache=True) 39 | 40 | for event in events_data: 41 | print(f'{event.summary}, {event.start}, {event.end}, {event.all_day_event}') 42 | print("============================================") 43 | 44 | 45 | if __name__ == "__main__": 46 | main() 47 | -------------------------------------------------------------------------------- /icons/climacell_drizzle.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 12 | 13 | 19 | 20 | 21 | -------------------------------------------------------------------------------- /icons/few_clouds.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | -------------------------------------------------------------------------------- /icons/scattered_clouds_fog.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | -------------------------------------------------------------------------------- /display.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/python3 2 | import sys 3 | import os 4 | import logging 5 | import datetime 6 | from PIL import Image 7 | from utility import configure_logging 8 | 9 | libdir = "./lib/e-Paper/RaspberryPi_JetsonNano/python/lib" 10 | if os.path.exists(libdir): 11 | sys.path.append(libdir) 12 | 13 | configure_logging() 14 | 15 | # Dear future me: consider converting this to a WAVESHARE_VERSION variable instead if you ever intend to support more screen sizes. 16 | 17 | waveshare_epd75_version = os.getenv("WAVESHARE_EPD75_VERSION", "2") 18 | 19 | if (waveshare_epd75_version == "1"): 20 | from waveshare_epd import epd7in5 as epd7in5 21 | elif (waveshare_epd75_version == "2B"): 22 | from waveshare_epd import epd7in5b_V2 as epd7in5 23 | else: 24 | from waveshare_epd import epd7in5_V2 as epd7in5 25 | 26 | try: 27 | epd = epd7in5.EPD() 28 | logging.debug("Initialize screen") 29 | epd.init() 30 | 31 | # Full screen refresh at 2 AM 32 | if datetime.datetime.now().minute == 0 and datetime.datetime.now().hour == 2: 33 | logging.debug("Clear screen") 34 | epd.Clear() 35 | 36 | filename = sys.argv[1] 37 | 38 | logging.debug("Read image file: " + filename) 39 | Himage = Image.open(filename) 40 | logging.info("Display image file on screen") 41 | 42 | if waveshare_epd75_version == "2B": 43 | Limage_Other = Image.new('1', (epd.height, epd.width), 255) # 255: clear the frame 44 | epd.display(epd.getbuffer(Himage), epd.getbuffer(Limage_Other)) 45 | else: 46 | epd.display(epd.getbuffer(Himage)) 47 | epd.sleep() 48 | 49 | except IOError as e: 50 | logging.exception(e) 51 | 52 | except KeyboardInterrupt: 53 | logging.debug("Keyboard Interrupt - Exit") 54 | epd7in5.epdconfig.module_exit() 55 | exit() 56 | -------------------------------------------------------------------------------- /icons/rain_night_heavy.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | -------------------------------------------------------------------------------- /icons/rain_snow_mix.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | -------------------------------------------------------------------------------- /alert_providers/meteireann.py: -------------------------------------------------------------------------------- 1 | from alert_providers.base_provider import BaseAlertProvider 2 | import logging 3 | 4 | class MetEireannAlertProvider(BaseAlertProvider): 5 | ''' 6 | Consume the Met Eireann JSON alert data; format described at 7 | https://www.met.ie/Open_Data/Warnings/Met_Eireann_Warning_description_June2020.pdf . 8 | ''' 9 | 10 | def __init__(self, feed_url): 11 | self.feed_url = feed_url 12 | 13 | def get_alert(self): 14 | alert_data = self.get_response_json(self.feed_url) 15 | logging.debug("get_alert() - {}".format(alert_data)) 16 | 17 | for item in alert_data: 18 | # ignore potato blight warnings, as I'm not a potato farmer? 19 | if item["headline"] == "Blight Advisory": 20 | continue 21 | level = item["level"].capitalize() # "yellow" => "Yellow" 22 | # add the level to the description to make something like 23 | # "Yellow: Wind warning for Donegal, Leitrim, Mayo, Sligo" 24 | value = "%s: %s" % (level, item["headline"]) 25 | return value 26 | 27 | return "" 28 | 29 | # Since typically there is _not_ a weather warning in place, here's an example of what one looks like: 30 | # 31 | # [{ 32 | # "id": 1, 33 | # "capId": "2.49.0.1.372.0.220405114905.N_Norm004_Weather", 34 | # "type": "Wind", 35 | # "severity": "Moderate", 36 | # "certainty": "Likely", 37 | # "level": "Yellow", 38 | # "issued": "2022-04-05T12:49:05+01:00", 39 | # "updated": "2022-04-05T12:49:05+01:00", 40 | # "onset": "2022-04-06T13:00:00+01:00", 41 | # "expiry": "2022-04-06T21:00:00+01:00", 42 | # "headline": "Wind warning for Donegal, Leitrim, Mayo, Sligo", 43 | # "description": "Very strong southwest winds veering northwest are expected on Wednesday afternoon and evening. Winds will be strongest at the coast with some severe gusts at times too, which may make driving conditions difficult. ", 44 | # "regions": ["EI06"], 45 | # "status": "Warning" 46 | # }] 47 | -------------------------------------------------------------------------------- /icons/climacell_ice_pellets_heavy.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 8 | 19 | 20 | 21 | -------------------------------------------------------------------------------- /icons/climacell_freezing_rain_light.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 15 | 16 | 21 | 22 | -------------------------------------------------------------------------------- /icons/climacell_fog_light.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 8 | 17 | 22 | 23 | -------------------------------------------------------------------------------- /icons/shower_spells.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | -------------------------------------------------------------------------------- /icons/climacell_fog.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 17 | 22 | 23 | -------------------------------------------------------------------------------- /icons/climacell_rain_light.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 15 | 16 | 22 | 23 | 24 | -------------------------------------------------------------------------------- /icons/sleet.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 12 | 13 | 14 | 15 | 16 | 19 | 20 | 21 | 22 | 23 | 25 | 26 | 27 | 28 | 29 | 31 | 32 | 33 | 34 | 35 | 37 | 38 | 39 | 40 | -------------------------------------------------------------------------------- /calendar_providers/ics.py: -------------------------------------------------------------------------------- 1 | 2 | import datetime 3 | from calendar_providers.base_provider import BaseCalendarProvider, CalendarEvent 4 | from utility import is_stale 5 | import os 6 | import logging 7 | import pickle 8 | import icalevents.icalevents 9 | from dateutil import tz 10 | 11 | ttl = float(os.getenv("CALENDAR_TTL", 1 * 60 * 60)) 12 | 13 | 14 | class ICSCalendar(BaseCalendarProvider): 15 | 16 | def __init__(self, ics_calendar_url, max_event_results, from_date, to_date): 17 | self.ics_calendar_url = ics_calendar_url 18 | self.max_event_results = max_event_results 19 | self.from_date = from_date 20 | self.to_date = to_date 21 | 22 | def get_calendar_events(self) -> list[CalendarEvent]: 23 | calendar_events = [] 24 | ics_calendar_pickle = 'cache_ics.pickle' 25 | if is_stale(os.getcwd() + "/" + ics_calendar_pickle, ttl): 26 | logging.debug("Pickle is stale, fetching ICS Calendar") 27 | 28 | ics_events = icalevents.icalevents.events(self.ics_calendar_url, start=self.from_date, end=self.to_date) 29 | ics_events.sort(key=lambda x: x.start.replace(tzinfo=None)) 30 | 31 | logging.debug(ics_events) 32 | 33 | for ics_event in ics_events[0:self.max_event_results]: 34 | event_end = ics_event.end 35 | 36 | # CalDav Calendar marks the 'end' of all-day-events as 37 | # the day _after_ the last day. eg, Today's all day event ends tomorrow! 38 | # So subtract a day, if the event is an all day event 39 | if ics_event.all_day: 40 | event_end = event_end - datetime.timedelta(days=1) 41 | 42 | # convert to local timezone 43 | event_end = ics_event.end.replace(tzinfo=tz.tzutc()).astimezone(tz.tzlocal()) 44 | event_start = ics_event.start.replace(tzinfo=tz.tzutc()).astimezone(tz.tzlocal()) 45 | 46 | calendar_events.append(CalendarEvent(ics_event.summary, event_start, event_end, ics_event.all_day)) 47 | 48 | with open(ics_calendar_pickle, 'wb') as cal: 49 | pickle.dump(calendar_events, cal) 50 | else: 51 | logging.info("Found in cache") 52 | with open(ics_calendar_pickle, 'rb') as cal: 53 | calendar_events = pickle.load(cal) 54 | 55 | return calendar_events 56 | -------------------------------------------------------------------------------- /weather_providers/base_provider.py: -------------------------------------------------------------------------------- 1 | import os 2 | from abc import ABC, abstractmethod 3 | from utility import get_xml_from_url, get_json_from_url 4 | import logging 5 | from astral import LocationInfo 6 | from astral.sun import sun 7 | import datetime 8 | import pytz 9 | 10 | 11 | class BaseWeatherProvider(ABC): 12 | 13 | ttl = float(os.getenv("WEATHER_TTL", 1 * 60 * 60)) 14 | 15 | @abstractmethod 16 | def get_weather(self): 17 | """ 18 | Implement this method. 19 | Return a dictionary in this format: 20 | { "temperatureMin": "2.0", "temperatureMax": "15.1", "icon": "mostly_cloudy", "description": "Cloudy with light breezes" } 21 | """ 22 | pass 23 | 24 | def f_to_c(self, fahrenheit): 25 | """ 26 | Return the Celsius value from a given Fahrenheit 27 | """ 28 | return float((fahrenheit - 32) * 5/9) 29 | 30 | def c_to_f(self, celsius): 31 | """ 32 | Return the Fahrenheit value from a given Celsius 33 | """ 34 | return (float(celsius)*9/5) + 32 35 | 36 | def is_daytime(self, location_lat, location_long): 37 | """ 38 | Return whether it's daytime for a given lat/long. 39 | """ 40 | 41 | # adjust icon for sunrise and sunset 42 | dt = datetime.datetime.now(pytz.utc) 43 | city = LocationInfo(location_lat, location_long) 44 | s = sun(city.observer, date=dt) 45 | verdict = False 46 | if dt > s['sunset'] or dt < s['sunrise']: 47 | verdict = False 48 | else: 49 | verdict = True 50 | 51 | logging.debug( 52 | "is_daytime({}, {}) - {}" 53 | .format(str(location_lat), str(location_long), str(verdict))) 54 | 55 | return verdict 56 | 57 | def get_response_json(self, url, headers={}): 58 | """ 59 | Perform an HTTP GET for a `url` with optional `headers`. 60 | Caches the response in `cache_file_name` for WEATHER_TTL seconds. 61 | Returns the response as JSON 62 | """ 63 | return get_json_from_url(url, headers, "cache_weather.json", self.ttl) 64 | 65 | def get_response_xml(self, url, headers={}): 66 | """ 67 | Perform an HTTP GET for a `url` with optional `headers`. 68 | Caches the response in `cache_file_name` for WEATHER_TTL seconds. 69 | Returns the response as an XML ElementTree 70 | """ 71 | return get_xml_from_url(url, headers, "cache_weather.xml", self.ttl) 72 | -------------------------------------------------------------------------------- /run.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | # shellcheck source=env.sh 4 | . env.sh 5 | 6 | function log { 7 | echo "---------------------------------------" 8 | echo "${1^^}" 9 | echo "---------------------------------------" 10 | } 11 | 12 | if [[ $WAVESHARE_EPD75_VERSION = 1 ]]; then 13 | export WAVESHARE_WIDTH=640 14 | export WAVESHARE_HEIGHT=384 15 | else 16 | export WAVESHARE_WIDTH=800 17 | export WAVESHARE_HEIGHT=480 18 | fi 19 | 20 | if [[ $PRIVACY_MODE_XKCD = 1 ]]; then 21 | log "Get XKCD comic strip" 22 | if ! .venv/bin/python3 xkcd_get.py; then 23 | .venv/bin/python3 display.py xkcd-comic-strip.png 24 | fi 25 | elif [[ $PRIVACY_MODE_LITERATURE_CLOCK = 1 ]]; then 26 | log "Get Literature Clock" 27 | if ! .venv/bin/python3 screen-literature-clock-get.py; then 28 | .venv/bin/cairosvg -o screen-literature-clock.png -f png --dpi 300 --output-width $WAVESHARE_WIDTH --output-height $WAVESHARE_HEIGHT screen-literature-clock.svg 29 | .venv/bin/python3 display.py screen-literature-clock.png 30 | fi 31 | else 32 | log "Add weather info" 33 | if ! .venv/bin/python3 screen-weather-get.py; then 34 | log "⚠️Error getting weather, stopping." 35 | exit 1 36 | fi 37 | 38 | log "Add Calendar info" 39 | if ! .venv/bin/python3 screen-calendar-get.py; then 40 | log "⚠️Error getting calendar info, stopping." 41 | exit 1 42 | fi 43 | 44 | # Only layout 5 shows a calendar, so save a few seconds. 45 | if [[ "$SCREEN_LAYOUT" -eq 5 ]]; then 46 | log "Add Calendar month" 47 | if ! .venv/bin/python3 screen-calendar-month.py; then 48 | log "⚠️Error getting calendar month info, stopping." 49 | exit 1 50 | fi 51 | fi 52 | 53 | if [[ -f screen-custom-get.py ]]; then 54 | log "Add Custom data" 55 | if ! .venv/bin/python3 screen-custom-get.py; then 56 | log "⚠️Error getting custom data, stopping." 57 | exit 1 58 | fi 59 | 60 | elif [[ ! -f screen-output-custom-temp.svg ]]; then 61 | # Create temporary empty svg since the main SVG needs it 62 | echo "" > screen-output-custom-temp.svg 63 | fi 64 | 65 | 66 | log "Export to PNG" 67 | 68 | .venv/bin/cairosvg -o screen-output.png -f png --dpi 300 --output-width $WAVESHARE_WIDTH --output-height $WAVESHARE_HEIGHT screen-output-weather.svg 69 | 70 | log "Display on screen" 71 | 72 | .venv/bin/python3 display.py screen-output.png 73 | fi 74 | -------------------------------------------------------------------------------- /screen-calendar-month.py: -------------------------------------------------------------------------------- 1 | import datetime 2 | import calendar 3 | from utility import update_svg, configure_locale, configure_logging 4 | import locale 5 | import babel 6 | import logging 7 | from collections import deque 8 | import drawsvg as draw 9 | 10 | configure_logging() 11 | configure_locale() 12 | 13 | try: 14 | babel_locale = babel.Locale(locale.getlocale()[0]) 15 | logging.debug(babel_locale) 16 | except babel.core.UnknownLocaleError: 17 | logging.error("Could not get locale from environment. Using default locale.") 18 | babel_locale = babel.Locale("") 19 | 20 | 21 | def main(): 22 | logging.info("Generating SVG for calendar month") 23 | 24 | # Python does not know about the locale's first day of week 🤦 https://stackoverflow.com/a/4265852/974369 25 | # Use babel to set it instead 26 | calendar.setfirstweekday(babel_locale.first_week_day) 27 | 28 | now = datetime.datetime.now() 29 | current_year, current_month, current_day = now.year, now.month, now.day 30 | 31 | # Get this month's calendar as a matrix 32 | cal = calendar.monthcalendar(current_year, current_month) 33 | 34 | # Create a new SVG drawing 35 | dwg = draw.Drawing(width=500, height=500, origin=(0,0), id='month-cal', transform='translate(500, 240)') 36 | 37 | cell_width = 40 38 | cell_height = 30 39 | 40 | # Have the day abbreviations respect the locale's first day of the week 41 | day_abbr = deque(list(calendar.day_abbr)) 42 | day_abbr.rotate(-calendar.firstweekday()) 43 | 44 | # Header for days of the week 45 | for i, day in enumerate(day_abbr): 46 | dwg.append(draw.Text(day[:2], font_size=None, x=i*cell_width + 20, y= 20, fill='black' )) 47 | 48 | # Days of the month per week 49 | for i, week in enumerate(cal): 50 | for j, day in enumerate(week): 51 | if day != 0: # calendar.monthcalendar pads with 0s 52 | text_fill = 'black' 53 | if day == current_day: 54 | text_fill = 'red' 55 | text = draw.Text(str(day), font_size=None, x=j*cell_width + 20, y=(i+2)*cell_height - 10, width=cell_width, height=cell_height, fill=text_fill) 56 | dwg.append(text) 57 | 58 | svg_output = dwg.as_svg() 59 | # Remove the line 60 | svg_output = svg_output.split('\n', 1)[1] 61 | 62 | output_svg_filename = 'screen-output-weather.svg' 63 | output_dict = {'MONTH_CAL': svg_output} 64 | logging.info("main() - {}".format(output_dict)) 65 | logging.info("Updating SVG") 66 | update_svg(output_svg_filename, output_svg_filename, output_dict) 67 | 68 | if __name__ == "__main__": 69 | main() 70 | -------------------------------------------------------------------------------- /icons/climacell_partly_cloudy_day.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 7 | 13 | 14 | 17 | 18 | 19 | 22 | 23 | 24 | 27 | 28 | 29 | 32 | 33 | 34 | -------------------------------------------------------------------------------- /icons/climacell_clear_day.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 8 | 10 | 12 | 14 | 16 | 19 | 22 | 25 | 28 | 29 | -------------------------------------------------------------------------------- /icons/tornado_hurricane.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 21 | 25 | 26 | 27 | 28 | -------------------------------------------------------------------------------- /icons/drought.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | -------------------------------------------------------------------------------- /icons/dust_ash_sand.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 9 | 10 | 11 | 12 | 13 | 16 | 17 | 18 | 19 | 20 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | 41 | 42 | 43 | 44 | 45 | 46 | 47 | 48 | 49 | 50 | 51 | 52 | 53 | 54 | 55 | 56 | 57 | 58 | 59 | 60 | 61 | 62 | 63 | 64 | 65 | 66 | 67 | 68 | 69 | 70 | 71 | 72 | 73 | 74 | 75 | 76 | 77 | 78 | 79 | 80 | 81 | -------------------------------------------------------------------------------- /icons/haze.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 11 | 12 | 13 | 14 | 15 | 20 | 21 | 22 | 23 | 24 | 29 | 30 | 31 | 32 | 33 | 38 | 39 | 40 | 41 | -------------------------------------------------------------------------------- /icons/climacell_snow.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 11 | 12 | 30 | 31 | -------------------------------------------------------------------------------- /icons/climacell_freezing_rain.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 23 | 28 | 29 | -------------------------------------------------------------------------------- /icons/volcano.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | -------------------------------------------------------------------------------- /calendar_providers/caldav.py: -------------------------------------------------------------------------------- 1 | 2 | import pickle 3 | import caldav 4 | from utility import is_stale 5 | import os 6 | import logging 7 | import datetime 8 | from .base_provider import BaseCalendarProvider, CalendarEvent 9 | 10 | 11 | ttl = float(os.getenv("CALENDAR_TTL", 1 * 60 * 60)) 12 | 13 | 14 | class CalDavCalendar(BaseCalendarProvider): 15 | 16 | def __init__(self, calendar_url, calendar_id, max_event_results, from_date, to_date, username=None, password=None): 17 | self.calendar_url = calendar_url 18 | self.calendar_id = calendar_id 19 | self.max_event_results = max_event_results 20 | self.username = username 21 | self.password = password 22 | self.from_date = from_date 23 | self.to_date = to_date 24 | 25 | def get_calendar_events(self): 26 | 27 | caldav_calendar_pickle = 'cache_caldav.pickle' 28 | calendar_events: list[CalendarEvent] = [] 29 | 30 | if is_stale(os.getcwd() + "/" + caldav_calendar_pickle, ttl): 31 | logging.debug("Pickle is stale, fetching Caldav Calendar") 32 | 33 | with caldav.DAVClient(url=self.calendar_url, username=self.username, password=self.password) as client: 34 | my_principal = client.principal() 35 | 36 | calendar = my_principal.calendar(cal_id=self.calendar_id) 37 | event_results = calendar.date_search(start=self.from_date, end=self.to_date, expand=True) 38 | events_data = [] 39 | 40 | for result in event_results: 41 | for component in result.icalendar_instance.subcomponents: 42 | events_data.append(component) 43 | 44 | # Sort by start date. Since some are dates, and some are datetimes, a simple string sort works 45 | events_data.sort(key=lambda x: str(x['DTSTART'].dt)) 46 | 47 | for event in events_data[0:self.max_event_results]: 48 | 49 | # If a dtend isn't included, calculate it from the duration 50 | if 'DTEND' in event: 51 | event_end = event['DTEND'].dt 52 | if 'DURATION' in event: 53 | event_end = event['DTSTART'].dt + event['DURATION'].dt 54 | 55 | all_day_event = False 56 | # CalDav Calendar marks the 'end' of all-day-events as 57 | # the day _after_ the last day. eg, Today's all day event ends tomorrow! 58 | # So subtract a day, if the event is an all day event 59 | if type(event_end) == datetime.date: 60 | event_end = event_end - datetime.timedelta(days=1) 61 | all_day_event = True 62 | 63 | calendar_events.append(CalendarEvent(str(event['SUMMARY']), event['DTSTART'].dt, event_end, all_day_event)) 64 | 65 | with open(caldav_calendar_pickle, 'wb') as cal: 66 | pickle.dump(calendar_events, cal) 67 | 68 | return calendar_events 69 | else: 70 | logging.info("Found in cache") 71 | with open(caldav_calendar_pickle, 'rb') as cal: 72 | calendar_events = pickle.load(cal) 73 | return calendar_events 74 | -------------------------------------------------------------------------------- /env.sh.sample: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | # Pick a weather provider 4 | # Climacell API Key 5 | # export CLIMACELL_APIKEY=xxxxxxxxxxxxxx 6 | # Or, OpenWeatherMap API Key 7 | # export OPENWEATHERMAP_APIKEY=xxxxxxxxxxxxxx 8 | # Or, MetOffice Weather DataHub API Key 9 | # export METOFFICEDATAHUB_API_KEY=xxxxxxxxxxxxxx 10 | # Or, AccuWeather API Key and Location Key 11 | # export ACCUWEATHER_APIKEY=xxxxxxxxxxxxxx 12 | # export ACCUWEATHER_LOCATIONKEY=xxxxxxxxxxxxxx 13 | # Or, Met.no self identification 14 | # export METNO_SELF_IDENTIFICATION=your_email_address 15 | # Or, Met Eireann (Ireland, no keys required) 16 | # export WEATHER_MET_EIREANN=1 17 | # Or, weather.gov self identification 18 | # export WEATHERGOV_SELF_IDENTIFICATION=you@example.com 19 | # Or, SMHI self identification 20 | # export SMHI_SELF_IDENTIFICATION=you@example.com 21 | 22 | # Your latitude and longitude to pass to weather providers 23 | export WEATHER_LATITUDE=51.5077 24 | export WEATHER_LONGITUDE=-0.1277 25 | 26 | # Choose CELSIUS or FAHRENHEIT 27 | export WEATHER_FORMAT=CELSIUS 28 | 29 | # Pick a calendar provider 30 | # Google Calendar ID, you can get this from Google Calendar Settings 31 | export GOOGLE_CALENDAR_ID=primary 32 | # If your Google Calendar is a family calendar or doesn't allow setting timezones 33 | # export GOOGLE_CALENDAR_TIME_ZONE_NAME=Asia/Kuala_Lumpur 34 | # Or if you use Outlook Calendar, use python3 outlook_util.py to get available Calendar IDs 35 | # export OUTLOOK_CALENDAR_ID=AQMkAxyz... 36 | # Or if you use ICS Calendar, 37 | # export ICS_CALENDAR_URL=https://calendar.google.com/calendar/ical/xxxxxxxxxxxx/xxxxxxxxxxxxxx/basic.ics 38 | # Or if you have a CalDave calendar 39 | # export CALDAV_CALENDAR_URL=https://nextcloud.example.com/remote.php/dav/principals/users/123456/ 40 | # export CALDAV_USERNAME=username 41 | # export CALDAV_PASSWORD=password 42 | # export CALDAV_CALENDAR_ID=xxxxxxxxxx 43 | 44 | # Most new Waveshare are 2, older ones are 1 (SKU: 13504) 45 | # For 7.5 inch B with Red, use "2B" (SKU: 13505) 46 | export WAVESHARE_EPD75_VERSION=2 47 | 48 | # Choose an alert provider (optional) 49 | # MetOffice Alerts - 50 | # export ALERT_METOFFICE_FEED_URL=https://www.metoffice.gov.uk/public/data/PWSCache/WarningsRSS/Region/se 51 | # Met Eireann Alerts - 52 | # The FIPS code for the regions of Ireland are listed here: http://www.statoids.com/uie.html 53 | # Visit https://www.met.ie/Open_Data/json/ and choose the appropriate "warning_EIXX" JSON 54 | # file for your region, using that FIPS code. 55 | # export ALERT_MET_EIREANN_FEED_URL=https://www.met.ie/Open_Data/json/warning_EI07.json 56 | # Weather.gov Alerts requires self identification 57 | # export ALERT_WEATHERGOV_SELF_IDENTIFICATION=you@example.com 58 | 59 | # Which layout to use. 1, 2, 3... 60 | export SCREEN_LAYOUT=1 61 | 62 | # Include all calendar events from today, even if they are past. 63 | # export CALENDAR_INCLUDE_PAST_EVENTS_FOR_TODAY=1 64 | 65 | # How long, in seconds, to cache weather for 66 | export WEATHER_TTL=3600 67 | # How long, in seconds, to cache the calendar for 68 | export CALENDAR_TTL=3600 69 | 70 | # Set a language, but ensure it's installed first. Run locale -a 71 | # export LANG=ko_KR.UTF-8 72 | 73 | # You can set this to DEBUG for troubleshooting, otherwise leave it at INFO. 74 | export LOG_LEVEL=INFO 75 | 76 | # Privacy mode. Just displays an XKCD comic instead. 77 | export PRIVACY_MODE_XKCD=0 78 | export PRIVACY_MODE_LITERATURE_CLOCK=0 79 | -------------------------------------------------------------------------------- /icons/climacell_rain.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 10 | 11 | 33 | 34 | -------------------------------------------------------------------------------- /weather_providers/visualcrossing.py: -------------------------------------------------------------------------------- 1 | import logging 2 | import datetime 3 | from weather_providers.base_provider import BaseWeatherProvider 4 | 5 | 6 | class VisualCrossing(BaseWeatherProvider): 7 | def __init__(self, visualcrossing_apikey, location_lat, location_long, units): 8 | self.visualcrossing_apikey = visualcrossing_apikey 9 | self.location_lat = location_lat 10 | self.location_long = location_long 11 | self.units = units 12 | 13 | # Map VisualCrossing icons to local icons 14 | # Reference: https://www.visualcrossing.com/resources/documentation/weather-api/defining-icon-set-in-the-weather-api/ 15 | def get_icon_from_visualcrossing_weathercode(self, weathercode, is_daytime): 16 | 17 | icon_dict = { 18 | "snow": "snow", # Amount of snow is greater than zero 19 | "rain": "climacell_rain_light" if is_daytime else "rain_night_light", # Amount of rainfall is greater than zero 20 | "fog": "climacell_fog", # Visibility is low (lower than one kilometer or mile) 21 | "wind": "wind", # Wind speed is high (greater than 30 kph or mph) 22 | "cloudy": "mostly_cloudy" if is_daytime else "mostly_cloudy_night", # Cloud cover is greater than 75% cover 23 | "partly-cloudy-day": "scattered_clouds" if is_daytime else "partlycloudynight", # Cloud cover is greater than 25% cover during day time. 24 | "partly-cloudy-night": "partlycloudynight", # Cloud cover is greater than 25% cover during night time. 25 | "clear-day": "clear_sky_day" if is_daytime else "clearnight", # Cloud cover is less than 25% cover during day time 26 | "clear-night": "clearnight" # Cloud cover is less than 25% cover during day time 27 | } 28 | 29 | icon = icon_dict[weathercode] 30 | logging.debug( 31 | "get_icon_by_weathercode({}, {}) - {}" 32 | .format(weathercode, is_daytime, icon)) 33 | 34 | return icon 35 | 36 | # Get weather from VisualCrossing Timeline API 37 | # https://www.visualcrossing.com/resources/documentation/weather-api/timeline-weather-api/ 38 | def get_weather(self): 39 | 40 | url = ("https://weather.visualcrossing.com/VisualCrossingWebServices/rest/services/timeline/{},{}?unitGroup={}&key={}&include=fcst,alerts" 41 | .format(self.location_lat, self.location_long, "us" if self.units != "metric" else "metric", self.visualcrossing_apikey)) 42 | 43 | response_data = self.get_response_json(url) 44 | 45 | current_day = datetime.datetime.now().strftime("%Y-%m-%d") 46 | 47 | for day_forecast in response_data["days"]: 48 | if day_forecast["datetime"] == current_day: 49 | weather_data = day_forecast 50 | 51 | logging.debug("get_weather() - {}".format(weather_data)) 52 | 53 | daytime = self.is_daytime(self.location_lat, self.location_long) 54 | 55 | # { "temperatureMin": "2.0", "temperatureMax": "15.1", "icon": "mostly_cloudy", "description": "Cloudy with light breezes" } 56 | weather = {} 57 | weather["temperatureMin"] = weather_data["tempmin"] 58 | weather["temperatureMax"] = weather_data["tempmax"] 59 | weather["icon"] = self.get_icon_from_visualcrossing_weathercode(weather_data["icon"], daytime) 60 | weather["description"] = weather_data["description"] 61 | logging.debug(weather) 62 | return weather 63 | -------------------------------------------------------------------------------- /icons/blizzard.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | -------------------------------------------------------------------------------- /icons/climacell_freezing_rain_heavy.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 11 | 38 | 39 | -------------------------------------------------------------------------------- /screen-template.2.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | HIGH_ONE/LOW_ONE 9 | WEATHER_DESC_1 10 | WEATHER_DESC_2 11 | 12 | CAL_DATETIME_1 13 | CAL_DESC_1 14 | 15 | 16 | CAL_DATETIME_2 17 | CAL_DESC_2 18 | 19 | 20 | CAL_DATETIME_3 21 | CAL_DESC_3 22 | 23 | 24 | CAL_DATETIME_4 25 | CAL_DESC_4 26 | 27 | 28 | CAL_DATETIME_5 29 | CAL_DESC_5 30 | 31 | 32 | CAL_DATETIME_6 33 | CAL_DESC_6 34 | 35 | 36 | CAL_DATETIME_7 37 | CAL_DESC_7 38 | 39 | 40 | CAL_DATETIME_8 41 | CAL_DESC_8 42 | 43 | DAY_NAME 44 | 45 | DAY_ONE 46 | 47 | 48 | TIME_NOW 49 | 50 | 51 | 52 | ALERT_MESSAGE 53 | 54 | -------------------------------------------------------------------------------- /icons/climacell_tstorm.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 21 | 25 | 31 | 36 | 37 | -------------------------------------------------------------------------------- /icons/night_partly_cloudy_rain.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 25 | 28 | 31 | 34 | 37 | 38 | 39 | 40 | -------------------------------------------------------------------------------- /icons/climacell_rain_heavy.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 31 | 32 | 38 | 39 | 40 | -------------------------------------------------------------------------------- /icons/climacell_mostly_clear_day.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 6 | 9 | 12 | 15 | 18 | 21 | 24 | 27 | 30 | 31 | 35 | 40 | 41 | -------------------------------------------------------------------------------- /icons/ice_pellets.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | -------------------------------------------------------------------------------- /icons/climacell_snow_heavy.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 11 | 12 | 42 | 43 | -------------------------------------------------------------------------------- /screen-template.1.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | ALERT_MESSAGE 10 | HIGH_ONE/LOW_ONE 11 | WEATHER_DESC_1 12 | WEATHER_DESC_2 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | CAL_DATETIME_1 23 | CAL_DESC_1 24 | 25 | 26 | 27 | CAL_DATETIME_2 28 | CAL_DESC_2 29 | 30 | 31 | 32 | CAL_DATETIME_3 33 | CAL_DESC_3 34 | 35 | 36 | 37 | CAL_DATETIME_4 38 | CAL_DESC_4 39 | 40 | 41 | 42 | DAY_NAME 43 | 44 | DAY_ONE 45 | 46 | TIME_NOW 47 | 48 | 49 | 50 | 51 | -------------------------------------------------------------------------------- /screen-literature-clock-get.py: -------------------------------------------------------------------------------- 1 | import random 2 | import codecs 3 | import textwrap 4 | from utility import is_stale 5 | import requests 6 | import csv 7 | import datetime 8 | import re 9 | import math 10 | 11 | 12 | if is_stale('litclock_annotated.csv', 86400): 13 | url = "https://raw.githubusercontent.com/JohannesNE/literature-clock/master/litclock_annotated.csv" 14 | response = requests.get(url) 15 | response.raise_for_status() 16 | with open('litclock_annotated.csv', 'w') as text_file: 17 | text_file.write(response.text) 18 | 19 | time_rows = [] 20 | current_time = datetime.datetime.now().strftime("%H:%M") 21 | # current_time = "07:32" 22 | with open('litclock_annotated.csv', 'r') as file: 23 | reader = csv.DictReader(file, 24 | fieldnames=[ 25 | "time", "time_human", "full_quote", "book_title", "author_name", "sfw"], 26 | delimiter='|', 27 | lineterminator='\n', 28 | quotechar=None, quoting=csv.QUOTE_NONE) 29 | for row in reader: 30 | if row["time"] == current_time and row["sfw"] != "nsfw": 31 | time_rows.append(row) 32 | 33 | 34 | if len(time_rows) == 0: 35 | print("No quotes found for this time.") 36 | exit() 37 | else: 38 | # chosen_item = random.choice(time_rows) 39 | chosen_item = min(time_rows, key=lambda x: len(x["full_quote"])) 40 | print(chosen_item) 41 | quote = chosen_item["full_quote"] 42 | book = chosen_item["book_title"] 43 | author = chosen_item["author_name"] 44 | human_time = chosen_item["time_human"] 45 | 46 | # replace newlines with spaces 47 | quote = quote.replace("
", " ") 48 | quote = quote.replace("
", " ") 49 | quote = quote.replace("
", " ") 50 | quote = quote.replace(u"\u00A0", " ") # non breaking space 51 | 52 | # replace punctuation with simpler counterparts 53 | transl_table = dict([(ord(x), ord(y)) for x, y in zip(u"‘’´“”—–-", u"'''\"\"---")]) 54 | quote = quote.translate(transl_table) 55 | human_time = human_time.translate(transl_table) 56 | quote = quote.encode('ascii', 'ignore').decode('utf-8') 57 | human_time = human_time.encode('ascii', 'ignore').decode('utf-8') 58 | 59 | quote_length = len(quote) 60 | 61 | # Try to calculate font size and max chars based on quote length 62 | goes_into = (quote_length / 100) if quote_length > 80 else 0 63 | font_size = 60 - (goes_into * 8) 64 | max_chars_per_line = 23 + (goes_into * 6) 65 | 66 | # Some upper and lower limit adjustments 67 | font_size = 25 if font_size < 25 else font_size 68 | max_chars_per_line = 55 if max_chars_per_line > 55 else max_chars_per_line 69 | 70 | font_size = math.ceil(font_size) 71 | max_chars_per_line = math.floor(max_chars_per_line) 72 | 73 | attribution = f"- {book}, {author}" 74 | if len(attribution) > 55: 75 | attribution = attribution[:55] + "…" 76 | 77 | print(f"Quote length: {quote_length}, Font size: {font_size}, Max chars per line: {max_chars_per_line}") 78 | 79 | quote_pattern = re.compile(re.escape(human_time), re.IGNORECASE) 80 | # Replace human time by itself but surrounded by pipes for later processing. 81 | quote = quote_pattern.sub(lambda x: f"|{x.group()}|", quote, count=1) 82 | 83 | lines = textwrap.wrap(quote, width=max_chars_per_line, break_long_words=True) 84 | 85 | generated_quote = "" 86 | time_ends_on_next_line = False 87 | for line in lines: 88 | start_span = "" 89 | end_span = "" 90 | 91 | if line.count("|") == 2: 92 | line = line.replace("|", "", 1) 93 | line = line.replace("|", "", 1) 94 | 95 | if line.count("|") == 1 and not time_ends_on_next_line: 96 | line = line.replace("|", "", 1) 97 | time_ends_on_next_line = True 98 | end_span = "" 99 | 100 | if line.count("|") == 1 and time_ends_on_next_line: 101 | line = line.replace("|", "", 1) 102 | time_ends_on_next_line = False 103 | start_span = "" 104 | 105 | generated_quote += f""" 106 | {start_span}{line}{end_span} 107 | """ 108 | generated_quote += f""" 109 | {attribution} 110 | """ 111 | 112 | svg_template = f""" 113 | 114 | 115 | 116 | 117 | 118 | {generated_quote} 119 | 120 | 121 | 122 | """ 123 | 124 | output_svg_filename = 'screen-literature-clock.svg' 125 | 126 | svg_output = svg_template 127 | 128 | codecs.open(output_svg_filename, 'w', encoding='utf-8').write(svg_output) 129 | -------------------------------------------------------------------------------- /icons/day_partly_cloudy_rain.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | -------------------------------------------------------------------------------- /icons/rain_icepellets_mix.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | -------------------------------------------------------------------------------- /icons/mostly_cloudy_night.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | -------------------------------------------------------------------------------- /screen-calendar-get.py: -------------------------------------------------------------------------------- 1 | import datetime 2 | import os.path 3 | import os 4 | import logging 5 | import emoji 6 | from xml.sax.saxutils import escape 7 | from calendar_providers.base_provider import CalendarEvent 8 | from calendar_providers.caldav import CalDavCalendar 9 | from calendar_providers.google import GoogleCalendar 10 | from calendar_providers.ics import ICSCalendar 11 | from calendar_providers.outlook import OutlookCalendar 12 | from utility import get_formatted_time, update_svg, configure_logging, get_formatted_date, configure_locale 13 | 14 | configure_locale() 15 | configure_logging() 16 | 17 | # note: increasing this will require updates to the SVG template to accommodate more events 18 | max_event_results = 10 19 | 20 | google_calendar_id = os.getenv("GOOGLE_CALENDAR_ID", "primary") 21 | outlook_calendar_id = os.getenv("OUTLOOK_CALENDAR_ID", None) 22 | 23 | caldav_calendar_url = os.getenv('CALDAV_CALENDAR_URL', None) 24 | caldav_username = os.getenv("CALDAV_USERNAME", None) 25 | caldav_password = os.getenv("CALDAV_PASSWORD", None) 26 | caldav_calendar_id = os.getenv("CALDAV_CALENDAR_ID", None) 27 | 28 | ics_calendar_url = os.getenv("ICS_CALENDAR_URL", None) 29 | 30 | ttl = float(os.getenv("CALENDAR_TTL", 1 * 60 * 60)) 31 | 32 | 33 | def get_formatted_calendar_events(fetched_events: list[CalendarEvent]) -> dict: 34 | formatted_events = {} 35 | event_count = len(fetched_events) 36 | 37 | for index in range(max_event_results): 38 | event_label_id = str(index + 1) 39 | if index <= event_count - 1: 40 | formatted_events['CAL_DATETIME_' + event_label_id] = get_datetime_formatted(fetched_events[index].start, fetched_events[index].end, fetched_events[index].all_day_event) 41 | formatted_events['CAL_DATETIME_START_' + event_label_id] = get_datetime_formatted(fetched_events[index].start, fetched_events[index].end, fetched_events[index].all_day_event, True) 42 | formatted_events['CAL_DESC_' + event_label_id] = fetched_events[index].summary 43 | else: 44 | formatted_events['CAL_DATETIME_' + event_label_id] = "" 45 | formatted_events['CAL_DESC_' + event_label_id] = "" 46 | 47 | return formatted_events 48 | 49 | 50 | def get_datetime_formatted(event_start, event_end, is_all_day_event, start_only=False): 51 | 52 | if is_all_day_event or type(event_start) == datetime.date: 53 | start = datetime.datetime.combine(event_start, datetime.time.min) 54 | end = datetime.datetime.combine(event_end, datetime.time.min) 55 | 56 | start_day = get_formatted_date(start, include_time=False) 57 | end_day = get_formatted_date(end, include_time=False) 58 | if start == end: 59 | day = start_day 60 | else: 61 | day = "{} - {}".format(start_day, end_day) 62 | elif type(event_start) == datetime.datetime: 63 | start_date = event_start 64 | end_date = event_end 65 | if start_date.date() == end_date.date(): 66 | start_formatted = get_formatted_date(start_date) 67 | end_formatted = get_formatted_time(end_date) 68 | else: 69 | start_formatted = get_formatted_date(start_date) 70 | end_formatted = get_formatted_date(end_date) 71 | day = start_formatted if start_only else "{} - {}".format(start_formatted, end_formatted) 72 | else: 73 | day = '' 74 | return day 75 | 76 | 77 | def main(): 78 | 79 | output_svg_filename = 'screen-output-weather.svg' 80 | 81 | today_start_time = datetime.datetime.utcnow() 82 | if os.getenv("CALENDAR_INCLUDE_PAST_EVENTS_FOR_TODAY", "0") == "1": 83 | today_start_time = datetime.datetime.combine(datetime.datetime.utcnow(), datetime.datetime.min.time()) 84 | oneyearlater_iso = (datetime.datetime.now().astimezone() 85 | + datetime.timedelta(days=365)).astimezone() 86 | 87 | if outlook_calendar_id: 88 | logging.info("Fetching Outlook Calendar Events") 89 | provider = OutlookCalendar(outlook_calendar_id, max_event_results, today_start_time, oneyearlater_iso) 90 | elif caldav_calendar_url: 91 | logging.info("Fetching Caldav Calendar Events") 92 | provider = CalDavCalendar(caldav_calendar_url, caldav_calendar_id, max_event_results, 93 | today_start_time, oneyearlater_iso, caldav_username, caldav_password) 94 | elif ics_calendar_url: 95 | logging.info("Fetching ics Calendar Events") 96 | provider = ICSCalendar(ics_calendar_url, max_event_results, today_start_time, oneyearlater_iso) 97 | else: 98 | logging.info("Fetching Google Calendar Events") 99 | provider = GoogleCalendar(google_calendar_id, max_event_results, today_start_time, oneyearlater_iso) 100 | 101 | calendar_events = provider.get_calendar_events() 102 | output_dict = get_formatted_calendar_events(calendar_events) 103 | 104 | # XML escape for safety 105 | for key, value in output_dict.items(): 106 | output_dict[key] = escape(value) 107 | 108 | # Surround emojis with font-family emoji so it's rendered properly. Workaround for cairo not using fallback fonts. 109 | for key, value in output_dict.items(): 110 | output_dict[key] = emoji.replace_emoji(value, replace=lambda chars, data_dict: '' + chars + '') 111 | 112 | logging.info("main() - {}".format(output_dict)) 113 | 114 | logging.info("Updating SVG") 115 | update_svg(output_svg_filename, output_svg_filename, output_dict) 116 | 117 | 118 | if __name__ == "__main__": 119 | main() 120 | -------------------------------------------------------------------------------- /weather_providers/weathergov.py: -------------------------------------------------------------------------------- 1 | import logging 2 | from utility import get_json_from_url 3 | from weather_providers.base_provider import BaseWeatherProvider 4 | 5 | 6 | class WeatherGov(BaseWeatherProvider): 7 | def __init__(self, weathergov_self_id, location_lat, location_long, units): 8 | self.weathergov_self_id = weathergov_self_id 9 | self.location_lat = location_lat 10 | self.location_long = location_long 11 | self.units = units 12 | 13 | 14 | # Map weather.gov Icon URLs to icons 15 | # Reference https://api.weather.gov/icons and https://www.weather.gov/forecast-icons 16 | def get_icon_from_weathergov_icon_urls(self, icon_url, is_daytime): 17 | logging.debug(icon_url) 18 | # https://api.weather.gov/icons/land/day/sct/rain,30?size=medium --> sct 19 | weathergov_icon = icon_url.replace("https://","").split("/")[4].split(",")[0].split("?")[0] 20 | 21 | icon_dict = { 22 | "skc": "clear_sky_day" if is_daytime else "clearnight", 23 | "few": "few_clouds" if is_daytime else "partlycloudynight", 24 | "sct": "scattered_clouds" if is_daytime else "partlycloudynight", 25 | "bkn": "mostly_cloudy" if is_daytime else "mostly_cloudy_night", 26 | "ovc": "overcast", 27 | "wind_skc": "wind", 28 | "wind_few": "wind", 29 | "wind_sct": "wind", 30 | "wind_bkn": "wind", 31 | "wind_ovc": "wind", 32 | "snow": "snow", 33 | "rain_snow": "sleet", 34 | "rain_sleet": "sleet", 35 | "snow_sleet": "sleet", 36 | "fzra": "climacell_freezing_rain", 37 | "rain_fzra": "climacell_freezing_rain", 38 | "snow_fzra": "climacell_freezing_rain", 39 | "sleet": "sleet", 40 | "rain": "climacell_rain_light" if is_daytime else 'rain_night_light', 41 | "rain_showers": "climacell_rain" if is_daytime else "rain_night", 42 | "rain_showers_hi": "day_partly_cloudy_rain" if is_daytime else "night_partly_cloudy_rain", 43 | "tsra": "thundershower_rain", 44 | "tsra_sct": "scattered_thundershowers", 45 | "tsra_hi": "scattered_thundershowers", 46 | "tornado": "tornado_hurricane", 47 | "hurricane": "tornado_hurricane", 48 | "tropical_storm": "tornado_hurricane", 49 | "dust": "dust_ash_sand", 50 | "smoke": "fire_smoke", 51 | "haze": "haze", 52 | "hot": "very_hot", 53 | "cold": "cold", 54 | "blizzard": "blizzard", 55 | "fog": "climacell_fog" 56 | } 57 | 58 | return icon_dict[weathergov_icon] 59 | 60 | def get_forecast_url(self, lat, long): 61 | logging.info("Using lat long to figure out the Weather.gov forecast URL") 62 | lookup_url = "https://api.weather.gov/points/{},{}".format(lat, long) 63 | lookup_data = get_json_from_url(lookup_url, {'User-Agent':'({0})'.format(self.weathergov_self_id)}, "cache_weather_gov_lookup.json", 3600) 64 | logging.debug(lookup_data) 65 | return lookup_data["properties"]["forecast"] 66 | 67 | # Get weather from Weather.Gov, US only 68 | # https://www.weather.gov/documentation/services-web-api 69 | def get_weather(self): 70 | 71 | forecast_url = self.get_forecast_url(self.location_lat, self.location_long) 72 | # https://api.weather.gov/gridpoints/TOP/31,80/forecast" 73 | logging.info(forecast_url) 74 | 75 | response_data = self.get_response_json(forecast_url, {'User-Agent':'({0})'.format(self.weathergov_self_id)}) 76 | weather_data = response_data 77 | logging.debug("get_weather() - {}".format(weather_data)) 78 | 79 | daytime = self.is_daytime(self.location_lat, self.location_long) 80 | 81 | # Weather.gov doesn't provide a min max temperature. It uses the current and upcoming temperatures as min max instead. 82 | current_forecast = weather_data["properties"]["periods"][0] 83 | upcoming_forecast = weather_data["properties"]["periods"][1] 84 | min_temp = min(current_forecast["temperature"], upcoming_forecast["temperature"]) 85 | max_temp = max(current_forecast["temperature"], upcoming_forecast["temperature"]) 86 | 87 | # {'number': 2, 'name': 'Tonight', 'startTime': '2022-03-06T18:00:00-06:00', 'endTime': '2022-03-07T06:00:00-06:00', 'isDaytime': False, 'temperature': 20, 'temperatureUnit': 'F', 'temperatureTrend': None, 'windSpeed': '10 to 15 mph', 'windDirection': 'N', 'icon': 'https://api.weather.gov/icons/land/night/snow,30/snow,20?size=medium', 'shortForecast': 'Chance Rain And Snow', 'detailedForecast': 'A chance of rain and snow before 3am. Mostly cloudy, with a low around 20. North wind 10 to 15 mph, with gusts as high as 25 mph. Chance of precipitation is 30%.'} 88 | # current_forecast = day_forecast if daytime else night_forecast 89 | 90 | weather = {} 91 | weather["description"] = current_forecast["shortForecast"] 92 | weather["temperatureMin"] = min_temp if self.units != "metric" else self.f_to_c(min_temp) 93 | weather["temperatureMax"] = max_temp if self.units != "metric" else self.f_to_c(max_temp) 94 | weather["icon"] = self.get_icon_from_weathergov_icon_urls(current_forecast["icon"], daytime) 95 | logging.debug(weather) 96 | return weather 97 | -------------------------------------------------------------------------------- /calendar_providers/outlook.py: -------------------------------------------------------------------------------- 1 | 2 | import datetime 3 | from calendar_providers.base_provider import BaseCalendarProvider, CalendarEvent 4 | from utility import is_stale 5 | import os 6 | import logging 7 | import pickle 8 | import msal 9 | import requests 10 | import sys 11 | import json 12 | from dateutil import tz 13 | 14 | ttl = float(os.getenv("CALENDAR_TTL", 1 * 60 * 60)) 15 | 16 | 17 | class OutlookCalendar(BaseCalendarProvider): 18 | 19 | def __init__(self, outlook_calendar_id, max_event_results, from_date, to_date): 20 | self.max_event_results = max_event_results 21 | self.from_date = from_date 22 | self.to_date = to_date 23 | self.outlook_calendar_id = outlook_calendar_id 24 | 25 | def get_access_token(self): 26 | mscache = msal.SerializableTokenCache() 27 | if os.path.exists("outlooktoken.bin"): 28 | mscache.deserialize(open("outlooktoken.bin", "r").read()) 29 | 30 | app = msal.PublicClientApplication("3b49f0d7-201a-4b5d-b2b4-8f4c3e6c8a30", 31 | authority="https://login.microsoftonline.com/consumers", 32 | token_cache=mscache) 33 | 34 | result = None 35 | 36 | accounts = app.get_accounts() 37 | 38 | if accounts: 39 | chosen = accounts[0] 40 | result = app.acquire_token_silent(["https://graph.microsoft.com/Calendars.Read"], account=chosen) 41 | 42 | if not result: 43 | logging.info("No token exists in cache, login is required.") 44 | 45 | flow = app.initiate_device_flow(scopes=["https://graph.microsoft.com/Calendars.Read"]) 46 | if "user_code" not in flow: 47 | raise ValueError( 48 | "Fail to create device flow. Err: %s" % json.dumps(flow, indent=4)) 49 | 50 | print("") 51 | print(flow["message"]) 52 | sys.stdout.flush() 53 | 54 | result = app.acquire_token_by_device_flow(flow) 55 | 56 | logging.debug(result) 57 | 58 | if "access_token" in result: 59 | if mscache.has_state_changed: 60 | open("outlooktoken.bin", "w").write(mscache.serialize()) 61 | 62 | return result["access_token"] 63 | else: 64 | logging.error(result.get("error")) 65 | logging.error(result.get("error_description")) 66 | logging.error(result.get("correlation_id")) 67 | raise Exception(result.get("error")) 68 | 69 | def get_outlook_calendar_events(self, calendar_id, from_date, to_date, access_token): 70 | 71 | from_date_iso = from_date.replace(microsecond=0).isoformat() 72 | to_date_iso = to_date.isoformat() 73 | 74 | headers = {'Authorization': 'Bearer ' + access_token} 75 | endpoint_calendar_view = \ 76 | "https://graph.microsoft.com/v1.0/me/calendars/{0}/calendarview?startdatetime={1}&enddatetime={2}&$orderby=start/dateTime&$top={3}" 77 | events_data = requests.get( 78 | endpoint_calendar_view.format(calendar_id, 79 | requests.utils.quote(from_date_iso), 80 | requests.utils.quote(to_date_iso), 81 | self.max_event_results), 82 | headers=headers).json() 83 | return events_data 84 | 85 | def get_calendar_events(self, bypass_cache=False) -> list[CalendarEvent]: 86 | calendar_events = [] 87 | outlook_calendar_pickle = 'cache_outlookcalendar.pickle' 88 | if bypass_cache or is_stale(os.getcwd() + "/" + outlook_calendar_pickle, ttl): 89 | logging.debug("Cache is stale, calling the Outlook Calendar API") 90 | 91 | access_token = self.get_access_token() 92 | events_data = self.get_outlook_calendar_events( 93 | self.outlook_calendar_id, 94 | self.from_date, 95 | self.to_date, 96 | access_token) 97 | logging.debug(events_data) 98 | 99 | if not bypass_cache: 100 | with open(outlook_calendar_pickle, 'wb') as cal: 101 | pickle.dump(events_data, cal) 102 | else: 103 | logging.info("Found in cache") 104 | with open(outlook_calendar_pickle, 'rb') as cal: 105 | events_data = pickle.load(cal) 106 | 107 | for event in events_data["value"]: 108 | start_date = datetime.datetime.strptime(event["start"]["dateTime"], "%Y-%m-%dT%H:%M:%S.0000000") 109 | end_date = datetime.datetime.strptime(event["end"]["dateTime"], "%Y-%m-%dT%H:%M:%S.0000000") 110 | 111 | summary = event["subject"] 112 | is_all_day = event['isAllDay'] 113 | 114 | # Outlook Calendar marks the 'end' of all-day-events as 115 | # the day _after_ the last day. eg, Today's all day event ends tomorrow midnight. 116 | # So subtract a day 117 | if is_all_day: 118 | end_date = end_date - datetime.timedelta(days=1) 119 | else: 120 | # Convert start/end to local time 121 | start_date = start_date.replace(tzinfo=tz.tzutc()) 122 | start_date = start_date.astimezone(tz.tzlocal()) 123 | end_date = end_date.replace(tzinfo=tz.tzutc()) 124 | end_date = end_date.astimezone(tz.tzlocal()) 125 | 126 | calendar_events.append(CalendarEvent(summary, start_date, end_date, is_all_day)) 127 | 128 | return calendar_events 129 | -------------------------------------------------------------------------------- /calendar_providers/google.py: -------------------------------------------------------------------------------- 1 | import datetime 2 | from calendar_providers.base_provider import BaseCalendarProvider, CalendarEvent 3 | from utility import is_stale, xor_decode 4 | import os 5 | import logging 6 | import pickle 7 | from google_auth_oauthlib.flow import InstalledAppFlow 8 | from google.auth.transport.requests import Request 9 | from googleapiclient.discovery import build 10 | 11 | ttl = float(os.getenv("CALENDAR_TTL", 1 * 60 * 60)) 12 | google_calendar_timezone = os.getenv("GOOGLE_CALENDAR_TIME_ZONE_NAME", None) 13 | 14 | 15 | class GoogleCalendar(BaseCalendarProvider): 16 | def __init__(self, google_calendar_id, max_event_results, from_date, to_date): 17 | self.max_event_results = max_event_results 18 | self.from_date = from_date 19 | self.to_date = to_date 20 | self.google_calendar_id = google_calendar_id 21 | 22 | def get_google_credentials(self): 23 | 24 | google_token_pickle = 'token.pickle' 25 | 26 | google_api_scopes = ['https://www.googleapis.com/auth/calendar.readonly'] 27 | 28 | credentials = None 29 | # The file token.pickle stores the user's access and refresh tokens, and is 30 | # created automatically when the authorization flow completes for the first 31 | # time. 32 | if os.path.exists(google_token_pickle): 33 | with open(google_token_pickle, 'rb') as token: 34 | credentials = pickle.load(token) 35 | 36 | # If there are no (valid) credentials available, let the user log in. 37 | if not credentials or not credentials.valid: 38 | if credentials and credentials.expired and credentials.refresh_token: 39 | credentials.refresh(Request()) 40 | else: 41 | flow = InstalledAppFlow.from_client_config({"installed": { 42 | "client_id": "872428123454-jjp9mvs2ha4at913874ik2ua6fosi23d.apps.googleusercontent.com", 43 | "project_id": "waveshare-epaper-display", 44 | "auth_uri": "https://accounts.google.com/o/oauth2/auth", 45 | "token_uri": "https://oauth2.googleapis.com/token", 46 | "auth_provider_x509_cert_url": "https://www.googleapis.com/oauth2/v1/certs", 47 | # Pointless xor. For embedded/desktop apps, the client secret is not considered a secret but is called a secret 🙃 48 | # So here it's just for storage. 49 | # https://stackoverflow.com/questions/71111416/how-to-protect-client-credentials-in-a-desktop-app 50 | # https://stackoverflow.com/questions/59416326/safely-distribute-oauth-2-0-client-secret-in-desktop-applications-in-python 51 | # https://stackoverflow.com/questions/78857716/can-i-include-google-oauth-client-secret-in-my-desktop-application 52 | "client_secret": xor_decode("HwI5FzprZiBiE0AtJ3A7IjZ+LB4VCBQjHmcIN1IBJz0QBjg=", "XMzDj3Kb4j2j_3jK_8dwoeuir3mm3jKb"), 53 | "redirect_uris": ["http://localhost"]}}, google_api_scopes) 54 | 55 | credentials = flow.run_local_server() 56 | # Save the credentials for the next run 57 | with open(google_token_pickle, 'wb') as token: 58 | pickle.dump(credentials, token) 59 | 60 | return credentials 61 | 62 | def get_calendar_events(self) -> list[CalendarEvent]: 63 | calendar_events = [] 64 | google_calendar_pickle = 'cache_calendar.pickle' 65 | 66 | service = build('calendar', 'v3', credentials=self.get_google_credentials(), cache_discovery=False) 67 | 68 | events_result = None 69 | 70 | if is_stale(os.getcwd() + "/" + google_calendar_pickle, ttl): 71 | logging.debug("Pickle is stale, calling the Calendar API") 72 | 73 | # Call the Calendar API 74 | events_result = service.events().list( 75 | calendarId=self.google_calendar_id, 76 | timeMin=self.from_date.isoformat() + 'Z', 77 | timeZone=google_calendar_timezone, 78 | maxResults=self.max_event_results, 79 | singleEvents=True, 80 | orderBy='startTime').execute() 81 | 82 | for event in events_result.get('items', []): 83 | if event['start'].get('date'): 84 | is_all_day = True 85 | start_date = datetime.datetime.strptime(event['start'].get('date'), "%Y-%m-%d") 86 | end_date = datetime.datetime.strptime(event['end'].get('date'), "%Y-%m-%d") 87 | # Google Calendar marks the 'end' of all-day-events as 88 | # the day _after_ the last day. eg, Today's all day event ends tomorrow! 89 | # So subtract a day 90 | end_date = end_date - datetime.timedelta(days=1) 91 | else: 92 | is_all_day = False 93 | start_date = datetime.datetime.strptime(event['start'].get('dateTime'), "%Y-%m-%dT%H:%M:%S%z") 94 | end_date = datetime.datetime.strptime(event['end'].get('dateTime'), "%Y-%m-%dT%H:%M:%S%z") 95 | 96 | summary = event['summary'] 97 | 98 | calendar_events.append(CalendarEvent(summary, start_date, end_date, is_all_day)) 99 | 100 | with open(google_calendar_pickle, 'wb') as cal: 101 | pickle.dump(calendar_events, cal) 102 | 103 | else: 104 | logging.info("Found in cache") 105 | with open(google_calendar_pickle, 'rb') as cal: 106 | calendar_events = pickle.load(cal) 107 | 108 | if len(calendar_events) == 0: 109 | logging.info("No upcoming events found.") 110 | 111 | return calendar_events 112 | -------------------------------------------------------------------------------- /weather_providers/accuweather.py: -------------------------------------------------------------------------------- 1 | import logging 2 | from weather_providers.base_provider import BaseWeatherProvider 3 | 4 | 5 | class AccuWeather(BaseWeatherProvider): 6 | def __init__(self, accuweather_apikey, location_lat, location_long, location_key, units): 7 | self.accuweather_apikey = accuweather_apikey 8 | self.location_lat = location_lat 9 | self.location_long = location_long 10 | self.location_key = location_key 11 | self.units = units 12 | 13 | # Map Accuweather icons to local icons 14 | # Reference: https://developer.accuweather.com/weather-icons 15 | def get_icon_from_accuweather_weathercode(self, weathercode, is_daytime): 16 | 17 | icon_dict = { 18 | 1: "clear_sky_day" if is_daytime else "clearnight", # Day - Sunny 19 | 2: "clear_sky_day" if is_daytime else "clearnight", # Day - Mostly Sunny 20 | 3: "few_clouds" if is_daytime else "partlycloudynight", # Day - Partly Sunny 21 | 4: "scattered_clouds" if is_daytime else "partlycloudynight", # Day - Intermittent Clouds 22 | 5: "haze", # Day - Hazy Sunshine 23 | 6: "mostly_cloudy" if is_daytime else "mostly_cloudy_night", # Day - Mostly Cloudy 24 | 7: "climacell_cloudy" if is_daytime else 'mostly_cloudy_night', # DayNight - Cloudy 25 | 8: "overcast", # DayNight - Dreary (Overcast) 26 | 11: "climacell_fog", # DayNight - Fog 27 | 12: 'climacell_rain_light' if is_daytime else 'rain_night_light', # DayNight - Showers 28 | 13: 'day_partly_cloudy_rain' if is_daytime else 'night_partly_cloudy_rain', # Day - Mostly Cloudy w/ Showers 29 | 14: 'day_partly_cloudy_rain' if is_daytime else 'night_partly_cloudy_rain', # Day - Partly Sunny w/ Showers 30 | 15: "thundershower_rain", # DayNight - T-Storms 31 | 16: "scattered_thundershowers", # Day - Mostly Cloudy w/ T-Storms 32 | 17: "scattered_thundershowers", # Day - Partly Sunny w/ T-Storms 33 | 18: "climacell_rain" if is_daytime else "rain_night", # DayNight - Rain 34 | 19: "climacell_flurries", # DayNight - Flurries 35 | 20: "climacell_flurries", # Day - Mostly Cloudy w/ Flurries 36 | 21: "climacell_flurries", # Day - Partly Sunny w/ Flurries 37 | 22: "snow", # DayNight - Snow 38 | 23: "snow", # Day - Mostly Cloudy w/ Snow 39 | 24: "climacell_freezing_rain", # DayNight - Ice 40 | 25: "sleet", # DayNight - Sleet 41 | 26: "climacell_freezing_rain", # DayNight - Freezing Rain 42 | 29: "sleet", # DayNight - Rain and Snow 43 | 30: "very_hot", # DayNight - Hot 44 | 31: "cold", # DayNight - Cold 45 | 32: "wind", # DayNight - Windy 46 | 33: "clear_sky_day" if is_daytime else "clearnight", # Night - Clear 47 | 34: "clear_sky_day" if is_daytime else "clearnight", # Night - Mostly Clear 48 | 35: "few_clouds" if is_daytime else "partlycloudynight", # Night - Partly Cloudy 49 | 36: "scattered_clouds" if is_daytime else "partlycloudynight", # Night - Intermittent Clouds 50 | 37: "haze", # Night - Hazy Moonlight 51 | 38: "mostly_cloudy" if is_daytime else "mostly_cloudy_night", # Night - Mostly Cloudy 52 | 39: 'day_partly_cloudy_rain' if is_daytime else 'night_partly_cloudy_rain', # Night - Partly Cloudy w/ Showers 53 | 40: 'day_partly_cloudy_rain' if is_daytime else 'night_partly_cloudy_rain', # Night - Mostly Cloudy w/ Showers 54 | 41: "thundershower_rain", # Night - Partly Cloudy w/ T-Storms 55 | 42: "thundershower_rain", # Night - Mostly Cloudy w/ T-Storms 56 | 43: "climacell_flurries", # Night - Mostly Cloudy w/ Flurries 57 | 44: "snow" # Night - Mostly Cloudy w/ Snow 58 | } 59 | 60 | icon = icon_dict[weathercode] 61 | logging.debug( 62 | "get_icon_by_weathercode({}, {}) - {}" 63 | .format(weathercode, is_daytime, icon)) 64 | 65 | return icon 66 | 67 | # Get weather from Accuweather Daily Forecast API 68 | # https://developer.accuweather.com/accuweather-forecast-api/apis/get/forecasts/v1/daily/1day/%7BlocationKey%7D 69 | def get_weather(self): 70 | 71 | url = ("http://dataservice.accuweather.com/forecasts/v1/daily/1day/{}?apikey={}&details=true&metric={}" 72 | .format(self.location_key, self.accuweather_apikey, "true" if self.units == "metric" else "false")) 73 | 74 | response_data = self.get_response_json(url) 75 | weather_data = response_data 76 | logging.debug("get_weather() - {}".format(weather_data)) 77 | 78 | daytime = self.is_daytime(self.location_lat, self.location_long) 79 | accuweather_icon = weather_data["DailyForecasts"][0]["Day"]["Icon"] if daytime else weather_data["DailyForecasts"][0]["Night"]["Icon"] 80 | # { "temperatureMin": "2.0", "temperatureMax": "15.1", "icon": "mostly_cloudy", "description": "Cloudy with light breezes" } 81 | weather = {} 82 | weather["temperatureMin"] = weather_data["DailyForecasts"][0]["Temperature"]["Minimum"]["Value"] 83 | weather["temperatureMax"] = weather_data["DailyForecasts"][0]["Temperature"]["Maximum"]["Value"] 84 | weather["icon"] = self.get_icon_from_accuweather_weathercode(accuweather_icon, daytime) 85 | weather["description"] = weather_data["DailyForecasts"][0]["Day"]["ShortPhrase"] if daytime else weather_data["DailyForecasts"][0]["Night"]["ShortPhrase"] 86 | logging.debug(weather) 87 | return weather 88 | --------------------------------------------------------------------------------