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..3251187 100644 --- a/.gitignore +++ b/.gitignore @@ -5,4 +5,7 @@ google_secret.json icons/*.png test.py config.json - +infowindow.jpg +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..11bfd0b 100644 --- a/README.md +++ b/README.md @@ -2,54 +2,132 @@ # 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! +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! +*Please be aware that this version is built for the v2 version of the e-ink screen!* -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. +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 | Installation | Configuration | - Running | + Running
## Features * **Calendar** - * Google Calendar is the only calendar currently supported + * Google Calendar + * CalDAV Calendar (added for Nextcloud support) * **Todo List** * Todoist * Teamwork.com + * CalDAV Todos (added for Nextcloud support) * **Weather** * Open Weather Map current data only. Future plan for forecast data. ## Installation +### Raspberry Pi setup +Activate SPI on your Raspberry Pi by using the `raspi-config` tool under Interface Options and reboot. + +Also for some RaspiOS versions, you have to install the `libopenjp2-7` package: +```bash +sudo apt-get install libopenjp2-7 libxslt1 +``` + ### 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` +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` + +### Clone the e-Paper driver from waveshare +Waveshare sometimes changes things in their driver. So this part might need some changes, be aware! +```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 +``` ### 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. +Run the following commands to install the requirements. I stuck to basic standard modules for +ease of installation. +```bash +cd /home/pi/InfoWindow +export CFLAGS=-fcommon +sudo apt install python3-dev +python3 -m venv venv +. venv/bin/activate +pip install -r requirements.txt +``` ## 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. +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. + +## Optional: Increase lifetime of your SD-Card +If you want to increase the lifetime of the SD-Card, add the following line to `/etc/fstab` and reboot: + +`tmpfs /tmp tmpfs defaults,noatime,nosuid,size=100m 0 0` + +With this line, the `/tmp` folder will be held in RAM and will not be written to the SD-Card. + +## Optional: Screen saver +Always displaying the same colors at the same spots might have some negative effect on your E-Ink screen. To remedy +this, there is a simple additional script, which displays all three colors on the whole screen: I recommend to let +this run once every night, i.e. at 1 minute past 5 with: +* Run `crontab -e` +* insert `1 5 * * * /home/pi/InfoWindow/venv/bin/python3 /home/pi/InfoWindow/screensaver.py > /dev/null 2>&1` ### 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. +* 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. +* timeformat: 12h / 24h -### 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` +### Todo (Module) +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. -### Weather +There is a bug in the Google API which will prevent to show repeated Tasks once one is marked as completed. See (and +upvote): +* https://support.google.com/calendar/thread/3706294 +* https://support.google.com/calendar/thread/4113489 +* https://support.google.com/calendar/thread/111623199 +* https://support.google.com/calendar/thread/113398139 + +### Weather (Module) 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` +### Google calendar and ToDo list (Modules) +To use the google APIs, you first have to login to the [google cloud console](https://console.cloud.google.com/apis/). +In the google cloud console, do the following things: +1) Create a project and give it a name, i.e. `infowindow` and switch to the context of this project if not already + active. +2) Create a [new oauth consent screen](https://console.cloud.google.com/apis/credentials/consent) (just enter a name + should be enough). +3) Create a [new oauth 2.0 client id](https://console.cloud.google.com/apis/credentials). Choosing type `other` should + work just fine. Finally, download the json file provided by the google cloud console and store it in the repo + directory (i.e. `/home/pi/InfoWindow/google_secret.json`) on the Raspberry Pi. +### CalDAV calendar and ToDo list (Modules) +To use CalDAV, configure the corresponding modules in the `config.json`. If you use a Nextcloud server, you can +find the CalDAV URL in the settings of your calendar. As a example (where `USERNAME` is you username): +`https://cloud.domain.tld/remote.php/dav/calendars/USERNAME` + +#### Calendar +There are are additional sections in the config for this module: +* additional: A list of additional calendar names (summary) to fetch. To use i.e. birthdays, add "Contacts" (also if + you use google in german. +* ignored: A list of events to be removed from the calendar 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. +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` - +* insert `*/6 * * * * /home/pi/InfoWindow/venv/bin/python3 /home/pi/InfoWindow/infowindow.py --cron > /dev/null 2>&1` diff --git a/clearScreen.py b/clearScreen.py index af59ca7..4c92d76 100644 --- a/clearScreen.py +++ b/clearScreen.py @@ -1,3 +1,3 @@ -from mod_infowindow import infowindow -iw = infowindow.InfoWindow() -iw.display() +from mod_infowindow import infowindow +iw = infowindow.InfoWindow() +iw.display() diff --git a/config.json-sample b/config.json-sample index 48ba33c..6e90b49 100644 --- a/config.json-sample +++ b/config.json-sample @@ -1,14 +1,43 @@ -{ - "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" + }, + "todo": { + "api_key": "1234" + }, + "calendar": { + "ignored": ["Buy ticket!"], + "today_text_color": "red", + "today_background_color": "white" + }, + "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/driver/__init__.py b/driver/__init__.py deleted file mode 100644 index e69de29..0000000 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..6e8d475 100755 --- a/infowindow.py +++ b/infowindow.py @@ -1,176 +1,346 @@ -#!/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 + +import sys +import os.path +import json +import logging +import string +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_google as modTodoGoogle # Google todo +from mod_todo import mod_caldav as modTodoCaldav # Caldav todo +from mod_calendar import mod_google as modCalendarGoogle # Google calendar +from mod_calendar import mod_caldav as modCalendarCaldav # Caldav calendar +from mod_weather import mod_owm as modWeather # WEATHER + +# TODO: Create dictionaries for API args. so that they can be custom. + +# Configuration ############################################################### +config_path = os.path.join(iw_utils.getCWD(), "config.json") +with open(config_path) 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"] +todo_opts["timeformat"] = config_data["general"]["timeformat"] +todo_opts["todo_google"] = config_data["todo_google"] +todo_opts["todo_caldav"] = config_data["todo_caldav"] +calendar_opts = config_data["calendar"] +weather_opts = config_data["weather"] +infowindow_opts = {} +# give the timeformat to all the modules needing it +calendar_opts["timeformat"] = config_data["general"]["timeformat"] +calendar_opts["sunday_first_dow"] = config_data["general"]["sunday_first_dow"] +calendar_opts["calendar_google"] = config_data["calendar_google"] +calendar_opts["calendar_caldav"] = config_data["calendar_caldav"] +calendar_opts["timezone"] = config_data["general"]["timezone"] +weather_opts["timeformat"] = config_data["general"]["timeformat"] +infowindow_opts["timeformat"] = config_data["general"]["timeformat"] +infowindow_opts["cell_spacing"] = config_data["general"]["cell_spacing"] + +# END CONFIGURATION ########################################################### +############################################################################### + +# Setup Logging - set an env var DEBUG to anything to enable debug +log_level = logging.INFO +if os.getenv('DEBUG', False): + log_level = logging.DEBUG + +logging.basicConfig( + format='%(asctime)s %(levelname)-7s %(message)s', + datefmt='%Y-%d-%m %H:%M:%S', + level=log_level +) + +logging.info("Configuration Complete") + +# helper to calculate max char width and height +def get_max_char_size(iw, chars, font): + max_width = 0 + max_height = 0 + for char in chars: + left, top, right, bottom = iw.getFont(font).getbbox(char) + width, height = right - left, bottom - top + if width > max_width: + max_width = width + if height > max_height: + max_height = height + return max_width, max_height + + +def render_centered_text(iw, text, font, color, center_position, y_position): + length = iw.getFont(font).getlength(text) + x_position = int(center_position - (length / 2)) + iw.text(x_position, y_position, text, font, color) + + +# Main Program ################################################################ +def main(): + # Instantiate API modules + todoGoogle = modTodoGoogle.ToDo(todo_opts) + todoCaldav = modTodoCaldav.ToDo(todo_opts) + calGoogle = modCalendarGoogle.Cal(calendar_opts) + calCaldav = modCalendarCaldav.Cal(calendar_opts) + weather = modWeather.Weather(weather_opts) + + # Setup e-ink initial drawings + iw = infowindow.InfoWindow(infowindow_opts) + + # Set some things + weather_font = "robotoBlack18" + temperature_font = "robotoBlack54" + calendar_date_font = "robotoBlack14" + calendar_entry_font = "robotoBlack22" + calendar_entry_font_highlited = "robotoBlack22" + tasks_font = "robotoBlack22" + tasks_font_highlited = "robotoBlack22" + + # Weather Grid + temp_rect_width = 128 + temp_rect_left = (iw.width / 2) - (temp_rect_width / 2) + temp_rect_right = (iw.width / 2) + (temp_rect_width / 2) + + iw.line(335, 0, 335, 64, 'black') # First Vertical Line + iw.rectangle(temp_rect_left, 0, temp_rect_right, 64, 'red') + iw.line(465, 0, 465, 64, 'black') # Second Vertical Line + + # Center cal + iw.line(392, 90, 392, 480, 'black') # Left Black line + iw.rectangle(393, 64, 406, 480, 'red') # Red Rectangle + iw.line(407, 90, 407, 480, 'black') # Right Black line + + # Calendar + iw.line(0, 64, 800, 64, 'black') # Top Line + iw.rectangle(0, 65, 800, 90, 'red') # Red Rectangle + iw.line(0, 91, 800, 91, 'black') # Bottom Black Line + + # DISPLAY TO DO INFO + # ========================================================================= + todo_items = sorted( + todoGoogle.list() + todoCaldav.list(), + key=lambda x: (x["priority"] == 0, x["priority"]) + ) + logging.debug("Todo Items") + logging.debug("-----------------------------------------------------------------------") + + (text_width, text_height) = get_max_char_size(iw, string.printable, tasks_font) + line_height = text_height + (2 * infowindow_opts["cell_spacing"]) + + current_task_y = 92 + for todo_item in todo_items: + color = 'black' + current_font = tasks_font + if 'today' in list(todo_item.keys()): + if todo_item['today']: + color = 'red' + current_font = tasks_font_highlited + + iw.text(416, (current_task_y + infowindow_opts["cell_spacing"]), todo_item['content'].strip(), + current_font, color) + iw.line(408, (current_task_y + line_height + 1), 800, (current_task_y + line_height + 1), 'black') + + # set next loop height + current_task_y = (current_task_y + line_height + 2) + logging.debug("ITEM: %s" % todo_item['content'].strip()) + + # DISPLAY CALENDAR INFO + # ========================================================================= + cal_items = sorted(calGoogle.list() + calCaldav.list(), key=lambda x: x["start_ts"], reverse=False) + logging.debug("Calendar Items") + logging.debug("-----------------------------------------------------------------------") + + (text_width, text_height) = get_max_char_size(iw, string.digits, calendar_date_font) + if calendar_opts['timeformat'] == "12h": + left, top, right, bottom = iw.getFont(calendar_date_font).getbbox(': pm') + else: + left, top, right, bottom = iw.getFont(calendar_date_font).getbbox('.') + date_time_width, date_time_height = right - left, bottom - top + date_time_width = date_time_width + (4 * text_width) + if text_height > date_time_height: + date_time_height = text_height + + (chars_max_width, chars_max_height) = get_max_char_size(iw, string.printable, calendar_entry_font) + line_height = (2 * date_time_height) + (2 * infowindow_opts["cell_spacing"]) + + def render_calendar(x_min, x_max, loop_start=0): + current_index = 0 + current_calendar_y = 92 + current_days_away = -1 + current_weeks_away = -1 + current_week = -1 + loop_date_time_width = x_min + date_time_width + first_loop = True + new_week = False + + current_font = calendar_entry_font + for cal_item in cal_items[loop_start:]: + font_color = 'black' + new_week = False + + if cal_item['today']: + current_font = calendar_entry_font_highlited + font_color = calendar_opts['today_text_color'] + iw.rectangle(x_min, current_calendar_y, + x_max, (current_calendar_y + line_height), + calendar_opts['today_background_color']) + + # draw horizontal line(s) at the top + # on first run, initialize several vars with the first values from the first event + if current_days_away < 0: + current_days_away = cal_item['days_away'] + if current_weeks_away < 0: + current_weeks_away = cal_item['weeks_away'] + if current_week < 0: + current_week = cal_item['week'] + + if first_loop: + # don't draw a line at the top of the first event + first_loop = False + divider_str = "initial element" + else: + # per default, draw a dashed line (same day event) + divider_str = "same day" + for x in range(x_min, x_max, 8): + iw.line(x, current_calendar_y, x + 3, current_calendar_y, 'black') + iw.line(x + 4, current_calendar_y, x + 7, current_calendar_y, 'white') + + # override the dotted line with a black line since the "days away" number changed + if current_days_away != cal_item['days_away']: + current_days_away = cal_item['days_away'] + divider_str = "new day" + iw.line(x_min, current_calendar_y, x_max, current_calendar_y, 'black') + + # override the dotted line with a black rectangle ("thicker line") since the week changed number changed + if current_week != cal_item['week']: + current_week = cal_item['week'] + divider_str = "new week day" + new_week = True + iw.rectangle(x_min, (current_calendar_y - 1), x_max, current_calendar_y, 'black') + + # check if the new event is a week away (the "weeks away" number changed) + if current_weeks_away != cal_item['weeks_away']: + # override the black line with a red a rectangle ("thicker line") the "weeks away" number changed + current_weeks_away = cal_item['weeks_away'] + + # decide on style depending on what the option above was + if new_week: + divider_str = "in one week and the week changes" + for x in range(x_min, x_max, 23): + iw.rectangle(x, (current_calendar_y - 1), x + 11, current_calendar_y, 'black') + iw.rectangle(x + 12, (current_calendar_y - 1), x + 23, current_calendar_y, 'red') + else: + divider_str = "in one week" + # iw.rectangle(x_min, (current_calendar_y - 1), x_max, current_calendar_y, 'red') + iw.line(x_min, current_calendar_y, x_max, current_calendar_y, 'red') + + # draw ending horizontal line, just to ensure that the last element is not hanging in the air + # this gets overridden almost all the time + iw.line(x_min, (current_calendar_y + line_height + 2), x_max, (current_calendar_y + line_height + 2), + 'black') + + # draw vertical line + iw.line((loop_date_time_width + (2 * infowindow_opts["cell_spacing"]) + 1), current_calendar_y, + (loop_date_time_width + (2 * infowindow_opts["cell_spacing"]) + 1), + (current_calendar_y + line_height), 'black') + + # draw event date + iw.text((infowindow_opts["cell_spacing"]) + x_min, + current_calendar_y, + cal_item['date'].strip(), calendar_date_font, font_color) + # draw event time + iw.text((infowindow_opts["cell_spacing"]) + x_min, + current_calendar_y + 1 + date_time_height, + cal_item['time'].strip(), calendar_date_font, font_color) + # draw event text + calendar_event_text_start = loop_date_time_width + (3 * infowindow_opts["cell_spacing"]) + 1 + max_event_text_length = x_max - calendar_event_text_start - infowindow_opts["cell_spacing"] + iw.text(calendar_event_text_start, + (current_calendar_y + ((line_height - chars_max_height) / 2)), + iw.truncate(cal_item['content'].strip(), current_font, max_event_text_length), + current_font, font_color) + + # set new line height for next round + current_calendar_y = (current_calendar_y + line_height + 2) + # logging.debug("ITEM: "+str(cal_item['date']), str(cal_item['time']), str(cal_item['content'])) + logging.debug("ITEM (%s): %s" % (divider_str, cal_item['content'].strip())) + current_index = cal_items.index(cal_item) + if current_calendar_y > 480: + logging.debug("Max height detected, breaking loop") + break + + return current_index + + last_item = render_calendar(0, 391) + if current_task_y == 92: # there are no tasks + render_calendar(408, 800, last_item + 1) + left_column_title = "CALENDAR 1/2" + right_column_title = "CALENDAR 2/2" + else: + left_column_title = "CALENDAR" + right_column_title = "TODO" + render_centered_text(iw, left_column_title, 'robotoBlack24', 'white', 200, 64) + render_centered_text(iw, right_column_title, 'robotoBlack24', 'white', 600, 64) + + # 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" + + # Weather left box + deg_symbol = "°" + iw.bitmap(2, 2, weather['icon']) + iw.text(90, 2, weather['description'].title().strip(), 'robotoBlack24', 'black') + iw.text(90, 35, weather['sunrise'], weather_font, 'black') + iw.text(192, 35, weather['sunset'], weather_font, 'black') + + # Temp ( adjust for str length ) + temp_string = str(weather['temp_cur']) + deg_symbol + left, top, right, bottom = iw.getFont(temperature_font).getbbox(temp_string) + text_width, text_height = right - left, bottom - top + temp_left = (iw.width / 2) - (text_width / 2) + iw.text(temp_left, 2, temp_string, temperature_font, 'white') + t_desc_posx = (temp_left + text_width) - 18 + iw.text(t_desc_posx, 28, u_temp, 'robotoBlack24', 'white') + + # Wind + iw.bitmap(480, 0, "windSmall.bmp") # Wind Icon + iw.text(520, 5, weather['wind']['dir'], weather_font, 'black') + iw.text(480, 35, str(weather['wind']['speed']) + u_speed, weather_font, 'black') + iw.line(576, 0, 576, 64, 'black') # Third Vertical Line + + # Rain + iw.bitmap(616, 0, "rainSmall.bmp") # Rain Icon + iw.text(601, 29, "1hr: " + str(weather['rain']['1h']), weather_font, 'black') + iw.text(601, 44, "3hr: " + str(weather['rain']['3h']), weather_font, 'black') + iw.line(687, 0, 687, 64, 'black') # Fourth Vertical Line + + # Snow + iw.bitmap(728, 0, "snowSmall.bmp") # Snow Icon + iw.text(716, 29, "1hr: " + str(weather['snow']['1h']), weather_font, 'black') + iw.text(716, 44, "3hr: " + str(weather['snow']['3h']), weather_font, 'black') + + # Write to screen + # ========================================================================= + iw.display(rotation) + + +if __name__ == '__main__': + main() diff --git a/mod_calendar/mod_caldav.py b/mod_calendar/mod_caldav.py new file mode 100644 index 0000000..270ba0e --- /dev/null +++ b/mod_calendar/mod_caldav.py @@ -0,0 +1,175 @@ +from caldav import DAVClient +from dateutil.parser import parse as dtparse +from dateutil.tz import gettz +from datetime import datetime as dt, timedelta, time, date +from dateutil.rrule import rrulestr +import pytz +import re +import logging + +# Disable excessive logging from caldav library +# logging.getLogger("caldav").setLevel(logging.WARNING) + +def replace_birth_year_with_age(summary): + match = re.search(r"\((\d{4})\)", summary) # Find (YYYY) + if match: + birth_year = int(match.group(1)) + current_year = dt.now().year + age = current_year - birth_year + summary = summary.replace(f"({birth_year})", f"(Age {age})") # Replace with age + return summary + +class Cal: + def __init__(self, options): + logging.debug("Initializing Module: Calendar: CalDAV") + self.enabled = options["calendar_caldav"]["enabled"] + if self.enabled: + self.client = DAVClient( + url=options["calendar_caldav"]["caldav_url"], + username=options["calendar_caldav"]["username"], + password=options["calendar_caldav"]["password"], + ) + self.timeformat = options["timeformat"] + self.additional = options["calendar_caldav"]["additional"] + self.ignored = options["ignored"] + self.sunday_first_dow = options["sunday_first_dow"] + self.local_tz = gettz(options.get("calendar_caldav", {}).get("timezone", "Europe/Zurich")) + + def list(self): + if not self.enabled: + logging.debug("Calendar: CalDAV not enabled") + return [] + + events = [] + items = [] + now = dt.now(self.local_tz) + + # Fetch calendars + principal = self.client.principal() + calendars = principal.calendars() + + logging.info(f"Available CalDAV calendars: {', '.join([x.name for x in calendars])})") + selected_calendars = [ + cal for cal in calendars if cal.name in self.additional or not self.additional + ] + + for calendar in selected_calendars: + logging.debug(f"Fetching calendar: {calendar.name}") + results = calendar.search(start=now - timedelta(days=1), end=now + timedelta(days=60), event=True, expand=True) + + logging.debug(f"Found {len(results)} results") + + for event in results: + ical = event.icalendar_instance + + for comp in ical.walk(): + if comp.name != "VEVENT": + continue + + summary = 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) + + logging.debug(f"Raw DTSTART: {start_orig}, DTEND: {end_orig}, RRULE: {rrule}, is_all_day: {is_all_day}") + + if rrule: + rrule_str = "RRULE:" + rrule.to_ical().decode() + rule_start = dt.combine(start_orig, time.min).replace(tzinfo=self.local_tz) if is_all_day else start_orig + rule = rrulestr(rrule_str, dtstart=rule_start) + next_occurrence = rule.after(now.replace(hour=0, minute=0, second=0, microsecond=0), inc=True) + if not next_occurrence: + logging.debug(f"No next occurrence found for recurring event: {summary}") + continue + if isinstance(next_occurrence, date) and not isinstance(next_occurrence, dt): + start = dt.combine(next_occurrence, time.min).replace(tzinfo=self.local_tz) + else: + if next_occurrence.tzinfo is None: + start = next_occurrence.replace(tzinfo=self.local_tz) + else: + start = next_occurrence.astimezone(self.local_tz) + + if is_all_day: + end = start + timedelta(days=1) - timedelta(seconds=1) + else: + end = start + timedelta(hours=1) + else: + start = start_orig + if is_all_day: + start = start if isinstance(start, dt) else dt.combine(start, time.min).replace(tzinfo=self.local_tz) + if end_orig: + end_date = end_orig.dt if hasattr(end_orig, 'dt') else end_orig + end = dt.combine(end_date, time.min).replace(tzinfo=self.local_tz) - timedelta(seconds=1) + else: + end = start + timedelta(days=1) + else: + if start.tzinfo is None: + start = pytz.utc.localize(start) + start = start.astimezone(self.local_tz) + + 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) + end = end.astimezone(self.local_tz) + else: + end = start + timedelta(hours=1) + + if end < now: + logging.debug(f"Skipping event (in the past): {summary} (start: {start}, end: {end}, now: {now})") + continue + + logging.debug(f"Adding event: {summary} at {start.isoformat()} (end: {end.isoformat()})") + events.append((start.isoformat(), summary)) + + if not events: + return [] + + events.sort() + items = [] + + for start_str, summary in events: + start = dtparse(start_str) + now_local = dt.now(self.local_tz) + logging.debug(f"Checking if today: event {summary} at {start}, now is {now_local}") + if start.date() <= now_local.date(): + today = True + else: + today = False + + days_away = (start.date() - now_local.date()).days + logging.debug(f"days_away: {days_away}") + weeks_away = days_away // 7 + + if self.timeformat == "12h": + st_date = start.strftime('%m-%d') + st_time = start.strftime('%I:%M %p') + else: + st_date = start.strftime('%d.%m') + st_time = start.strftime('%H:%M') + + if self.sunday_first_dow: + week = int(start.strftime('%U')) + else: + week = int(start.strftime('%W')) + + items.append({ + "summary": summary, + "date": st_date, + "time": st_time, + "week": week, + "start_ts": start.timestamp(), + "today": today, + "days_away": days_away, + "weeks_away": weeks_away, + "content": f"{summary}", + }) + + return items diff --git a/mod_calendar/mod_google.py b/mod_calendar/mod_google.py index b8439f0..7d119b7 100644 --- a/mod_calendar/mod_google.py +++ b/mod_calendar/mod_google.py @@ -1,40 +1,100 @@ -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 - +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, options): + logging.debug("Initializing Module: Calendar: Google") + self.enabled = options["calendar_google"]["enabled"] + if self.enabled: + ga = mod_google_auth.GoogleAuth() + self.creds = ga.login() + self.timeformat = options["timeformat"] + self.additional = options["calendar_google"]["additional"] + self.ignored = options["ignored"] + self.sunday_first_dow = options["sunday_first_dow"] + + def list(self): + if not self.enabled: + logging.debug("Calendar: Google not enabled") + return [] + + calendar_ids = [] + events = {} + items = [] + + service = build('calendar', 'v3', credentials=self.creds) + now = dt.utcnow().isoformat() + 'Z' + + page_token = None + while True: + calendar_list = service.calendarList().list(pageToken=page_token).execute() + for calendar_list_entry in calendar_list['items']: + if "primary" in list(calendar_list_entry.keys()): + if calendar_list_entry['primary']: + calendar_ids.append(calendar_list_entry['id']) + elif calendar_list_entry['summary'] in self.additional: + calendar_ids.append(calendar_list_entry['id']) + page_token = calendar_list.get('nextPageToken') + if not page_token: + break + + for id in calendar_ids: + result = service.events().list(calendarId=id, timeMin=now, + maxResults=30, + singleEvents=True, + orderBy='startTime').execute() + + for event in result.get('items', []): + if event['summary'] in self.ignored: + continue + initial_start = event['start'].get('dateTime', event['start'].get('date')) + start = "%s-0" % initial_start + counter = 0 + while start in list(events.keys()): + counter += 1 + start = "%s-%s" % (initial_start, counter) + + events[start] = event + + day_start_ts_now = dt.timestamp(dt.now().replace(hour=0, minute=0, second=0, microsecond=0)) + + for event_key in sorted(events.keys()): + start = events[event_key]['start'].get('dateTime', events[event_key]['start'].get('date')) + if int(dt.strftime(dtparse(start), '%Y%m%d')) <= int(dt.strftime(dt.today(), '%Y%m%d')): + today = True + else: + today = False + + # Sunrise and Sunset. + if self.timeformat == "12h": + st_date = dt.strftime(dtparse(start), '%m-%d') + st_time = dt.strftime(dtparse(start), '%I:%M %p') + else: + st_date = dt.strftime(dtparse(start), '%d.%m') + st_time = dt.strftime(dtparse(start), '%H:%M') + + if self.sunday_first_dow: + week = dt.strftime(dtparse(start), '%U') + else: + week = dt.strftime(dtparse(start), '%W') + + event_start_ts_now = dt.timestamp(dtparse(start).replace(hour=0, minute=0, second=0, microsecond=0)) + + items.append({ + "date": st_date, + "time": st_time, + "content": events[event_key]['summary'], + "today": today, + "week": int(week), + "start_ts": dt.timestamp(dtparse(start)), + "days_away": (event_start_ts_now - day_start_ts_now) // 86400, # days away + "weeks_away": (event_start_ts_now - day_start_ts_now) // 604800 # weeks away + }) + + return items diff --git a/mod_infowindow/infowindow.py b/mod_infowindow/infowindow.py index 26ff8cf..8a6e9fb 100644 --- a/mod_infowindow/infowindow.py +++ b/mod_infowindow/infowindow.py @@ -1,80 +1,124 @@ -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)) +from driver import epd7in5b_V2 +from PIL import Image +from PIL import ImageDraw +from PIL import ImageFont +from PIL import ImageChops +import os, sys +import logging +import tempfile + + +class InfoWindow: + def __init__(self, options): + self.epd = epd7in5b_V2.EPD() + self.epd.init() + self.width = 800 + self.height = 480 + self.red_image = Image.new(mode="1", size=(800, 480), color=1) + self.black_image = Image.new(mode="1", size=(800, 480), color=1) + self.red_draw = ImageDraw.Draw(self.red_image) + self.black_draw = ImageDraw.Draw(self.black_image) + self.fonts = {} + self.initFonts() + self.tmpImagePathRed = os.path.join(tempfile.gettempdir(), "InfoWindowRed.png") + self.tmpImagePathBlack = os.path.join(tempfile.gettempdir(), "InfoWindowBlack.png") + self.timeformat = options['timeformat'] + + def getCWD(self): + path = os.path.dirname(os.path.realpath(sys.argv[0])) + return path + + def line(self, left_1, top_1, left_2, top_2, fill): + if fill == 'black': + self.black_draw.line((left_1, top_1, left_2, top_2), fill=0) + elif fill == 'red': + self.red_draw.line((left_1, top_1, left_2, top_2), fill=0) + elif fill == 'white': + self.black_draw.line((left_1, top_1, left_2, top_2), fill=1) + self.red_draw.line((left_1, top_1, left_2, top_2), fill=1) + + def rectangle(self, tl, tr, bl, br, fill): + if fill == 'black': + self.black_draw.rectangle(((tl, tr), (bl, br)), fill=0) + elif fill == 'red': + self.red_draw.rectangle(((tl, tr), (bl, br)), fill=0) + elif fill == 'white': + self.black_draw.rectangle(((tl, tr), (bl, br)), fill=1) + self.red_draw.rectangle(((tl, tr), (bl, br)), fill=1) + + def text(self, left, top, text, font, fill): + if fill == 'black': + font = self.fonts[font] + self.black_draw.text((left, top), text, font=font, fill=0) + elif fill == 'red': + font = self.fonts[font] + self.black_draw.text((left, top), text, font=font, fill=0) + self.red_draw.text((left, top), text, font=font, fill=0) + elif fill == 'white': + font = self.fonts[font] + self.red_draw.text((left, top), text, font=font, fill=1) + self.black_draw.text((left, top), text, font=font, fill=1) + + def rotate(self, angle): + self.red_image.rotate(angle) + self.black_image.rotate(angle) + + def bitmap(self, x, y, image_path): + bitmap = Image.open(self.getCWD()+"/icons/"+image_path) + self.black_draw.bitmap((x, y), bitmap, fill=0) + + def getFont(self, font_name): + return self.fonts[font_name] + + def initFonts(self): + roboto = self.getCWD()+"/fonts/roboto/Roboto-" + self.fonts = { + + 'robotoBlack14': ImageFont.truetype(roboto + "Black.ttf", 14), + 'robotoBlack18': ImageFont.truetype(roboto + "Black.ttf", 18), + 'robotoBold22': ImageFont.truetype(roboto + "Bold.ttf", 22), + 'robotoBlack22': ImageFont.truetype(roboto + "Black.ttf", 22), + 'robotoBlack24': ImageFont.truetype(roboto + "Black.ttf", 24), + 'robotoBlack54': ImageFont.truetype(roboto + "Black.ttf", 54), + } + + def truncate(self, string, font, max_size): + num_chars = len(string) + for char in string: + (_unused_a, _unused_b, np_x, np_y) = self.getFont(font).getbbox(string) + if np_x >= max_size: + string = string[:-1] + + if np_x <= max_size: + return string + + return string + + def display(self, angle): + self.black_image = self.black_image.rotate(angle) + self.red_image = self.red_image.rotate(angle) + + new_image_found = False + if os.path.exists(self.tmpImagePathRed): + diff = ImageChops.difference(self.red_image, Image.open(self.tmpImagePathRed)) + if diff.getbbox(): + new_image_found = True + else: + new_image_found = True + + if os.path.exists(self.tmpImagePathBlack): + diff = ImageChops.difference(self.black_image, Image.open(self.tmpImagePathBlack)) + if diff.getbbox(): + new_image_found = True + else: + new_image_found = True + + if new_image_found: + logging.info("New information in the image detected. Updating the screen.") + self.black_image.save(self.tmpImagePathBlack) + self.red_image.save(self.tmpImagePathRed) + self.epd.display(self.epd.getbuffer(self.black_image), self.epd.getbuffer(self.red_image)) + self.epd.sleep() + + else: + logging.info("No new information found. Not updating the screen.") diff --git a/mod_todo/mod_caldav.py b/mod_todo/mod_caldav.py new file mode 100644 index 0000000..5e049e9 --- /dev/null +++ b/mod_todo/mod_caldav.py @@ -0,0 +1,109 @@ +from caldav import DAVClient +from datetime import datetime, timedelta, date, UTC +import logging + +today = date.today() +tomorrow = date.today() + timedelta(days=1) + +class ToDo: + def __init__(self, options): + logging.debug("Initializing Module: ToDo: CalDAV") + self.enabled = options["todo_caldav"]["enabled"] + if self.enabled: + self.client = DAVClient( + url=options["todo_caldav"]["caldav_url"], + username=options["todo_caldav"]["username"], + password=options["todo_caldav"]["password"], + ) + self.timeformat = options["timeformat"] + self.additional = options["todo_caldav"]["additional"] + # self.sunday_first_dow = options["sunday_first_dow"] + + def list(self): + if not self.enabled: + logging.debug("Todo: CalDAV not enabled") + return [] + + todos_without_date = [] + todos_with_date = [] + todos = [] + now = datetime.now(UTC) + + # Fetch calendars + principal = self.client.principal() + calendars = principal.calendars() + + # Filter calendars + logging.info(f"Available CalDAV calendars for todo: {', '.join([x.name for x in calendars])}") + selected_calendars = [ + cal for cal in calendars if cal.name in self.additional or not self.additional + ] + + for calendar in selected_calendars: + logging.debug(f"Fetching todos from calendar: {calendar.name}") + results = calendar.search( + start=now - timedelta(days=360), end=now + timedelta(days=60), todo=True, expand=True + ) + logging.debug(f"Found {len(results)} results: {results}") + + for todo in results: + ical = todo.icalendar_instance + logging.debug(f"Todo URL: {todo}") + for comp in ical.walk(): + if comp.name != "VTODO": + continue + + summary = comp.get("SUMMARY", "No Title") + if "DUE" in comp.keys(): + logging.debug(f"Found {summary} with DUE") + due = comp.get("DUE").dt + if isinstance(due, datetime): # Ensure it's datetime, not date + due_str = due.isoformat() + else: + due = datetime.combine(due, datetime.min.time()) + due_str = due.isoformat() + + logging.debug(f"Due {due_str}") + todos_with_date.append((due_str, comp)) + + elif "DTSTART" in comp.keys(): + logging.debug(f"Found {summary} with DTSTART") + start = comp.get("DTSTART").dt + if isinstance(start, datetime): # Ensure it's datetime, not date + start_str = start.isoformat() + else: + start = datetime.combine(start, datetime.min.time()) + start_str = start.isoformat() + + logging.debug(f"Start {start_str}") + todos_with_date.append((start_str, comp)) + + else: + todos_without_date.append((summary, comp)) + + for summary, todo in sorted(todos_without_date): + todos.append({"content": todo.get("SUMMARY", "No Title"), + "priority": todo.get("PRIORITY", 0), + "today": False + }) + + for due_str, todo in sorted(todos_with_date, key=lambda tup: tup[0]): + is_today = False + due = datetime.fromisoformat(due_str.replace("Z", "+00:00")).date() + if due < today: + is_today = True + todo['SUMMARY'] = f"Overdue: {todo['SUMMARY']}" + todo['PRIORITY'] = 1 + elif due == today: + is_today = True + elif due == tomorrow: + pass + else: + continue + + todos.append({"content": todo.get("SUMMARY", "No Title"), + "priority": todo.get("PRIORITY", 0), + "today": is_today + }) + + return todos diff --git a/mod_todo/mod_google.py b/mod_todo/mod_google.py index 5390436..6030869 100644 --- a/mod_todo/mod_google.py +++ b/mod_todo/mod_google.py @@ -1,33 +1,68 @@ -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 +from mod_utils import mod_google_auth +from googleapiclient.discovery import build +from datetime import datetime, timedelta, date +import logging + +today = date.today() +tomorrow = date.today() + timedelta(days=1) + +class ToDo: + def __init__(self, options): + # This module authenticates from Google Auth API. We pull in the auth module + # wrapper to keep it clean. + logging.debug("Initializing Module: ToDo: Google") + self.enabled = options["todo_google"]["enabled"] + if self.enabled: + ga = mod_google_auth.GoogleAuth() + self.creds = ga.login() + + def list(self): + if not self.enabled: + logging.debug("Todo: Google not enabled") + return [] + + service = build('tasks', 'v1', credentials=self.creds) + + tasks_with_due = [] + items_with_due = [] + items_without_due = [] + + # Fetch Results from all lists where todo is in the name + tasklists = service.tasklists().list().execute() + for tasklist in tasklists['items']: + if "todo" in tasklist['title'].lower(): + results = service.tasks().list(tasklist=tasklist['id']).execute() + + if 'items' in list(results.keys()): + for task in results['items']: + if 'due' in list(task.keys()): + tasks_with_due.append(task) + else: + items_without_due.append({ + "content": task['title'], + "priority": int(task['position']), + "today": False + }) + + for task in sorted(tasks_with_due, key=lambda x: x['due']): + is_today = False + due = datetime.fromisoformat(task['due'].replace("Z", "+00:00")).date() + if due < today: + is_today = True + task['title'] = f"Overdue: {task['title']}" + task['position'] = 1 + elif due == today: + is_today = True + elif due == tomorrow: + pass + else: + continue + + items_with_due.append({ + "content": task['title'], + "priority": int(task['position']), + "today": is_today + }) + + # Return results to main program + return items_with_due + items_without_due diff --git a/mod_todo/mod_teamwork.py b/mod_todo/mod_teamwork.py index d2fadf2..21c742f 100644 --- a/mod_todo/mod_teamwork.py +++ b/mod_todo/mod_teamwork.py @@ -1,43 +1,38 @@ -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 - - - - - - +import urllib.request, urllib.error, urllib.parse, 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 = urllib.request.Request("https://{0}/{1}".format(self.company, action)) + request.add_header("Authorization", "BASIC " + base64.b64encode(self.key + ":xxx")) + + response = urllib.request.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 index 1cf5235..c0b74ff 100644 --- a/mod_todo/mod_todoist.py +++ b/mod_todo/mod_todoist.py @@ -1,29 +1,34 @@ -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 +import todoist +import logging + + +class ToDo: + def __init__(self, opts): + logging.debug("Todo API: TODOIST") + self.api = False + if not opts['api_key']: + logging.warning("Not loading Todo API, since no api key is configured") + else: + 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 + if self.api: + 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 diff --git a/mod_utils/iw_utils.py b/mod_utils/iw_utils.py index f16ec94..ea462dc 100644 --- a/mod_utils/iw_utils.py +++ b/mod_utils/iw_utils.py @@ -1,21 +1,21 @@ -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 +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() diff --git a/mod_utils/mod_google_auth.py b/mod_utils/mod_google_auth.py index 658ae9b..5e5aa80 100644 --- a/mod_utils/mod_google_auth.py +++ b/mod_utils/mod_google_auth.py @@ -1,72 +1,75 @@ -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 +from google_auth_oauthlib import flow +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 + self.path = os.path.dirname(os.path.realpath(sys.argv[0])) + + def login(self): + + # Check for pickle. + # if os.path.exists('token.pickle'): + pickle_token_file_path = os.path.join(self.path, 'token.pickle') + if os.path.exists(pickle_token_file_path): + logger.info("token.pickle Exists. Attempting read") + with open(pickle_token_file_path, 'rb') as token: + self.creds = pickle.load(token) + else: + logger.info("%s NOT FOUND" % pickle_token_file_path) + + # 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 + google_secrets_file_path = os.path.join(self.path, 'google_secret.json') + if not os.path.exists(google_secrets_file_path): + logger.info("%s does not exist" % google_secrets_file_path) + + # Requires input from user. Write error to e-ink if is run from cron. + if iw_utils.isCron(): + iw_utils.HandleError('Message') + + appflow = flow.InstalledAppFlow.from_client_secrets_file( + google_secrets_file_path, self.scopes + ) + + if iw_utils.isCron(): + appflow.run_local_server() + else: + appflow.run_console() + + self.creds = appflow.credentials + + # Write pickle file + logger.info("Writing %s file", pickle_token_file_path) + with open(pickle_token_file_path, 'wb') as token: + pickle.dump(self.creds, token) + + return self.creds diff --git a/mod_weather/mod_owm.py b/mod_weather/mod_owm.py index 16f0ee3..b51d97f 100644 --- a/mod_weather/mod_owm.py +++ b/mod_weather/mod_owm.py @@ -1,106 +1,109 @@ -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 +import requests +from datetime import datetime as dt +import os +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'] + self.timeformat = options['timeformat'] + + 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. + if self.timeformat == "12h": + sunrise = dt.fromtimestamp(data['sys'].get('sunrise')).strftime('%I:%M %p') + sunset = dt.fromtimestamp(data['sys'].get('sunset')).strftime('%I:%M %p') + else: + sunrise = dt.fromtimestamp(data['sys'].get('sunrise')).strftime('%H:%M') + sunset = dt.fromtimestamp(data['sys'].get('sunset')).strftime('%H:%M') + + # 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 wType in data: + 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 + } + + + + diff --git a/requirements.txt b/requirements.txt index 8c11f53..cf99768 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,92 +1,10 @@ -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 +Pillow +spidev; platform_system == "Linux" and "arm" in platform_machine +google-api-python-client +google-auth-oauthlib +python-dateutil +requests +todoist-python +RPi.GPIO; platform_system == "Linux" and "arm" in platform_machine +gpiozero; platform_system == "Linux" and "arm" in platform_machine +caldav \ No newline at end of file diff --git a/screensaver.py b/screensaver.py new file mode 100755 index 0000000..0c9b317 --- /dev/null +++ b/screensaver.py @@ -0,0 +1,41 @@ +#!/usr/bin/env python3 + +import logging +import os +from PIL import Image + +from driver import epd7in5b_V2 + +# Setup Logging - change to logging.DEBUG if you are having issues. +logging.basicConfig(level=logging.INFO) +logging.info("Screen saver starting") + +def display_image(epd, black_fill, red_fill): + width = 800 + height = 480 + + # Create 1-bit monochrome images like the working infowindow.py + black_image = Image.new('1', (width, height), black_fill) + red_image = Image.new('1', (width, height), red_fill) + + epd.display(epd.getbuffer(black_image), epd.getbuffer(red_image)) + + +def main(): + epd = epd7in5b_V2.EPD() + epd.init() + + logging.info("Display black screen") + display_image(epd, black_fill=0, red_fill=1) + + logging.info("Display red screen") + display_image(epd, black_fill=1, red_fill=0) + + logging.info("Display white screen") + epd.Clear() + epd.sleep() + logging.info("Screen saver finished") + + +if __name__ == '__main__': + main()