diff --git a/.gitignore b/.gitignore index d2413c55..119bd7c1 100644 --- a/.gitignore +++ b/.gitignore @@ -1,24 +1,21 @@ __pycache__ *.__pycache__ *.pyc -screen-output* -screen-custom-get.py +screen_custom_get.py env.sh +config.toml *.log -*.pickle credentials* -*.json !.vscode/* -*.bin test.svg *.xml venv -cache_ .venv .env -xkcd-comic-strip.png +.notes +*.bak -screen-literature*.svg -screen-literature*.png -litclock_annotated.csv +# Generated files, tokens, cache, etc. +data/* +!data/.gitkeep diff --git a/.tombi.toml b/.tombi.toml new file mode 100644 index 00000000..917ac119 --- /dev/null +++ b/.tombi.toml @@ -0,0 +1,7 @@ +toml-version = "v1.0.0" +[files] +include = ["**/*.toml"] + +[format] +[format.rules] +key-value-equals-sign-alignment = true diff --git a/.vscode/settings.json b/.vscode/settings.json index 69d602a4..0b8dd2de 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -1,7 +1,11 @@ { "python.defaultInterpreterPath": ".venv/bin/python", - "python.envFile": "${workspaceFolder}/env.sh", "python.terminal.activateEnvironment": true, "python.terminal.activateEnvInCurrentTerminal": true, - "markdown.extension.toc.updateOnSave": false + "markdown.extension.toc.updateOnSave": false, + "python.useEnvironmentsExtension": false, + "flake8.interpreter": [".venv/bin/python"], + "flake8.enabled": true, + "flake8.args": ["--max-line-length=180"], + } diff --git a/.vscode/tasks.json b/.vscode/tasks.json deleted file mode 100644 index af74911b..00000000 --- a/.vscode/tasks.json +++ /dev/null @@ -1,12 +0,0 @@ -{ - // See https://go.microsoft.com/fwlink/?LinkId=733558 - // for the documentation about the tasks.json format - "version": "2.0.0", - "tasks": [ - { - "label": "Source Shell", - "type": "shell", - "command": "source env.sh; printenv > ${workspaceFolder}/.env;" - } - ] -} \ No newline at end of file diff --git a/CHANGELOG.md b/CHANGELOG.md index e7c3ae32..28bcb7c5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,11 @@ +## 2026-06-21 +* Moved from env.sh to config.toml for configuration, as it was growing too large. It's a breaking change. A helper script, utils/migrate_env_to_toml.py is provided. +* Folders restructured for better organization, root project was getting very messy. +* Added a 'data' folder for the temporary files, tokens, cache output +* Renamed files to use underscore for consistency +* If upgrading, I recommend renaming the existing folder to a backup. Clone a fresh copy of the repo. Then copy the env.sh into the new folder and run `utils/migrate_env_to_toml.py` to generate a config.toml. +* A backup branch, just before the refactor, is at `master-20260621` + ## 2025-04-13 * Ability to use systemd as the scheduler, instead of crontab. Added by [martinezjavier](https://github.com/mendhak/waveshare-epaper-display/pull/100). diff --git a/README.md b/README.md index 04263639..e5ed7598 100644 --- a/README.md +++ b/README.md @@ -7,6 +7,7 @@ The screen will display date, time, weather icon with high and low, and calendar - [Setup the PI](#setup-the-pi) - [Using this application](#using-this-application) - [Setup dependencies](#setup-dependencies) +- [Set your Waveshare version](#set-your-waveshare-version) - [Set your location](#set-your-location) - [Pick a Weather provider](#pick-a-weather-provider) - [OpenWeatherMap](#openweathermap) @@ -51,7 +52,7 @@ Optional - a picture frame. I used a [18x13cm (7x5 inch) frame](https://www.tesc ### Prepare the Pi -Use the [Raspberry Pi imager](https://www.raspberrypi.com/software/) and install Raspberry Pi OS (tested with Raspberry Pi OS Lite, October 2023 edition). +Use the [Raspberry Pi imager](https://www.raspberrypi.com/software/) and install Raspberry Pi OS Lite. Ensure that you have SSH access, or direct access, to the Raspberry Pi, and that it can connect to the Internet to download packages. In most cases you would do this via the imager's settings screen: set the username and password, enable SSH and set up WiFi access. @@ -87,46 +88,61 @@ This should create a `/home/pi/waveshare-epaper-display` directory. sudo raspi-config nonint do_spi 0 #This enables SPI sudo reboot +### Copy the config file -### Waveshare version +Copy the example config file to `config.toml`, this is where all the settings will be stored. -Copy `env.sh.sample` (example environment variables) to `env.sh` + cd waveshare-epaper-display + cp config.example.toml config.toml + +## Set your Waveshare version -Modify the `env.sh` file and set the version of your Waveshare 7.5" e-Paper Module (newer ones are version 2, red one is 2B) +Set the version of your Waveshare 7.5" e-Paper Module (newer ones are version 2, red one is 2B): - export WAVESHARE_EPD75_VERSION=2 + [display] + waveshare_version = "2" ## Set your location Whichever weather provider you use, you'll need to provide the location and units to display in. -Modify the `env.sh` file and update with the latitude and longitude of your location. -As needed, change the temperature format (CELSIUS or FAHRENHEIT). +Edit `config.toml` and update the latitude and longitude under the `[weather]` section. - export WEATHER_LATITUDE=51.3656 - export WEATHER_LONGITUDE=0.1963 - export WEATHER_FORMAT=CELSIUS + [weather] + latitude = 51.5077 + longitude = -0.1278 + format = "CELSIUS" ## Pick a Weather provider -You can pick between OpenWeatherMap, Met Office, AccuWeather, Met.no, Weeather.gov, VisualCrossing, and Climacell to provide temperature and weather forecasts. -You can switch between them too, by providing the keys and commenting out other ones, but remember to delete the `cache_weather.json` if you switch weather providers. +You can pick between OpenWeatherMap, Met Office, AccuWeather, Met.no, Weather.gov, VisualCrossing, and Climacell to provide temperature and weather forecasts. + +In `config.toml`, under the same `[weather]` section, set the `provider` to the provider you want to use. For example: + + [weather] + ... your location details from above ... + provider = "metoffice" # metoffice, climacell, accuweather, openweathermap, metno, met_eireann, weathergov, smhi, visualcrossing + +Then configure the API key in the corresponding provider section below. ### OpenWeatherMap Register on the [OpenWeathermap](https://openweathermap.org) website, and go to the [API Keys page](https://home.openweathermap.org/api_keys), that's the key you'll need. -Add it to the env.sh file. - export OPENWEATHERMAP_APIKEY=xxxxxx +In `config.toml`, add your API key: + + [weather.providers.openweathermap] + api_key = "xxxxxx" ### Met Office (UK) Create an account [on the Met Office Weather DataHub](https://datahub.metoffice.gov.uk) site. Next, [under subscriptions](https://datahub.metoffice.gov.uk/profile/subscriptions) - subscribe to the 'Site Specific' product 'Global Spot'. This will get you an API key. -Add the API key to the env.sh file: +In `config.toml`, add your API key: - export METOFFICEDATAHUB_API_KEY=eyJ......... + [weather.providers.metoffice] + api_key = "eyJ........." ### AccuWeather @@ -139,72 +155,93 @@ You'll also need an AccuWeather Location Key. Do a normal [AccuWeather search](https://www.accuweather.com/) for your location. The last number in the URL is the Location Key. In the example of [London](https://www.accuweather.com/en/gb/london/ec4a-2/weather-forecast/328328), it's `328328`. -Add the API Key and Location Key to the `env.sh`. +In `config.toml`, add both keys: - export ACCUWEATHER_APIKEY=xxxxxx - export ACCUWEATHER_LOCATIONKEY=328328 + [weather.providers.accuweather] + api_key = "xxxxxx" + location_key = "328328" ### Met.no -Met.no's [Terms of Service](https://api.met.no/doc/TermsOfService) requires you to identify yourself. The purpose is to ensure they can contact you in case you overload or abuse their servers. For this reason, you just need to set your email address in `env.sh` like so: +Met.no's [Terms of Service](https://api.met.no/doc/TermsOfService) requires you to identify yourself. The purpose is to ensure they can contact you in case you overload or abuse their servers. For this reason, you just need to set your email address in `config.toml`: - export METNO_SELF_IDENTIFICATION=you@example.com + [weather.providers.metno] + self_identification = "you@example.com" Note that the Met.no API provides 6 hours of forecast, rather than a full day. ### Met Éireann (Ireland) -[Met Éireann](https://www.met.ie/) publish their forecast data under a [Creative Commons Attribution 4.0 International license (CC BY 4.0)](https://creativecommons.org/licenses/by/4.0/). All you need to do to use it is to uncomment this line in `env.sh`: +[Met Éireann](https://www.met.ie/) publish their forecast data under a [Creative Commons Attribution 4.0 International license (CC BY 4.0)](https://creativecommons.org/licenses/by/4.0/). No API key required. - export WEATHER_MET_EIREANN=1 +In `config.toml`, just set the provider: -Note that a condition of use of this data is that weather alerts be displayed, so ALERT_MET_EIREANN_FEED_URL should be uncommented, too. + [weather] + provider = "met_eireann" + +Note that a condition of use of this data is that weather alerts also be displayed, so you should configure the Met Éireann alert provider below. ### Weather.gov (US) Weather.gov requires you to [identify your application](https://www.weather.gov/documentation/services-web-api). This can be any made up string, or an email address. -Set its value in the `env.sh` as shown: - export WEATHERGOV_SELF_IDENTIFICATION=you@example.com +In `config.toml`, set your identifier: + + [weather.providers.weathergov] + self_identification = "you@example.com" Warning: YMMV. During my testing, I found the weather.gov API would start returning errors and then suddenly work again. ### Climacell (tomorrow.io) Register on the [Climacell site](https://www.climacell.co/weather-api/), and when you do you should be given an API Key. -Modify the `env.sh` file and put your Climacell API key in there. - export CLIMACELL_APIKEY=xxxxxx +In `config.toml`, add your API key: + + [weather.providers.climacell] + api_key = "xxxxxx" ### VisualCrossing -Register on [VisualCrossing](https://www.visualcrossing.com/). Under Account Details, you should be able to generate an API key. Once you have that, simply add it to `env.sh` as shown: +Register on [VisualCrossing](https://www.visualcrossing.com/). Under Account Details, you should be able to generate an API key. Once you have that, add it to `config.toml`: - export VISUALCROSSING_APIKEY=XXXXXXXXXXXXXXXXXXXXXX + [weather.providers.visualcrossing] + api_key = "XXXXXXXXXXXXXXXXXXXXXX" ### SMHI (Sweden) -SMHI requires you to identify yourself. Just set your own email, +SMHI requires you to identify yourself. Just set your own email in `config.toml`: - export SMHI_SELF_IDENTIFICATION=you@example.com + [weather.providers.smhi] + self_identification = "you@example.com" ## Pick a severe weather warning provider This is an optional step. By doing nothing you simply won't see severe weather warnings. +In `config.toml`, set enabled to true, and the `provider` name you wish to use under `[alerts]`: + + [alerts] + enabled = true + provider = "metoffice" # metoffice, met_eireann, weathergov + ### Met Office (UK) Go to the [Met Office RSS Feeds page](https://www.metoffice.gov.uk/weather/guides/rss) and copy the URL of the RSS feed based on your region. -Set its value in the `env.sh` as shown below. For example, London would be: - export ALERT_METOFFICE_FEED_URL=https://www.metoffice.gov.uk/public/data/PWSCache/WarningsRSS/Region/se +In `config.toml`, add the feed URL: + + [alerts.providers.metoffice] + feed_url = "https://www.metoffice.gov.uk/public/data/PWSCache/WarningsRSS/Region/se" ### Weather.gov (US) Weather.gov requires you to [identify your application](https://www.weather.gov/documentation/services-web-api). This can be any made up string, or an email address. -Set its value in the `env.sh` as shown: - export ALERT_WEATHERGOV_SELF_IDENTIFICATION=you@example.com +In `config.toml`, set your identifier: + + [alerts.providers.weathergov] + self_identification = "you@example.com" This provider will use the same latitude and longitude as specified for the weather provider. @@ -215,27 +252,24 @@ Warning: YMMV. During my testing, I found the weather.gov API would start return To use alerts from Met Éireann, visit https://www.met.ie/Open_Data/json/ and choose the appropriate "warning_EIXX" JSON file for your region, using each county's FIPS code. This code can be found in the table on http://www.statoids.com/uie.html, in the pre-2014 section. For example, this is the file for Dublin: - export ALERT_MET_EIREANN_FEED_URL=https://www.met.ie/Open_Data/json/warning_EI07.json + [alerts.providers.met_eireann] + feed_url = "https://www.met.ie/Open_Data/json/warning_EI07.json" -## Pick a Calendar provider +## Pick your calendar providers -You can use Google Calendar or Outlook Calendar to display events. +You can use Google Calendar, Outlook Calendar, ICS, or CalDAV to display events. +You can mix and match providers, and add multiple calendars of each type. +Events from all enabled calendars will be merged and sorted by date. ### Google Calendar -The script will by default get its info from your primary Google Calendar. If you need to pick a specific calendar you will need its ID. To get its ID, open up [Google Calendar](https://calendar.google.com) and go to the settings for your preferred calendar. Under the 'Integrate Calendar' section you will see a Calendar ID which looks like `xyz12345@group.calendar.google.com`. Set that value in `env.sh` - -```bash -export GOOGLE_CALENDAR_ID=xyz12345@group.calendar.google.com -``` - -#### Get a Google Calendar token +You will need to run an Oauth process once manually to allow this application query your Google Calendar for information. -You will need to run an Oauth process once manually to allow the Python code to get a token, which lets it query Google Calendar for information. +#### Get a token -Run: +Run: - .venv/bin/python3 screen-calendar-get.py + .venv/bin/python3 utils/google_calendar_util.py The script will prompt you to visit a URL in your browser, then it will sit there and wait. The URL will look like `https://accounts.google.com/o/...` and will be very long. @@ -247,56 +281,85 @@ Copy the URL it was trying to go to (eg: http://localhost:8080/...) and in anoth curl "http://localhost:8080/..." -On the first screen you should see the auth flow complete, and a new `token.pickle` file appears. +On the first screen you should see the auth flow complete, and a new `data/token_google.pickle` file appears. The script should now be able to run in the future without prompting required. +#### Get your calendar ID + +The script will then print out a list of your Google Calendars with names and IDs. Copy the ID of the calendar you want, then add a section to `config.toml` to enable it, like this: + + [[calendar.providers.google]] + enabled = true + id = "xyz12345@group.calendar.google.com" + time_zone = "Europe/London" # Optional: for family calendars + +To add multiple Google calendars, include additional `[[calendar.providers.google]]` blocks. + +You can also get a calendar's ID manually by opening up [Google Calendar](https://calendar.google.com) and going to the settings for your preferred calendar. Under the 'Integrate Calendar' section you will see a Calendar ID which looks like `xyz12345@group.calendar.google.com`. + + ### Outlook Calendar The setup is much simpler, just run this script which will give instructions on how to login: - .venv/bin/python3 outlook_util.py + .venv/bin/python3 utils/outlook_util.py Login with the Microsoft account you want to get the calendar from, and accept the consent screen. After a moment, the script will then display a set of Calendar IDs and some sample events from those Calendars. -Copy the ID of the calendar you want, and add it to env.sh like so: +Copy the ID of the calendar you want, and add it to `config.toml`: - export OUTLOOK_CALENDAR_ID=AQMkAxyz... + [[calendar.providers.outlook]] + enabled = true + calendar_id = "AQMkAxyz..." -Note that if you set an Outlook Calendar ID, the Google Calendar will be ignored. +To add multiple Outlook calendars, include additional `[[calendar.providers.outlook]]` blocks. ### ICS Calendar -ICS is simple, get the ICS URL for a calendar, and place it in `env.sh`. +ICS is simple but can be buggy. Get the ICS URL for a calendar, and add it to `config.toml`: - export ICS_CALENDAR_URL=https://calendar.google.com/calendar/ical/xxxxxxxxxxxx/xxxxxxxxxxxxxx/basic.ics + [[calendar.providers.ics]] + enabled = true + url = "https://calendar.google.com/calendar/ical/xxxxxxxxxxxx/xxxxxxxxxxxxxx/basic.ics" There is no username/password support. +To add multiple ICS calendars, include additional `[[calendar.providers.ics]]` blocks. + ### CalDav Calendar -For CalDav you will need the CalDav URL, username, and password. +For CalDav you will need the CalDav URL, username, and password. Add them to `config.toml`: - export CALDAV_CALENDAR_URL=https://nextcloud.example.com/remote.php/dav/principals/users/123456/ - export CALDAV_USERNAME=username - export CALDAV_PASSWORD=password + [[calendar.providers.caldav]] + enabled = true + url = "https://nextcloud.example.com/remote.php/dav/principals/users/123456/" + username = "username" + password = "password" + id = "your-calendar-id" Some CalDav features may not work well as the protocol is heavily undocumented, proprietary, and many servers don't implement it the same way. +To add multiple CalDav calendars, include additional `[[calendar.providers.caldav]]` blocks. + ## Pick a layout This is an optional step. There are a few different layouts to choose from. +In `config.toml`, set the layout under `[display]`: -| `export SCREEN_LAYOUT=1`
This is the default | `export SCREEN_LAYOUT=2`
More calendar entries and less emphasis on weather and time | + [display] + screen_output_layout = "1" + +| `screen_output_layout = "1"`
This is the default | `screen_output_layout = "2"`
More calendar entries and less emphasis on weather and time | | --- | --- | | [![Layout 1](screenshots/001.png)](screenshots/001.png) | [![Layout 2](screenshots/002.png)](screenshots/002.png) | -| `export SCREEN_LAYOUT=3`
Calendar entries on left, less emphasis on weather | `export SCREEN_LAYOUT=4`
Shows hour instead of time. Meant for color screens. | +| `screen_output_layout = "3"`
Calendar entries on left, less emphasis on weather | `screen_output_layout = "4"`
Shows hour instead of time. Meant for color screens. | | --- | --- | | [![Layout 3](screenshots/003.png)](screenshots/003.png) | [![Layout 4](screenshots/004.png)](screenshots/004.png) | -| `export SCREEN_LAYOUT=5`
Calendar entries on left, with a month calendar for at-a-glance | | +| `screen_output_layout = "5"`
Calendar entries on left, with a month calendar for at-a-glance | | | --- | --- | | [![Layout 5](screenshots/005.png)](screenshots/005.png) | | @@ -326,20 +389,20 @@ Alternatively, you can use a systemd timer. There are example systemd units avai that starts a service every minute that runs the script. To achieve this, execute the following commands. mkdir -p ~/.config/systemd/user/ - cp waveshare-epaper-display.service.example ~/.config/systemd/user/waveshare-epaper-display.service - cp waveshare-epaper-display.timer.example ~/.config/systemd/user/waveshare-epaper-display.timer + cp utils/waveshare_epaper_display.service.example ~/.config/systemd/user/waveshare_epaper_display.service + cp utils/waveshare_epaper_display.timer.example ~/.config/systemd/user/waveshare_epaper_display.timer systemctl --user daemon-reload - systemctl --user enable waveshare-epaper-display.timer + systemctl --user enable waveshare_epaper_display.timer loginctl enable-linger ## Custom Data This is an optional step, to add your own custom data to the screen. For example this could be API calls, data from Home Assistant, PiHole stats, or something external. -Rename `screen-custom-get.py.sample` to `screen-custom-get.py`. Do your custom code, and set the value of `custom_value_1` to the value you want to display. Run `./run.sh` and it'll appear on screen. +Rename `scripts/screen_custom_get.py.sample` to `scripts/screen_custom_get.py`. Do your custom code, and set the value of `custom_value_1` to the value you want to display. Run `./run.sh` and it'll appear on screen. -Next, modify `screen-custom.svg` and change the various x, y, font size values to adjust its appearance and position. -You can add more values by adding more SVG elements for custom_value_2, custom_value_3, and so on, and set its value in the `output_dict` in `screen-custom.get.py`. +Next, modify `templates/screen_custom.svg` and change the various x, y, font size values to adjust its appearance and position. +You can add more values by adding more SVG elements for custom_value_2, custom_value_3, and so on, and set its value in the `output_dict` in `scripts/screen_custom_get.py`. ## How to use a different display language @@ -356,9 +419,10 @@ To install a new locale, go through the locale wizard: Select the locales you want to install, be sure to pick the ones that have `.UTF-8` in the name. -Edit the `env.sh` file and at the top, set the language like so: +In `config.toml`, set the language under `[locale]`: - export LANG=ko_KR.UTF-8 + [locale] + language = "ko_KR.UTF-8" The next time `run.sh` runs, the output should have the chosen language. @@ -422,10 +486,13 @@ The next time `run.sh` runs, the output image should have the chosen font. ## Privacy Mode -This mode hides away everything and just displays an XKCD comic or a literary quote for the time. In env.sh, set: +This mode hides away everything and just displays an XKCD comic or a literary quote for the time. In `config.toml`, enable privacy mode and select the mode: + [privacy] + enabled = true + mode = "xkcd" # or "literature" -| `export PRIVACY_MODE_XKCD=1`
XKCD comic | `export PRIVACY_MODE_LITERATURE_CLOCK=1`
Literature clock mode | +| `xkcd = true`
XKCD comic | `literature_clock = true`
Literature clock mode | | --- | --- | | [![XKCD](screenshots/pvt_xkcd.png)](screenshots/pvt_xkcd.png) | [![Literature](screenshots/pvt_literature.png)](screenshots/pvt_literature.png) | @@ -437,12 +504,19 @@ If the scripts don't work at all, try going through the Waveshare sample code li You may want to further troubleshoot if you're seeing or not seeing something expected. If you've set up the cron job as shown above, a `run.log` file will appear which contains some info and errors. -If there isn't enough information in there, you can set `export LOG_LEVEL=DEBUG` in the `env.sh` and the `run.log` will contain even more information. +If there isn't enough information in there, you can set the log level in `config.toml`: + + [locale] + log_level = "DEBUG" The scripts cache the calendar and weather information, to avoid hitting weather API rate limits. -If you want to force a weather update, you can delete the `cache_weather.json`. -If you want to force a calendar update, you can delete the `cache_calendar.pickle` or `cache_outlookcalendar.pickle`. -If you want to force a re-login to Google or Outlook, delete the `token.pickle` or `outlooktoken.bin`. +All temporary files are stored in the `data/` folder. + +If you want to force a weather update, delete `data/cache_weather.json`. +If you want to force a calendar update, delete `data/cache_all_calendars.pickle`. +If you want to force a re-login to Google or Outlook, delete `data/token_google.pickle` or `data/token_outlook.bin`. + +To clear all cached data at once, run: `rm -rf data/*` ## Waveshare documentation and sample code @@ -465,7 +539,7 @@ This is the best place to start for troubleshooting - try to make sure the examp ## Debugging locally -It's possible to run and debug the application locally with virtual environments. The last step fails, as it's trying to write to GPIO, but that's not an issue since the aim of local development is to generate and view the `screen-output.png`. +It's possible to run and debug the application locally with virtual environments. The last step fails, as it's trying to write to GPIO, but that's not an issue since the aim of local development is to generate and view the `data/screen_output.png`. Do this before opening VSCode: @@ -480,6 +554,8 @@ pip install -r requirements.txt Then, open VSCode with the project, and it should automatically detect and switch to the virtual environment in the terminal. -To run the project, just run `./run.sh`. It will pick up env.sh variables, and run the various Python scripts. +Copy `config.example.toml` to `config.toml` and configure your settings. + +To run the project, just run `./run.sh`. It will read config.toml and run the various Python scripts. -To debug the project, open a Python script file such as `screen-calendar-get.py` or `screen-weather-get.py`, and press F5. It will generate a .env from env.sh, and run the script. It can hit breakpoints, no problem. +To debug the project, open a Python script file such as `scripts/screen_calendar_get.py` or `scripts/screen_weather_get.py`, and press F5. It will use config.toml for settings and run the script. It can hit breakpoints, no problem. diff --git a/alert_providers/base_provider.py b/alert_providers/base_provider.py index ea2db2f5..f2dcf350 100644 --- a/alert_providers/base_provider.py +++ b/alert_providers/base_provider.py @@ -1,12 +1,11 @@ import os from abc import ABC, abstractmethod -from utility import get_xml_from_url, get_json_from_url +import xml.etree.ElementTree as ET +import requests class BaseAlertProvider(ABC): - ttl = float(os.getenv("ALERT_TTL", 1 * 60 * 60)) - @abstractmethod def get_alert(self): """ @@ -15,22 +14,23 @@ def get_alert(self): "Yellow warning of wind..." """ pass - + def get_response_json(self, url, headers={}): """ Perform an HTTP GET for a `url` with optional `headers`. - Caches the response in `cache_file_name` for ALERT_TTL seconds. Returns the response as JSON """ - return get_json_from_url(url, headers, "cache_severe_alert.json", self.ttl) + response = requests.get(url, headers=headers) + response.raise_for_status() + return response.json() def get_response_xml(self, url, headers={}): """ Perform an HTTP GET for a `url` with optional `headers`. - Caches the response in `cache_file_name` for ALERT_TTL seconds. Returns the response as an XML ElementTree - """ - return get_xml_from_url(url, headers, "cache_severe_alert.xml", self.ttl) + """ + response = requests.get(url, headers=headers) + response.raise_for_status() + return ET.fromstring(response.text) - \ No newline at end of file diff --git a/calendar_providers/caldav.py b/calendar_providers/caldav.py index e495896c..13c780dd 100644 --- a/calendar_providers/caldav.py +++ b/calendar_providers/caldav.py @@ -1,16 +1,9 @@ - -import pickle import caldav -from utility import is_stale -import os import logging import datetime from .base_provider import BaseCalendarProvider, CalendarEvent -ttl = float(os.getenv("CALENDAR_TTL", 1 * 60 * 60)) - - class CalDavCalendar(BaseCalendarProvider): def __init__(self, calendar_url, calendar_id, max_event_results, from_date, to_date, username=None, password=None): @@ -22,52 +15,41 @@ def __init__(self, calendar_url, calendar_id, max_event_results, from_date, to_d self.from_date = from_date self.to_date = to_date - def get_calendar_events(self): - - caldav_calendar_pickle = 'cache_caldav.pickle' + def get_calendar_events(self) -> list[CalendarEvent]: calendar_events: list[CalendarEvent] = [] - if is_stale(os.getcwd() + "/" + caldav_calendar_pickle, ttl): - logging.debug("Pickle is stale, fetching Caldav Calendar") - - with caldav.DAVClient(url=self.calendar_url, username=self.username, password=self.password) as client: - my_principal = client.principal() + logging.debug("Fetching CalDAV Calendar") - calendar = my_principal.calendar(cal_id=self.calendar_id) - event_results = calendar.date_search(start=self.from_date, end=self.to_date, expand=True) - events_data = [] + with caldav.DAVClient(url=self.calendar_url, username=self.username, password=self.password) as client: + my_principal = client.principal() - for result in event_results: - for component in result.icalendar_instance.subcomponents: - events_data.append(component) + calendar = my_principal.calendar(cal_id=self.calendar_id) + event_results = calendar.date_search(start=self.from_date, end=self.to_date, expand=True) + events_data = [] - # Sort by start date. Since some are dates, and some are datetimes, a simple string sort works - events_data.sort(key=lambda x: str(x['DTSTART'].dt)) + for result in event_results: + for component in result.icalendar_instance.subcomponents: + events_data.append(component) - for event in events_data[0:self.max_event_results]: + # Sort by start date. Since some are dates, and some are datetimes, a simple string sort works + events_data.sort(key=lambda x: str(x['DTSTART'].dt)) - # If a dtend isn't included, calculate it from the duration - if 'DTEND' in event: - event_end = event['DTEND'].dt - if 'DURATION' in event: - event_end = event['DTSTART'].dt + event['DURATION'].dt + for event in events_data[0:self.max_event_results]: - all_day_event = False - # CalDav Calendar marks the 'end' of all-day-events as - # the day _after_ the last day. eg, Today's all day event ends tomorrow! - # So subtract a day, if the event is an all day event - if type(event_end) == datetime.date: - event_end = event_end - datetime.timedelta(days=1) - all_day_event = True + # If a dtend isn't included, calculate it from the duration + if 'DTEND' in event: + event_end = event['DTEND'].dt + if 'DURATION' in event: + event_end = event['DTSTART'].dt + event['DURATION'].dt - calendar_events.append(CalendarEvent(str(event['SUMMARY']), event['DTSTART'].dt, event_end, all_day_event)) + all_day_event = False + # CalDAV Calendar marks the 'end' of all-day-events as + # the day _after_ the last day. eg, Today's all day event ends tomorrow! + # So subtract a day, if the event is an all day event + if type(event_end) == datetime.date: + event_end = event_end - datetime.timedelta(days=1) + all_day_event = True - with open(caldav_calendar_pickle, 'wb') as cal: - pickle.dump(calendar_events, cal) + calendar_events.append(CalendarEvent(str(event['SUMMARY']), event['DTSTART'].dt, event_end, all_day_event)) - return calendar_events - else: - logging.info("Found in cache") - with open(caldav_calendar_pickle, 'rb') as cal: - calendar_events = pickle.load(cal) - return calendar_events + return calendar_events diff --git a/calendar_providers/google.py b/calendar_providers/google.py index 202a93f7..79aa4801 100644 --- a/calendar_providers/google.py +++ b/calendar_providers/google.py @@ -1,6 +1,6 @@ import datetime from calendar_providers.base_provider import BaseCalendarProvider, CalendarEvent -from utility import is_stale, xor_decode +from scripts.utility import xor_decode import os import logging import pickle @@ -8,9 +8,6 @@ from google.auth.transport.requests import Request from googleapiclient.discovery import build -ttl = float(os.getenv("CALENDAR_TTL", 1 * 60 * 60)) -google_calendar_timezone = os.getenv("GOOGLE_CALENDAR_TIME_ZONE_NAME", None) - class GoogleCalendar(BaseCalendarProvider): def __init__(self, google_calendar_id, max_event_results, from_date, to_date): @@ -21,12 +18,12 @@ def __init__(self, google_calendar_id, max_event_results, from_date, to_date): def get_google_credentials(self): - google_token_pickle = 'token.pickle' + google_token_pickle = 'data/token_google.pickle' google_api_scopes = ['https://www.googleapis.com/auth/calendar.readonly'] credentials = None - # The file token.pickle stores the user's access and refresh tokens, and is + # The file token_google.pickle stores the user's access and refresh tokens, and is # created automatically when the authorization flow completes for the first # time. if os.path.exists(google_token_pickle): @@ -52,7 +49,13 @@ def get_google_credentials(self): "client_secret": xor_decode("HwI5FzprZiBiE0AtJ3A7IjZ+LB4VCBQjHmcIN1IBJz0QBjg=", "XMzDj3Kb4j2j_3jK_8dwoeuir3mm3jKb"), "redirect_uris": ["http://localhost"]}}, google_api_scopes) - credentials = flow.run_local_server() + credentials = flow.run_local_server(port=0, + authorization_prompt_message="""\n\nPlease visit this URL in a browser to allow this application to read your Google Calendars. When the browser attempts to redirect to localhost, remember to copy the URL and curl to it in a new session.""" + """\n\n{url}\n\n""", + success_message="""\n\nThe authentication flow has completed.\n""" + """You may close this window or tab.\n""" + """Return to the original waveshare setup, and a list of your calendars should be displayed.""", + open_browser=False) # Save the credentials for the next run with open(google_token_pickle, 'wb') as token: pickle.dump(credentials, token) @@ -61,49 +64,34 @@ def get_google_credentials(self): def get_calendar_events(self) -> list[CalendarEvent]: calendar_events = [] - google_calendar_pickle = 'cache_calendar.pickle' service = build('calendar', 'v3', credentials=self.get_google_credentials(), cache_discovery=False) - events_result = None - - if is_stale(os.getcwd() + "/" + google_calendar_pickle, ttl): - logging.debug("Pickle is stale, calling the Calendar API") - - # Call the Calendar API - events_result = service.events().list( - calendarId=self.google_calendar_id, - timeMin=self.from_date.isoformat() + 'Z', - timeZone=google_calendar_timezone, - maxResults=self.max_event_results, - singleEvents=True, - orderBy='startTime').execute() - - for event in events_result.get('items', []): - if event['start'].get('date'): - is_all_day = True - start_date = datetime.datetime.strptime(event['start'].get('date'), "%Y-%m-%d") - end_date = datetime.datetime.strptime(event['end'].get('date'), "%Y-%m-%d") - # Google Calendar marks the 'end' of all-day-events as - # the day _after_ the last day. eg, Today's all day event ends tomorrow! - # So subtract a day - end_date = end_date - datetime.timedelta(days=1) - else: - is_all_day = False - start_date = datetime.datetime.strptime(event['start'].get('dateTime'), "%Y-%m-%dT%H:%M:%S%z") - end_date = datetime.datetime.strptime(event['end'].get('dateTime'), "%Y-%m-%dT%H:%M:%S%z") - - summary = event['summary'] - - calendar_events.append(CalendarEvent(summary, start_date, end_date, is_all_day)) - - with open(google_calendar_pickle, 'wb') as cal: - pickle.dump(calendar_events, cal) - - else: - logging.info("Found in cache") - with open(google_calendar_pickle, 'rb') as cal: - calendar_events = pickle.load(cal) + # Call the Calendar API + events_result = service.events().list( + calendarId=self.google_calendar_id, + timeMin=self.from_date.isoformat() + 'Z', + maxResults=self.max_event_results, + singleEvents=True, + orderBy='startTime').execute() + + for event in events_result.get('items', []): + if event['start'].get('date'): + is_all_day = True + start_date = datetime.datetime.strptime(event['start'].get('date'), "%Y-%m-%d") + end_date = datetime.datetime.strptime(event['end'].get('date'), "%Y-%m-%d") + # Google Calendar marks the 'end' of all-day-events as + # the day _after_ the last day. eg, Today's all day event ends tomorrow! + # So subtract a day + end_date = end_date - datetime.timedelta(days=1) + else: + is_all_day = False + start_date = datetime.datetime.strptime(event['start'].get('dateTime'), "%Y-%m-%dT%H:%M:%S%z") + end_date = datetime.datetime.strptime(event['end'].get('dateTime'), "%Y-%m-%dT%H:%M:%S%z") + + summary = event['summary'] + + calendar_events.append(CalendarEvent(summary, start_date, end_date, is_all_day)) if len(calendar_events) == 0: logging.info("No upcoming events found.") diff --git a/calendar_providers/ics.py b/calendar_providers/ics.py index fefa3340..de3e605e 100644 --- a/calendar_providers/ics.py +++ b/calendar_providers/ics.py @@ -1,16 +1,10 @@ import datetime from calendar_providers.base_provider import BaseCalendarProvider, CalendarEvent -from utility import is_stale -import os import logging -import pickle import icalevents.icalevents -from dateutil import tz from tzlocal import get_localzone -ttl = float(os.getenv("CALENDAR_TTL", 1 * 60 * 60)) - class ICSCalendar(BaseCalendarProvider): @@ -22,31 +16,40 @@ def __init__(self, ics_calendar_url, max_event_results, from_date, to_date): def get_calendar_events(self) -> list[CalendarEvent]: calendar_events = [] - ics_calendar_pickle = 'cache_ics.pickle' - if is_stale(os.getcwd() + "/" + ics_calendar_pickle, ttl): - logging.debug("Pickle is stale, fetching ICS Calendar") - ics_events = icalevents.icalevents.events(self.ics_calendar_url, start=self.from_date, end=self.to_date, tzinfo=get_localzone(), strict=True, sort=True) + logging.debug("Fetching ICS Calendar") + + # icalevents library requires timezone-aware datetimes when processing ICS files + # that contain timezone information, otherwise it throws naive/aware comparison errors + local_tz = get_localzone() + from_date = self.from_date if self.from_date.tzinfo else self.from_date.replace(tzinfo=local_tz) + to_date = self.to_date if self.to_date.tzinfo else self.to_date.replace(tzinfo=local_tz) + + ics_events = icalevents.icalevents.events(self.ics_calendar_url, start=from_date, end=to_date, tzinfo=local_tz, strict=True, sort=True) + + logging.debug(ics_events) - logging.debug(ics_events) + for ics_event in ics_events[0:self.max_event_results]: + event_start = ics_event.start + event_end = ics_event.end - for ics_event in ics_events[0:self.max_event_results]: - event_start = ics_event.start - event_end = ics_event.end + # ICS Calendar marks the 'end' of all-day-events as + # the day _after_ the last day. eg, Today's all day event ends tomorrow! + # So subtract a day, if the event is an all day event + if ics_event.all_day: + event_end = event_end - datetime.timedelta(days=1) - # CalDav Calendar marks the 'end' of all-day-events as - # the day _after_ the last day. eg, Today's all day event ends tomorrow! - # So subtract a day, if the event is an all day event - if ics_event.all_day: - event_end = event_end - datetime.timedelta(days=1) + # Normalize to naive datetime for consistent sorting + if isinstance(event_start, datetime.date) and not isinstance(event_start, datetime.datetime): + event_start = datetime.datetime.combine(event_start, datetime.time.min) + elif isinstance(event_start, datetime.datetime) and event_start.tzinfo is not None: + event_start = event_start.astimezone(local_tz).replace(tzinfo=None) - calendar_events.append(CalendarEvent(ics_event.summary, event_start, event_end, ics_event.all_day)) + if isinstance(event_end, datetime.date) and not isinstance(event_end, datetime.datetime): + event_end = datetime.datetime.combine(event_end, datetime.time.min) + elif isinstance(event_end, datetime.datetime) and event_end.tzinfo is not None: + event_end = event_end.astimezone(local_tz).replace(tzinfo=None) - with open(ics_calendar_pickle, 'wb') as cal: - pickle.dump(calendar_events, cal) - else: - logging.info("Found in cache") - with open(ics_calendar_pickle, 'rb') as cal: - calendar_events = pickle.load(cal) + calendar_events.append(CalendarEvent(ics_event.summary, event_start, event_end, ics_event.all_day)) return calendar_events diff --git a/calendar_providers/outlook.py b/calendar_providers/outlook.py index 08eabbc5..03c61e8c 100644 --- a/calendar_providers/outlook.py +++ b/calendar_providers/outlook.py @@ -1,17 +1,12 @@ import datetime from calendar_providers.base_provider import BaseCalendarProvider, CalendarEvent -from utility import is_stale import os import logging -import pickle import msal import requests import sys import json -from dateutil import tz - -ttl = float(os.getenv("CALENDAR_TTL", 1 * 60 * 60)) class OutlookCalendar(BaseCalendarProvider): @@ -24,8 +19,8 @@ def __init__(self, outlook_calendar_id, max_event_results, from_date, to_date): def get_access_token(self): mscache = msal.SerializableTokenCache() - if os.path.exists("outlooktoken.bin"): - mscache.deserialize(open("outlooktoken.bin", "r").read()) + if os.path.exists("data/token_outlook.bin"): + mscache.deserialize(open("data/token_outlook.bin", "r").read()) app = msal.PublicClientApplication("3b49f0d7-201a-4b5d-b2b4-8f4c3e6c8a30", authority="https://login.microsoftonline.com/consumers", @@ -57,7 +52,7 @@ def get_access_token(self): if "access_token" in result: if mscache.has_state_changed: - open("outlooktoken.bin", "w").write(mscache.serialize()) + open("data/token_outlook.bin", "w").write(mscache.serialize()) return result["access_token"] else: @@ -82,27 +77,17 @@ def get_outlook_calendar_events(self, calendar_id, from_date, to_date, access_to headers=headers).json() return events_data - def get_calendar_events(self, bypass_cache=False) -> list[CalendarEvent]: + def get_calendar_events(self) -> list[CalendarEvent]: calendar_events = [] - outlook_calendar_pickle = 'cache_outlookcalendar.pickle' - if bypass_cache or is_stale(os.getcwd() + "/" + outlook_calendar_pickle, ttl): - logging.debug("Cache is stale, calling the Outlook Calendar API") - - access_token = self.get_access_token() - events_data = self.get_outlook_calendar_events( - self.outlook_calendar_id, - self.from_date, - self.to_date, - access_token) - logging.debug(events_data) - - if not bypass_cache: - with open(outlook_calendar_pickle, 'wb') as cal: - pickle.dump(events_data, cal) - else: - logging.info("Found in cache") - with open(outlook_calendar_pickle, 'rb') as cal: - events_data = pickle.load(cal) + logging.debug("Calling the Outlook Calendar API") + + access_token = self.get_access_token() + events_data = self.get_outlook_calendar_events( + self.outlook_calendar_id, + self.from_date, + self.to_date, + access_token) + logging.debug(events_data) for event in events_data["value"]: start_date = datetime.datetime.strptime(event["start"]["dateTime"], "%Y-%m-%dT%H:%M:%S.0000000") @@ -116,12 +101,6 @@ def get_calendar_events(self, bypass_cache=False) -> list[CalendarEvent]: # So subtract a day if is_all_day: end_date = end_date - datetime.timedelta(days=1) - else: - # Convert start/end to local time - start_date = start_date.replace(tzinfo=tz.tzutc()) - start_date = start_date.astimezone(tz.tzlocal()) - end_date = end_date.replace(tzinfo=tz.tzutc()) - end_date = end_date.astimezone(tz.tzlocal()) calendar_events.append(CalendarEvent(summary, start_date, end_date, is_all_day)) diff --git a/config.example.toml b/config.example.toml new file mode 100644 index 00000000..2128b7db --- /dev/null +++ b/config.example.toml @@ -0,0 +1,110 @@ +# Waveshare Epaper Display Configuration +# +# This is a simplified example config. For other providers, see README.md +# which has copy-paste examples for each weather, alert, and calendar provider. +# +# -------------------------------------------- +# DISPLAY CONFIGURATION +# -------------------------------------------- + +[display] +# Most new Waveshare are 2, older ones are 1 (SKU: 13504) +# For 7.5 inch B with Red, use "2B" (SKU: 13505) +waveshare_version = "2" + +# Which layout to use. 1, 2, 3... +screen_output_layout = 1 + +# ------------------------------------------- +# WEATHER CONFIGURATION +# ------------------------------------------- + +[weather] +# Your location (required for all providers) +latitude = 51.5077 +longitude = -0.1278 + +# Temperature: "CELSIUS" or "FAHRENHEIT" +format = "CELSIUS" + +# How long to cache weather data (in seconds) +cache_ttl_seconds = 3600 + +# Choose ONE provider: metoffice, accuweather, openweathermap, metno, +# met_eireann, weathergov, smhi, visualcrossing, climacell +# See README.md for setup instructions for each provider +provider = "metoffice" + +# MetOffice (UK) example: +[weather.providers.metoffice] +api_key = "your-metoffice-api-key" + +# ------------------------------------------- +# ALERTS CONFIGURATION (Optional) +# ------------------------------------------- + +[alerts] +# Set to false to disable alerts entirely +enabled = false + +# How long to cache alert data (in seconds) +cache_ttl_seconds = 3600 + +# Choose ONE provider: metoffice, met_eireann, weathergov +provider = "metoffice" + +# See README.md for setup instructions for each provider +# MetOffice (UK) example: +[alerts.providers.metoffice] +feed_url = "https://www.metoffice.gov.uk/public/data/PWSCache/WarningsRSS/Region/se" + +# -------------------------------------------- +# CALENDAR CONFIGURATION +# -------------------------------------------- + +[calendar] +# How long to cache calendar data (in seconds) +cache_ttl_seconds = 3600 + +# Maximum number of events to display +max_events = 10 + +# Include all events from today, even if they're in the past +include_past_events_for_today = false + +# See README.md for setup instructions for each provider +# Google Calendar example: +# Get your Calendar ID by running .venv/bin/python3 utils/google_calendar_util.py +[[calendar.providers.google]] +enabled = true +id = "your-google-calendar-id@group.calendar.google.com" +time_zone = "Europe/London" # Optional, for family calendars that don't support timezone + +# You can add multiple calendars by adding more [[calendar.providers.google]] blocks: +# [[calendar.providers.google]] +# enabled = true +# id = "another-calendar-id@group.calendar.google.com" + + +# --------------------------------------------- +# PRIVACY MODE +# --------------------------------------------- + +[privacy] +# Enable privacy mode (hides everything and replaces with one of the below) +enabled = false + +# Mode: "xkcd" for XKCD comic, "literature" for literary clock +mode = "literature" # options: xkcd, literature + +# --------------------------------------------- +# LOCALIZATION & LOGGING +# --------------------------------------------- + +[locale] +# Set a language locale (e.g., "en_GB.UTF-8", "ko_KR.UTF-8") +# Leave empty to use system default. Run `locale -a` to see available locales. +language = "" + +# Logging level: "DEBUG", "INFO", "WARNING", or "ERROR" +log_level = "INFO" diff --git a/data/.gitkeep b/data/.gitkeep new file mode 100644 index 00000000..e69de29b diff --git a/env.sh.sample b/env.sh.sample deleted file mode 100755 index 2ab359e0..00000000 --- a/env.sh.sample +++ /dev/null @@ -1,78 +0,0 @@ -#!/usr/bin/env bash - -# Pick a weather provider -# Climacell API Key -# export CLIMACELL_APIKEY=xxxxxxxxxxxxxx -# Or, OpenWeatherMap API Key -# export OPENWEATHERMAP_APIKEY=xxxxxxxxxxxxxx -# Or, MetOffice Weather DataHub API Key -# export METOFFICEDATAHUB_API_KEY=xxxxxxxxxxxxxx -# Or, AccuWeather API Key and Location Key -# export ACCUWEATHER_APIKEY=xxxxxxxxxxxxxx -# export ACCUWEATHER_LOCATIONKEY=xxxxxxxxxxxxxx -# Or, Met.no self identification -# export METNO_SELF_IDENTIFICATION=your_email_address -# Or, Met Eireann (Ireland, no keys required) -# export WEATHER_MET_EIREANN=1 -# Or, weather.gov self identification -# export WEATHERGOV_SELF_IDENTIFICATION=you@example.com -# Or, SMHI self identification -# export SMHI_SELF_IDENTIFICATION=you@example.com - -# Your latitude and longitude to pass to weather providers -export WEATHER_LATITUDE=51.5077 -export WEATHER_LONGITUDE=-0.1277 - -# Choose CELSIUS or FAHRENHEIT -export WEATHER_FORMAT=CELSIUS - -# Pick a calendar provider -# Google Calendar ID, you can get this from Google Calendar Settings -export GOOGLE_CALENDAR_ID=primary -# If your Google Calendar is a family calendar or doesn't allow setting timezones -# export GOOGLE_CALENDAR_TIME_ZONE_NAME=Asia/Kuala_Lumpur -# Or if you use Outlook Calendar, use python3 outlook_util.py to get available Calendar IDs -# export OUTLOOK_CALENDAR_ID=AQMkAxyz... -# Or if you use ICS Calendar, -# export ICS_CALENDAR_URL=https://calendar.google.com/calendar/ical/xxxxxxxxxxxx/xxxxxxxxxxxxxx/basic.ics -# Or if you have a CalDave calendar -# export CALDAV_CALENDAR_URL=https://nextcloud.example.com/remote.php/dav/principals/users/123456/ -# export CALDAV_USERNAME=username -# export CALDAV_PASSWORD=password -# export CALDAV_CALENDAR_ID=xxxxxxxxxx - -# Most new Waveshare are 2, older ones are 1 (SKU: 13504) -# For 7.5 inch B with Red, use "2B" (SKU: 13505) -export WAVESHARE_EPD75_VERSION=2 - -# Choose an alert provider (optional) -# MetOffice Alerts - -# export ALERT_METOFFICE_FEED_URL=https://www.metoffice.gov.uk/public/data/PWSCache/WarningsRSS/Region/se -# Met Eireann Alerts - -# The FIPS code for the regions of Ireland are listed here: http://www.statoids.com/uie.html -# Visit https://www.met.ie/Open_Data/json/ and choose the appropriate "warning_EIXX" JSON -# file for your region, using that FIPS code. -# export ALERT_MET_EIREANN_FEED_URL=https://www.met.ie/Open_Data/json/warning_EI07.json -# Weather.gov Alerts requires self identification -# export ALERT_WEATHERGOV_SELF_IDENTIFICATION=you@example.com - -# Which layout to use. 1, 2, 3... -export SCREEN_LAYOUT=1 - -# Include all calendar events from today, even if they are past. -# export CALENDAR_INCLUDE_PAST_EVENTS_FOR_TODAY=1 - -# How long, in seconds, to cache weather for -export WEATHER_TTL=3600 -# How long, in seconds, to cache the calendar for -export CALENDAR_TTL=3600 - -# Set a language, but ensure it's installed first. Run locale -a -# export LANG=ko_KR.UTF-8 - -# You can set this to DEBUG for troubleshooting, otherwise leave it at INFO. -export LOG_LEVEL=INFO - -# Privacy mode. Just displays an XKCD comic instead. -export PRIVACY_MODE_XKCD=0 -export PRIVACY_MODE_LITERATURE_CLOCK=0 diff --git a/requirements.txt b/requirements.txt index 21096af7..d694cde1 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,53 +1,53 @@ -astral==3.2 -Babel==2.11.0 -cachetools==5.2.0 -cairocffi==1.4.0 -CairoSVG==2.5.2 -caldav==0.10.0 -certifi==2022.9.24 -cffi==1.15.1 -charset-normalizer==2.1.1 -cryptography==38.0.1 -cssselect2==0.7.0 -DateTime==4.3 -defusedxml==0.7.1 -drawsvg==2.3.0 -emoji==2.11.0 -google-api-core==2.10.2 -google-api-python-client==2.65.0 -google-auth==2.14.1 -google-auth-httplib2==0.1.0 -google-auth-oauthlib==0.7.1 -googleapis-common-protos==1.56.4 -httplib2==0.20.4 -humanize==4.6.0 -icalendar==6.3.1 -icalevents==0.3.0 -idna==3.4 -lxml==4.9.1 -msal==1.20.0 -oauthlib==3.2.2 -Pillow==9.3.0 -protobuf==4.21.9 -pyasn1==0.4.8 -pyasn1-modules==0.2.8 -pycparser==2.21 -PyJWT==2.6.0 -pyparsing==2.4.7 -python-dateutil==2.9.0.post0 -pytz==2025.2 -pytz-deprecation-shim==0.1.0.post0 -requests==2.31.0 -requests-oauthlib==1.3.1 -RPi.GPIO==0.7.1 -rsa==4.9 -six==1.17.0 -spidev==3.6 -tinycss2==1.2.1 -tzdata==2025.2 -tzlocal==4.2 -uritemplate==4.1.1 -urllib3==2.5.0 -vobject==0.9.6.1 -webencodings==0.5.1 -zope.interface==5.5.1 +astral>=3.2 +Babel>=2.11.0 +cachetools>=5.2.0 +cairocffi>=1.4.0 +CairoSVG==2.9.0 +caldav>=0.10.0 +certifi>=2022.9.24 +cffi>=1.15.1 +charset-normalizer>=2.1.1 +cryptography>=38.0.1 +cssselect2>=0.7.0 +DateTime>=4.3 +defusedxml>=0.7.1 +drawsvg>=2.3.0 +emoji>=2.11.0 +google-api-core>=2.10.2 +google-api-python-client>=2.65.0 +google-auth>=2.14.1 +google-auth-httplib2>=0.1.0 +google-auth-oauthlib>=0.7.1 +googleapis-common-protos>=1.56.4 +httplib2>=0.20.4 +humanize>=4.6.0 +icalendar>=6.3.1 +icalevents>=0.3.0 +idna>=3.4 +lxml>=4.9.1 +msal>=1.20.0 +oauthlib>=3.2.2 +Pillow>=9.3.0 +protobuf>=4.21.9 +pyasn1>=0.4.8 +pyasn1-modules>=0.2.8 +pycparser>=2.21 +PyJWT>=2.6.0 +pyparsing>=2.4.7 +python-dateutil>=2.9.0.post0 +pytz>=2025.2 +pytz-deprecation-shim>=0.1.0.post0 +requests>=2.31.0 +requests-oauthlib>=1.3.1 +RPi.GPIO>=0.7.1 +rsa>=4.9 +six>=1.17.0 +spidev>=3.6 +tinycss2>=1.2.1 +tzdata>=2025.2 +tzlocal>=4.2 +uritemplate>=4.1.1 +urllib3 +vobject>=0.9.6.1 +webencodings>=0.5.1 +zope.interface>=5.5.1 diff --git a/run.sh b/run.sh index 65ca52d2..04eba82a 100755 --- a/run.sh +++ b/run.sh @@ -1,14 +1,31 @@ #!/usr/bin/env bash -# shellcheck source=env.sh -. env.sh - function log { echo "---------------------------------------" echo "${1^^}" echo "---------------------------------------" } +if [[ -f env.sh ]]; then + echo "This project has switched to using config.toml. " + echo "Run .venv/bin/python3 utils/migrate_env_to_toml.py to generate your config.toml from the existing env.sh." + echo "Or, copy config.example.toml to config.toml and edit the values you need" + echo "You can then edit it to make any adjustments." + echo "Remember to remove the env.sh afterwards." + exit 1 +fi + +if [[ ! -f config.toml ]]; then + echo "No config.toml found. Copy config.example.toml to config.toml and edit." + exit 1 +fi + +mkdir -p data + +# Read some specific values as env vars, it's needed here +eval $(.venv/bin/python3 scripts/run_config_toml_helper.py) + + if [[ $WAVESHARE_EPD75_VERSION = 1 ]]; then export WAVESHARE_WIDTH=640 export WAVESHARE_HEIGHT=384 @@ -17,26 +34,28 @@ else export WAVESHARE_HEIGHT=480 fi -if [[ $PRIVACY_MODE_XKCD = 1 ]]; then - log "Get XKCD comic strip" - if .venv/bin/python3 xkcd_get.py; then - .venv/bin/python3 display.py xkcd-comic-strip.png - fi -elif [[ $PRIVACY_MODE_LITERATURE_CLOCK = 1 ]]; then - log "Get Literature Clock" - if .venv/bin/python3 screen-literature-clock-get.py; then - .venv/bin/cairosvg -o screen-literature-clock.png -f png --dpi 300 --output-width $WAVESHARE_WIDTH --output-height $WAVESHARE_HEIGHT screen-literature-clock.svg - .venv/bin/python3 display.py screen-literature-clock.png +if [[ $PRIVACY_ENABLED = 1 ]]; then + if [[ $PRIVACY_MODE = "xkcd" ]]; then + log "Get XKCD comic strip" + if .venv/bin/python3 scripts/screen_xkcd_get.py; then + .venv/bin/python3 scripts/display.py data/xkcd-comic-strip.png + fi + else + log "Get Literature Clock" + if .venv/bin/python3 scripts/screen_literature_clock_get.py; then + .venv/bin/cairosvg -u -o data/screen_literature_clock.png -f png --dpi 300 --output-width $WAVESHARE_WIDTH --output-height $WAVESHARE_HEIGHT data/screen_literature_clock.svg + .venv/bin/python3 scripts/display.py data/screen_literature_clock.png + fi fi else log "Add weather info" - if ! .venv/bin/python3 screen-weather-get.py; then + if ! .venv/bin/python3 scripts/screen_weather_get.py; then log "⚠️Error getting weather, stopping." exit 1 fi log "Add Calendar info" - if ! .venv/bin/python3 screen-calendar-get.py; then + if ! .venv/bin/python3 scripts/screen_calendar_get.py; then log "⚠️Error getting calendar info, stopping." exit 1 fi @@ -44,30 +63,35 @@ else # Only layout 5 shows a calendar, so save a few seconds. if [[ "$SCREEN_LAYOUT" -eq 5 ]]; then log "Add Calendar month" - if ! .venv/bin/python3 screen-calendar-month.py; then + if ! .venv/bin/python3 scripts/screen_calendar_month.py; then log "⚠️Error getting calendar month info, stopping." exit 1 fi fi - if [[ -f screen-custom-get.py ]]; then + if [[ -f scripts/screen_custom_get.py ]]; then log "Add Custom data" - if ! .venv/bin/python3 screen-custom-get.py; then + if ! .venv/bin/python3 scripts/screen_custom_get.py; then log "⚠️Error getting custom data, stopping." exit 1 fi + fi - elif [[ ! -f screen-output-custom-temp.svg ]]; then - # Create temporary empty svg since the main SVG needs it - echo "" > screen-output-custom-temp.svg + # Create temporary empty svg if it doesn't exist or is empty + if [[ ! -f data/screen_output_custom_temp.svg ]] || [[ ! -s data/screen_output_custom_temp.svg ]]; then + echo -n '' > data/screen_output_custom_temp.svg fi log "Export to PNG" - .venv/bin/cairosvg -o screen-output.png -f png --dpi 300 --output-width $WAVESHARE_WIDTH --output-height $WAVESHARE_HEIGHT screen-output-weather.svg + # .venv/bin/cairosvg -u -o screen_output.png -f png --dpi 300 --output-width $WAVESHARE_WIDTH --output-height $WAVESHARE_HEIGHT screen_output_weather.svg + if ! .venv/bin/cairosvg -u -o data/screen_output.png -f png --dpi 300 --output-width $WAVESHARE_WIDTH --output-height $WAVESHARE_HEIGHT data/screen_output_weather.svg; then + log "⚠️Error exporting to PNG, stopping." + exit 1 + fi log "Display on screen" - .venv/bin/python3 display.py screen-output.png + .venv/bin/python3 scripts/display.py data/screen_output.png fi diff --git a/screen-calendar-get.py b/screen-calendar-get.py deleted file mode 100755 index 3210b54e..00000000 --- a/screen-calendar-get.py +++ /dev/null @@ -1,120 +0,0 @@ -import datetime -import os.path -import os -import logging -import emoji -from xml.sax.saxutils import escape -from calendar_providers.base_provider import CalendarEvent -from calendar_providers.caldav import CalDavCalendar -from calendar_providers.google import GoogleCalendar -from calendar_providers.ics import ICSCalendar -from calendar_providers.outlook import OutlookCalendar -from utility import get_formatted_time, update_svg, configure_logging, get_formatted_date, configure_locale - -configure_locale() -configure_logging() - -# note: increasing this will require updates to the SVG template to accommodate more events -max_event_results = 10 - -google_calendar_id = os.getenv("GOOGLE_CALENDAR_ID", "primary") -outlook_calendar_id = os.getenv("OUTLOOK_CALENDAR_ID", None) - -caldav_calendar_url = os.getenv('CALDAV_CALENDAR_URL', None) -caldav_username = os.getenv("CALDAV_USERNAME", None) -caldav_password = os.getenv("CALDAV_PASSWORD", None) -caldav_calendar_id = os.getenv("CALDAV_CALENDAR_ID", None) - -ics_calendar_url = os.getenv("ICS_CALENDAR_URL", None) - -ttl = float(os.getenv("CALENDAR_TTL", 1 * 60 * 60)) - - -def get_formatted_calendar_events(fetched_events: list[CalendarEvent]) -> dict: - formatted_events = {} - event_count = len(fetched_events) - - for index in range(max_event_results): - event_label_id = str(index + 1) - if index <= event_count - 1: - formatted_events['CAL_DATETIME_' + event_label_id] = get_datetime_formatted(fetched_events[index].start, fetched_events[index].end, fetched_events[index].all_day_event) - 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) - formatted_events['CAL_DESC_' + event_label_id] = fetched_events[index].summary - else: - formatted_events['CAL_DATETIME_' + event_label_id] = "" - formatted_events['CAL_DESC_' + event_label_id] = "" - - return formatted_events - - -def get_datetime_formatted(event_start, event_end, is_all_day_event, start_only=False): - - if is_all_day_event or type(event_start) == datetime.date: - start = datetime.datetime.combine(event_start, datetime.time.min) - end = datetime.datetime.combine(event_end, datetime.time.min) - - start_day = get_formatted_date(start, include_time=False) - end_day = get_formatted_date(end, include_time=False) - if start == end: - day = start_day - else: - day = "{} - {}".format(start_day, end_day) - elif type(event_start) == datetime.datetime: - start_date = event_start - end_date = event_end - if start_date.date() == end_date.date(): - start_formatted = get_formatted_date(start_date) - end_formatted = get_formatted_time(end_date) - else: - start_formatted = get_formatted_date(start_date) - end_formatted = get_formatted_date(end_date) - day = start_formatted if start_only else "{} - {}".format(start_formatted, end_formatted) - else: - day = '' - return day - - -def main(): - - output_svg_filename = 'screen-output-weather.svg' - - today_start_time = datetime.datetime.utcnow() - if os.getenv("CALENDAR_INCLUDE_PAST_EVENTS_FOR_TODAY", "0") == "1": - today_start_time = datetime.datetime.combine(datetime.datetime.utcnow(), datetime.datetime.min.time()) - oneyearlater_iso = (datetime.datetime.now().astimezone() - + datetime.timedelta(days=365)).astimezone() - - if outlook_calendar_id: - logging.info("Fetching Outlook Calendar Events") - provider = OutlookCalendar(outlook_calendar_id, max_event_results, today_start_time, oneyearlater_iso) - elif caldav_calendar_url: - logging.info("Fetching Caldav Calendar Events") - provider = CalDavCalendar(caldav_calendar_url, caldav_calendar_id, max_event_results, - today_start_time, oneyearlater_iso, caldav_username, caldav_password) - elif ics_calendar_url: - logging.info("Fetching ics Calendar Events") - today_start_time = datetime.datetime.now().astimezone() - provider = ICSCalendar(ics_calendar_url, max_event_results, today_start_time, oneyearlater_iso) - else: - logging.info("Fetching Google Calendar Events") - provider = GoogleCalendar(google_calendar_id, max_event_results, today_start_time, oneyearlater_iso) - - calendar_events = provider.get_calendar_events() - output_dict = get_formatted_calendar_events(calendar_events) - - # XML escape for safety - for key, value in output_dict.items(): - output_dict[key] = escape(value) - - # Surround emojis with font-family emoji so it's rendered properly. Workaround for cairo not using fallback fonts. - for key, value in output_dict.items(): - output_dict[key] = emoji.replace_emoji(value, replace=lambda chars, data_dict: '' + chars + '') - - logging.info("main() - {}".format(output_dict)) - - logging.info("Updating SVG") - update_svg(output_svg_filename, output_svg_filename, output_dict) - - -if __name__ == "__main__": - main() diff --git a/screen-custom-get.py.sample b/screen-custom-get.py.sample deleted file mode 100644 index 02133238..00000000 --- a/screen-custom-get.py.sample +++ /dev/null @@ -1,23 +0,0 @@ -import logging -from utility import update_svg, configure_logging - -configure_logging() - -def main(): - output_svg_filename = 'screen-custom.svg' - - # If you make changes to this file be sure to make a backup in case you ever update! - - # Add custom code here like getting PiHole Status, car charger status, API calls. - # Assign the value you want to display to custom_value_1, and it will replace CUSTOM_DATA_1 in screen-custom.svg. - # You can edit the screen-custom.svg to change appearance, position, font size, add more custom data. - custom_value_1 = ""; - - logging.info("Updating SVG") - output_dict = { - 'CUSTOM_DATA_1' : custom_value_1 - } - update_svg('screen-custom.svg', 'screen-output-custom-temp.svg', output_dict) - -if __name__ == "__main__": - main() diff --git a/screen-weather-get.py b/screen-weather-get.py deleted file mode 100644 index d01a983f..00000000 --- a/screen-weather-get.py +++ /dev/null @@ -1,192 +0,0 @@ -#!/usr/bin/python - -import datetime -import sys -import os -import logging -from weather_providers import climacell, openweathermap, metofficedatahub, metno, meteireann, accuweather, visualcrossing, weathergov, smhi -from alert_providers import metofficerssfeed, weathergovalerts -from alert_providers import meteireann as meteireannalertprovider -from utility import get_formatted_time, update_svg, configure_logging, configure_locale -import textwrap -import html - -configure_locale() -configure_logging() - - -def format_weather_description(weather_description): - if len(weather_description) < 20: - return {1: weather_description, 2: ''} - - splits = textwrap.fill(weather_description, 20, break_long_words=False, - max_lines=2, placeholder='...').split('\n') - weather_dict = {1: splits[0]} - weather_dict[2] = splits[1] if len(splits) > 1 else '' - return weather_dict - - -def get_weather(location_lat, location_long, units): - - # gather relevant environment configs - climacell_apikey = os.getenv("CLIMACELL_APIKEY") - openweathermap_apikey = os.getenv("OPENWEATHERMAP_APIKEY") - metoffice_apikey = os.getenv("METOFFICEDATAHUB_API_KEY") - accuweather_apikey = os.getenv("ACCUWEATHER_APIKEY") - accuweather_locationkey = os.getenv("ACCUWEATHER_LOCATIONKEY") - metno_self_id = os.getenv("METNO_SELF_IDENTIFICATION") - visualcrossing_apikey = os.getenv("VISUALCROSSING_APIKEY") - use_met_eireann = os.getenv("WEATHER_MET_EIREANN") - weathergov_self_id = os.getenv("WEATHERGOV_SELF_IDENTIFICATION") - smhi_self_id = os.getenv("SMHI_SELF_IDENTIFICATION") - - if ( - not climacell_apikey - and not openweathermap_apikey - and not metoffice_apikey - and not accuweather_apikey - and not metno_self_id - and not visualcrossing_apikey - and not use_met_eireann - and not weathergov_self_id - and not smhi_self_id - ): - logging.error("No weather provider has been configured (Climacell, OpenWeatherMap, Weather.gov, MetOffice, AccuWeather, Met.no, Met Eireann, VisualCrossing...)") - sys.exit(1) - - if visualcrossing_apikey: - logging.info("Getting weather from Visual Crossing") - weather_provider = visualcrossing.VisualCrossing(visualcrossing_apikey, location_lat, location_long, units) - - elif use_met_eireann: - logging.info("Getting weather from Met Eireann") - weather_provider = meteireann.MetEireann(location_lat, location_long, units) - - elif weathergov_self_id: - logging.info("Getting weather from Weather.gov") - weather_provider = weathergov.WeatherGov(weathergov_self_id, location_lat, location_long, units) - - elif metno_self_id: - logging.info("Getting weather from Met.no") - weather_provider = metno.MetNo(metno_self_id, location_lat, location_long, units) - - elif accuweather_apikey: - logging.info("Getting weather from Accuweather") - weather_provider = accuweather.AccuWeather(accuweather_apikey, location_lat, - location_long, - accuweather_locationkey, - units) - - elif metoffice_apikey: - logging.info("Getting weather from Met Office Weather Datahub") - weather_provider = metofficedatahub.MetOffice(metoffice_apikey, - location_lat, - location_long, - units) - - elif openweathermap_apikey: - logging.info("Getting weather from OpenWeatherMap") - weather_provider = openweathermap.OpenWeatherMap(openweathermap_apikey, - location_lat, - location_long, - units) - - elif climacell_apikey: - logging.info("Getting weather from Climacell") - weather_provider = climacell.Climacell(climacell_apikey, location_lat, location_long, units) - - elif smhi_self_id: - logging.info("Getting weather from SMHI") - weather_provider = smhi.SMHI(smhi_self_id, location_lat, location_long, units) - - weather = weather_provider.get_weather() - logging.info("weather - {}".format(weather)) - return weather - - -def format_alert_description(alert_message): - return html.escape(alert_message) - - -def get_alert_message(location_lat, location_long): - alert_message = "" - alert_metoffice_feed_url = os.getenv("ALERT_METOFFICE_FEED_URL") - alert_weathergov_self_id = os.getenv("ALERT_WEATHERGOV_SELF_IDENTIFICATION") - alert_meteireann_feed_url = os.getenv("ALERT_MET_EIREANN_FEED_URL") - - if alert_weathergov_self_id: - logging.info("Getting weather alert from Weather.gov API") - alert_provider = weathergovalerts.WeatherGovAlerts(location_lat, location_long, alert_weathergov_self_id) - alert_message = alert_provider.get_alert() - - elif alert_metoffice_feed_url: - logging.info("Getting weather alert from Met Office RSS Feed") - alert_provider = metofficerssfeed.MetOfficeRssFeed(alert_metoffice_feed_url) - alert_message = alert_provider.get_alert() - - elif alert_meteireann_feed_url: - logging.info("Getting weather alert from Met Eireann") - alert_provider = meteireannalertprovider.MetEireannAlertProvider(alert_meteireann_feed_url) - alert_message = alert_provider.get_alert() - - logging.info("alert - {}".format(alert_message)) - return alert_message - - -def main(): - - template_name = os.getenv("SCREEN_LAYOUT", "1") - location_lat = os.getenv("WEATHER_LATITUDE", "51.5077") - location_long = os.getenv("WEATHER_LONGITUDE", "-0.1277") - weather_format = os.getenv("WEATHER_FORMAT", "CELSIUS") - - if (weather_format == "CELSIUS"): - units = "metric" - degrees = "°C" - else: - units = "imperial" - degrees = "°F" - - weather = get_weather(location_lat, location_long, units) - - if not weather: - logging.error("Unable to fetch weather payload. SVG will not be updated.") - return - - weather_desc = format_weather_description(weather["description"]) - - alert_message = get_alert_message(location_lat, location_long) - alert_message = format_alert_description(alert_message) - - time_now = get_formatted_time(datetime.datetime.now()) - time_now_font_size = "100px" - - if len(time_now) > 6: - time_now_font_size = str(100 - (len(time_now)-5) * 5) + "px" - - output_dict = { - 'LOW_ONE': "{}{}".format(str(round(weather['temperatureMin'])), degrees), - 'HIGH_ONE': "{}{}".format(str(round(weather['temperatureMax'])), degrees), - 'ICON_ONE': weather["icon"], - 'WEATHER_DESC_1': weather_desc[1], - 'WEATHER_DESC_2': weather_desc[2], - 'TIME_NOW_FONT_SIZE': time_now_font_size, - 'TIME_NOW': time_now, - 'HOUR_NOW': datetime.datetime.now().strftime("%-I %p"), - 'DAY_ONE': datetime.datetime.now().strftime("%b %-d, %Y"), - 'DAY_NAME': datetime.datetime.now().strftime("%A"), - 'ALERT_MESSAGE_VISIBILITY': "visible" if alert_message else "hidden", - 'ALERT_MESSAGE': alert_message - } - - logging.info(output_dict) - - logging.info("Updating SVG") - - template_svg_filename = f'screen-template.{template_name}.svg' - output_svg_filename = 'screen-output-weather.svg' - update_svg(template_svg_filename, output_svg_filename, output_dict) - - -if __name__ == "__main__": - main() diff --git a/display.py b/scripts/display.py similarity index 78% rename from display.py rename to scripts/display.py index 1aacd72f..8866e0ec 100755 --- a/display.py +++ b/scripts/display.py @@ -1,20 +1,23 @@ #!/usr/bin/python3 -import sys -import os import logging import datetime +import os +import sys +import tomllib from PIL import Image -from utility import configure_logging +sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) +from scripts.utility import configure_logging libdir = "./lib/e-Paper/RaspberryPi_JetsonNano/python/lib" if os.path.exists(libdir): sys.path.append(libdir) -configure_logging() +with open("config.toml", "rb") as f: + config = tomllib.load(f) -# Dear future me: consider converting this to a WAVESHARE_VERSION variable instead if you ever intend to support more screen sizes. +configure_logging(config.get("locale", {}).get("log_level", "INFO")) -waveshare_epd75_version = os.getenv("WAVESHARE_EPD75_VERSION", "2") +waveshare_epd75_version = config.get("display", {}).get("waveshare_version", "2") if (waveshare_epd75_version == "1"): from waveshare_epd import epd7in5 as epd7in5 diff --git a/scripts/run_config_toml_helper.py b/scripts/run_config_toml_helper.py new file mode 100644 index 00000000..7e35bdf9 --- /dev/null +++ b/scripts/run_config_toml_helper.py @@ -0,0 +1,17 @@ +#! /usr/bin/env python3 +import tomllib + +config = tomllib.load(open("config.toml", "rb")) +disp = config.get("display", {}) +priv = config.get("privacy", {}) +loc = config.get("locale", {}) + +print(f'export WAVESHARE_EPD75_VERSION="{disp.get("waveshare_version", "2")}"') +print(f'export SCREEN_LAYOUT={disp.get("screen_output_layout", 1)}') + +privacy_enabled = priv.get("enabled", False) +privacy_mode = priv.get("mode", "literature") # default: literature + +print(f'export PRIVACY_ENABLED={1 if privacy_enabled else 0}') +print(f'export PRIVACY_MODE="{privacy_mode}"') +print(f'export LANG="{loc.get("language", "")}"') diff --git a/scripts/screen_calendar_get.py b/scripts/screen_calendar_get.py new file mode 100755 index 00000000..ebcff3ee --- /dev/null +++ b/scripts/screen_calendar_get.py @@ -0,0 +1,177 @@ +import datetime +import logging +import emoji +import tomllib +import pickle +import sys +import os +from xml.sax.saxutils import escape + +sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) + +from calendar_providers.base_provider import CalendarEvent +from calendar_providers.caldav import CalDavCalendar +from calendar_providers.google import GoogleCalendar +from calendar_providers.ics import ICSCalendar +from calendar_providers.outlook import OutlookCalendar +from scripts.utility import get_formatted_time, update_svg, configure_logging, get_formatted_date, configure_locale, is_stale + +with open("config.toml", "rb") as f: + config = tomllib.load(f) + +configure_locale() +configure_logging(config.get("locale", {}).get("log_level", "INFO")) + +calendar_config = config["calendar"] +max_event_results = calendar_config.get("max_events", 10) +ttl = float(calendar_config.get("cache_ttl_seconds", 3600)) +include_past_events_for_today = calendar_config.get("include_past_events_for_today", False) + +# arrays of calendars +providers = calendar_config.get("providers", {}) +google_calendars = providers.get("google", []) +outlook_calendars = providers.get("outlook", []) +ics_calendars = providers.get("ics", []) +caldav_calendars = providers.get("caldav", []) + + +def get_formatted_calendar_events(fetched_events: list[CalendarEvent]) -> dict: + formatted_events = {} + event_count = len(fetched_events) + + for index in range(max_event_results): + event_label_id = str(index + 1) + if index <= event_count - 1: + formatted_events['CAL_DATETIME_' + event_label_id] = get_datetime_formatted(fetched_events[index].start, fetched_events[index].end, fetched_events[index].all_day_event) + 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) + formatted_events['CAL_DESC_' + event_label_id] = fetched_events[index].summary + else: + formatted_events['CAL_DATETIME_' + event_label_id] = "" + formatted_events['CAL_DESC_' + event_label_id] = "" + + return formatted_events + + +def get_datetime_formatted(event_start, event_end, is_all_day_event, start_only=False): + + if is_all_day_event or type(event_start) == datetime.date: + start = datetime.datetime.combine(event_start, datetime.time.min) + end = datetime.datetime.combine(event_end, datetime.time.min) + + start_day = get_formatted_date(start, include_time=False) + end_day = get_formatted_date(end, include_time=False) + if start == end: + day = start_day + else: + day = "{} - {}".format(start_day, end_day) + elif type(event_start) == datetime.datetime: + start_date = event_start + end_date = event_end + if start_date.date() == end_date.date(): + start_formatted = get_formatted_date(start_date) + end_formatted = get_formatted_time(end_date) + else: + start_formatted = get_formatted_date(start_date) + end_formatted = get_formatted_date(end_date) + day = start_formatted if start_only else "{} - {}".format(start_formatted, end_formatted) + else: + day = '' + return day + + +def fetch_all_calendar_events(): + """Fetch events from all enabled calendar providers. No caching - always fetch fresh.""" + all_calendar_events = [] + + today_start_time = datetime.datetime.now(datetime.UTC).replace(tzinfo=None) + if include_past_events_for_today: + today_start_time = datetime.datetime.combine(datetime.datetime.now(datetime.UTC).date(), datetime.datetime.min.time()) + oneyearlater_iso = (datetime.datetime.now().astimezone() + + datetime.timedelta(days=365)).astimezone() + + for google_cal in google_calendars: + if google_cal.get("enabled", True): + calendar_id = google_cal.get("id", "primary") + logging.info(f"Fetching Google Calendar: {calendar_id}") + provider = GoogleCalendar(calendar_id, max_event_results, today_start_time, oneyearlater_iso) + all_calendar_events.extend(provider.get_calendar_events()) + + for outlook_cal in outlook_calendars: + if outlook_cal.get("enabled", True): + calendar_id = outlook_cal.get("calendar_id") + logging.info(f"Fetching Outlook Calendar: {calendar_id}") + provider = OutlookCalendar(calendar_id, max_event_results, today_start_time, oneyearlater_iso) + all_calendar_events.extend(provider.get_calendar_events()) + + for ics_cal in ics_calendars: + if ics_cal.get("enabled", True): + ics_url = ics_cal.get("url") + logging.info(f"Fetching ICS Calendar: {ics_url}") + provider = ICSCalendar(ics_url, max_event_results, today_start_time, oneyearlater_iso) + all_calendar_events.extend(provider.get_calendar_events()) + + for caldav_cal in caldav_calendars: + if caldav_cal.get("enabled", True): + caldav_url = caldav_cal.get("url") + caldav_id = caldav_cal.get("id") + caldav_user = caldav_cal.get("username") + caldav_pass = caldav_cal.get("password") + logging.info(f"Fetching CalDAV Calendar: {caldav_url}") + provider = CalDavCalendar(caldav_url, caldav_id, max_event_results, + today_start_time, oneyearlater_iso, caldav_user, caldav_pass) + all_calendar_events.extend(provider.get_calendar_events()) + + return all_calendar_events + + +def main(): + + output_svg_filename = 'data/screen_output_weather.svg' + cache_file = 'data/cache_all_calendars.pickle' + + if is_stale(cache_file, ttl): + logging.info("Cache is stale, fetching fresh calendar data") + all_calendar_events = fetch_all_calendar_events() + + with open(cache_file, 'wb') as f: + pickle.dump(all_calendar_events, f) + logging.info(f"Saved {len(all_calendar_events)} events to cache") + else: + logging.info("Using cached calendar data") + with open(cache_file, 'rb') as f: + all_calendar_events = pickle.load(f) + + logging.debug("All calendar events before normalization: {}".format(all_calendar_events)) + + # convert tz-aware datetimes to local time, then make all naive for sorting + from dateutil import tz as dateutil_tz + normalized = [] + for event in all_calendar_events: + start = event.start + end = event.end + if hasattr(start, 'tzinfo') and start.tzinfo is not None: + start = start.astimezone(dateutil_tz.tzlocal()).replace(tzinfo=None) + if hasattr(end, 'tzinfo') and end.tzinfo is not None: + end = end.astimezone(dateutil_tz.tzlocal()).replace(tzinfo=None) + normalized.append(CalendarEvent(event.summary, start, end, event.all_day_event)) + + all_calendar_events = sorted(normalized, key=lambda x: x.start) + calendar_events = all_calendar_events + output_dict = get_formatted_calendar_events(calendar_events) + + # XML escape for safety + for key, value in output_dict.items(): + output_dict[key] = escape(value) + + # Surround emojis with font-family emoji so it's rendered properly. Workaround for cairo not using fallback fonts. + for key, value in output_dict.items(): + output_dict[key] = emoji.replace_emoji(value, replace=lambda chars, data_dict: '' + chars + '') + + logging.info("main() - {}".format(output_dict)) + + logging.info("Updating SVG") + update_svg(output_svg_filename, output_svg_filename, output_dict) + + +if __name__ == "__main__": + main() diff --git a/screen-calendar-month.py b/scripts/screen_calendar_month.py similarity index 90% rename from screen-calendar-month.py rename to scripts/screen_calendar_month.py index cc28b5d5..cc43260f 100755 --- a/screen-calendar-month.py +++ b/scripts/screen_calendar_month.py @@ -1,6 +1,9 @@ import datetime import calendar -from utility import update_svg, configure_locale, configure_logging +import os +import sys +sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) +from scripts.utility import update_svg, configure_locale, configure_logging import locale import babel import logging @@ -59,7 +62,7 @@ def main(): # Remove the line svg_output = svg_output.split('\n', 1)[1] - output_svg_filename = 'screen-output-weather.svg' + output_svg_filename = 'data/screen_output_weather.svg' output_dict = {'MONTH_CAL': svg_output} logging.info("main() - {}".format(output_dict)) logging.info("Updating SVG") diff --git a/scripts/screen_custom_get.py.sample b/scripts/screen_custom_get.py.sample new file mode 100644 index 00000000..00e844b7 --- /dev/null +++ b/scripts/screen_custom_get.py.sample @@ -0,0 +1,26 @@ +import logging +import os +import sys +sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) +from scripts.utility import update_svg, configure_logging + +configure_logging() + +def main(): + output_svg_filename = 'screen_custom.svg' + + # If you make changes to this file be sure to make a backup in case you ever update! + + # Add custom code here like getting PiHole Status, car charger status, API calls. + # Assign the value you want to display to custom_value_1, and it will replace CUSTOM_DATA_1 in screen_custom.svg. + # You can edit the screen_custom.svg to change appearance, position, font size, add more custom data. + custom_value_1 = ""; + + logging.info("Updating SVG") + output_dict = { + 'CUSTOM_DATA_1' : custom_value_1 + } + update_svg('templates/screen_custom.svg', 'data/screen_output_custom_temp.svg', output_dict) + +if __name__ == "__main__": + main() diff --git a/screen-literature-clock-get.py b/scripts/screen_literature_clock_get.py similarity index 91% rename from screen-literature-clock-get.py rename to scripts/screen_literature_clock_get.py index 6d71b8b6..9fdc0e9b 100644 --- a/screen-literature-clock-get.py +++ b/scripts/screen_literature_clock_get.py @@ -1,7 +1,10 @@ import random import codecs import textwrap -from utility import is_stale +import os +import sys +sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) +from scripts.utility import is_stale import requests import csv import datetime @@ -9,17 +12,17 @@ import math -if is_stale('litclock_annotated.csv', 86400): +if is_stale('data/literature_clock_quotes.csv', 86400): url = "https://raw.githubusercontent.com/JohannesNE/literature-clock/master/litclock_annotated.csv" response = requests.get(url) response.raise_for_status() - with open('litclock_annotated.csv', 'w') as text_file: + with open('data/literature_clock_quotes.csv', 'w') as text_file: text_file.write(response.text) time_rows = [] current_time = datetime.datetime.now().strftime("%H:%M") # current_time = "07:32" -with open('litclock_annotated.csv', 'r') as file: +with open('data/literature_clock_quotes.csv', 'r') as file: reader = csv.DictReader(file, fieldnames=[ "time", "time_human", "full_quote", "book_title", "author_name", "sfw"], @@ -121,7 +124,7 @@ """ -output_svg_filename = 'screen-literature-clock.svg' +output_svg_filename = 'data/screen_literature_clock.svg' svg_output = svg_template diff --git a/scripts/screen_weather_get.py b/scripts/screen_weather_get.py new file mode 100644 index 00000000..625e61cb --- /dev/null +++ b/scripts/screen_weather_get.py @@ -0,0 +1,277 @@ +#!/usr/bin/python + +import datetime +import sys +import os +import logging +import json + +sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) + +from weather_providers import climacell, openweathermap, metofficedatahub, metno, meteireann, accuweather, visualcrossing, weathergov, smhi +from alert_providers import metofficerssfeed, weathergovalerts +from alert_providers import meteireann as meteireannalertprovider +from scripts.utility import get_formatted_time, update_svg, configure_logging, configure_locale, is_stale +import textwrap +import html +import tomllib + +with open("config.toml", "rb") as f: + config = tomllib.load(f) + +configure_locale() +configure_logging(config.get("locale", {}).get("log_level", "INFO")) + + +def format_weather_description(weather_description): + if len(weather_description) < 20: + return {'1': weather_description, '2': ''} + + splits = textwrap.fill(weather_description, 20, break_long_words=False, + max_lines=2, placeholder='...').split('\n') + weather_dict = {'1': splits[0]} + weather_dict['2'] = splits[1] if len(splits) > 1 else '' + return weather_dict + + +def get_weather(location_lat, location_long, units, weather_provider_name, provider_config): + + api_key = provider_config.get("api_key", None) + location_key = provider_config.get("location_key", None) + self_identification = provider_config.get("self_identification", None) + + match weather_provider_name: + case "visualcrossing": + logging.info("Getting weather from Visual Crossing") + if not api_key: + logging.error("Visual Crossing API key not configured.") + sys.exit(1) + weather_provider = visualcrossing.VisualCrossing(api_key, location_lat, location_long, units) + case "met_eireann": + logging.info("Getting weather from Met Eireann") + weather_provider = meteireann.MetEireann(location_lat, location_long, units) + case "weathergov": + logging.info("Getting weather from Weather.gov") + if not self_identification: + logging.error("Weather.gov self identification not configured.") + sys.exit(1) + weather_provider = weathergov.WeatherGov(self_identification, location_lat, location_long, units) + case "metno": + logging.info("Getting weather from Met.no") + if not self_identification: + logging.error("Met.no self identification not configured.") + sys.exit(1) + weather_provider = metno.MetNo(self_identification, location_lat, location_long, units) + case "accuweather": + logging.info("Getting weather from Accuweather") + if not api_key or not location_key: + logging.error("AccuWeather API key or location key not configured.") + sys.exit(1) + weather_provider = accuweather.AccuWeather(api_key, location_lat, + location_long, + location_key, + units) + case "metoffice": + logging.info("Getting weather from Met Office Weather Datahub") + if not api_key: + logging.error("Met Office Weather Datahub API key not configured.") + sys.exit(1) + weather_provider = metofficedatahub.MetOffice(api_key, + location_lat, + location_long, + units) + case "openweathermap": + logging.info("Getting weather from OpenWeatherMap") + if not api_key: + logging.error("OpenWeatherMap API key not configured.") + sys.exit(1) + weather_provider = openweathermap.OpenWeatherMap(api_key, + location_lat, + location_long, + units) + case "climacell": + logging.info("Getting weather from Climacell") + if not api_key: + logging.error("Climacell API key not configured.") + sys.exit(1) + weather_provider = climacell.Climacell(api_key, location_lat, location_long, units) + case "smhi": + logging.info("Getting weather from SMHI") + if not self_identification: + logging.error("SMHI self identification not configured.") + sys.exit(1) + weather_provider = smhi.SMHI(self_identification, location_lat, location_long, units) + case _: + logging.error(f"Unsupported weather provider: {weather_provider_name}") + sys.exit(1) + + weather = weather_provider.get_weather() + logging.info("weather - {}".format(weather)) + return weather + + +def format_alert_description(alert_message): + return html.escape(alert_message) + + +def get_alert_message(location_lat, location_long, alerts_provider_name, alerts_config): + alert_message = "" + + feed_url = alerts_config.get("feed_url", None) + self_identification = alerts_config.get("self_identification", None) + + match alerts_provider_name: + case "weathergov": + logging.info("Getting weather alert from Weather.gov API") + if not self_identification: + logging.error("Weather.gov self identification not configured.") + sys.exit(1) + alert_provider = weathergovalerts.WeatherGovAlerts(location_lat, location_long, self_identification) + alert_message = alert_provider.get_alert() + case "metoffice": + logging.info("Getting weather alert from Met Office RSS Feed") + if not feed_url: + logging.error("Met Office RSS feed URL not configured.") + sys.exit(1) + alert_provider = metofficerssfeed.MetOfficeRssFeed(feed_url) + alert_message = alert_provider.get_alert() + case "met_eireann": + logging.info("Getting weather alert from Met Eireann") + if not feed_url: + logging.error("Met Eireann feed URL not configured.") + sys.exit(1) + alert_provider = meteireannalertprovider.MetEireannAlertProvider(feed_url) + alert_message = alert_provider.get_alert() + case _: + logging.error(f"Unsupported alert provider: {alerts_provider_name}") + sys.exit(1) + + logging.info("alert - {}".format(alert_message)) + return alert_message + + +def fetch_weather(location_lat, location_long, units, weather_provider_name, provider_config): + weather_ttl = float(config["weather"].get("cache_ttl_seconds", 3600)) + cache_file = 'data/cache_weather.json' + + if is_stale(cache_file, weather_ttl): + logging.info("Cache is stale, fetching fresh weather data") + weather = get_weather(location_lat, location_long, units, weather_provider_name, provider_config) + if not weather: + logging.error("Unable to fetch weather payload. SVG will not be updated.") + return None, None + weather_desc = format_weather_description(weather["description"]) + + with open(cache_file, 'w') as f: + json.dump({"weather": weather, "weather_desc": weather_desc}, f) + logging.info("Saved weather data to cache") + return weather, weather_desc + else: + logging.info("Using cached weather data") + with open(cache_file, 'r') as f: + cache_data = json.load(f) + return cache_data["weather"], cache_data["weather_desc"] + + +def fetch_alert(location_lat, location_long, alerts_provider_name, alerts_config): + alerts_ttl = float(config.get("alerts", {}).get("cache_ttl_seconds", 3600)) + cache_file = 'data/cache_alerts.json' + + if is_stale(cache_file, alerts_ttl): + logging.info("Cache is stale, fetching fresh alert data") + alert_message = get_alert_message(location_lat, location_long, alerts_provider_name, alerts_config) + alert_message = format_alert_description(alert_message) + + with open(cache_file, 'w') as f: + json.dump({"alert_message": alert_message}, f) + logging.info("Saved alert data to cache") + return alert_message + else: + logging.info("Using cached alert data") + with open(cache_file, 'r') as f: + return json.load(f)["alert_message"] + + +def main(): + + weather_provider_name = config["weather"].get("provider", "") + + if not weather_provider_name: + logging.error("No weather provider configured. Please set the 'provider' field in the config.toml file.") + sys.exit(1) + else: + logging.info(f"Selected weather provider: {weather_provider_name}") + + provider_config = config["weather"].get("providers", {}).get(weather_provider_name, {}) + + location_lat = config["weather"].get("latitude", "51.5077") + location_long = config["weather"].get("longitude", "-0.1277") + weather_format = config["weather"].get("format", "CELSIUS") + + if (weather_format == "CELSIUS"): + units = "metric" + degrees = "°C" + else: + units = "imperial" + degrees = "°F" + + weather, weather_desc = fetch_weather(location_lat, location_long, units, weather_provider_name, provider_config) + if weather is None: + logging.error("Unable to fetch weather data") + return + + logging.debug(f"Fetched weather: {weather}") + logging.debug(f"Fetched weather description: {weather_desc}") + + alerts_config = config.get("alerts", {}) + alerts_enabled = alerts_config.get("enabled", False) + + alert_message = "" + if alerts_enabled: + alerts_provider_name = alerts_config.get("provider", None) + + if alerts_provider_name: + logging.info(f"Selected alert provider: {alerts_provider_name}") + alerts_config = alerts_config.get("providers", {}).get(alerts_provider_name, None) + + if alerts_config: + alert_message = fetch_alert(location_lat, location_long, alerts_provider_name, alerts_config) + else: + logging.error(f"Alert provider '{alerts_provider_name}' is not configured in the config.toml file.") + + logging.debug(f"Fetched alert message: {alert_message}") + + template_name = config.get("display", {}).get("screen_output_layout", "1") + + time_now = get_formatted_time(datetime.datetime.now()) + time_now_font_size = "100px" + + if len(time_now) > 6: + time_now_font_size = str(100 - (len(time_now)-5) * 5) + "px" + + output_dict = { + 'LOW_ONE': "{}{}".format(str(round(weather['temperatureMin'])), degrees), + 'HIGH_ONE': "{}{}".format(str(round(weather['temperatureMax'])), degrees), + 'ICON_ONE': weather["icon"], + 'WEATHER_DESC_1': weather_desc['1'], + 'WEATHER_DESC_2': weather_desc['2'], + 'TIME_NOW_FONT_SIZE': time_now_font_size, + 'TIME_NOW': time_now, + 'HOUR_NOW': datetime.datetime.now().strftime("%-I %p"), + 'DAY_ONE': datetime.datetime.now().strftime("%b %-d, %Y"), + 'DAY_NAME': datetime.datetime.now().strftime("%A"), + 'ALERT_MESSAGE_VISIBILITY': "visible" if alert_message else "hidden", + 'ALERT_MESSAGE': alert_message + } + + logging.info(output_dict) + + logging.info("Updating SVG") + + template_svg_filename = f'templates/screen_template.{template_name}.svg' + output_svg_filename = 'data/screen_output_weather.svg' + update_svg(template_svg_filename, output_svg_filename, output_dict) + + +if __name__ == "__main__": + main() diff --git a/xkcd_get.py b/scripts/screen_xkcd_get.py similarity index 69% rename from xkcd_get.py rename to scripts/screen_xkcd_get.py index 209be7b4..87efb690 100644 --- a/xkcd_get.py +++ b/scripts/screen_xkcd_get.py @@ -1,16 +1,18 @@ import requests import logging import os -from PIL import Image -from utility import is_stale, configure_logging import sys +from PIL import Image +sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) +from scripts.utility import is_stale, configure_logging + configure_logging() def xkcd_get_img(): - xkcd_file_name = "xkcd-comic-strip.png" + xkcd_file_name = "data/screen_xkcd_comic_strip.png" if not is_stale(xkcd_file_name, 3600): - logging.info("xkcd-comic-strip.png is still fresh. Skipping download.") + logging.info("data/screen_xkcd_comic_strip.png is still fresh. Skipping download.") sys.exit(1) logging.info("Downloading xkcd-json") @@ -20,8 +22,8 @@ def xkcd_get_img(): logging.info("Downloading xkcd_img") logging.info(result["img"]) - path = os.path.dirname(os.path.realpath(__file__)) - filename = path + '/' + os.path.basename(xkcd_file_name) + project_root_dir = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) + filename = project_root_dir + '/' + xkcd_file_name if os.path.exists(filename): os.remove(filename) image_response = requests.get(result["img"]) diff --git a/utility.py b/scripts/utility.py similarity index 93% rename from utility.py rename to scripts/utility.py index f0cb9cd8..5808e6a4 100644 --- a/utility.py +++ b/scripts/utility.py @@ -23,13 +23,13 @@ def configure_locale(): logging.debug("Could not set locale") -def configure_logging(): +def configure_logging(log_level="INFO"): """ Sets up logging with a specific logging format. Call this at the beginning of a script. Then using logging methods as normal """ - log_level = os.getenv("LOG_LEVEL", "INFO") + log_format = "%(asctime)s %(levelname)-8s [%(filename)s:%(lineno)d] %(message)s" log_dateformat = "%Y-%m-%d:%H:%M:%S" logging.basicConfig(level=log_level, format=log_format, datefmt=log_dateformat) @@ -71,12 +71,17 @@ def update_svg(template_svg_filename, output_svg_filename, output_dict): def is_stale(filepath, ttl): """ Checks if the specified `filepath` is older than the `ttl` in seconds - Returns true if the file doesn't exist. + Returns true if the file doesn't exist or is empty (0 bytes). """ verdict = True if (os.path.isfile(filepath)): - verdict = time.time() - os.path.getmtime(filepath) > ttl + # Check if file is empty (0 bytes) - treat as stale + if os.path.getsize(filepath) == 0: + logging.debug("is_stale({}) - file is empty (0 bytes)".format(filepath)) + verdict = True + else: + verdict = time.time() - os.path.getmtime(filepath) > ttl logging.debug( "is_stale({}) - {}" @@ -188,12 +193,12 @@ def get_formatted_date(dt, include_time=True): return dt.strftime(formatter_day + " " + formatted_time) -def get_sunset_time(): +def get_sunset_time(lat, long): """ Return the time at which darkness begins, aka 'tonight' """ - location_lat = os.getenv("WEATHER_LATITUDE", "51.5077") - location_long = os.getenv("WEATHER_LONGITUDE", "-0.1277") + location_lat = lat + location_long = long dt = datetime.datetime.now(pytz.utc) city = LocationInfo(location_lat, location_long) s = sun(city.observer, date=dt) diff --git a/screen-custom.svg b/templates/screen_custom.svg similarity index 71% rename from screen-custom.svg rename to templates/screen_custom.svg index 6ce2807c..51e04088 100644 --- a/screen-custom.svg +++ b/templates/screen_custom.svg @@ -1,7 +1,7 @@ - + CUSTOM_DATA_1 - \ No newline at end of file + diff --git a/screen-template.1.svg b/templates/screen_template.1.svg similarity index 97% rename from screen-template.1.svg rename to templates/screen_template.1.svg index eb427a9c..b051a542 100644 --- a/screen-template.1.svg +++ b/templates/screen_template.1.svg @@ -3,7 +3,7 @@ - + ALERT_MESSAGE @@ -45,6 +45,6 @@ TIME_NOW - + diff --git a/screen-template.2.svg b/templates/screen_template.2.svg similarity index 96% rename from screen-template.2.svg rename to templates/screen_template.2.svg index 7fc560cb..9bbe2882 100644 --- a/screen-template.2.svg +++ b/templates/screen_template.2.svg @@ -1,9 +1,9 @@ - + - + HIGH_ONE/LOW_ONE WEATHER_DESC_1 @@ -47,7 +47,7 @@ TIME_NOW - + ALERT_MESSAGE diff --git a/screen-template.3.svg b/templates/screen_template.3.svg similarity index 98% rename from screen-template.3.svg rename to templates/screen_template.3.svg index 1b046123..b2f3445b 100644 --- a/screen-template.3.svg +++ b/templates/screen_template.3.svg @@ -18,7 +18,7 @@ - + DAY_ONE @@ -57,7 +57,7 @@ WEATHER_DESC_1 WEATHER_DESC_2 - + ALERT_MESSAGE diff --git a/screen-template.4.svg b/templates/screen_template.4.svg similarity index 98% rename from screen-template.4.svg rename to templates/screen_template.4.svg index c5b520ab..9aecd156 100644 --- a/screen-template.4.svg +++ b/templates/screen_template.4.svg @@ -18,7 +18,7 @@ - + DAY_ONE @@ -55,7 +55,7 @@ WEATHER_DESC_1 WEATHER_DESC_2 - + ALERT_MESSAGE diff --git a/screen-template.5.svg b/templates/screen_template.5.svg similarity index 97% rename from screen-template.5.svg rename to templates/screen_template.5.svg index 288bfbaa..ab49c0ce 100755 --- a/screen-template.5.svg +++ b/templates/screen_template.5.svg @@ -17,7 +17,7 @@ DAY_ONE - + HIGH_ONE/LOW_ONE @@ -112,7 +112,7 @@ ALERT_MESSAGE - +