diff --git a/.gitattributes b/.gitattributes
new file mode 100644
index 0000000..ae6b62c
--- /dev/null
+++ b/.gitattributes
@@ -0,0 +1,20 @@
+# Set default behavior to automatically normalize line endings
+* text=auto
+
+# Force LF line endings for all text files
+*.py text eol=lf
+*.txt text eol=lf
+*.md text eol=lf
+*.json text eol=lf
+*.sh text eol=lf
+*.yml text eol=lf
+*.yaml text eol=lf
+
+# Binary files
+*.png binary
+*.jpg binary
+*.jpeg binary
+*.gif binary
+*.ico binary
+*.ttf binary
+*.pickle binary
diff --git a/.gitignore b/.gitignore
index 2248d0b..5632fa7 100644
--- a/.gitignore
+++ b/.gitignore
@@ -2,7 +2,15 @@ google_secret.json
*.pickle
*.code-workspace
*.pyc
+__pycache__/
+*.egg-info/
+dist/build/
+.mypy_cache/
icons/*.png
test.py
config.json
-
+infowindow.jpg
+venv/
+.venv/
+.idea/
+driver
diff --git a/3d_files/fusion360/README.md b/3d_files/fusion360/README.md
index 8ef2602..9e64ad2 100644
--- a/3d_files/fusion360/README.md
+++ b/3d_files/fusion360/README.md
@@ -1 +1 @@
-This file is the "Assembly" of all the parts needed to print. I left out the model of the Raspberry PI due to licensing. If you need the raspberry pi model, do a search on GrabCad there are a ton!
+This file is the "Assembly" of all the parts needed to print. I left out the model of the Raspberry PI due to licensing. If you need the raspberry pi model, do a search on GrabCad there are a ton!
diff --git a/README.md b/README.md
index a468091..b6e6b6e 100644
--- a/README.md
+++ b/README.md
@@ -1,55 +1,223 @@
-
+
+# InfoWindow
-# Infowindow
-Rapsberry pi powered e-ink display for displaying information in an always on state. There are several other iterations of this project online, but they didnt do quite what I wanted them to. This is my version. Also keeping up my python skills as they dont get used as much as they used to!
+Raspberry Pi powered e-ink display showing calendar, todo items and weather in an always-on state.
+Built for the **Waveshare 7.5" V2 three-colour (black/red/white)** e-ink display.
+
+The goal is not a full-featured PIM — it is an *in your face* reminder of what is coming up next.
+Details stay in your calendar app, phone, or browser.
-The functionality is not meant to be an "end all solution for calendaring and Todo lists" The intent is to provide an *always on* display to show me what is coming up next. I can then check in browser, phone, etc for details and updates to the data. In your face reminder.
+---
+
## Features
-* **Calendar**
- * Google Calendar is the only calendar currently supported
-* **Todo List**
- * Todoist
- * Teamwork.com
-* **Weather**
- * Open Weather Map current data only. Future plan for forecast data.
+
+| Area | Backends |
+|---|---|
+| **Calendar** | Google Calendar, CalDAV (Nextcloud etc.) |
+| **Todo** | Google Tasks, CalDAV (Nextcloud etc.), Teamwork |
+| **Weather** | OpenWeatherMap (current conditions) |
+
+Multiple backends can be enabled simultaneously — results are merged and sorted.
+
+---
+
+## Hardware setup
+
+### Enable SPI
+```bash
+sudo raspi-config # Interface Options → SPI → Enable
+sudo reboot
+```
+
+### System packages
+```bash
+sudo apt install python3-dev libopenjp2-7 libxslt1.1
+```
+
+### Clone this repo
+```bash
+git clone https://github.com/oxivanisher/InfoWindow.git /home/pi/InfoWindow
+```
+
+### Clone the Waveshare e-Paper driver
+The driver is not bundled — clone it separately and symlink it into the project.
+Waveshare occasionally restructures their repo, so the path may need adjusting.
+```bash
+git clone https://github.com/waveshareteam/e-Paper.git /home/pi/e-Paper
+ln -s /home/pi/e-Paper/RaspberryPi_JetsonNano/python/lib/waveshare_epd/ \
+ /home/pi/InfoWindow/driver
+```
+
+---
## Installation
-### Get software
-Clone this repo onto your raspberry pi. Does not really matter where it is, but good option is in the `pi` users home directory: `/home/pi/InfoWindow`
-### Setup python modules
-Run `pip install -r requirements.txt`. This should install all required modules. I stuck to basic standard modules for ease of installation.
+```bash
+cd /home/pi/InfoWindow
+python3 -m venv venv
+source venv/bin/activate
+pip install -e ".[rpi,google,caldav]"
+```
+
+Install only the extras you need:
+
+| Extra | Installs |
+|---|---|
+| `rpi` | Raspberry Pi GPIO libraries (required on Pi) |
+| `google` | Google Calendar / Tasks API client |
+| `caldav` | CalDAV client (Nextcloud, etc.) |
+| `todoist` | Todoist stub *(v8 API discontinued — not functional)* |
+
+---
## Configuration
-You will need to configure a few things such as API Keys and location. Copy config.json-sample to config.json. Edit config.json to add your api keys and other information.
+
+Copy the sample and fill in your details:
+```bash
+cp config.json-sample config.json
+```
### General
-* rotation: 0 - This is the rotation of the display in degrees. Leave at zero if you use it as a desktop display. Change to 180 if you have it mounted and hanging from a shelf.
+| Key | Values | Description |
+|---|---|---|
+| `rotation` | `0` / `180` | `0` for desktop use, `180` if mounted hanging from a shelf |
+| `timeformat` | `12h` / `24h` | Clock format used throughout |
+| `sunday_first_dow` | `true` / `false` | Week start day for calendar grouping |
+| `cell_spacing` | integer | Pixel padding inside cells |
+| `timezone` | e.g. `Europe/Zurich` | Local timezone |
+
+### Weather — OpenWeatherMap
+```json
+"weather": {
+ "api_key": "your-owm-key",
+ "city": "Zurich,CH",
+ "units": "metric"
+}
+```
+`units` accepts `metric`, `imperial`, or `standard` (Kelvin).
+
+### Google Calendar and Tasks
+
+1. Go to the [Google Cloud Console](https://console.cloud.google.com/apis/).
+2. Create a project (e.g. `infowindow`).
+3. Create an [OAuth consent screen](https://console.cloud.google.com/apis/credentials/consent).
+4. Create an [OAuth 2.0 client ID](https://console.cloud.google.com/apis/credentials) (type: *Desktop app*).
+5. Download the JSON file and save it as `google_secret.json` in the project directory.
+
+Enable the backends in `config.json`:
+```json
+"calendar_google": { "enabled": true, "additional": ["Contacts", "Birthdays"] },
+"todo_google": { "enabled": true }
+```
+
+> **Note:** There is a known Google API bug where repeated tasks stop appearing once one
+> instance is marked complete. See the [Google support thread](https://support.google.com/calendar/thread/3706294).
-### Todo
-Todoist is the current active module in this code. It only requires `api_key`. Teamwork also requires a 'site' key. If using google tasks, leave this as null `todo: null`
-* api_key: Enter your todoist API key.
+### CalDAV (Nextcloud etc.)
-### Weather
-Open Weather Map is where the data is coming from in the default module. This requires a few keys.
-* api_key: Get your api key from OWM website.
-* city: Look at OWM docs to figure what your city name is. Mine is "Sacramento,US"
-* units: This can either be `imperial` or `metric`
+For Nextcloud the CalDAV URL is usually:
+`https://cloud.example.com/remote.php/dav/calendars/USERNAME`
+```json
+"calendar_caldav": {
+ "enabled": true,
+ "caldav_url": "https://cloud.example.com/remote.php/dav/calendars/USERNAME",
+ "username": "john",
+ "password": "secret",
+ "additional": ["Personal", "Birthdays"]
+},
+"todo_caldav": {
+ "enabled": true,
+ "caldav_url": "https://cloud.example.com/remote.php/dav/calendars/USERNAME",
+ "username": "john",
+ "password": "secret",
+ "additional": ["Tasks"]
+}
+```
+
+`additional` is a list of calendar names to include. Leave it empty (`[]`) to include all.
+
+`ignored` (under `calendar`) is a list of event titles to suppress from the display.
+
+---
## Running
-### First Run
-You should run the script manually the first time so that Googles auth modules can run interactivly. Once that has completed you will want to add this to CRON so it runs every few minutes automatically.
-### Cron Run (Normal use)
-* Run `crontab -e`
-* insert `*/6 * * * * /usr/bin/python /home/pi/InfoWindow/infowindow.py --cron`
+### First run — Google authentication
+If you use Google backends, run the script **manually once** so the OAuth flow can open
+interactively and save a token:
+```bash
+source venv/bin/activate
+python infowindow.py
+```
+
+Subsequent runs use the saved `token.pickle` and refresh it automatically.
+
+### systemd (recommended)
+
+The `dist/` directory contains ready-to-use systemd units.
+Link them into place — no need to copy, so pulling updates keeps them current:
+
+```bash
+sudo ln -s /home/pi/InfoWindow/dist/infowindow.service /etc/systemd/system/
+sudo ln -s /home/pi/InfoWindow/dist/infowindow.timer /etc/systemd/system/
+sudo ln -s /home/pi/InfoWindow/dist/infowindow-screensaver.service /etc/systemd/system/
+sudo ln -s /home/pi/InfoWindow/dist/infowindow-screensaver.timer /etc/systemd/system/
+
+sudo systemctl daemon-reload
+sudo systemctl enable --now infowindow.timer
+sudo systemctl enable --now infowindow-screensaver.timer
+```
+
+> **Note:** The service files currently hardcode `User=pi`. Adjust if your username differs.
+
+The **updater** runs every 6 minutes. The **screensaver** runs once daily at 05:01 to cycle
+the display through black, red, and white — preventing image retention.
+
+#### Useful commands
+```bash
+journalctl -u infowindow -f # live logs from the updater
+journalctl -u infowindow-screensaver # screensaver logs
+systemctl status infowindow.timer # next scheduled run
+```
+
+---
+
+## Optional: extend SD-card lifetime
+
+Add this line to `/etc/fstab` and reboot to keep `/tmp` in RAM:
+```
+tmpfs /tmp tmpfs defaults,noatime,nosuid,size=100m 0 0
+```
+
+---
+
+## Local development
+
+The display driver is not available outside a Raspberry Pi, but a **mock display**
+activates automatically on any other machine. It composites the black and red layers
+into a colour preview PNG and opens it with your default image viewer.
+
+```bash
+# One-time setup (no rpi extra needed)
+python3 -m venv .venv
+source .venv/bin/activate
+pip install -e ".[google,caldav]"
+
+# Run
+python infowindow.py
+# → preview saved to /tmp/InfoWindowPreview.png
+```
+The mock faithfully reproduces the Waveshare rendering rules: red wins over black when
+both layers have ink at the same pixel.
diff --git a/clearScreen.py b/clearScreen.py
deleted file mode 100644
index af59ca7..0000000
--- a/clearScreen.py
+++ /dev/null
@@ -1,3 +0,0 @@
-from mod_infowindow import infowindow
-iw = infowindow.InfoWindow()
-iw.display()
diff --git a/config.json-sample b/config.json-sample
index 48ba33c..ebd4b1c 100644
--- a/config.json-sample
+++ b/config.json-sample
@@ -1,14 +1,44 @@
-{
- "general": {
- "rotation": 180
- },
- "todo": {
- "api_key": "1234"
- },
- "calendar": null,
- "weather": {
- "api_key": "1234",
- "city": "Sacramento,US",
- "units": "imperial"
- }
-}
\ No newline at end of file
+{
+ "general": {
+ "rotation": 180,
+ "timeformat": "12h",
+ "sunday_first_dow": false,
+ "cell_spacing": 2,
+ "timezone": "Europe/Zurich"
+ },
+ "calendar": {
+ "font_size": 22,
+ "ignored": ["Buy ticket!"],
+ "today_text_color": "red",
+ "today_background_color": "white"
+ },
+ "todo": {
+ "font_size": 22
+ },
+ "calendar_google": {
+ "enabled": true,
+ "additional": ["Contacts", "Birthdays"]
+ },
+ "calendar_caldav": {
+ "enabled": true,
+ "caldav_url": "https://your.domain.tld/some/caldav",
+ "username": "john",
+ "password": "supersecret",
+ "additional": ["Contact birthdays", "some calendar"]
+ },
+ "todo_google": {
+ "enabled": true
+ },
+ "todo_caldav": {
+ "enabled": true,
+ "caldav_url": "https://your.domain.tld/some/caldav",
+ "username": "john",
+ "password": "supersecret",
+ "additional": ["another calendar"]
+ },
+ "weather": {
+ "api_key": "1234",
+ "city": "Sacramento,US",
+ "units": "imperial"
+ }
+}
diff --git a/dist/infowindow-screensaver.service b/dist/infowindow-screensaver.service
new file mode 100644
index 0000000..291d32a
--- /dev/null
+++ b/dist/infowindow-screensaver.service
@@ -0,0 +1,20 @@
+[Unit]
+Description=InfoWindow screensaver
+After=network.target
+
+[Service]
+ExecStart=/home/pi/InfoWindow/venv/bin/python3 /home/pi/InfoWindow/screensaver.py
+WorkingDirectory=/home/pi/InfoWindow/
+CacheDirectory=InfoWindow
+TimeoutStartSec=3m
+StandardOutput=journal
+StandardError=journal
+SyslogIdentifier=infowindow-screensaver
+NotifyAccess=all
+User=pi
+Group=pi
+Nice=1
+Type=oneshot
+
+[Install]
+WantedBy=multi-user.target
diff --git a/dist/infowindow-screensaver.timer b/dist/infowindow-screensaver.timer
new file mode 100644
index 0000000..c5a0cdf
--- /dev/null
+++ b/dist/infowindow-screensaver.timer
@@ -0,0 +1,10 @@
+[Unit]
+Description=InfoWindow screensaver
+
+[Timer]
+Unit=infowindow-screensaver.service
+#OnCalendar=5:1
+OnCalendar=5:1
+
+[Install]
+WantedBy=timers.target
diff --git a/dist/infowindow.service b/dist/infowindow.service
new file mode 100644
index 0000000..3650071
--- /dev/null
+++ b/dist/infowindow.service
@@ -0,0 +1,20 @@
+[Unit]
+Description=InfoWindow updater
+After=network.target
+
+[Service]
+ExecStart=/home/pi/InfoWindow/venv/bin/python3 /home/pi/InfoWindow/infowindow.py --cron
+WorkingDirectory=/home/pi/InfoWindow/
+CacheDirectory=InfoWindow
+TimeoutStartSec=3m
+StandardOutput=journal
+StandardError=journal
+SyslogIdentifier=infowindow
+NotifyAccess=all
+User=pi
+Group=pi
+Nice=1
+Type=oneshot
+
+[Install]
+WantedBy=multi-user.target
diff --git a/dist/infowindow.timer b/dist/infowindow.timer
new file mode 100644
index 0000000..4580bd4
--- /dev/null
+++ b/dist/infowindow.timer
@@ -0,0 +1,9 @@
+[Unit]
+Description=InfoWindow updater
+
+[Timer]
+Unit=infowindow.service
+OnCalendar=*:0/6
+
+[Install]
+WantedBy=timers.target
diff --git a/driver/epd7in5b.py b/driver/epd7in5b.py
deleted file mode 100644
index 2791cf9..0000000
--- a/driver/epd7in5b.py
+++ /dev/null
@@ -1,212 +0,0 @@
-##
- # @filename : epd7in5.py
- # @brief : Implements for Dual-color e-paper library
- # @author : Yehui from Waveshare
- #
- # Copyright (C) Waveshare July 10 2017
- #
- # Permission is hereby granted, free of charge, to any person obtaining a copy
- # of this software and associated documnetation files (the "Software"), to deal
- # in the Software without restriction, including without limitation the rights
- # to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
- # copies of the Software, and to permit persons to whom the Software is
- # furished to do so, subject to the following conditions:
- #
- # The above copyright notice and this permission notice shall be included in
- # all copies or substantial portions of the Software.
- #
- # THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
- # IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
- # FITNESS OR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
- # AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
- # LIABILITY WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
- # OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
- # THE SOFTWARE.
- #
-
-import epdif
-from PIL import Image
-import RPi.GPIO as GPIO
-import logging
-
-# Display resolution
-EPD_WIDTH = 640
-EPD_HEIGHT = 384
-
-# EPD7IN5 commands
-PANEL_SETTING = 0x00
-POWER_SETTING = 0x01
-POWER_OFF = 0x02
-POWER_OFF_SEQUENCE_SETTING = 0x03
-POWER_ON = 0x04
-POWER_ON_MEASURE = 0x05
-BOOSTER_SOFT_START = 0x06
-DEEP_SLEEP = 0x07
-DATA_START_TRANSMISSION_1 = 0x10
-DATA_STOP = 0x11
-DISPLAY_REFRESH = 0x12
-IMAGE_PROCESS = 0x13
-LUT_FOR_VCOM = 0x20
-LUT_BLUE = 0x21
-LUT_WHITE = 0x22
-LUT_GRAY_1 = 0x23
-LUT_GRAY_2 = 0x24
-LUT_RED_0 = 0x25
-LUT_RED_1 = 0x26
-LUT_RED_2 = 0x27
-LUT_RED_3 = 0x28
-LUT_XON = 0x29
-PLL_CONTROL = 0x30
-TEMPERATURE_SENSOR_COMMAND = 0x40
-TEMPERATURE_CALIBRATION = 0x41
-TEMPERATURE_SENSOR_WRITE = 0x42
-TEMPERATURE_SENSOR_READ = 0x43
-VCOM_AND_DATA_INTERVAL_SETTING = 0x50
-LOW_POWER_DETECTION = 0x51
-TCON_SETTING = 0x60
-TCON_RESOLUTION = 0x61
-SPI_FLASH_CONTROL = 0x65
-REVISION = 0x70
-GET_STATUS = 0x71
-AUTO_MEASUREMENT_VCOM = 0x80
-READ_VCOM_VALUE = 0x81
-VCM_DC_SETTING = 0x82
-
-class EPD:
- def __init__(self):
- self.reset_pin = epdif.RST_PIN
- self.dc_pin = epdif.DC_PIN
- self.busy_pin = epdif.BUSY_PIN
- self.width = EPD_WIDTH
- self.height = EPD_HEIGHT
-
- def digital_write(self, pin, value):
- epdif.epd_digital_write(pin, value)
-
- def digital_read(self, pin):
- return epdif.epd_digital_read(pin)
-
- def delay_ms(self, delaytime):
- epdif.epd_delay_ms(delaytime)
-
- def send_command(self, command):
- self.digital_write(self.dc_pin, GPIO.LOW)
- # the parameter type is list but not int
- # so use [command] instead of command
- epdif.spi_transfer([command])
-
- def send_data(self, data):
- self.digital_write(self.dc_pin, GPIO.HIGH)
- # the parameter type is list but not int
- # so use [data] instead of data
- epdif.spi_transfer([data])
-
- def init(self):
- if (epdif.epd_init() != 0):
- return -1
- self.reset()
- self.send_command(POWER_SETTING)
- self.send_data(0x37)
- self.send_data(0x00)
- self.send_command(PANEL_SETTING)
- self.send_data(0xCF)
- self.send_data(0x08)
- self.send_command(BOOSTER_SOFT_START)
- self.send_data(0xc7)
- self.send_data(0xcc)
- self.send_data(0x28)
- self.send_command(POWER_ON)
- self.wait_until_idle()
- self.send_command(PLL_CONTROL)
- self.send_data(0x3c)
- self.send_command(TEMPERATURE_CALIBRATION)
- self.send_data(0x00)
- self.send_command(VCOM_AND_DATA_INTERVAL_SETTING)
- self.send_data(0x77)
- self.send_command(TCON_SETTING)
- self.send_data(0x22)
- self.send_command(TCON_RESOLUTION)
- self.send_data(0x02) #source 640
- self.send_data(0x80)
- self.send_data(0x01) #gate 384
- self.send_data(0x80)
- self.send_command(VCM_DC_SETTING)
- self.send_data(0x1E) #decide by LUT file
- self.send_command(0xe5) #FLASH MODE
- self.send_data(0x03)
-
- def wait_until_idle(self):
- while(self.digital_read(self.busy_pin) == 0): # 0: busy, 1: idle
- #logging.debug("DRIVER: (wait_until_idle)")
- #self.delay_ms(100)
- self.delay_ms(50)
-
- def reset(self):
- self.digital_write(self.reset_pin, GPIO.LOW) # module reset
- self.delay_ms(200)
- self.digital_write(self.reset_pin, GPIO.HIGH)
- self.delay_ms(200)
-
- def get_frame_buffer(self, image):
- buf = [0x00] * int(self.width * self.height / 4)
- # Set buffer to value of Python Imaging Library image.
- # Image must be in mode L.
- image_grayscale = image.convert('L')
- imwidth, imheight = image_grayscale.size
- if imwidth != self.width or imheight != self.height:
- raise ValueError('Image must be same dimensions as display \
- ({0}x{1}).' .format(self.width, self.height))
-
- pixels = image_grayscale.load()
- for y in range(self.height):
- for x in range(self.width):
- # Set the bits for the column of pixels at the current position.
- if pixels[x, y] < 64: # black
- buf[int((x + y * self.width) / 4)] &= ~(0xC0 >> (x % 4 * 2))
- elif pixels[x, y] < 192: # convert gray to red
- buf[int((x + y * self.width) / 4)] &= ~(0xC0 >> (x % 4 * 2))
- buf[int((x + y * self.width) / 4)] |= 0x40 >> (x % 4 * 2)
- else: # white
- buf[int((x + y * self.width) / 4)] |= 0xC0 >> (x % 4 * 2)
- return buf
-
- def display_frame(self, frame_buffer):
- self.send_command(DATA_START_TRANSMISSION_1)
- logging.debug("DRIVER: ENTERING FOR LOOP")
- for i in range(0, int(self.width / 4 * self.height)):
- temp1 = frame_buffer[i]
- j = 0
- while (j < 4):
- if ((temp1 & 0xC0) == 0xC0):
- temp2 = 0x03
- elif ((temp1 & 0xC0) == 0x00):
- temp2 = 0x00
- else:
- temp2 = 0x04
- temp2 = (temp2 << 4) & 0xFF
- temp1 = (temp1 << 2) & 0xFF
- j += 1
- if((temp1 & 0xC0) == 0xC0):
- temp2 |= 0x03
- elif ((temp1 & 0xC0) == 0x00):
- temp2 |= 0x00
- else:
- temp2 |= 0x04
- temp1 = (temp1 << 2) & 0xFF
- self.send_data(temp2)
- j += 1
- logging.debug("SENDING DISPLAY_REFRESH COMMAND")
- self.send_command(DISPLAY_REFRESH)
- logging.debug("DELAY 100 MS")
- self.delay_ms(100)
- logging.debug("WAIT UNTIL IDLE")
- self.wait_until_idle()
-
- def sleep(self):
- self.send_command(POWER_OFF)
- self.wait_until_idle()
- self.send_command(DEEP_SLEEP)
- self.send_data(0xa5)
-
-### END OF FILE ###
-
diff --git a/driver/epdif.py b/driver/epdif.py
deleted file mode 100644
index d158589..0000000
--- a/driver/epdif.py
+++ /dev/null
@@ -1,63 +0,0 @@
-##
- # @filename : epdif.py
- # @brief : EPD hardware interface implements (GPIO, SPI)
- # @author : Yehui from Waveshare
- #
- # Copyright (C) Waveshare July 10 2017
- #
- # Permission is hereby granted, free of charge, to any person obtaining a copy
- # of this software and associated documnetation files (the "Software"), to deal
- # in the Software without restriction, including without limitation the rights
- # to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
- # copies of the Software, and to permit persons to whom the Software is
- # furished to do so, subject to the following conditions:
- #
- # The above copyright notice and this permission notice shall be included in
- # all copies or substantial portions of the Software.
- #
- # THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
- # IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
- # FITNESS OR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
- # AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
- # LIABILITY WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
- # OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
- # THE SOFTWARE.
- #
-
-import spidev
-import RPi.GPIO as GPIO
-import time
-
-# Pin definition
-RST_PIN = 17
-DC_PIN = 25
-CS_PIN = 8
-BUSY_PIN = 24
-
-# SPI device, bus = 0, device = 0
-SPI = spidev.SpiDev(0, 0)
-
-def epd_digital_write(pin, value):
- GPIO.output(pin, value)
-
-def epd_digital_read(pin):
- return GPIO.input(pin)
-
-def epd_delay_ms(delaytime):
- time.sleep(delaytime / 1000.0)
-
-def spi_transfer(data):
- SPI.writebytes(data)
-
-def epd_init():
- GPIO.setmode(GPIO.BCM)
- GPIO.setwarnings(False)
- GPIO.setup(RST_PIN, GPIO.OUT)
- GPIO.setup(DC_PIN, GPIO.OUT)
- GPIO.setup(CS_PIN, GPIO.OUT)
- GPIO.setup(BUSY_PIN, GPIO.IN)
- SPI.max_speed_hz = 2000000
- SPI.mode = 0b00
- return 0
-
-### END OF FILE ###
diff --git a/infowindow.py b/infowindow.py
index 156163b..7b30323 100755
--- a/infowindow.py
+++ b/infowindow.py
@@ -1,176 +1,6 @@
-#!/usr/bin/env python
-
-import sys
-import json
-import logging
-import traceback
-from mod_infowindow import infowindow
-
-# Select pluggable module for todo list, calendar and weather.
-# Replace the mod_ with one of:
-# TODO: mod_todoist, mod_teamwork
-# CALENDAR: mod_google, mod_ical
-# WEATHER: mod_owm, mod_wunderground
-from mod_utils import iw_utils
-from mod_todo import mod_todoist as modTodo # TODO
-from mod_calendar import mod_google as modCalendar # CALENDAR
-from mod_weather import mod_owm as modWeather # WEATHER
-
-# TODO: Create dictionaries for API args. so that they can be custom.
-
-# Configuration ###############################################################
-with open(iw_utils.getCWD()+"/config.json") as config_file:
- config_data = json.load(config_file)
-
-## Rotation. 0 for desktop, 180 for hanging upside down
-rotation = config_data["general"]["rotation"]
-todo_opts = config_data["todo"]
-calendar_opts = config_data["calendar"]
-weather_opts = config_data["weather"]
-
-# END CONFIGURATION ###########################################################
-###############################################################################
-
-# Setup Logging - change to logging.DEBUG if you are having issues.
-logging.basicConfig(level=logging.DEBUG)
-logging.info("Configuration Complete")
-
-# Custom exception handler. Need to handle exceptions and send them to the
-# display since this will run headless most of the time. This gives the user
-# enough info to know that they need to troubleshoot.
-def HandleException(et, val, tb):
- iw = infowindow.InfoWindow()
- iw.text(0, 10, "EXCEPTION IN PROGRAM", 'robotoBlack18', 'black')
- iw.text(0, 30, str(val), 'robotoBlack18', 'black')
- iw.text(0, 60, "Please run program from command line interactivly to resolve", 'robotoBlack18', 'black')
- print "EXCEPTION IN PROGRAM =================================="
- print val
- print et
- print tb
- print "END EXCEPTION ========================================="
- iw.display(rotation)
-
-sys.excepthook = HandleException
-
-# Main Program ################################################################
-def main():
- # Instantiate API modules
- todo = modTodo.ToDo(todo_opts)
- cal = modCalendar.Cal(calendar_opts)
- weather = modWeather.Weather(weather_opts)
-
- ## Setup e-ink initial drawings
- iw = infowindow.InfoWindow()
-
- ### Weather Grid
- temp_rect_width = 102
- temp_rect_left = (iw.width / 2) - (temp_rect_width / 2)
- temp_rect_right = (iw.width / 2) + (temp_rect_width / 2)
-
- iw.line(268, 0, 268, 64, 'black') # First Vertical Line
- iw.rectangle(temp_rect_left, 0, temp_rect_right, 64, 'red')
- iw.line(372, 0, 372, 64, 'black') # Second Vertical Line
-
- iw.bitmap(375, 0, "windSmall.bmp") # Wind Icon
- iw.line(461, 0, 461, 64, 'black') # Third Vertical Line
-
- iw.bitmap(464, 0, "rainSmall.bmp") # Rain Icon
- iw.line(550, 0, 550, 64, 'black') # Fourth Vertical Line
-
- iw.bitmap(554, 0, "snowSmall.bmp") # Snow Icon
-
- # Center cal/todo divider line
- iw.line(314, 90, 314, 384, 'black') # Left Black line
- iw.rectangle(315, 64, 325, 384, 'red') # Red Rectangle
- iw.line(326, 90, 326, 384, 'black') # Right Black line
-
-
- # Calendar / Todo Title Line
- iw.line(0, 64, 640, 64, 'black') # Top Line
- iw.rectangle(0, 65, 640, 90, 'red') # Red Rectangle
- iw.line(0, 91, 640, 91, 'black') # Bottom Black Line
-
- # Todo / Weather Titles
- iw.text(440, 64, "TODO", 'robotoBlack24', 'white')
- iw.text(95, 64, "CALENDAR", 'robotoBlack24', 'white')
-
-
- # DISPLAY TODO INFO
- # =========================================================================
- todo_items = todo.list()
- logging.debug("Todo Items")
- logging.debug("-----------------------------------------------------------------------")
- t_y = 94
- for todo_item in todo_items:
- iw.text(333, t_y, str(todo_item['content']), 'robotoRegular18', 'black')
- t_y = (t_y + 24)
- iw.line(325, (t_y - 2), 640, (t_y - 2), 'black')
- logging.debug("ITEM: "+todo_item['content'])
-
- # DISPLAY CALENDAR INFO
- # =========================================================================
- cal_items = cal.list()
- logging.debug("Calendar Items")
- logging.debug("-----------------------------------------------------------------------")
- c_y = 94
-
- # Time and date divider line
- (dt_x, dt_y) = iw.getFont('robotoRegular14').getsize('12-99-2000')
-
- for cal_item in cal_items:
- (x, y) = iw.text(3, c_y, str(cal_item['date']), 'robotoRegular14', 'black')
- iw.line((dt_x + 5), c_y, (dt_x + 5), (c_y +32), 'black')
- iw.text(3, (c_y + 15), str(cal_item['time']), 'robotoRegular14', 'black')
- iw.text((dt_x + 7), (c_y + 5), iw.truncate(str(cal_item['content']), 'robotoRegular18'), 'robotoRegular18', 'black')
- c_y = (c_y + 32)
- iw.line(0, (c_y - 2), 313, (c_y - 2), 'black')
- # logging.debug("ITEM: "+str(cal_item['date']), str(cal_item['time']), str(cal_item['content']))
- logging.debug("ITEM: "+str(cal_item['content']))
-
- # DISPLAY WEATHER INFO
- # =========================================================================
- weather = weather.list()
- logging.debug("Weather Info")
- logging.debug("-----------------------------------------------------------------------")
- # Set unit descriptors
- if weather_opts['units'] == 'imperial':
- u_speed = "mph"
- u_temp = "F"
- elif weather_opts['units'] == 'metric':
- u_speed = "m/sec"
- u_temp = "C"
- else:
- u_speed = "m/sec"
- u_temp = "K"
-
- deg_symbol = u"\u00b0"
- iw.bitmap(2, 2, weather['icon'])
- iw.text(70, 2, weather['description'].title(), 'robotoBlack24', 'black')
- iw.text(70, 35, weather['sunrise'], 'robotoRegular18', 'black')
- iw.text(154, 35, weather['sunset'], 'robotoRegular18', 'black')
-
- # Temp ( adjust for str length )
- (t_x, t_y) = iw.getFont('robotoBlack48').getsize(str(weather['temp_cur'])+deg_symbol)
- temp_left = (iw.width / 2) - (t_x / 2)
- iw.text(temp_left, 2, str(weather['temp_cur'])+deg_symbol, 'robotoBlack48', 'white')
- t_desc_posx = (temp_left + t_x) - 15
- iw.text(t_desc_posx, 25, u_temp, 'robotoBlack18', 'white')
-
- # Wind
- iw.text(405, 5, weather['wind']['dir'], 'robotoBlack18', 'black')
- iw.text(380, 35, str(weather['wind']['speed'])+u_speed, 'robotoRegular18', 'black')
-
- # Rain
- iw.text(481, 29, "1hr: "+str(weather['rain']['1h']), 'robotoRegular18', 'black')
- iw.text(481, 44, "3hr: "+str(weather['rain']['3h']), 'robotoRegular18', 'black')
-
- # Snow
- iw.text(573, 29, "1hr: "+str(weather['snow']['1h']), 'robotoRegular18', 'black')
- iw.text(573, 44, "3hr: "+str(weather['snow']['3h']), 'robotoRegular18', 'black')
-
- # Write to screen
- # =========================================================================
- iw.display(rotation)
-
-if __name__ == '__main__':
- main()
\ No newline at end of file
+#!/usr/bin/env python3
+"""Thin shim — keeps the existing systemd ExecStart path working."""
+from infowindow.__main__ import main
+
+if __name__ == "__main__":
+ main()
diff --git a/driver/__init__.py b/infowindow/__init__.py
similarity index 100%
rename from driver/__init__.py
rename to infowindow/__init__.py
diff --git a/infowindow/__main__.py b/infowindow/__main__.py
new file mode 100644
index 0000000..bb2d7cb
--- /dev/null
+++ b/infowindow/__main__.py
@@ -0,0 +1,87 @@
+#!/usr/bin/env python3
+"""InfoWindow — e-ink display updater."""
+from __future__ import annotations
+
+import logging
+import os
+
+from infowindow.config import load_config
+from infowindow.display import get_display
+from infowindow.display.canvas import Canvas
+from infowindow.layout import (
+ draw_layout,
+ fetch_all,
+ measure_todos,
+ render_calendar_column,
+ render_todos,
+ render_weather,
+ centered_text,
+)
+from infowindow.sources.loader import (
+ load_calendar_sources,
+ load_todo_sources,
+ load_weather_source,
+)
+
+log = logging.getLogger(__name__)
+
+
+def main() -> None:
+ logging.basicConfig(
+ format="%(asctime)s %(levelname)-7s %(message)s",
+ datefmt="%Y-%d-%m %H:%M:%S",
+ level=logging.DEBUG if os.getenv("DEBUG") else logging.INFO,
+ )
+
+ config = load_config()
+ general = config["general"]
+
+ todo_sources = load_todo_sources(config)
+ cal_sources = load_calendar_sources(config)
+ weather_source = load_weather_source(config)
+
+ todo_items, cal_items, weather_data = fetch_all(todo_sources, cal_sources, weather_source)
+
+ canvas = Canvas({"timeformat": general["timeformat"]})
+ cell_spacing = general.get("cell_spacing", 2)
+
+ draw_layout(canvas)
+
+ cal_opts = {
+ **config.get("calendar", {}),
+ "timeformat": general["timeformat"],
+ }
+ todo_opts = config.get("todo", {})
+
+ last_item, _ = render_calendar_column(canvas, cal_items, 0, 391, cal_opts, cell_spacing)
+
+ todo_height = measure_todos(canvas, todo_items, cell_spacing)
+
+ if not todo_items:
+ render_calendar_column(canvas, cal_items, 408, 800, cal_opts, cell_spacing, last_item + 1)
+ left_title, right_title = "CALENDAR 1/2", "CALENDAR 2/2"
+ else:
+ _, right_col_y = render_calendar_column(
+ canvas, cal_items, 408, 800, cal_opts, cell_spacing,
+ last_item + 1, max_y=480 - todo_height,
+ )
+ render_todos(canvas, todo_items, cell_spacing, start_y=right_col_y,
+ font_size=todo_opts.get("font_size", 22))
+ left_title, right_title = "CALENDAR", "CALENDAR"
+
+ centered_text(canvas, left_title, "robotoBlack24", "white", 200, 64)
+ centered_text(canvas, right_title, "robotoBlack24", "white", 600, 64)
+
+ if weather_data is not None:
+ render_weather(canvas, weather_data, config)
+
+ rotation = general.get("rotation", 0)
+ black_img, red_img = canvas.render(rotation)
+
+ device = get_display()
+ device.display(black_img, red_img)
+ device.sleep()
+
+
+if __name__ == "__main__":
+ main()
diff --git a/infowindow/config.py b/infowindow/config.py
new file mode 100644
index 0000000..3f569f3
--- /dev/null
+++ b/infowindow/config.py
@@ -0,0 +1,27 @@
+from __future__ import annotations
+
+import json
+import logging
+from pathlib import Path
+
+from infowindow.utils.paths import PROJECT_ROOT
+
+log = logging.getLogger(__name__)
+
+_DEFAULT_CONFIG_PATH = PROJECT_ROOT / "config.json"
+
+
+def load_config(path: Path | None = None) -> dict:
+ cfg_path = path or _DEFAULT_CONFIG_PATH
+ log.info("Loading config from %s", cfg_path)
+ with cfg_path.open() as fh:
+ config = json.load(fh)
+
+ general = config.get("general", {})
+
+ # Propagate shared general settings into sub-sections that need them.
+ for section in ("calendar_caldav", "calendar_google"):
+ config.setdefault(section, {})
+ config[section].setdefault("timezone", general.get("timezone", "UTC"))
+
+ return config
diff --git a/infowindow/display/__init__.py b/infowindow/display/__init__.py
new file mode 100644
index 0000000..161d55d
--- /dev/null
+++ b/infowindow/display/__init__.py
@@ -0,0 +1,14 @@
+import logging
+
+log = logging.getLogger(__name__)
+
+
+def get_display():
+ """Return a real EPD device, or a MockEPD when hardware is unavailable."""
+ try:
+ from infowindow.display.epd import RealEPD
+ return RealEPD()
+ except Exception as exc:
+ log.info("E-ink driver unavailable (%s), using mock display.", exc)
+ from infowindow.display.mock import MockEPD
+ return MockEPD()
diff --git a/infowindow/display/canvas.py b/infowindow/display/canvas.py
new file mode 100644
index 0000000..cbc237f
--- /dev/null
+++ b/infowindow/display/canvas.py
@@ -0,0 +1,108 @@
+from __future__ import annotations
+
+import logging
+from pathlib import Path
+
+from PIL import Image, ImageDraw, ImageFont
+
+from infowindow.utils.paths import FONTS_DIR, ICONS_DIR
+
+log = logging.getLogger(__name__)
+
+_FONT_VARIANTS: dict[str, tuple[str, int]] = {
+ "robotoBlack14": ("Black", 14),
+ "robotoBlack18": ("Black", 18),
+ "robotoBold22": ("Bold", 22),
+ "robotoBlack22": ("Black", 22),
+ "robotoBlack24": ("Black", 24),
+ "robotoBlack54": ("Black", 54),
+}
+
+
+class Canvas:
+ """Pure-PIL drawing surface for the two-layer (black + red) e-ink display."""
+
+ WIDTH = 800
+ HEIGHT = 480
+
+ def __init__(self, options: dict) -> None:
+ self.width = self.WIDTH
+ self.height = self.HEIGHT
+ self.black_image = Image.new("1", (self.WIDTH, self.HEIGHT), 1)
+ self.red_image = Image.new("1", (self.WIDTH, self.HEIGHT), 1)
+ self.black_draw = ImageDraw.Draw(self.black_image)
+ self.red_draw = ImageDraw.Draw(self.red_image)
+ self._fonts: dict[str, ImageFont.FreeTypeFont] = {}
+ self._init_fonts()
+ self.timeformat: str = options["timeformat"]
+
+ # ------------------------------------------------------------------
+ # Drawing primitives
+ # ------------------------------------------------------------------
+
+ def line(self, x1: int, y1: int, x2: int, y2: int, fill: str) -> None:
+ coords = (x1, y1, x2, y2)
+ if fill == "black":
+ self.black_draw.line(coords, fill=0)
+ elif fill == "red":
+ self.red_draw.line(coords, fill=0)
+ elif fill == "white":
+ self.black_draw.line(coords, fill=1)
+ self.red_draw.line(coords, fill=1)
+
+ def rectangle(self, tl: float, tr: float, bl: float, br: float, fill: str) -> None:
+ box = ((tl, tr), (bl, br))
+ if fill == "black":
+ self.black_draw.rectangle(box, fill=0)
+ elif fill == "red":
+ self.red_draw.rectangle(box, fill=0)
+ elif fill == "white":
+ self.black_draw.rectangle(box, fill=1)
+ self.red_draw.rectangle(box, fill=1)
+
+ def text(self, x: float, y: float, content: str, font: str, fill: str, anchor: str | None = None) -> None:
+ fnt = self._fonts[font]
+ pos = (x, y)
+ extra = {"anchor": anchor} if anchor else {}
+ if fill == "black":
+ self.black_draw.text(pos, content, font=fnt, fill=0, **extra)
+ elif fill == "red":
+ self.black_draw.text(pos, content, font=fnt, fill=0, **extra)
+ self.red_draw.text(pos, content, font=fnt, fill=0, **extra)
+ elif fill == "white":
+ self.black_draw.text(pos, content, font=fnt, fill=1, **extra)
+ self.red_draw.text(pos, content, font=fnt, fill=1, **extra)
+
+ def bitmap(self, x: int, y: int, image_name: str) -> None:
+ bmp = Image.open(ICONS_DIR / image_name)
+ self.black_draw.bitmap((x, y), bmp, fill=0)
+
+ # ------------------------------------------------------------------
+ # Font helpers
+ # ------------------------------------------------------------------
+
+ def get_font(self, name: str) -> ImageFont.FreeTypeFont:
+ return self._fonts[name]
+
+ def truncate(self, text: str, font: str, max_width: int) -> str:
+ fnt = self._fonts[font]
+ while fnt.getlength(text) > max_width and text:
+ text = text[:-1]
+ return text
+
+ # ------------------------------------------------------------------
+ # Output
+ # ------------------------------------------------------------------
+
+ def render(self, angle: int) -> tuple[Image.Image, Image.Image]:
+ """Return (black_image, red_image) rotated by *angle* degrees."""
+ return self.black_image.rotate(angle), self.red_image.rotate(angle)
+
+ # ------------------------------------------------------------------
+ # Internals
+ # ------------------------------------------------------------------
+
+ def _init_fonts(self) -> None:
+ for name, (variant, size) in _FONT_VARIANTS.items():
+ path = FONTS_DIR / f"Roboto-{variant}.ttf"
+ self._fonts[name] = ImageFont.truetype(str(path), size)
diff --git a/infowindow/display/epd.py b/infowindow/display/epd.py
new file mode 100644
index 0000000..c44a4a9
--- /dev/null
+++ b/infowindow/display/epd.py
@@ -0,0 +1,74 @@
+from __future__ import annotations
+
+import logging
+
+from PIL import Image, ImageChops
+
+from infowindow.utils.paths import cache_path
+
+log = logging.getLogger(__name__)
+
+# This import intentionally lives at module level so that ImportError
+# propagates to get_display() in __init__.py and triggers the mock fallback.
+from driver import epd7in5b_V2 # noqa: E402
+
+
+class RealEPD:
+ """Thin wrapper around the Waveshare epd7in5b_V2 driver.
+
+ Owns change-detection so the display is only refreshed when pixel
+ content actually differs from the previously saved images.
+ """
+
+ def __init__(self) -> None:
+ self._epd = epd7in5b_V2.EPD()
+ self._epd.init()
+ self._cache_black = cache_path("InfoWindowBlack.png")
+ self._cache_red = cache_path("InfoWindowRed.png")
+ log.info("Image cache: %s, %s", self._cache_black, self._cache_red)
+
+ def display(self, black_image: Image.Image, red_image: Image.Image) -> None:
+ changed = False
+ for img, path, name in (
+ (red_image, self._cache_red, "red"),
+ (black_image, self._cache_black, "black"),
+ ):
+ if path.exists():
+ diff = ImageChops.difference(
+ img.convert("L"),
+ Image.open(path).convert("L"),
+ )
+ bbox = diff.getbbox()
+ if bbox:
+ log.info("%s layer changed at %s", name, bbox)
+ changed = True
+ else:
+ log.info("No previous %s image found, treating as new.", name)
+ changed = True
+
+ if changed:
+ log.info("New information detected. Updating the screen.")
+ black_image.save(self._cache_black)
+ red_image.save(self._cache_red)
+ self._epd.display(
+ self._epd.getbuffer(black_image),
+ self._epd.getbuffer(red_image),
+ )
+ else:
+ log.info("No new information found. Not updating the screen.")
+
+ def sleep(self) -> None:
+ self._epd.sleep()
+
+ def init(self) -> None:
+ pass # already called in __init__
+
+ def clear(self) -> None:
+ self._epd.Clear()
+
+ def clear_cache(self) -> None:
+ """Delete the cached comparison images (called by screensaver)."""
+ for path in (self._cache_black, self._cache_red):
+ if path.exists():
+ path.unlink()
+ log.info("Removed cached image: %s", path)
diff --git a/infowindow/display/mock.py b/infowindow/display/mock.py
new file mode 100644
index 0000000..a25ca42
--- /dev/null
+++ b/infowindow/display/mock.py
@@ -0,0 +1,54 @@
+from __future__ import annotations
+
+import logging
+import subprocess
+
+from PIL import Image, ImageOps
+
+from infowindow.utils.paths import cache_path
+
+log = logging.getLogger(__name__)
+
+_PREVIEW_PATH = cache_path("InfoWindowPreview.png")
+
+
+class MockEPD:
+ """Local-development stand-in for the Waveshare e-ink driver.
+
+ Composites the two 1-bit layers into a colour preview PNG and opens
+ it with the default image viewer so the result is immediately visible.
+ """
+
+ def init(self) -> None:
+ log.info("MockEPD: init (no-op)")
+
+ def display(self, black_image: Image.Image, red_image: Image.Image) -> None:
+ width, height = black_image.size
+
+ preview = Image.new("RGB", (width, height), "white")
+
+ # Black layer first: where black_image pixel = 0 (has ink), paint black
+ black_layer = Image.new("RGB", (width, height), "black")
+ black_mask = ImageOps.invert(black_image.convert("L"))
+ preview.paste(black_layer, mask=black_mask)
+
+ # Red layer on top: where red_image pixel = 0 (has ink), paint red.
+ # Red wins over black — matches Waveshare 7.5" B V2 hardware behaviour.
+ red_layer = Image.new("RGB", (width, height), "red")
+ red_mask = ImageOps.invert(red_image.convert("L"))
+ preview.paste(red_layer, mask=red_mask)
+
+ preview.save(_PREVIEW_PATH)
+ log.info("MockEPD: preview saved to %s", _PREVIEW_PATH)
+
+ try:
+ subprocess.Popen(["xdg-open", str(_PREVIEW_PATH)])
+ except FileNotFoundError:
+ # xdg-open not available (e.g. macOS); fall back to PIL viewer
+ preview.show()
+
+ def sleep(self) -> None:
+ log.info("MockEPD: sleep (no-op)")
+
+ def clear_cache(self) -> None:
+ """No cache to clear on the mock."""
diff --git a/infowindow/layout.py b/infowindow/layout.py
new file mode 100644
index 0000000..85a3ad8
--- /dev/null
+++ b/infowindow/layout.py
@@ -0,0 +1,292 @@
+"""Rendering functions — pure drawing logic, no network or hardware calls."""
+from __future__ import annotations
+
+import logging
+import string
+from typing import Any
+
+from infowindow.display.canvas import Canvas
+from infowindow.sources.types import CalendarItem, TodoItem, WeatherData
+
+log = logging.getLogger(__name__)
+
+_ENTRY_FONT: dict[int, str] = {14: "robotoBlack14", 18: "robotoBlack18", 22: "robotoBlack22"}
+
+
+# ---------------------------------------------------------------------------
+# Helpers
+# ---------------------------------------------------------------------------
+
+def _safe_fetch(func, name: str) -> Any:
+ try:
+ return func()
+ except Exception as exc:
+ log.error("Failed to fetch %s, skipping: %s", name, exc, exc_info=True)
+ return None
+
+
+def _max_char_size(canvas: Canvas, chars: str, font: str) -> tuple[int, int]:
+ max_w = max_h = 0
+ for ch in chars:
+ left, top, right, bottom = canvas.get_font(font).getbbox(ch)
+ max_w = max(max_w, right - left)
+ max_h = max(max_h, bottom - top)
+ return max_w, max_h
+
+
+def centered_text(
+ canvas: Canvas,
+ text: str,
+ font: str,
+ color: str,
+ center_x: float,
+ y: float,
+) -> None:
+ length = canvas.get_font(font).getlength(text)
+ canvas.text(int(center_x - length / 2), y, text, font, color)
+
+
+# ---------------------------------------------------------------------------
+# Data fetching
+# ---------------------------------------------------------------------------
+
+def fetch_all(
+ todo_sources: list,
+ cal_sources: list,
+ weather_source: Any,
+) -> tuple[list[TodoItem], list[CalendarItem], WeatherData | None]:
+ todo_items: list[TodoItem] = []
+ for src in todo_sources:
+ result = _safe_fetch(src.list, f"todo/{type(src).__name__}")
+ if result:
+ todo_items.extend(result)
+ todo_items = sorted(todo_items, key=lambda x: (x["priority"] == 0, x["priority"]))
+
+ cal_items: list[CalendarItem] = []
+ for src in cal_sources:
+ result = _safe_fetch(src.list, f"calendar/{type(src).__name__}")
+ if result:
+ cal_items.extend(result)
+ cal_items = sorted(cal_items, key=lambda x: x["start_ts"])
+
+ weather: WeatherData | None = None
+ if weather_source is not None:
+ weather = _safe_fetch(weather_source.list, "weather")
+
+ return todo_items, cal_items, weather
+
+
+# ---------------------------------------------------------------------------
+# Static layout chrome
+# ---------------------------------------------------------------------------
+
+def draw_layout(canvas: Canvas) -> None:
+ """Draw the static grid lines and coloured bands that frame the content."""
+ w = canvas.width
+
+ # Weather strip top dividers
+ half = w / 2
+ temp_rect_left = half - 64
+ temp_rect_right = half + 64
+ canvas.line(335, 0, 335, 64, "black")
+ canvas.rectangle(temp_rect_left, 0, temp_rect_right, 64, "red")
+ canvas.line(465, 0, 465, 64, "black")
+
+ # Centre column divider (between calendar and todo)
+ canvas.line(392, 90, 392, 480, "black")
+ canvas.rectangle(393, 64, 406, 480, "red")
+ canvas.line(407, 90, 407, 480, "black")
+
+ # Horizontal band separating weather strip from content
+ canvas.line(0, 64, w, 64, "black")
+ canvas.rectangle(0, 65, w, 90, "red")
+ canvas.line(0, 91, w, 91, "black")
+
+
+# ---------------------------------------------------------------------------
+# Todo rendering
+# ---------------------------------------------------------------------------
+
+def measure_todos(canvas: Canvas, items: list[TodoItem], cell_spacing: int) -> int:
+ """Return the pixel height needed for the todo header row + all items."""
+ if not items:
+ return 0
+ _, date_h = _max_char_size(canvas, string.digits, "robotoBlack14")
+ line_height = 2 * date_h + 2 * cell_spacing # matches calendar row height
+ return len(items) * (line_height + 2) # last item may overflow, like calendar
+
+
+def render_todos(
+ canvas: Canvas,
+ items: list[TodoItem],
+ cell_spacing: int,
+ start_y: int = 92,
+ font_size: int = 22,
+) -> int:
+ """Draw todo header row + items; return y after the last rendered item."""
+ if not items:
+ return start_y
+
+ font = _ENTRY_FONT.get(font_size, "robotoBlack22")
+ _, date_h = _max_char_size(canvas, string.digits, "robotoBlack14")
+ line_height = 2 * date_h + 2 * cell_spacing # matches calendar row height
+
+ canvas.rectangle(408, start_y, 800, start_y + line_height + 2, "red")
+ todo_w = canvas.get_font(font).getlength("TODO")
+ canvas.text(int((408 + 800) / 2 - todo_w / 2), start_y + line_height // 2, "TODO", font, "white", anchor="lm")
+ canvas.line(408, start_y + line_height + 2, 800, start_y + line_height + 2, "black")
+
+ y = start_y + line_height + 2
+ text_x = 408 + cell_spacing
+ max_w = 800 - text_x - cell_spacing
+
+ for item in items:
+ color = "red" if item.get("today") else "black"
+ content = canvas.truncate(item["content"].strip(), font, max_w)
+ canvas.text(text_x, y + line_height // 2, content, font, color, anchor="lm")
+ canvas.line(408, y + line_height + 2, 800, y + line_height + 2, "black")
+ y += line_height + 2
+ if y > 480:
+ break
+
+ return y
+
+
+# ---------------------------------------------------------------------------
+# Calendar rendering
+# ---------------------------------------------------------------------------
+
+def render_calendar_column(
+ canvas: Canvas,
+ items: list[CalendarItem],
+ x_min: int,
+ x_max: int,
+ opts: dict,
+ cell_spacing: int,
+ start_index: int = 0,
+ max_y: int = 9999,
+) -> tuple[int, int]:
+ """Render calendar items into one column; return (last_index, next_y)."""
+ date_font = "robotoBlack14"
+ entry_font = _ENTRY_FONT.get(opts.get("font_size", 22), "robotoBlack22")
+
+ _, digit_h = _max_char_size(canvas, string.digits, date_font)
+ sep_str = ": pm" if opts.get("timeformat") == "12h" else "."
+ l, t, r, b = canvas.get_font(date_font).getbbox(sep_str)
+ sep_w, sep_h = r - l, b - t
+ date_col_w = sep_w + 4 * (canvas.get_font(date_font).getbbox("0")[2])
+ date_h = max(digit_h, sep_h)
+ _, entry_h = _max_char_size(canvas, string.printable, entry_font)
+ line_height = 2 * date_h + 2 * cell_spacing
+ date_col_x = x_min + date_col_w
+ divider_x = date_col_x + 2 * cell_spacing + 1
+
+ y = 92
+ cur_days_away = cur_weeks_away = cur_week = -1
+ first = True
+ last_index = start_index
+
+ for cal_item in items[start_index:]:
+ if y + line_height + 2 > max_y: # respect hard ceiling (e.g. todo section)
+ break
+
+ new_week = False
+
+ if cal_item["today"]:
+ canvas.rectangle(
+ x_min, y, x_max, y + line_height,
+ opts.get("today_background_color", "white"),
+ )
+
+ font_color = opts.get("today_text_color", "black") if cal_item["today"] else "black"
+
+ if cur_days_away < 0: cur_days_away = cal_item["days_away"]
+ if cur_weeks_away < 0: cur_weeks_away = cal_item["weeks_away"]
+ if cur_week < 0: cur_week = cal_item["week"]
+
+ if not first:
+ # Default: dashed line (same day)
+ for x in range(x_min, x_max, 8):
+ canvas.line(x, y, x + 3, y, "black")
+ canvas.line(x + 4, y, x + 7, y, "white")
+ first = False
+
+ if cur_days_away != cal_item["days_away"]:
+ cur_days_away = cal_item["days_away"]
+ canvas.line(x_min, y, x_max, y, "black")
+
+ if cur_week != cal_item["week"]:
+ cur_week = cal_item["week"]
+ new_week = True
+ canvas.rectangle(x_min, y - 1, x_max, y, "black")
+
+ if cur_weeks_away != cal_item["weeks_away"]:
+ cur_weeks_away = cal_item["weeks_away"]
+ if new_week:
+ for x in range(x_min, x_max, 23):
+ canvas.rectangle(x, y - 1, x + 11, y, "black")
+ canvas.rectangle(x + 12, y - 1, x + 23, y, "red")
+ else:
+ canvas.line(x_min, y, x_max, y, "red")
+
+ # Bottom guard line (usually overridden by next iteration)
+ canvas.line(x_min, y + line_height + 2, x_max, y + line_height + 2, "black")
+
+ # Vertical separator between date col and content col
+ canvas.line(divider_x, y, divider_x, y + line_height, "black")
+
+ # Date and time
+ canvas.text(x_min + cell_spacing, y, cal_item["date"].strip(), date_font, font_color)
+ canvas.text(x_min + cell_spacing, y + 1 + date_h, cal_item["time"].strip(), date_font, font_color)
+
+ # Event text (truncated to fit)
+ text_x = divider_x + cell_spacing + 1
+ max_w = x_max - text_x - cell_spacing
+ content = canvas.truncate(cal_item["content"].strip(), entry_font, max_w)
+ canvas.text(text_x, y + (line_height - entry_h) / 2, content, entry_font, font_color)
+
+ last_index = items.index(cal_item)
+ y += line_height + 2
+ if y > 480: # original overflow allowance — items may clip slightly at screen edge
+ break
+
+ return last_index, y
+
+
+# ---------------------------------------------------------------------------
+# Weather rendering
+# ---------------------------------------------------------------------------
+
+def render_weather(canvas: Canvas, data: WeatherData, config: dict) -> None:
+ units = config.get("weather", {}).get("units", "metric")
+ u_speed, u_temp = {
+ "imperial": ("mph", "F"),
+ "metric": ("m/sec", "C"),
+ }.get(units, ("m/sec", "K"))
+
+ deg = "°"
+ canvas.bitmap(2, 2, data["icon"])
+ canvas.text(90, 2, data["description"].title().strip(), "robotoBlack24", "black")
+ canvas.text(90, 35, data["sunrise"], "robotoBlack18", "black")
+ canvas.text(192, 35, data["sunset"], "robotoBlack18", "black")
+
+ temp_str = f"{data['temp_cur']}{deg}"
+ l, t, r, b = canvas.get_font("robotoBlack54").getbbox(temp_str)
+ text_w = r - l
+ temp_x = canvas.width / 2 - text_w / 2
+ canvas.text(temp_x, 2, temp_str, "robotoBlack54", "white")
+ canvas.text(temp_x + text_w - 18, 28, u_temp, "robotoBlack24", "white")
+
+ canvas.bitmap(480, 0, "windSmall.bmp")
+ canvas.text(520, 5, data["wind"]["dir"], "robotoBlack18", "black")
+ canvas.text(480, 35, f"{data['wind']['speed']}{u_speed}", "robotoBlack18", "black")
+ canvas.line(576, 0, 576, 64, "black")
+
+ canvas.bitmap(616, 0, "rainSmall.bmp")
+ canvas.text(601, 29, f"1hr: {data['rain']['1h']}", "robotoBlack18", "black")
+ canvas.text(601, 44, f"3hr: {data['rain']['3h']}", "robotoBlack18", "black")
+ canvas.line(687, 0, 687, 64, "black")
+
+ canvas.bitmap(728, 0, "snowSmall.bmp")
+ canvas.text(716, 29, f"1hr: {data['snow']['1h']}", "robotoBlack18", "black")
+ canvas.text(716, 44, f"3hr: {data['snow']['3h']}", "robotoBlack18", "black")
diff --git a/mod_calendar/__init__.py b/infowindow/sources/__init__.py
similarity index 100%
rename from mod_calendar/__init__.py
rename to infowindow/sources/__init__.py
diff --git a/mod_infowindow/__init__.py b/infowindow/sources/calendar/__init__.py
similarity index 100%
rename from mod_infowindow/__init__.py
rename to infowindow/sources/calendar/__init__.py
diff --git a/infowindow/sources/calendar/caldav.py b/infowindow/sources/calendar/caldav.py
new file mode 100644
index 0000000..05127de
--- /dev/null
+++ b/infowindow/sources/calendar/caldav.py
@@ -0,0 +1,156 @@
+from __future__ import annotations
+
+import logging
+import re
+from datetime import datetime as dt, timedelta, time, date
+
+import pytz
+from caldav import DAVClient
+from dateutil.parser import parse as dtparse
+from dateutil.rrule import rrulestr
+from dateutil.tz import gettz
+
+from infowindow.sources.types import CalendarItem
+
+log = logging.getLogger(__name__)
+
+
+def _replace_birth_year_with_age(summary: str) -> str:
+ match = re.search(r"\((\d{4})\)", summary)
+ if match:
+ age = dt.now().year - int(match.group(1))
+ summary = summary.replace(match.group(0), f"(Age {age})")
+ return summary
+
+
+class Cal:
+ def __init__(self, config: dict) -> None:
+ log.debug("Initializing Calendar: CalDAV")
+ cfg = config["calendar_caldav"]
+ self.enabled: bool = cfg.get("enabled", False)
+ if not self.enabled:
+ return
+ self._client = DAVClient(
+ url=cfg["caldav_url"],
+ username=cfg["username"],
+ password=cfg["password"],
+ timeout=30,
+ )
+ self._timeformat: str = config["general"]["timeformat"]
+ self._additional: list = cfg.get("additional", [])
+ self._ignored: list = config["calendar"].get("ignored", [])
+ self._sunday_first = config["general"].get("sunday_first_dow", False)
+ self._tz = gettz(cfg.get("timezone", "UTC"))
+
+ def list(self) -> list[CalendarItem]:
+ if not self.enabled:
+ log.debug("Calendar: CalDAV not enabled")
+ return []
+
+ now = dt.now(self._tz)
+ principal = self._client.principal()
+ calendars = principal.calendars()
+ log.info("CalDAV calendars: %s", ", ".join(c.name for c in calendars))
+
+ selected = [c for c in calendars if c.name in self._additional or not self._additional]
+ events: list[tuple[str, str]] = []
+
+ for cal in selected:
+ log.debug("Fetching calendar: %s", cal.name)
+ results = cal.search(
+ start=now - timedelta(days=1),
+ end=now + timedelta(days=60),
+ event=True,
+ expand=True,
+ )
+ for event in results:
+ for comp in event.icalendar_instance.walk():
+ if comp.name != "VEVENT":
+ continue
+ summary = str(comp.get("SUMMARY", "No Title"))
+ if summary in self._ignored:
+ continue
+ summary = summary.replace("\U0001F382", "_i_")
+ summary = _replace_birth_year_with_age(summary)
+
+ start_orig = comp.get("DTSTART").dt
+ end_orig = comp.get("DTEND", None)
+ rrule = comp.get("RRULE")
+ is_all_day = not isinstance(start_orig, dt)
+
+ start = self._resolve_start(start_orig, end_orig, rrule, is_all_day, now)
+ if start is None:
+ continue
+ end = self._resolve_end(start, end_orig, is_all_day)
+ if end < now:
+ continue
+ events.append((start.isoformat(), summary))
+
+ if not events:
+ return []
+
+ events.sort()
+ now_local = dt.now(self._tz)
+ items: list[CalendarItem] = []
+ for start_str, summary in events:
+ start = dtparse(start_str)
+ today = start.date() <= now_local.date()
+ days_away = (start.date() - now_local.date()).days
+ weeks_away = days_away // 7
+ week = int(start.strftime("%U" if self._sunday_first else "%W"))
+ st_date, st_time = self._format_datetime(start)
+ items.append(CalendarItem(
+ date=st_date, time=st_time, content=summary,
+ today=today, week=week,
+ start_ts=start.timestamp(),
+ days_away=days_away, weeks_away=weeks_away,
+ ))
+ return items
+
+ # ------------------------------------------------------------------
+ def _resolve_start(self, start_orig, end_orig, rrule, is_all_day, now) -> dt | None:
+ if rrule:
+ rrule_str = "RRULE:" + rrule.to_ical().decode()
+ rule_start = (
+ dt.combine(start_orig, time.min).replace(tzinfo=self._tz)
+ if is_all_day else start_orig
+ )
+ occurrence = rrulestr(rrule_str, dtstart=rule_start).after(
+ now.replace(hour=0, minute=0, second=0, microsecond=0), inc=True
+ )
+ if not occurrence:
+ return None
+ if isinstance(occurrence, date) and not isinstance(occurrence, dt):
+ return dt.combine(occurrence, time.min).replace(tzinfo=self._tz)
+ return (
+ occurrence.replace(tzinfo=self._tz)
+ if occurrence.tzinfo is None
+ else occurrence.astimezone(self._tz)
+ )
+
+ if is_all_day:
+ start = start_orig if isinstance(start_orig, dt) else dt.combine(start_orig, time.min).replace(tzinfo=self._tz)
+ else:
+ start = start_orig
+ if start.tzinfo is None:
+ start = pytz.utc.localize(start)
+ start = start.astimezone(self._tz)
+ return start
+
+ def _resolve_end(self, start: dt, end_orig, is_all_day: bool) -> dt:
+ if is_all_day:
+ if end_orig:
+ end_date = end_orig.dt if hasattr(end_orig, "dt") else end_orig
+ return dt.combine(end_date, time.min).replace(tzinfo=self._tz) - timedelta(seconds=1)
+ return start + timedelta(days=1)
+ if end_orig:
+ end = end_orig.dt if hasattr(end_orig, "dt") else end_orig
+ if end.tzinfo is None:
+ end = pytz.utc.localize(end)
+ return end.astimezone(self._tz)
+ return start + timedelta(hours=1)
+
+ def _format_datetime(self, start: dt) -> tuple[str, str]:
+ if self._timeformat == "12h":
+ return start.strftime("%m-%d"), start.strftime("%I:%M %p")
+ return start.strftime("%d.%m"), start.strftime("%H:%M")
diff --git a/infowindow/sources/calendar/google.py b/infowindow/sources/calendar/google.py
new file mode 100644
index 0000000..52b7eb5
--- /dev/null
+++ b/infowindow/sources/calendar/google.py
@@ -0,0 +1,94 @@
+from __future__ import annotations
+
+import logging
+from datetime import datetime as dt, timezone
+
+from dateutil.parser import parse as dtparse
+from googleapiclient.discovery import build
+
+from infowindow.sources.types import CalendarItem
+from infowindow.utils.google_auth import GoogleAuth
+
+log = logging.getLogger(__name__)
+logging.getLogger("googleapiclient.discovery_cache").setLevel(logging.ERROR)
+
+
+class Cal:
+ def __init__(self, config: dict) -> None:
+ log.debug("Initializing Calendar: Google")
+ cfg = config["calendar_google"]
+ self.enabled: bool = cfg.get("enabled", False)
+ if not self.enabled:
+ return
+ self._creds = GoogleAuth().login()
+ self._timeformat = config["general"]["timeformat"]
+ self._additional = cfg.get("additional", [])
+ self._ignored = config["calendar"].get("ignored", [])
+ self._sunday_first = config["general"].get("sunday_first_dow", False)
+
+ def list(self) -> list[CalendarItem]:
+ if not self.enabled:
+ log.debug("Calendar: Google not enabled")
+ return []
+
+ service = build("calendar", "v3", credentials=self._creds)
+ now_iso = dt.now(timezone.utc).isoformat()
+
+ # Collect calendar IDs
+ calendar_ids: list[str] = []
+ page_token = None
+ while True:
+ resp = service.calendarList().list(pageToken=page_token).execute()
+ for entry in resp["items"]:
+ if entry.get("primary"):
+ calendar_ids.append(entry["id"])
+ elif entry.get("summary") in self._additional:
+ calendar_ids.append(entry["id"])
+ page_token = resp.get("nextPageToken")
+ if not page_token:
+ break
+
+ # Fetch events, deduplicate by start time
+ events: dict[str, dict] = {}
+ for cal_id in calendar_ids:
+ result = service.events().list(
+ calendarId=cal_id, timeMin=now_iso,
+ maxResults=30, singleEvents=True, orderBy="startTime",
+ ).execute()
+ for event in result.get("items", []):
+ if event.get("summary") in self._ignored:
+ continue
+ raw_start = event["start"].get("dateTime", event["start"].get("date"))
+ key, counter = f"{raw_start}-0", 0
+ while key in events:
+ counter += 1
+ key = f"{raw_start}-{counter}"
+ events[key] = event
+
+ today_str = dt.today().strftime("%Y%m%d")
+ day_start_ts = dt.now().replace(hour=0, minute=0, second=0, microsecond=0).timestamp()
+ items: list[CalendarItem] = []
+
+ for key in sorted(events):
+ event = events[key]
+ raw_start = event["start"].get("dateTime", event["start"].get("date"))
+ start = dtparse(raw_start)
+
+ today = start.strftime("%Y%m%d") <= today_str
+ st_date, st_time = self._format_datetime(start)
+ week = int(start.strftime("%U" if self._sunday_first else "%W"))
+ event_day_ts = start.replace(hour=0, minute=0, second=0, microsecond=0).timestamp()
+
+ items.append(CalendarItem(
+ date=st_date, time=st_time, content=event["summary"],
+ today=today, week=week,
+ start_ts=start.timestamp(),
+ days_away=int((event_day_ts - day_start_ts) // 86400),
+ weeks_away=int((event_day_ts - day_start_ts) // 604800),
+ ))
+ return items
+
+ def _format_datetime(self, start: dt) -> tuple[str, str]:
+ if self._timeformat == "12h":
+ return start.strftime("%m-%d"), start.strftime("%I:%M %p")
+ return start.strftime("%d.%m"), start.strftime("%H:%M")
diff --git a/infowindow/sources/loader.py b/infowindow/sources/loader.py
new file mode 100644
index 0000000..18006d1
--- /dev/null
+++ b/infowindow/sources/loader.py
@@ -0,0 +1,54 @@
+from __future__ import annotations
+
+import importlib
+import logging
+from typing import Any
+
+log = logging.getLogger(__name__)
+
+# Registry maps config-section suffix → (module path, class name)
+_TODO_BACKENDS: dict[str, tuple[str, str]] = {
+ "google": ("infowindow.sources.todo.google", "ToDo"),
+ "caldav": ("infowindow.sources.todo.caldav", "ToDo"),
+ "todoist": ("infowindow.sources.todo.todoist", "ToDo"),
+ "teamwork": ("infowindow.sources.todo.teamwork", "ToDo"),
+}
+
+_CALENDAR_BACKENDS: dict[str, tuple[str, str]] = {
+ "google": ("infowindow.sources.calendar.google", "Cal"),
+ "caldav": ("infowindow.sources.calendar.caldav", "Cal"),
+}
+
+
+def _load(registry: dict[str, tuple[str, str]], config: dict, prefix: str) -> list[Any]:
+ sources = []
+ for name, (module_path, cls_name) in registry.items():
+ cfg_key = f"{prefix}_{name}"
+ if not config.get(cfg_key, {}).get("enabled", False):
+ continue
+ try:
+ mod = importlib.import_module(module_path)
+ sources.append(getattr(mod, cls_name)(config))
+ log.info("Loaded %s backend: %s", prefix, name)
+ except ImportError:
+ log.warning("Backend '%s/%s' not installed, skipping.", prefix, name)
+ except Exception as exc:
+ log.error("Failed to initialise %s/%s: %s", prefix, name, exc, exc_info=True)
+ return sources
+
+
+def load_todo_sources(config: dict) -> list:
+ return _load(_TODO_BACKENDS, config, "todo")
+
+
+def load_calendar_sources(config: dict) -> list:
+ return _load(_CALENDAR_BACKENDS, config, "calendar")
+
+
+def load_weather_source(config: dict):
+ """Return a Weather instance if an api_key is configured, else None."""
+ if config.get("weather", {}).get("api_key"):
+ from infowindow.sources.weather.owm import Weather
+ return Weather(config)
+ log.warning("No weather api_key in config; weather disabled.")
+ return None
diff --git a/mod_todo/__init__.py b/infowindow/sources/todo/__init__.py
similarity index 100%
rename from mod_todo/__init__.py
rename to infowindow/sources/todo/__init__.py
diff --git a/infowindow/sources/todo/caldav.py b/infowindow/sources/todo/caldav.py
new file mode 100644
index 0000000..5865097
--- /dev/null
+++ b/infowindow/sources/todo/caldav.py
@@ -0,0 +1,99 @@
+from __future__ import annotations
+
+import logging
+from datetime import datetime, timedelta, date, UTC
+
+from caldav import DAVClient
+
+from infowindow.sources.types import TodoItem
+
+log = logging.getLogger(__name__)
+
+_today = date.today()
+_tomorrow = _today + timedelta(days=1)
+
+
+class ToDo:
+ def __init__(self, config: dict) -> None:
+ log.debug("Initializing Todo: CalDAV")
+ cfg = config["todo_caldav"]
+ self.enabled: bool = cfg.get("enabled", False)
+ if not self.enabled:
+ return
+ self._client = DAVClient(
+ url=cfg["caldav_url"],
+ username=cfg["username"],
+ password=cfg["password"],
+ timeout=30,
+ )
+ self._additional: list = cfg.get("additional", [])
+
+ def list(self) -> list[TodoItem]:
+ if not self.enabled:
+ log.debug("Todo: CalDAV not enabled")
+ return []
+
+ today = date.today()
+ tomorrow = today + timedelta(days=1)
+ now = datetime.now(UTC)
+
+ principal = self._client.principal()
+ calendars = principal.calendars()
+ log.info("CalDAV calendars for todo: %s", ", ".join(c.name for c in calendars))
+ selected = [c for c in calendars if c.name in self._additional or not self._additional]
+
+ with_date: list[tuple[str, object]] = []
+ without_date: list[tuple[str, object]] = []
+
+ for cal in selected:
+ log.debug("Fetching todos from: %s", cal.name)
+ results = cal.search(
+ start=now - timedelta(days=360), end=now + timedelta(days=60),
+ todo=True, expand=True,
+ )
+ for todo in results:
+ for comp in todo.icalendar_instance.walk():
+ if comp.name != "VTODO":
+ continue
+ summary = str(comp.get("SUMMARY", "No Title"))
+ if "DUE" in comp:
+ due = comp.get("DUE").dt
+ if not isinstance(due, datetime):
+ due = datetime.combine(due, datetime.min.time())
+ with_date.append((due.isoformat(), comp))
+ elif "DTSTART" in comp:
+ start = comp.get("DTSTART").dt
+ if not isinstance(start, datetime):
+ start = datetime.combine(start, datetime.min.time())
+ with_date.append((start.isoformat(), comp))
+ else:
+ without_date.append((summary, comp))
+
+ items: list[TodoItem] = []
+
+ for _, comp in sorted(without_date):
+ items.append(TodoItem(
+ content=str(comp.get("SUMMARY", "No Title")),
+ priority=int(comp.get("PRIORITY", 0)),
+ today=False,
+ ))
+
+ for due_str, comp in sorted(with_date, key=lambda t: t[0]):
+ due_date = datetime.fromisoformat(due_str.replace("Z", "+00:00")).date()
+ if due_date < today:
+ is_today = True
+ comp["SUMMARY"] = f"Overdue: {comp['SUMMARY']}"
+ comp["PRIORITY"] = 1
+ elif due_date == today:
+ is_today = True
+ elif due_date == tomorrow:
+ is_today = False
+ else:
+ continue
+ items.append(TodoItem(
+ content=str(comp.get("SUMMARY", "No Title")),
+ priority=int(comp.get("PRIORITY", 0)),
+ today=is_today,
+ ))
+
+ return items
diff --git a/infowindow/sources/todo/google.py b/infowindow/sources/todo/google.py
new file mode 100644
index 0000000..6ebae51
--- /dev/null
+++ b/infowindow/sources/todo/google.py
@@ -0,0 +1,69 @@
+from __future__ import annotations
+
+import logging
+from datetime import datetime, timedelta, date
+
+from googleapiclient.discovery import build
+
+from infowindow.sources.types import TodoItem
+from infowindow.utils.google_auth import GoogleAuth
+
+log = logging.getLogger(__name__)
+
+
+class ToDo:
+ def __init__(self, config: dict) -> None:
+ log.debug("Initializing Todo: Google")
+ cfg = config["todo_google"]
+ self.enabled: bool = cfg.get("enabled", False)
+ if not self.enabled:
+ return
+ self._creds = GoogleAuth().login()
+
+ def list(self) -> list[TodoItem]:
+ if not self.enabled:
+ log.debug("Todo: Google not enabled")
+ return []
+
+ today = date.today()
+ tomorrow = today + timedelta(days=1)
+ service = build("tasks", "v1", credentials=self._creds)
+
+ with_due: list[dict] = []
+ without_due: list[TodoItem] = []
+
+ tasklists = service.tasklists().list().execute()
+ for tasklist in tasklists.get("items", []):
+ if "todo" not in tasklist["title"].lower():
+ continue
+ results = service.tasks().list(tasklist=tasklist["id"]).execute()
+ for task in results.get("items", []):
+ if "due" in task:
+ with_due.append(task)
+ else:
+ without_due.append(TodoItem(
+ content=task["title"],
+ priority=int(task["position"]),
+ today=False,
+ ))
+
+ items: list[TodoItem] = []
+ for task in sorted(with_due, key=lambda t: t["due"]):
+ due_date = datetime.fromisoformat(task["due"].replace("Z", "+00:00")).date()
+ if due_date < today:
+ task["title"] = f"Overdue: {task['title']}"
+ task["position"] = "1"
+ is_today = True
+ elif due_date == today:
+ is_today = True
+ elif due_date == tomorrow:
+ is_today = False
+ else:
+ continue
+ items.append(TodoItem(
+ content=task["title"],
+ priority=int(task["position"]),
+ today=is_today,
+ ))
+
+ return items + without_due
diff --git a/infowindow/sources/todo/teamwork.py b/infowindow/sources/todo/teamwork.py
new file mode 100644
index 0000000..f7b39de
--- /dev/null
+++ b/infowindow/sources/todo/teamwork.py
@@ -0,0 +1,46 @@
+from __future__ import annotations
+
+import base64
+import json
+import logging
+import urllib.error
+import urllib.parse
+import urllib.request
+
+from infowindow.sources.types import TodoItem
+
+log = logging.getLogger(__name__)
+
+_PRIORITY_MAP = {"high": 1, "medium": 2, "low": 3, "none": 4}
+
+
+class ToDo:
+ def __init__(self, config: dict) -> None:
+ log.debug("Initializing Todo: Teamwork")
+ cfg = config.get("todo_teamwork", {})
+ self.enabled: bool = cfg.get("enabled", False)
+ if not self.enabled:
+ return
+ self._site = cfg["site"]
+ token = base64.b64encode(f"{cfg['api_key']}:xxx".encode()).decode()
+ self._auth_header = f"BASIC {token}"
+
+ def list(self) -> list[TodoItem]:
+ if not self.enabled:
+ log.debug("Todo: Teamwork not enabled")
+ return []
+
+ url = f"https://{self._site}/tasks.json?sort=priority"
+ req = urllib.request.Request(url, headers={"Authorization": self._auth_header})
+ with urllib.request.urlopen(req, timeout=30) as resp:
+ data = json.loads(resp.read())
+
+ items: list[TodoItem] = []
+ for task in data.get("todo-items", []):
+ priority = _PRIORITY_MAP.get(str(task.get("priority", "")).lower(), 8)
+ items.append(TodoItem(
+ content=task["content"],
+ priority=priority,
+ today=False,
+ ))
+ return items
diff --git a/infowindow/sources/todo/todoist.py b/infowindow/sources/todo/todoist.py
new file mode 100644
index 0000000..2ad60bd
--- /dev/null
+++ b/infowindow/sources/todo/todoist.py
@@ -0,0 +1,25 @@
+from __future__ import annotations
+
+import logging
+
+from infowindow.sources.types import TodoItem
+
+log = logging.getLogger(__name__)
+
+# NOTE: The todoist-python package (v8 API) is unmaintained and the API has
+# been discontinued. This module is kept as a stub. To restore Todoist
+# support, migrate to todoist-api-python (REST API v2).
+
+
+class ToDo:
+ def __init__(self, config: dict) -> None:
+ log.debug("Initializing Todo: Todoist (stub — v8 API is discontinued)")
+ self.enabled = config.get("todo_todoist", {}).get("enabled", False)
+ if self.enabled:
+ log.warning(
+ "Todoist backend is enabled but the underlying API is discontinued. "
+ "Returning empty list. Migrate to todoist-api-python to restore support."
+ )
+
+ def list(self) -> list[TodoItem]:
+ return []
diff --git a/infowindow/sources/types.py b/infowindow/sources/types.py
new file mode 100644
index 0000000..be402a9
--- /dev/null
+++ b/infowindow/sources/types.py
@@ -0,0 +1,69 @@
+from __future__ import annotations
+
+from typing import TYPE_CHECKING, Protocol, TypedDict, runtime_checkable
+
+if TYPE_CHECKING:
+ from PIL import Image
+
+
+class TodoItem(TypedDict):
+ content: str
+ priority: int
+ today: bool
+
+
+class CalendarItem(TypedDict):
+ date: str
+ time: str
+ content: str
+ today: bool
+ week: int
+ start_ts: float
+ days_away: int
+ weeks_away: int
+
+
+class WindData(TypedDict):
+ dir: str
+ speed: int
+
+
+class PrecipData(TypedDict):
+ h1: float
+ h3: float
+
+
+class WeatherData(TypedDict):
+ description: str
+ humidity: int
+ temp_cur: int
+ temp_min: int
+ temp_max: int
+ sunrise: str
+ sunset: str
+ rain: dict[str, float]
+ snow: dict[str, float]
+ wind: WindData
+ icon: str
+
+
+@runtime_checkable
+class TodoSource(Protocol):
+ def list(self) -> list[TodoItem]: ...
+
+
+@runtime_checkable
+class CalendarSource(Protocol):
+ def list(self) -> list[CalendarItem]: ...
+
+
+@runtime_checkable
+class WeatherSource(Protocol):
+ def list(self) -> WeatherData: ...
+
+
+@runtime_checkable
+class DisplayDevice(Protocol):
+ def init(self) -> None: ...
+ def display(self, black_image: Image.Image, red_image: Image.Image) -> None: ...
+ def sleep(self) -> None: ...
diff --git a/mod_utils/__init__.py b/infowindow/sources/weather/__init__.py
similarity index 100%
rename from mod_utils/__init__.py
rename to infowindow/sources/weather/__init__.py
diff --git a/infowindow/sources/weather/owm.py b/infowindow/sources/weather/owm.py
new file mode 100644
index 0000000..7b791cc
--- /dev/null
+++ b/infowindow/sources/weather/owm.py
@@ -0,0 +1,65 @@
+from __future__ import annotations
+
+import logging
+import math
+from datetime import datetime as dt
+
+import requests
+
+from infowindow.sources.types import WeatherData
+
+log = logging.getLogger(__name__)
+
+_DIRECTIONS = [
+ (337.5, "N"), (292.5, "NW"), (247.5, "W"), (202.5, "SW"),
+ (157.5, "S"), (122.5, "SE"), (67.5, "E"), (22.5, "NE"),
+]
+
+
+def _degrees_to_dir(deg: float) -> str:
+ for threshold, label in _DIRECTIONS:
+ if deg > threshold:
+ return label
+ return "N"
+
+
+class Weather:
+ _URL = "http://api.openweathermap.org/data/2.5/weather"
+
+ def __init__(self, config: dict) -> None:
+ log.debug("Initializing Weather: OpenWeatherMap")
+ cfg = config["weather"]
+ self._api_key = cfg["api_key"]
+ self._city = cfg["city"]
+ self._units = cfg["units"]
+ self._timeformat = config["general"]["timeformat"]
+
+ def list(self) -> WeatherData:
+ resp = requests.get(
+ self._URL,
+ params={"q": self._city, "units": self._units, "appid": self._api_key},
+ timeout=30,
+ )
+ resp.raise_for_status()
+ data = resp.json()
+
+ fmt = "%I:%M %p" if self._timeformat == "12h" else "%H:%M"
+ sunrise = dt.fromtimestamp(data["sys"]["sunrise"]).strftime(fmt)
+ sunset = dt.fromtimestamp(data["sys"]["sunset"]).strftime(fmt)
+
+ def precip(key: str) -> dict[str, float]:
+ return {"1h": data[key].get("1h", 0), "3h": data[key].get("3h", 0)} if key in data else {"1h": 0, "3h": 0}
+
+ return WeatherData(
+ description=data["weather"][0]["description"],
+ humidity=data["main"]["humidity"],
+ temp_cur=math.ceil(data["main"]["temp"]),
+ temp_min=math.ceil(data["main"]["temp_min"]),
+ temp_max=math.ceil(data["main"]["temp_max"]),
+ sunrise=sunrise,
+ sunset=sunset,
+ rain=precip("rain"),
+ snow=precip("snow"),
+ wind={"dir": _degrees_to_dir(data["wind"]["deg"]), "speed": round(data["wind"]["speed"])},
+ icon=f"{data['weather'][0]['icon']}.bmp",
+ )
diff --git a/mod_weather/__init__.py b/infowindow/utils/__init__.py
similarity index 100%
rename from mod_weather/__init__.py
rename to infowindow/utils/__init__.py
diff --git a/infowindow/utils/google_auth.py b/infowindow/utils/google_auth.py
new file mode 100644
index 0000000..c5075e6
--- /dev/null
+++ b/infowindow/utils/google_auth.py
@@ -0,0 +1,61 @@
+from __future__ import annotations
+
+import logging
+import pickle
+import sys
+
+from google.auth.transport.requests import Request
+from google_auth_oauthlib import flow
+
+from infowindow.utils.paths import PROJECT_ROOT
+
+log = logging.getLogger(__name__)
+
+_TOKEN_PATH = PROJECT_ROOT / "token.pickle"
+_SECRETS_PATH = PROJECT_ROOT / "google_secret.json"
+
+
+def _is_cron() -> bool:
+ return len(sys.argv) >= 2 and sys.argv[1] == "--cron"
+
+
+class GoogleAuth:
+ SCOPES = [
+ "https://www.googleapis.com/auth/calendar",
+ "https://www.googleapis.com/auth/tasks",
+ ]
+
+ def __init__(self) -> None:
+ log.info("Initializing GoogleAuth")
+ self._creds = None
+
+ def login(self):
+ if _TOKEN_PATH.exists():
+ log.info("Loading token from %s", _TOKEN_PATH)
+ with _TOKEN_PATH.open("rb") as fh:
+ self._creds = pickle.load(fh)
+
+ if not self._creds or not self._creds.valid:
+ log.info("Credentials missing or invalid.")
+ if self._creds and self._creds.expired and self._creds.refresh_token:
+ log.info("Refreshing Google credentials.")
+ self._creds.refresh(Request())
+ else:
+ if not _SECRETS_PATH.exists():
+ raise FileNotFoundError(f"Google secret not found: {_SECRETS_PATH}")
+ if _is_cron():
+ raise RuntimeError(
+ "Google credentials require interactive login. "
+ "Run infowindow.py manually once to authenticate."
+ )
+ app_flow = flow.InstalledAppFlow.from_client_secrets_file(
+ str(_SECRETS_PATH), self.SCOPES
+ )
+ app_flow.run_console()
+ self._creds = app_flow.credentials
+
+ log.info("Saving credentials to %s", _TOKEN_PATH)
+ with _TOKEN_PATH.open("wb") as fh:
+ pickle.dump(self._creds, fh)
+
+ return self._creds
diff --git a/infowindow/utils/paths.py b/infowindow/utils/paths.py
new file mode 100644
index 0000000..622a471
--- /dev/null
+++ b/infowindow/utils/paths.py
@@ -0,0 +1,15 @@
+import os
+import tempfile
+from pathlib import Path
+
+# Project root is three levels up from this file:
+# infowindow/utils/paths.py -> infowindow/utils/ -> infowindow/ -> project root
+PROJECT_ROOT: Path = Path(__file__).resolve().parents[2]
+FONTS_DIR: Path = PROJECT_ROOT / "fonts" / "roboto"
+ICONS_DIR: Path = PROJECT_ROOT / "icons"
+
+
+def cache_path(filename: str) -> Path:
+ """Return a path inside the systemd CacheDirectory (or system tmp as fallback)."""
+ cache_dir = os.environ.get("CACHE_DIRECTORY", tempfile.gettempdir())
+ return Path(cache_dir) / filename
diff --git a/mod_calendar/mod_google.py b/mod_calendar/mod_google.py
deleted file mode 100644
index b8439f0..0000000
--- a/mod_calendar/mod_google.py
+++ /dev/null
@@ -1,40 +0,0 @@
-from mod_utils import mod_google_auth
-from googleapiclient.discovery import build
-from dateutil.parser import parse as dtparse
-from datetime import datetime as dt
-import logging
-
-# Silence goofy google deprecated errors
-logging.getLogger('googleapiclient.discovery_cache').setLevel(logging.ERROR)
-
-
-class Cal:
- def __init__(self, api_key):
- ga = mod_google_auth.GoogleAuth()
- self.creds = ga.login()
-
- def list(self):
- service = build('calendar', 'v3', credentials=self.creds)
-
- now = dt.utcnow().isoformat() + 'Z'
- result = service.events().list(calendarId='primary', timeMin=now,
- maxResults=20,
- singleEvents=True,
- orderBy='startTime').execute()
-
- events = result.get('items', [])
-
- # 2019-11-05T10:00:00-08:00
- items = []
- for event in events:
- start = event['start'].get('dateTime', event['start'].get('date'))
- st_date = dt.strftime(dtparse(start), format='%m-%d-%Y')
- st_time = dt.strftime(dtparse(start), format='%I:%M%p')
- items.append({
- "date": st_date,
- "time": st_time,
- "content": event['summary']
- })
-
- return items
-
diff --git a/mod_infowindow/infowindow.py b/mod_infowindow/infowindow.py
deleted file mode 100644
index 26ff8cf..0000000
--- a/mod_infowindow/infowindow.py
+++ /dev/null
@@ -1,80 +0,0 @@
-from driver import epd7in5b
-from PIL import Image
-from PIL import ImageDraw
-from PIL import ImageFont
-import os, sys
-
-class InfoWindow():
- def __init__(self):
- self.epd = epd7in5b.EPD()
- self.epd.init()
- self.width = 640
- self.height = 384
- self.image = Image.new('L', (640, 384), 255)
- self.draw = ImageDraw.Draw(self.image)
- self.initFonts()
-
- def getCWD(self):
- path = os.path.dirname(os.path.realpath(sys.argv[0]))
- return path
-
- def getImage(self):
- return self.image
-
- def getDraw(self):
- return self.draw
-
- def getEpd(self):
- return self.epd
-
- def line(self, left_1, top_1, left_2, top_2, fill, width=1):
- self.draw.line((left_1, top_1, left_2, top_2), fill=fill)
-
- def rectangle(self, tl, tr, bl, br, fill):
- self.draw.rectangle(((tl, tr), (bl, br)), fill = fill)
-
- def text(self, left, top, text, font, fill):
- font = self.fonts[font]
- self.draw.text((left, top), text, font = font, fill = fill)
- return self.draw.textsize(text, font=font)
-
- def rotate(self, angle):
- self.image.rotate(angle)
-
- # def chord(self, x, y, xx, yy, xxx, yyy, fill):
- # self.draw.chord((x, y, xx, yy), xxx, yyy, fill)
-
- def bitmap(self, x, y, image_path):
- bitmap = Image.open(self.getCWD()+"/icons/"+image_path)
- #self.image.paste((0, 0), (x, y), 'black', bitmap)
- self.draw.bitmap((x, y), bitmap)
-
- def getFont(self, font_name):
- return self.fonts[font_name]
-
- def initFonts(self):
- roboto = self.getCWD()+"/fonts/roboto/Roboto-"
- self.fonts = {
-
- 'robotoBlack24': ImageFont.truetype(roboto+"Black.ttf", 24),
- 'robotoBlack18': ImageFont.truetype(roboto+"Black.ttf", 18),
- 'robotoRegular18': ImageFont.truetype(roboto+"Regular.ttf", 18),
- 'robotoRegular14': ImageFont.truetype(roboto+"Regular.ttf", 14),
- 'robotoBlack48': ImageFont.truetype(roboto+"Black.ttf", 48)
- }
-
- def truncate(self, str, font):
- num_chars = len(str)
- for char in str:
- (np_x, np_y) = self.getFont(font).getsize(str)
- if np_x >= 235:
- str = str[:-1]
-
- if np_x <= 235:
- return str
-
- return str
-
- def display(self, angle):
- self.image = self.image.rotate(angle)
- self.epd.display_frame(self.epd.get_frame_buffer(self.image))
diff --git a/mod_todo/mod_google.py b/mod_todo/mod_google.py
deleted file mode 100644
index 5390436..0000000
--- a/mod_todo/mod_google.py
+++ /dev/null
@@ -1,33 +0,0 @@
-from mod_utils import mod_google_auth
-from googleapiclient.discovery import build
-import logging
-
-logger = logging.getLogger(__name__)
-
-
-class ToDo:
- def __init__(self, api_key):
- # This module authenticates from Google Auth API. We pull in the auth module
- # wrapper to keep it clean.
- logger.info("Initializing Module: ToDo: GOOGLE")
- ga = mod_google_auth.GoogleAuth()
- self.creds = ga.login()
-
- def list(self):
- logging.info("Entering ToDo.list()")
- service = build('tasks', 'v1', credentials=self.creds)
-
- # Fetch Results
- results = service.tasks().list(tasklist='YVJWSXk4cXVhZk1aSGlmag').execute()
-
- items = []
-
- # Loop through results and format them for ingest
- for task in results['items']:
- items.append({
- "content": task['title'],
- "priority": task['position']
- })
-
- # Return results to main program
- return items
\ No newline at end of file
diff --git a/mod_todo/mod_teamwork.py b/mod_todo/mod_teamwork.py
deleted file mode 100644
index d2fadf2..0000000
--- a/mod_todo/mod_teamwork.py
+++ /dev/null
@@ -1,43 +0,0 @@
-import urllib2, base64
-import json
-import logging
-
-class ToDo:
- def __init__(self, opts):
- logging.debug("Todo API: TEAMWORK")
- self.company = opts['site']
- self.key = opts['api_key']
-
- def list(self):
- action = "tasks.json?sort=priority"
- request = urllib2.Request("https://{0}/{1}".format(self.company, action))
- request.add_header("Authorization", "BASIC " + base64.b64encode(self.key + ":xxx"))
-
- response = urllib2.urlopen(request)
- data = json.loads(response.read())
- items = []
-
- for task in data['todo-items']:
- if task['priority'] == 'high':
- priority = 1
- elif task['priority'] == 'medium':
- priority = 2
- elif task['priority'] == 'low':
- priority = 3
- elif task['priority'] == 'None':
- priority = 4
- else:
- priority = 8
-
- items.append({
- "content": task['content'],
- "priority": priority
- })
-
- return items
-
-
-
-
-
-
diff --git a/mod_todo/mod_todoist.py b/mod_todo/mod_todoist.py
deleted file mode 100644
index 1cf5235..0000000
--- a/mod_todo/mod_todoist.py
+++ /dev/null
@@ -1,29 +0,0 @@
-import todoist
-import logging
-
-
-class ToDo:
- def __init__(self, opts):
- logging.debug("Todo API: TODOIST")
- self.api = todoist.TodoistAPI(opts['api_key'])
- self.api.sync()
-
- def list(self):
- items = []
- # Loop through original array from Todoist and pull out
- # items of interest
- for item in self.api.state['items']:
- if item['checked'] == 0:
- items.append({
- "content": item['content'],
- "priority": item['priority'],
- })
-
- # Sort the array by priority
- items = sorted(items, key = lambda i: i['priority'])
-
- # Reverse list, since Todoist sets priority in reverse.
- # On web interface HIGH=Priority1, but stored in API as 4. who knows?!
- items.reverse()
-
- return items
\ No newline at end of file
diff --git a/mod_utils/iw_utils.py b/mod_utils/iw_utils.py
deleted file mode 100644
index f16ec94..0000000
--- a/mod_utils/iw_utils.py
+++ /dev/null
@@ -1,21 +0,0 @@
-import os
-import sys
-
-def isCron():
- if len(sys.argv) == 2:
- if(sys.argv[1] == '--cron'):
- return True
- return False
-
-def getCWD():
- path = os.path.dirname(os.path.realpath(sys.argv[0]))
- return path
-
-# Custom Error handler. This function will display the error message
-# on the e-ink display and exit.
-def HandleError(msg):
- print "ERROR IN PROGRAM ======================================"
- print "Program requires user input. Please run from console"
- print "ERR: " + msg
- print "END ERROR ============================================="
- quit()
\ No newline at end of file
diff --git a/mod_utils/mod_google_auth.py b/mod_utils/mod_google_auth.py
deleted file mode 100644
index 658ae9b..0000000
--- a/mod_utils/mod_google_auth.py
+++ /dev/null
@@ -1,72 +0,0 @@
-from apiclient.discovery import build
-from google_auth_oauthlib.flow import InstalledAppFlow
-from google.auth.transport.requests import Request
-from mod_utils import iw_utils
-import pickle
-import os.path
-import sys
-import logging
-
-logger = logging.getLogger(__name__)
-
-
-class GoogleAuth:
- def __init__(self):
- logger.info("Initializing Module: GoogleAuth")
- self.scopes = [
- 'https://www.googleapis.com/auth/calendar',
- 'https://www.googleapis.com/auth/tasks'
- ]
-
- self.creds = None
-
- def getCWD(self):
- path = os.path.dirname(os.path.realpath(sys.argv[0]))
- return path
-
- def login(self):
-
- # Check for pickle.
- # if os.path.exists('token.pickle'):
- if os.path.exists(self.getCWD()+'/token.pickle'):
- logger.info("token.pickle Exists. Attempting read")
- with open(self.getCWD()+'/token.pickle', 'rb') as token:
- self.creds = pickle.load(token)
- else:
- logger.info(self.getCWD+"/token.pickle NOT FOUND")
-
- # If there are no valid creds, let user login.
- # If we get to this point there is a user interaction that needs
- # to happen. Must generate some sort of display on e-ink to let the
- # user know that they need to run interactivly.
- if not self.creds or not self.creds.valid:
- logger.info("Credentials do not exist, or are not valid.")
-
- # Requires input from user. Write error to e-ink if is run from cron.
- # if iw_utils.isCron():
- # iw_utils.HandleError("Google Credentials do not exist, or are not valid")
-
- if self.creds and self.creds.expired and self.creds.refresh_token:
- logging.info("Refreshing Google Auth Credentials")
- self.creds.refresh(Request())
- else:
- # Check to see if google_secret.json exists. Throw error if not
- if not os.path.exists(self.getCWD+'/google_secret.json'):
- logger.info(self.getCWD+"/google_secret.json does not exist")
-
- # Requires input from user. Write error to e-ink if is run from cron.
- if iw_utils.isCron():
- iw_utils.HandleError('Message')
-
- flow = InstalledAppFlow.from_client_secrets_file(
- self.getCWD()+'/google_secret.json', self.scopes
- )
-
- self.creds = flow.run_console()
-
- # Write pickle file
- logger.info("Writing "+self.getCWD()+"/token.pickle file")
- with open(self.getCWD()+'/token.pickle', 'wb') as token:
- pickle.dump(self.creds, token)
-
- return self.creds
\ No newline at end of file
diff --git a/mod_weather/mod_owm.py b/mod_weather/mod_owm.py
deleted file mode 100644
index 16f0ee3..0000000
--- a/mod_weather/mod_owm.py
+++ /dev/null
@@ -1,106 +0,0 @@
-import requests
-from datetime import datetime as dt
-import os
-import json
-import math
-from PIL import Image
-import logging
-
-
-class Weather:
- def __init__(self, options):
- logging.debug("Weather API: Open Weather Map")
- self.api_key = options['api_key']
- self.icon_path = "icons/"
- self.city = options['city']
- self.units = options['units']
-
- def pngToBmp(self, icon):
- img = Image.open(self.icon_path+str(icon))
- r,g,b,a = img.split()
- # img.merge("RGB", (r, g, b))
- basename = os.path.splitext(icon)[0]
- img = img.convert('1')
- img.save(self.icon_path+basename+".bmp")
- return basename+".bmp"
-
- def getIcon(self, iconUrl):
- # check for icon
- bn = os.path.basename(iconUrl)
- for root, dirs, files in os.walk(self.icon_path):
- if not bn in files:
- with open(self.icon_path+bn, "wb") as file:
- response = requests.get(iconUrl)
- file.write(response.content)
- file.close()
-
- return self.pngToBmp(bn)
-
- def degreesToTextDesc(self, deg):
- if deg > 337.5: return "N"
- if deg > 292.5: return "NW"
- if deg > 247.5: return "W"
- if deg > 202.5: return "SW"
- if deg > 157.5: return "S"
- if deg > 122.5: return "SE"
- if deg > 67.5: return "E"
- if deg > 22.5: return "NE"
- return "N"
-
- def list(self):
- url = 'http://api.openweathermap.org/data/2.5/weather'
- r = requests.get('{}?q={}&units={}&appid={}'.format(url, self.city, self.units, self.api_key))
-
- data = r.json()
-
- # Sunrise and Sunset.
- sunrise = dt.fromtimestamp(data['sys'].get('sunrise')).strftime('%I:%M%p')
- sunset = dt.fromtimestamp(data['sys'].get('sunset')).strftime('%I:%M%p')
-
- # Rain and Snow
- wTypes = ['rain', 'snow']
- for wType in wTypes:
- # Check to see if dictionary has values for rain or snow.
- # if it does NOT, set zero values for consistancy.
- if data.has_key(wType):
- setattr(self, wType, {
- "1h": data[wType].get('1h'),
- "3h": data[wType].get('3h')
- })
- else:
- setattr(self, wType, {
- "1h": 0,
- "3h": 0
- })
-
- # Fetch Wind Data
- wind = {
- "dir": self.degreesToTextDesc(data['wind'].get('deg')),
- "speed": int(round(data['wind'].get('speed')))
- #"speed": 33
- }
-
- #icon = self.getIcon("http://openweathermap.org/img/wn/"+data['weather'][0].get('icon')+".png")
- icon = os.path.basename(data['weather'][0].get('icon'))+".bmp"
-
- return {
- "description": data['weather'][0].get('description'),
- "humidity": data['main'].get('humidity'),
- "temp_cur": int(math.ceil(data['main'].get('temp'))),
- #"temp_cur": int(9),
- "temp_min": int(math.ceil(data['main'].get('temp_min'))),
- "temp_max": int(math.ceil(data['main'].get('temp_max'))),
- #"temp_min": int(100),
- #"temp_max": int(112),
- "sunrise": sunrise,
- "sunset": sunset,
- "rain": self.rain,
- "snow": self.snow,
- "wind": wind,
- "icon": icon
- }
-
-
-
-
-
\ No newline at end of file
diff --git a/pyproject.toml b/pyproject.toml
new file mode 100644
index 0000000..c364247
--- /dev/null
+++ b/pyproject.toml
@@ -0,0 +1,41 @@
+[build-system]
+requires = ["setuptools>=61"]
+build-backend = "setuptools.build_meta"
+
+[project]
+name = "infowindow"
+version = "0.1.0"
+description = "E-ink info display for Raspberry Pi"
+requires-python = ">=3.11"
+dependencies = [
+ "Pillow",
+ "requests",
+]
+
+[project.optional-dependencies]
+rpi = [
+ "spidev",
+ "RPi.GPIO",
+ "gpiozero",
+]
+google = [
+ "google-api-python-client",
+ "google-auth-oauthlib",
+]
+caldav = [
+ "caldav",
+ "python-dateutil",
+ "pytz",
+]
+todoist = [
+ # todoist-python (v8 API) is unmaintained; kept for compatibility only.
+ # Migrate to todoist-api-python when updating this backend.
+ "todoist-python",
+]
+
+[project.scripts]
+infowindow = "infowindow.__main__:main"
+
+[tool.setuptools.packages.find]
+where = ["."]
+include = ["infowindow*"]
diff --git a/requirements.txt b/requirements.txt
deleted file mode 100644
index 8c11f53..0000000
--- a/requirements.txt
+++ /dev/null
@@ -1,92 +0,0 @@
-arandr==0.1.9
-arrow==0.11.0
-asn1crypto==0.24.0
-automationhat==0.2.0
-blinker==1.4
-blinkt==0.1.2
-buttonshim==0.0.2
-cachetools==3.1.1
-Cap1xxx==0.1.3
-certifi==2019.9.11
-chardet==3.0.4
-Click==7.0
-colorama==0.3.7
-colorzero==1.1
-configparser==3.5.0b2
-cookies==2.2.1
-cryptography==2.6.1
-drumhat==0.1.0
-entrypoints==0.3
-enum34==1.1.6
-envirophat==1.0.0
-ExplorerHAT==0.4.2
-Flask==1.0.2
-fourletterphat==0.1.0
-funcsigs==1.0.2
-google-api-python-client==1.7.11
-google-auth==1.6.3
-google-auth-httplib2==0.0.3
-google-auth-oauthlib==0.4.1
-gpiozero==1.5.1
-gtasks==0.1.3
-httplib2==0.14.0
-ics==0.5
-idna==2.8
-ipaddress==1.0.17
-itsdangerous==0.24
-Jinja2==2.10
-keyring==17.1.1
-keyrings.alt==3.1.1
-MarkupSafe==1.1.0
-microdotphat==0.2.1
-mock==2.0.0
-mote==0.0.4
-motephat==0.0.2
-numpy==1.16.2
-oauthlib==3.1.0
-olefile==0.46
-pantilthat==0.0.7
-pbr==4.2.0
-phatbeat==0.1.1
-pianohat==0.1.0
-picamera==1.13
-picraft==1.0
-piglow==1.2.4
-pigpio==1.44
-Pillow==5.4.1
-pyasn1==0.4.7
-pyasn1-modules==0.2.7
-pycairo==1.16.2
-pycrypto==2.6.1
-pygame==1.9.4.post1
-PyGObject==3.30.4
-pyinotify==0.9.6
-PyJWT==1.7.0
-pyOpenSSL==19.0.0
-pyserial==3.4
-python-dateutil==2.8.0
-python-teamwork==0.1.3
-pyxdg==0.25
-rainbowhat==0.1.0
-requests==2.22.0
-requests-oauthlib==1.2.0
-responses==0.9.0
-RPi.GPIO==0.7.0
-rsa==4.0
-RTIMULib==7.2.1
-scrollphat==0.0.7
-scrollphathd==1.2.1
-SecretStorage==2.3.1
-sense-hat==2.2.0
-simplejson==3.16.0
-six==1.12.0
-skywriter==0.0.7
-sn3218==1.2.7
-spidev==3.3
-todoist-python==8.1.0
-touchphat==0.0.1
-twython==3.7.0
-unicornhathd==0.0.4
-uritemplate==3.0.0
-urllib3==1.25.6
-Werkzeug==0.14.1
diff --git a/screensaver.py b/screensaver.py
new file mode 100755
index 0000000..29ec90b
--- /dev/null
+++ b/screensaver.py
@@ -0,0 +1,53 @@
+#!/usr/bin/env python3
+"""InfoWindow screensaver — cycles black/red/white screens to prevent ghosting."""
+from __future__ import annotations
+
+import logging
+import random
+
+from PIL import Image
+
+from infowindow.display import get_display
+
+logging.basicConfig(level=logging.INFO)
+log = logging.getLogger(__name__)
+
+
+def main() -> None:
+ log.info("Screen saver starting")
+ device = get_display()
+ device.init()
+
+ colors = ["black", "red", "white"]
+ random.shuffle(colors)
+
+ for color in colors:
+ log.info("Displaying %s screen", color)
+ width, height = 800, 480
+ if color == "black":
+ black = Image.new("1", (width, height), 0)
+ red = Image.new("1", (width, height), 1)
+ device.display(black, red)
+ elif color == "red":
+ black = Image.new("1", (width, height), 1)
+ red = Image.new("1", (width, height), 0)
+ device.display(black, red)
+ else:
+ if hasattr(device, "clear"):
+ device.clear()
+ else:
+ white = Image.new("1", (width, height), 1)
+ device.display(white, white)
+
+ log.info("Sleeping display")
+ device.sleep()
+
+ # Remove cached images so infowindow forces a full redraw next run.
+ if hasattr(device, "clear_cache"):
+ device.clear_cache()
+
+ log.info("Screen saver finished")
+
+
+if __name__ == "__main__":
+ main()
diff --git a/setup.py b/setup.py
deleted file mode 100644
index e69de29..0000000