├── 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 |
--------------------------------------------------------------------------------
/screen-custom.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/icons/overcast.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/.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 |
--------------------------------------------------------------------------------
/icons/climacell_mostly_cloudy.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/icons/clear_sky_day.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/icons/cold.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/icons/clearnight.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/icons/wind.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/icons/climacell_partly_cloudy_night.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/icons/partlycloudynight.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/icons/climacell_ice_pellets_light.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/icons/climacell_mostly_clear_night.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/icons/mostly_cloudy.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/icons/rain_night.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/icons/climacell_cloudy.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/icons/climacell_flurries.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/icons/freezing_rain.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/icons/scattered_thundershowers.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/icons/climacell_ice_pellets.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/icons/fire_smoke.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/icons/climacell_snow_light.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/icons/climacell_freezing_drizzle.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/icons/few_clouds.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/icons/scattered_clouds_fog.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/icons/rain_snow_mix.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/icons/climacell_freezing_rain_light.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/icons/climacell_fog_light.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/icons/shower_spells.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/icons/climacell_fog.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/icons/climacell_rain_light.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/icons/sleet.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/icons/climacell_clear_day.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/icons/tornado_hurricane.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/icons/drought.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/icons/dust_ash_sand.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/icons/haze.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/icons/climacell_snow.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/icons/climacell_freezing_rain.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/icons/volcano.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/icons/climacell_freezing_rain_heavy.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/screen-template.2.svg:
--------------------------------------------------------------------------------
1 |
2 |
54 |
--------------------------------------------------------------------------------
/icons/climacell_tstorm.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/icons/night_partly_cloudy_rain.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/icons/climacell_rain_heavy.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/icons/climacell_mostly_clear_day.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/icons/ice_pellets.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/icons/climacell_snow_heavy.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/screen-template.1.svg:
--------------------------------------------------------------------------------
1 |
2 |
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 |
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 |
--------------------------------------------------------------------------------
/icons/rain_icepellets_mix.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/icons/mostly_cloudy_night.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------