├── .gitignore ├── README.md ├── frame_diagram.png ├── screen ├── display.py ├── image_transform_local.py ├── pics │ ├── Example_1.jpg │ └── Example_2.jpg └── waveshare_epd │ ├── __init__.py │ ├── epd7in5_V2.py │ └── epdconfig.py └── server ├── Dockerfile.py ├── dockerfile.py ├── eink_image.py ├── fonts └── Roboto-Medium.ttf ├── gmail_connector.py ├── gmail_connector_no_queue.py ├── main.py ├── requirements.txt └── secrets └── flask_key.json /.gitignore: -------------------------------------------------------------------------------- 1 | # Byte-compiled / optimized / DLL files 2 | __pycache__/ 3 | *.py[cod] 4 | *$py.class 5 | 6 | # C extensions 7 | *.so 8 | 9 | # Distribution / packaging 10 | .DS_Store 11 | .Python 12 | build/ 13 | develop-eggs/ 14 | dist/ 15 | downloads/ 16 | eggs/ 17 | .eggs/ 18 | lib/ 19 | lib64/ 20 | parts/ 21 | sdist/ 22 | var/ 23 | wheels/ 24 | pip-wheel-metadata/ 25 | share/python-wheels/ 26 | *.egg-info/ 27 | .installed.cfg 28 | *.egg 29 | MANIFEST 30 | 31 | # secrets 32 | secrets/ 33 | 34 | # PyInstaller 35 | # Usually these files are written by a python script from a template 36 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 37 | *.manifest 38 | *.spec 39 | 40 | # Installer logs 41 | pip-log.txt 42 | pip-delete-this-directory.txt 43 | 44 | # Unit test / coverage reports 45 | htmlcov/ 46 | .tox/ 47 | .nox/ 48 | .coverage 49 | .coverage.* 50 | .cache 51 | nosetests.xml 52 | coverage.xml 53 | *.cover 54 | *.py,cover 55 | .hypothesis/ 56 | .pytest_cache/ 57 | 58 | # Translations 59 | *.mo 60 | *.pot 61 | 62 | # Django stuff: 63 | *.log 64 | local_settings.py 65 | db.sqlite3 66 | db.sqlite3-journal 67 | 68 | # Flask stuff: 69 | instance/ 70 | .webassets-cache 71 | 72 | # Scrapy stuff: 73 | .scrapy 74 | 75 | # Sphinx documentation 76 | docs/_build/ 77 | 78 | # PyBuilder 79 | target/ 80 | 81 | # Jupyter Notebook 82 | .ipynb_checkpoints 83 | 84 | # IPython 85 | profile_default/ 86 | ipython_config.py 87 | 88 | # pyenv 89 | .python-version 90 | 91 | # pipenv 92 | # According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. 93 | # However, in case of collaboration, if having platform-specific dependencies or dependencies 94 | # having no cross-platform support, pipenv may install dependencies that don't work, or not 95 | # install all needed dependencies. 96 | #Pipfile.lock 97 | 98 | # PEP 582; used by e.g. github.com/David-OConnor/pyflow 99 | __pypackages__/ 100 | 101 | # Celery stuff 102 | celerybeat-schedule 103 | celerybeat.pid 104 | 105 | # SageMath parsed files 106 | *.sage.py 107 | 108 | # Environments 109 | .env 110 | .venv 111 | env/ 112 | venv/ 113 | ENV/ 114 | env.bak/ 115 | venv.bak/ 116 | 117 | # Spyder project settings 118 | .spyderproject 119 | .spyproject 120 | 121 | # Rope project settings 122 | .ropeproject 123 | 124 | # mkdocs documentation 125 | /site 126 | 127 | # mypy 128 | .mypy_cache/ 129 | .dmypy.json 130 | dmypy.json 131 | 132 | # Pyre type checker 133 | .pyre/ 134 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # dispatchPi 2 | ## A communicating e-paper picture frame, powered by a Raspberry Pi Zero 3 | 4 | 5 | 6 | ## [Follow the complete tutorial here!](https://malcolmosh.github.io/projects/2_dispatchpi/) 7 | 8 | The e-ink frame displays an image pulled from a fixed URL at regular intervals. At this URL resides a Flask app hosted on Google Cloud Run. Whenever it is pinged, it pulls the latest image received in a Gmail inbox and overlays text extracted from that same message. 9 | 10 | There are two folders to browse here: 11 | 12 | - **Screen** contains the code found on the Raspberry Pi device 13 | - **Server** holds the code hosted online in a dockerized Flask app 14 | 15 | **Diagram** 16 | 17 | 18 | 19 | -------------------------------------------------------------------------------- /frame_diagram.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/malcolmosh/dispatchPi/HEAD/frame_diagram.png -------------------------------------------------------------------------------- /screen/display.py: -------------------------------------------------------------------------------- 1 | import os 2 | #import sys 3 | import requests 4 | from waveshare_epd import epd7in5_V2 5 | from PIL import Image 6 | import glob, random 7 | import logging 8 | from pathlib import Path 9 | 10 | #import local functions 11 | from image_transform_local import Image_transform 12 | 13 | # find script directory 14 | dir_path = os.path.dirname(os.path.realpath(__file__)) 15 | 16 | # logging file 17 | log_file_path = os.path.join(dir_path, "image_log.log") 18 | 19 | # initialize logger - you can change it to debut to print the "debug" logging statements if encountering any errors 20 | logging.basicConfig(filename=log_file_path, 21 | level=logging.INFO, 22 | format='%(asctime)s.%(msecs)03d %(levelname)s %(module)s - %(funcName)s: %(message)s', 23 | datefmt='%Y-%m-%d %H:%M:%S',) 24 | 25 | #function to display image 26 | def show_image(image): 27 | try: 28 | # Display init, clear 29 | display = epd7in5_V2.EPD() 30 | display.init() #update 31 | 32 | #display the image 33 | display.display(display.getbuffer(image)) 34 | 35 | except IOError as e: 36 | print(e) 37 | 38 | finally: 39 | display.sleep() 40 | 41 | try: 42 | logging.info("Pulling image from web") 43 | # don't forget to point to the proper url view 44 | filename="https://YOUR_CLOUD_RUN_WEBSITE.a.run.app/PICK_THE_RIGHT_VEW" 45 | 46 | #pull image from web 47 | response = requests.get(filename, stream=True) 48 | response.raw.decode_content = True 49 | image = Image.open(response.raw) 50 | 51 | #push it to the screen 52 | show_image(image) 53 | 54 | 55 | #if an error occurs (connection slow or URL not accessible), print a random local picture instead 56 | except Exception as web_err: 57 | logging.error(f"Error pulling image from web: {web_err}") 58 | logging.info("Pulling image from local directory instead") 59 | 60 | try : 61 | pic_path = os.path.join(dir_path, "pics") 62 | logging.debug(f"Path to local pictures : {pic_path}") 63 | 64 | file_extensions = ['.jpg', '.jpeg', '.png', '.bmp', '.gif', '.JPG', '.JPEG', '.PNG', '.BMP', '.GIF'] # create a sect of file extensions 65 | 66 | all_images = [p.resolve() for p in Path(pic_path).glob("**/*") if p.suffix in file_extensions] # Find all matching files for the given patterns in all subfolders of pic_path 67 | 68 | if not all_images: 69 | raise ValueError("No images found in the local directory. Check that your folder contains all your file types specified.") 70 | 71 | #choose a random image path 72 | random_image = random.choice(all_images) 73 | logging.debug(f"Random image path : {random_image}") 74 | 75 | #run the local function to process and display it 76 | local_image=Image_transform(imported_image=random_image) 77 | image=local_image.render(fit="crop") 78 | show_image(image) 79 | 80 | except Exception as err: 81 | logging.error(f"The local image was not displayed : {err}") -------------------------------------------------------------------------------- /screen/image_transform_local.py: -------------------------------------------------------------------------------- 1 | import os 2 | from PIL import Image, ImageDraw, ImageFont, ImageOps 3 | import textwrap 4 | 5 | 6 | #function to transform the pic pulled from gmail into a 2 tone & resized image 7 | class Image_transform: 8 | def __init__(self, imported_image, fit="crop"): 9 | self.imported_image=imported_image 10 | 11 | def render(self, fit="crop"): 12 | # fit can be "width" or "crop" or "height" 13 | #we are using the screen in portrait mode and so flipping the default landscape mode 14 | w = 480 15 | h = 800 16 | 17 | #create canvas in portrait mode 18 | canvas = Image.new(mode="1", size=(w, h), color=255) #fill colour for blank space (so, clear frame first) 19 | draw = ImageDraw.Draw(canvas) 20 | 21 | #use the line below if we're working with a path and not an image file 22 | image = Image.open(self.imported_image) 23 | 24 | #use the line below if we're working with an image file directly 25 | #image = self.imported_image 26 | 27 | #Remove exif orientation 28 | image = ImageOps.exif_transpose(image) 29 | 30 | #option 1 : fit the whole width to the frame 31 | if fit=="width": 32 | #Resize image to fit width 33 | wpercent = (w/float(image.size[0])) 34 | hsize = int((float(image.size[1])*float(wpercent))) 35 | image = image.resize((w,hsize), Image.ANTIALIAS) 36 | 37 | #center the image vertically in the middle of the frame 38 | blank_space=h-image.size[1] 39 | adjust_height=int(blank_space/2) 40 | 41 | #paste on canvas with height adjustment 42 | canvas.paste(image, (0, 0+adjust_height)) 43 | 44 | #option 2 : fit the whole height to the frame 45 | if fit=="height": 46 | 47 | #Resize image by height 48 | hpercent = (h/float(image.size[1])) 49 | wsize = int((float(image.size[0])*float(hpercent))) 50 | image = image.resize((wsize,h), Image.ANTIALIAS) 51 | 52 | #center 53 | blank_space=h-image.size[1] 54 | adjust_height=int(blank_space/2) 55 | #Paste image on canvas 56 | canvas.paste(image, (0, 0+adjust_height)) 57 | 58 | #option 3 : crop the image in the center 59 | if fit=="crop": 60 | 61 | #Resize image by height 62 | hpercent = (h/float(image.size[1])) 63 | wsize = int((float(image.size[0])*float(hpercent))) 64 | image = image.resize((wsize,h), Image.ANTIALIAS) 65 | 66 | #Center the image on the frame. First, set overflow 67 | left = (image.size[0] - w)/2 68 | top = (image.size[1] - h)/2 69 | right = (image.size[0] + w)/2 70 | bottom = (image.size[1] + h)/2 71 | 72 | # Crop the center of the image 73 | image = image.crop((left, top, right, bottom)) 74 | 75 | #Paste image on canvas 76 | canvas.paste(image, (0, 0)) 77 | 78 | return(canvas) 79 | 80 | 81 | -------------------------------------------------------------------------------- /screen/pics/Example_1.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/malcolmosh/dispatchPi/HEAD/screen/pics/Example_1.jpg -------------------------------------------------------------------------------- /screen/pics/Example_2.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/malcolmosh/dispatchPi/HEAD/screen/pics/Example_2.jpg -------------------------------------------------------------------------------- /screen/waveshare_epd/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /screen/waveshare_epd/epd7in5_V2.py: -------------------------------------------------------------------------------- 1 | # ***************************************************************************** 2 | # * | File : epd7in5.py 3 | # * | Author : Waveshare team 4 | # * | Function : Electronic paper driver 5 | # * | Info : 6 | # *---------------- 7 | # * | This version: V4.0 8 | # * | Date : 2019-06-20 9 | # # | Info : python demo 10 | # ----------------------------------------------------------------------------- 11 | # Permission is hereby granted, free of charge, to any person obtaining a copy 12 | # of this software and associated documnetation files (the "Software"), to deal 13 | # in the Software without restriction, including without limitation the rights 14 | # to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 15 | # copies of the Software, and to permit persons to whom the Software is 16 | # furished to do so, subject to the following conditions: 17 | # 18 | # The above copyright notice and this permission notice shall be included in 19 | # all copies or substantial portions of the Software. 20 | # 21 | # THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 22 | # IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 23 | # FITNESS OR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 24 | # AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 25 | # LIABILITY WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 26 | # OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 27 | # THE SOFTWARE. 28 | # 29 | 30 | 31 | import logging 32 | from . import epdconfig 33 | 34 | # Display resolution 35 | EPD_WIDTH = 800 36 | EPD_HEIGHT = 480 37 | 38 | logger = logging.getLogger(__name__) 39 | 40 | class EPD: 41 | def __init__(self): 42 | self.reset_pin = epdconfig.RST_PIN 43 | self.dc_pin = epdconfig.DC_PIN 44 | self.busy_pin = epdconfig.BUSY_PIN 45 | self.cs_pin = epdconfig.CS_PIN 46 | self.width = EPD_WIDTH 47 | self.height = EPD_HEIGHT 48 | 49 | Voltage_Frame_7IN5_V2 = [ 50 | 0x6, 0x3F, 0x3F, 0x11, 0x24, 0x7, 0x17, 51 | ] 52 | 53 | LUT_VCOM_7IN5_V2 = [ 54 | 0x0, 0xF, 0xF, 0x0, 0x0, 0x1, 55 | 0x0, 0xF, 0x1, 0xF, 0x1, 0x2, 56 | 0x0, 0xF, 0xF, 0x0, 0x0, 0x1, 57 | 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 58 | 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 59 | 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 60 | 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 61 | ] 62 | 63 | LUT_WW_7IN5_V2 = [ 64 | 0x10, 0xF, 0xF, 0x0, 0x0, 0x1, 65 | 0x84, 0xF, 0x1, 0xF, 0x1, 0x2, 66 | 0x20, 0xF, 0xF, 0x0, 0x0, 0x1, 67 | 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 68 | 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 69 | 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 70 | 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 71 | ] 72 | 73 | LUT_BW_7IN5_V2 = [ 74 | 0x10, 0xF, 0xF, 0x0, 0x0, 0x1, 75 | 0x84, 0xF, 0x1, 0xF, 0x1, 0x2, 76 | 0x20, 0xF, 0xF, 0x0, 0x0, 0x1, 77 | 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 78 | 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 79 | 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 80 | 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 81 | ] 82 | 83 | LUT_WB_7IN5_V2 = [ 84 | 0x80, 0xF, 0xF, 0x0, 0x0, 0x1, 85 | 0x84, 0xF, 0x1, 0xF, 0x1, 0x2, 86 | 0x40, 0xF, 0xF, 0x0, 0x0, 0x1, 87 | 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 88 | 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 89 | 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 90 | 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 91 | ] 92 | 93 | LUT_BB_7IN5_V2 = [ 94 | 0x80, 0xF, 0xF, 0x0, 0x0, 0x1, 95 | 0x84, 0xF, 0x1, 0xF, 0x1, 0x2, 96 | 0x40, 0xF, 0xF, 0x0, 0x0, 0x1, 97 | 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 98 | 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 99 | 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 100 | 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 101 | ] 102 | 103 | # Hardware reset 104 | def reset(self): 105 | epdconfig.digital_write(self.reset_pin, 1) 106 | epdconfig.delay_ms(20) 107 | epdconfig.digital_write(self.reset_pin, 0) 108 | epdconfig.delay_ms(2) 109 | epdconfig.digital_write(self.reset_pin, 1) 110 | epdconfig.delay_ms(20) 111 | 112 | def send_command(self, command): 113 | epdconfig.digital_write(self.dc_pin, 0) 114 | epdconfig.digital_write(self.cs_pin, 0) 115 | epdconfig.spi_writebyte([command]) 116 | epdconfig.digital_write(self.cs_pin, 1) 117 | 118 | def send_data(self, data): 119 | epdconfig.digital_write(self.dc_pin, 1) 120 | epdconfig.digital_write(self.cs_pin, 0) 121 | epdconfig.spi_writebyte([data]) 122 | epdconfig.digital_write(self.cs_pin, 1) 123 | 124 | def send_data2(self, data): 125 | epdconfig.digital_write(self.dc_pin, 1) 126 | epdconfig.digital_write(self.cs_pin, 0) 127 | epdconfig.SPI.writebytes2(data) 128 | epdconfig.digital_write(self.cs_pin, 1) 129 | 130 | def ReadBusy(self): 131 | logger.debug("e-Paper busy") 132 | self.send_command(0x71) 133 | busy = epdconfig.digital_read(self.busy_pin) 134 | while(busy == 0): 135 | self.send_command(0x71) 136 | busy = epdconfig.digital_read(self.busy_pin) 137 | epdconfig.delay_ms(20) 138 | logger.debug("e-Paper busy release") 139 | 140 | def SetLut(self, lut_vcom, lut_ww, lut_bw, lut_wb, lut_bb): 141 | self.send_command(0x20) 142 | for count in range(0, 42): 143 | self.send_data(lut_vcom[count]) 144 | 145 | self.send_command(0x21) 146 | for count in range(0, 42): 147 | self.send_data(lut_ww[count]) 148 | 149 | self.send_command(0x22) 150 | for count in range(0, 42): 151 | self.send_data(lut_bw[count]) 152 | 153 | self.send_command(0x23) 154 | for count in range(0, 42): 155 | self.send_data(lut_wb[count]) 156 | 157 | self.send_command(0x24) 158 | for count in range(0, 42): 159 | self.send_data(lut_bb[count]) 160 | 161 | def init(self): 162 | if (epdconfig.module_init() != 0): 163 | return -1 164 | # EPD hardware init start 165 | self.reset() 166 | 167 | # self.send_command(0x06) # btst 168 | # self.send_data(0x17) 169 | # self.send_data(0x17) 170 | # self.send_data(0x28) # If an exception is displayed, try using 0x38 171 | # self.send_data(0x17) 172 | 173 | # self.send_command(0x01) #POWER SETTING 174 | # self.send_data(0x07) 175 | # self.send_data(0x07) #VGH=20V,VGL=-20V 176 | # self.send_data(0x3f) #VDH=15V 177 | # self.send_data(0x3f) #VDL=-15V 178 | 179 | self.send_command(0x01); # power setting 180 | self.send_data(0x17); # 1-0=11: internal power 181 | self.send_data(self.Voltage_Frame_7IN5_V2[6]); # VGH&VGL 182 | self.send_data(self.Voltage_Frame_7IN5_V2[1]); # VSH 183 | self.send_data(self.Voltage_Frame_7IN5_V2[2]); # VSL 184 | self.send_data(self.Voltage_Frame_7IN5_V2[3]); # VSHR 185 | 186 | self.send_command(0x82); # VCOM DC Setting 187 | self.send_data(self.Voltage_Frame_7IN5_V2[4]); # VCOM 188 | 189 | self.send_command(0x06); # Booster Setting 190 | self.send_data(0x27); 191 | self.send_data(0x27); 192 | self.send_data(0x2F); 193 | self.send_data(0x17); 194 | 195 | self.send_command(0x30); # OSC Setting 196 | self.send_data(self.Voltage_Frame_7IN5_V2[0]); # 2-0=100: N=4 ; 5-3=111: M=7 ; 3C=50Hz 3A=100HZ 197 | 198 | self.send_command(0x04) #POWER ON 199 | epdconfig.delay_ms(100) 200 | self.ReadBusy() 201 | 202 | self.send_command(0X00) #PANNEL SETTING 203 | self.send_data(0x3F) #KW-3f KWR-2F BWROTP 0f BWOTP 1f 204 | 205 | self.send_command(0x61) #tres 206 | self.send_data(0x03) #source 800 207 | self.send_data(0x20) 208 | self.send_data(0x01) #gate 480 209 | self.send_data(0xE0) 210 | 211 | self.send_command(0X15) 212 | self.send_data(0x00) 213 | 214 | self.send_command(0X50) #VCOM AND DATA INTERVAL SETTING 215 | self.send_data(0x10) 216 | self.send_data(0x07) 217 | 218 | self.send_command(0X60) #TCON SETTING 219 | self.send_data(0x22) 220 | 221 | self.send_command(0x65); # Resolution setting 222 | self.send_data(0x00); 223 | self.send_data(0x00); # 800*480 224 | self.send_data(0x00); 225 | self.send_data(0x00); 226 | 227 | self.SetLut(self.LUT_VCOM_7IN5_V2, self.LUT_WW_7IN5_V2, self.LUT_BW_7IN5_V2, self.LUT_WB_7IN5_V2, self.LUT_BB_7IN5_V2) 228 | # EPD hardware init end 229 | return 0 230 | 231 | def getbuffer(self, image): 232 | img = image 233 | imwidth, imheight = img.size 234 | if(imwidth == self.width and imheight == self.height): 235 | img = img.convert('1') 236 | elif(imwidth == self.height and imheight == self.width): 237 | # image has correct dimensions, but needs to be rotated 238 | img = img.rotate(90, expand=True).convert('1') 239 | else: 240 | logger.warning("Wrong image dimensions: must be " + str(self.width) + "x" + str(self.height)) 241 | # return a blank buffer 242 | return [0x00] * (int(self.width/8) * self.height) 243 | 244 | buf = bytearray(img.tobytes('raw')) 245 | # The bytes need to be inverted, because in the PIL world 0=black and 1=white, but 246 | # in the e-paper world 0=white and 1=black. 247 | for i in range(len(buf)): 248 | buf[i] ^= 0xFF 249 | return buf 250 | 251 | def display(self, image): 252 | self.send_command(0x13) 253 | self.send_data2(image) 254 | 255 | self.send_command(0x12) 256 | epdconfig.delay_ms(100) 257 | self.ReadBusy() 258 | 259 | def Clear(self): 260 | buf = [0x00] * (int(self.width/8) * self.height) 261 | self.send_command(0x10) 262 | self.send_data2(buf) 263 | self.send_command(0x13) 264 | self.send_data2(buf) 265 | self.send_command(0x12) 266 | epdconfig.delay_ms(100) 267 | self.ReadBusy() 268 | 269 | def sleep(self): 270 | self.send_command(0x02) # POWER_OFF 271 | self.ReadBusy() 272 | 273 | self.send_command(0x07) # DEEP_SLEEP 274 | self.send_data(0XA5) 275 | 276 | epdconfig.delay_ms(2000) 277 | epdconfig.module_exit() 278 | ### END OF FILE ### 279 | 280 | -------------------------------------------------------------------------------- /screen/waveshare_epd/epdconfig.py: -------------------------------------------------------------------------------- 1 | # /***************************************************************************** 2 | # * | File : epdconfig.py 3 | # * | Author : Waveshare team 4 | # * | Function : Hardware underlying interface 5 | # * | Info : 6 | # *---------------- 7 | # * | This version: V1.0 8 | # * | Date : 2019-06-21 9 | # * | Info : 10 | # ****************************************************************************** 11 | # Permission is hereby granted, free of charge, to any person obtaining a copy 12 | # of this software and associated documnetation files (the "Software"), to deal 13 | # in the Software without restriction, including without limitation the rights 14 | # to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 15 | # copies of the Software, and to permit persons to whom the Software is 16 | # furished to do so, subject to the following conditions: 17 | # 18 | # The above copyright notice and this permission notice shall be included in 19 | # all copies or substantial portions of the Software. 20 | # 21 | # THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 22 | # IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 23 | # FITNESS OR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 24 | # AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 25 | # LIABILITY WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 26 | # OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 27 | # THE SOFTWARE. 28 | # 29 | 30 | import os 31 | import logging 32 | import sys 33 | import time 34 | 35 | logger = logging.getLogger(__name__) 36 | 37 | 38 | class RaspberryPi: 39 | # Pin definition 40 | RST_PIN = 17 41 | DC_PIN = 25 42 | CS_PIN = 8 43 | BUSY_PIN = 24 44 | 45 | def __init__(self): 46 | import spidev 47 | import RPi.GPIO 48 | 49 | self.GPIO = RPi.GPIO 50 | self.SPI = spidev.SpiDev() 51 | 52 | def digital_write(self, pin, value): 53 | self.GPIO.output(pin, value) 54 | 55 | def digital_read(self, pin): 56 | return self.GPIO.input(pin) 57 | 58 | def delay_ms(self, delaytime): 59 | time.sleep(delaytime / 1000.0) 60 | 61 | def spi_writebyte(self, data): 62 | self.SPI.writebytes(data) 63 | 64 | def spi_writebyte2(self, data): 65 | self.SPI.writebytes2(data) 66 | 67 | def module_init(self): 68 | self.GPIO.setmode(self.GPIO.BCM) 69 | self.GPIO.setwarnings(False) 70 | self.GPIO.setup(self.RST_PIN, self.GPIO.OUT) 71 | self.GPIO.setup(self.DC_PIN, self.GPIO.OUT) 72 | self.GPIO.setup(self.CS_PIN, self.GPIO.OUT) 73 | self.GPIO.setup(self.BUSY_PIN, self.GPIO.IN) 74 | 75 | # SPI device, bus = 0, device = 0 76 | self.SPI.open(0, 0) 77 | self.SPI.max_speed_hz = 4000000 78 | self.SPI.mode = 0b00 79 | return 0 80 | 81 | def module_exit(self): 82 | logger.debug("spi end") 83 | self.SPI.close() 84 | 85 | logger.debug("close 5V, Module enters 0 power consumption ...") 86 | self.GPIO.output(self.RST_PIN, 0) 87 | self.GPIO.output(self.DC_PIN, 0) 88 | 89 | self.GPIO.cleanup() 90 | 91 | 92 | class JetsonNano: 93 | # Pin definition 94 | RST_PIN = 17 95 | DC_PIN = 25 96 | CS_PIN = 8 97 | BUSY_PIN = 24 98 | 99 | def __init__(self): 100 | import ctypes 101 | find_dirs = [ 102 | os.path.dirname(os.path.realpath(__file__)), 103 | '/usr/local/lib', 104 | '/usr/lib', 105 | ] 106 | self.SPI = None 107 | for find_dir in find_dirs: 108 | so_filename = os.path.join(find_dir, 'sysfs_software_spi.so') 109 | if os.path.exists(so_filename): 110 | self.SPI = ctypes.cdll.LoadLibrary(so_filename) 111 | break 112 | if self.SPI is None: 113 | raise RuntimeError('Cannot find sysfs_software_spi.so') 114 | 115 | import Jetson.GPIO 116 | self.GPIO = Jetson.GPIO 117 | 118 | def digital_write(self, pin, value): 119 | self.GPIO.output(pin, value) 120 | 121 | def digital_read(self, pin): 122 | return self.GPIO.input(self.BUSY_PIN) 123 | 124 | def delay_ms(self, delaytime): 125 | time.sleep(delaytime / 1000.0) 126 | 127 | def spi_writebyte(self, data): 128 | self.SPI.SYSFS_software_spi_transfer(data[0]) 129 | 130 | def module_init(self): 131 | self.GPIO.setmode(self.GPIO.BCM) 132 | self.GPIO.setwarnings(False) 133 | self.GPIO.setup(self.RST_PIN, self.GPIO.OUT) 134 | self.GPIO.setup(self.DC_PIN, self.GPIO.OUT) 135 | self.GPIO.setup(self.CS_PIN, self.GPIO.OUT) 136 | self.GPIO.setup(self.BUSY_PIN, self.GPIO.IN) 137 | self.SPI.SYSFS_software_spi_begin() 138 | return 0 139 | 140 | def module_exit(self): 141 | logger.debug("spi end") 142 | self.SPI.SYSFS_software_spi_end() 143 | 144 | logger.debug("close 5V, Module enters 0 power consumption ...") 145 | self.GPIO.output(self.RST_PIN, 0) 146 | self.GPIO.output(self.DC_PIN, 0) 147 | 148 | self.GPIO.cleanup() 149 | 150 | 151 | if os.path.exists('/sys/bus/platform/drivers/gpiomem-bcm2835'): 152 | implementation = RaspberryPi() 153 | else: 154 | implementation = JetsonNano() 155 | 156 | for func in [x for x in dir(implementation) if not x.startswith('_')]: 157 | setattr(sys.modules[__name__], func, getattr(implementation, func)) 158 | 159 | 160 | ### END OF FILE ### 161 | -------------------------------------------------------------------------------- /server/Dockerfile.py: -------------------------------------------------------------------------------- 1 | # Use an official lightweight Python image. 2 | FROM python:3.9-alpine 3 | 4 | # Copy and install requirements 5 | RUN apk update && apk add \ 6 | gcc \ 7 | g++ \ 8 | freetype-dev \ 9 | musl-dev \ 10 | jpeg-dev \ 11 | zlib-dev \ 12 | libjpeg \ 13 | python3-dev 14 | 15 | # Codebase setup 16 | RUN mkdir /app/ 17 | WORKDIR /app/ 18 | 19 | # Add all code 20 | ENV PYTHONPATH /dispatchpi 21 | ADD . /app/ 22 | 23 | # Install dependencies into this container so there's no need to 24 | # install anything at container run time. 25 | RUN pip install -r requirements.txt --upgrade 26 | 27 | # Service must listen to $PORT environment variable. 28 | # This default value facilitates local development. 29 | ENV PORT 8080 30 | 31 | # Run the web service on container startup. Here you use the gunicorn 32 | # server, with one worker process and 8 threads. For environments 33 | # with multiple CPU cores, increase the number of workers to match 34 | # the number of cores available. 35 | CMD exec gunicorn --bind 0.0.0.0:$PORT --workers 1 --threads 8 --timeout 0 main:app -------------------------------------------------------------------------------- /server/dockerfile.py: -------------------------------------------------------------------------------- 1 | # Use an official lightweight Python image. 2 | FROM python:3.9-alpine 3 | 4 | # Copy and install requirements 5 | RUN apk update && apk add \ 6 | gcc \ 7 | g++ \ 8 | freetype-dev \ 9 | musl-dev \ 10 | jpeg-dev \ 11 | zlib-dev \ 12 | libjpeg \ 13 | python3-dev 14 | 15 | # Codebase setup 16 | RUN mkdir /app/ 17 | WORKDIR /app/ 18 | 19 | # Add all code 20 | ENV PYTHONPATH /dispatchpi 21 | ADD . /app/ 22 | 23 | # Install dependencies into this container so there's no need to 24 | # install anything at container run time. 25 | RUN pip install -r requirements.txt --upgrade 26 | 27 | # Service must listen to $PORT environment variable. 28 | # This default value facilitates local development. 29 | ENV PORT 8080 30 | 31 | # Run the web service on container startup. Here you use the gunicorn 32 | # server, with one worker process and 8 threads. For environments 33 | # with multiple CPU cores, increase the number of workers to match 34 | # the number of cores available. 35 | CMD exec gunicorn --bind 0.0.0.0:$PORT --workers 1 --threads 8 --timeout 0 main:app -------------------------------------------------------------------------------- /server/eink_image.py: -------------------------------------------------------------------------------- 1 | import os 2 | from PIL import Image, ImageDraw, ImageFont, ImageOps 3 | import textwrap 4 | 5 | 6 | 7 | #function to transform the pic pulled from gmail into a 2 tone & resized image 8 | class Image_transform: 9 | def __init__(self, imported_image, fit="crop", message=""): 10 | self.imported_image=imported_image 11 | self.message=message 12 | 13 | def render(self, fit="crop"): 14 | # fit can be "width" or "crop" or "height" 15 | #we are using the screen in portrait mode and so flipping the default landscape mode 16 | w = 480 17 | h = 800 18 | 19 | #create canvas in portrait mode 20 | canvas = Image.new(mode="1", size=(w, h), color=255) #fill colour for blank space (so, clear frame first) 21 | draw = ImageDraw.Draw(canvas) 22 | 23 | #use the line below if we're working with a path and not an image file 24 | #image = Image.open(self.imported_image) 25 | 26 | #use the line below if we're working with an image file directly 27 | image = self.imported_image 28 | 29 | #Remove exif orientation 30 | image = ImageOps.exif_transpose(image) 31 | 32 | #option 1 : fit the whole width to the frame 33 | if fit=="width": 34 | #Resize image to fit width 35 | wpercent = (w/float(image.size[0])) 36 | hsize = int((float(image.size[1])*float(wpercent))) 37 | image = image.resize((w,hsize), Image.ANTIALIAS) 38 | 39 | #center the image vertically in the middle of the frame 40 | blank_space=h-image.size[1] 41 | adjust_height=int(blank_space/2) 42 | 43 | #paste on canvas with height adjustment 44 | canvas.paste(image, (0, 0+adjust_height)) 45 | 46 | #option 2 : fit the whole height to the frame 47 | if fit=="height": 48 | 49 | #Resize image by height 50 | hpercent = (h/float(image.size[1])) 51 | wsize = int((float(image.size[0])*float(hpercent))) 52 | image = image.resize((wsize,h), Image.ANTIALIAS) 53 | 54 | #center 55 | blank_space=h-image.size[1] 56 | adjust_height=int(blank_space/2) 57 | #Paste image on canvas 58 | canvas.paste(image, (0, 0+adjust_height)) 59 | 60 | #option 3 : crop the image in the center 61 | if fit=="crop": 62 | 63 | #Resize image by height 64 | hpercent = (h/float(image.size[1])) 65 | wsize = int((float(image.size[0])*float(hpercent))) 66 | image = image.resize((wsize,h), Image.ANTIALIAS) 67 | 68 | #Center the image on the frame. First, set overflow 69 | left = (image.size[0] - w)/2 70 | top = (image.size[1] - h)/2 71 | right = (image.size[0] + w)/2 72 | bottom = (image.size[1] + h)/2 73 | 74 | # Crop the center of the image 75 | image = image.crop((left, top, right, bottom)) 76 | 77 | #Paste image on canvas 78 | canvas.paste(image, (0, 0)) 79 | 80 | #print text on top of image 81 | #set font 82 | # find script directory 83 | dir_path = os.path.dirname(os.path.realpath(__file__)) 84 | font = ImageFont.truetype(os.path.join(dir_path, "fonts", "Roboto-Medium.ttf"), 25) 85 | 86 | message = textwrap.fill(self.message,width=25) 87 | 88 | # Calculate the width and height of rendered text 89 | textw, texth = draw.textsize(message, font=font) 90 | text_overflow = (w-textw)/2 91 | 92 | draw.text((0+text_overflow, 0), message, font=font, fill=0, align='center') 93 | 94 | return(canvas) 95 | 96 | 97 | -------------------------------------------------------------------------------- /server/fonts/Roboto-Medium.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/malcolmosh/dispatchPi/HEAD/server/fonts/Roboto-Medium.ttf -------------------------------------------------------------------------------- /server/gmail_connector.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | 3 | from googleapiclient.discovery import build 4 | import base64 5 | import re 6 | import io 7 | from PIL import Image 8 | import json 9 | import os 10 | 11 | from zoneinfo import ZoneInfo 12 | from datetime import datetime, timedelta 13 | 14 | from google.oauth2.credentials import Credentials 15 | 16 | from collections import deque 17 | 18 | # find script directory 19 | dir_path = os.path.dirname(os.path.realpath(__file__)) 20 | 21 | DATE_NOW = datetime.now(ZoneInfo("America/New_York")).date() 22 | 23 | class EmailImage(): 24 | # Class to store the details of an image attachment extracted from a Gmail inbox 25 | 26 | def __init__(self, unique_attachment_id : str, temporary_attachment_id : str, message_id : str, text : str, image : Image = None, display_date : datetime = None): 27 | self.unique_attachment_id = unique_attachment_id # fixed ID of the attachment for any API call 28 | self.temporary_attachment_id = temporary_attachment_id # ID of the attachment for the current API call 29 | self.message_id = message_id # id of the email 30 | self.text = text # body of the email 31 | self.image_as_string = image # image as a string 32 | self.display_date = display_date # date to show the image on the frame 33 | 34 | # Convert to dict for json serialization 35 | def to_dict(self): 36 | return { 37 | "unique_attachment_id": self.unique_attachment_id, 38 | "temporary_attachment_id": self.temporary_attachment_id, 39 | "message_id": self.message_id, 40 | "text": self.text, 41 | "display_date": self.display_date.isoformat() if self.display_date else None 42 | } 43 | 44 | @staticmethod 45 | # Convert from dict to EmailImage 46 | def from_dict(data): 47 | display_date = datetime.fromisoformat(data["display_date"]).date() if data["display_date"] else None 48 | return EmailImage(data["unique_attachment_id"], data["temporary_attachment_id"], data["message_id"], data["text"], display_date=display_date) 49 | 50 | 51 | class FIFOQueue(): 52 | # First-in First-out Queue (since emails are pulled in chronological order) 53 | def __init__(self, *elements): 54 | self._elements = deque(elements) 55 | 56 | def __len__(self): 57 | return len(self._elements) 58 | 59 | # The __iter__ method makes the class iterable 60 | def __iter__(self): 61 | return iter(self._elements) 62 | 63 | def enqueue(self, element): 64 | # if queue is empty or date of previous element is in the past 65 | if self.__len__() == 0 or self._elements[-1].display_date < DATE_NOW: 66 | # add element with today's date 67 | element.display_date = DATE_NOW 68 | 69 | # if display date of previous element is either today or in the future 70 | elif self._elements[-1].display_date >= DATE_NOW: 71 | # add element with +1 day to the previous element 72 | element.display_date = self._elements[-1].display_date + timedelta(days=1) 73 | 74 | # append element 75 | self._elements.append(element) 76 | 77 | def dequeue(self): 78 | # remove first element from queue 79 | return self._elements.popleft() 80 | 81 | def save_to_file(self, file_path): 82 | # save the queue to a json file - to make the queue persistent on Cloud Run we need to write it to an external file 83 | with open(file_path, 'w') as file: 84 | json.dump({i+1: element.to_dict() for i, element in enumerate(self._elements)}, file) 85 | 86 | def load_from_file(self, file_path): 87 | # load the queue from a json file 88 | with open(file_path, 'r') as file: 89 | elements = json.load(file) 90 | # Ignore the keys and only use the values 91 | self._elements = deque(EmailImage.from_dict(element) for element in elements.values()) 92 | 93 | # class that connects to Gmail and allows you to parse messages 94 | class GmailConnector(): 95 | 96 | def __init__(self, creds : Credentials, length_of_queue : int = 3, satellite_emails : list = []): 97 | self.user_id = 'me' 98 | # creds are the credentials used to connect to the gmail API 99 | self.creds = creds 100 | # A shared Gmail inbox is created specifically for the project. It will receive images from multiple senders. 101 | # We need to create a filter to only pull images from the right sender. 102 | # The satellite frame will display images receive from everyone except themselves. 103 | # The earth frame will only display images from the satellite frame. 104 | # Think of it like Apollo and Houston... The spaceship can see everything sent by anyone, whereas Houston only wants to hear from the spaceship. 105 | self.length_of_queue = length_of_queue # length of the dynamic image queue 106 | self.satellite_emails= satellite_emails # emails used by the satellite frame owner 107 | #create lists to attachments for all parties 108 | self.image_queues = { 109 | "satellite_frame": FIFOQueue(), 110 | "earth_frame": FIFOQueue(), 111 | } 112 | 113 | # load queues from file if they exist 114 | for target in self.image_queues: 115 | full_queue_path = os.path.join(dir_path, 'queues', f'{target}_queue.json') 116 | if os.path.exists(full_queue_path): 117 | self.image_queues[target].load_from_file(full_queue_path) 118 | 119 | # Build API call 120 | self.service = build('gmail', 'v1', credentials=self.creds) 121 | 122 | def get_text(self, parts): 123 | # get text included in the body of an email 124 | text = None 125 | if "parts" in parts: 126 | text = base64.urlsafe_b64decode((parts["parts"][0]["body"]["data"]).encode("ASCII")).decode("utf-8").replace('\r\n', '') 127 | elif parts['mimeType']=='text/plain': 128 | text = base64.urlsafe_b64decode((parts["body"]["data"]).encode("ASCII")).decode("utf-8").replace('\r\n', '') 129 | if text: 130 | return self.clean_text(text) 131 | 132 | def clean_text(self, string : str): 133 | # remove unwanted characters from text 134 | string = re.sub(r'\[.*?\]', ' ', string) 135 | return string 136 | 137 | def append_image_information(self, target, unique_attachment_id, temporary_attachment_id, message_id, body_text): 138 | 139 | # If the image is not already in the queue 140 | if unique_attachment_id not in [item.unique_attachment_id for item in self.image_queues[target]._elements]: 141 | print(f"Appending image to {target} queue") 142 | # store image details 143 | email_image = EmailImage(unique_attachment_id=unique_attachment_id, temporary_attachment_id=temporary_attachment_id, message_id=message_id, text=body_text) 144 | 145 | # if the queue is full, remove the first element 146 | if len(self.image_queues[target]) == self.length_of_queue: 147 | self.image_queues[target].dequeue() 148 | 149 | self.image_queues[target].enqueue(email_image) 150 | 151 | else: 152 | print(f"Image already in {target} queue") 153 | 154 | def pull_specific_image(self, temporary_attachment_id, message_id): 155 | #store image in utf-8 format 156 | img_data = self.service.users().messages().attachments().get(userId=self.user_id, messageId=message_id,id=temporary_attachment_id).execute() 157 | img_data=img_data['data'].encode('UTF-8') 158 | file_data=base64.urlsafe_b64decode(img_data) #decode string 159 | image_to_send=Image.open(io.BytesIO(file_data)) #open as an image 160 | return image_to_send 161 | 162 | def build_email_list(self, filter : str): 163 | try: 164 | emails = self.service.users().messages().list(userId=self.user_id, q = (filter)).execute() 165 | return emails 166 | except Exception as e: 167 | print(f"Error in build_email_list: {e}") 168 | return None 169 | 170 | def pull_images_and_update_queue(self, emails_to_parse : dict, target : str): 171 | # parse emails in chronological order 172 | 173 | # trim list of emails to length of queue 174 | trimmed_list_of_emails = emails_to_parse['messages'][:self.length_of_queue] 175 | 176 | for message in reversed(trimmed_list_of_emails): 177 | message_id=(message['id']) 178 | message_content=self.service.users().messages().get(userId=self.user_id, id=message_id, format='full').execute() 179 | 180 | #find the sender 181 | for header_parts in message_content['payload']['headers']: 182 | if header_parts['name']== "From": 183 | sender=(header_parts['value']) 184 | 185 | #initialize body text to empty 186 | body_text = "" 187 | for parts in message_content['payload']['parts']: 188 | # get text embedded in email content 189 | text = self.get_text(parts) 190 | # only append text if it is not empty 191 | if text: 192 | body_text = text 193 | 194 | # Get attachment & avoid collecting useless attachments that have no name (logos and other stuff) 195 | # We could also filter on parts['mimetype'] == 'image...' but the code below works well 196 | if 'attachmentId' in parts['body'] and parts['filename']!="": 197 | # Create a unique identifier for the attachment. Unfortunately, the attachment ID provided by the Gmail API is refreshed every time. 198 | # See https://stackoverflow.com/questions/28104157/how-can-i-find-the-definitive-attachmentid-for-an-attachment-retrieved-via-googl 199 | # It seems like it doesn't expire though, which means it can always be used to retrieve an attachment. 200 | unique_attachment_id = message_id + "_" + parts.get('partId') # Use message ID and part ID to create a unique identifier 201 | temporary_attachment_id = parts['body']['attachmentId'] # This ID changes at every API call 202 | 203 | print("Found image. Attempting to append to queue...") 204 | 205 | self.append_image_information(target, unique_attachment_id, temporary_attachment_id, message_id, body_text) 206 | 207 | 208 | def pull_attachments(self, target : str): 209 | print(f"Pulling attachments for the {target}") 210 | # target is either "satellite_frame" or "earth_frame" 211 | if target == "satellite_frame": 212 | filter = f"-from:{' OR '.join([email for email in self.satellite_emails])}" 213 | elif target == "earth_frame": 214 | filter = f"from:{' OR '.join([email for email in self.satellite_emails])}" 215 | 216 | list_of_emails = self.build_email_list(filter) 217 | 218 | if list_of_emails: 219 | self.pull_images_and_update_queue(list_of_emails, target) 220 | 221 | def display_from_queue(self, target : str): 222 | # target is either "satellite_frame" or "earth_frame" 223 | 224 | # get all dates in relevant queue 225 | dates_in_queue = [item.display_date for item in self.image_queues[target]._elements] 226 | 227 | # go to first date in queue that is today or later 228 | for index, item in enumerate(dates_in_queue): 229 | if item >= DATE_NOW: 230 | break 231 | 232 | # get image to display 233 | image = self.image_queues[target]._elements[index] 234 | output_text = image.text 235 | image_to_send = self.pull_specific_image(image.temporary_attachment_id, image.message_id) 236 | 237 | # save queue to file and create dir if it doesn't exist 238 | if not os.path.exists(dir_path + '/queues'): 239 | os.makedirs(dir_path + '/queues') 240 | 241 | self.image_queues[target].save_to_file(os.path.join(dir_path, 'queues', f'{target}_queue.json')) 242 | 243 | return(image_to_send,output_text) # return image and body of first email relevant to the initiator 244 | -------------------------------------------------------------------------------- /server/gmail_connector_no_queue.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | 3 | from __future__ import print_function 4 | 5 | from googleapiclient.discovery import build 6 | 7 | import base64 8 | import re 9 | 10 | # class that connects to Gmail and allows you to parse messages 11 | class Gmail_connector(): 12 | 13 | def __init__(self, creds): 14 | # creds are the credentials used to connect to the gmail API 15 | self.creds = creds 16 | # A shared Gmail inbox is created specifically for the project. 17 | # It will receive images from multiple senders. 18 | # We need to create a filter to only pull images from the right sender. 19 | # The satellite frame will display images receive from everyone except themselves. 20 | # The earth frame will only display images from the satellite frame. 21 | # Think in terms of Apollo and Houston... The rocketship can see everything but Houston only wants to hear from the rocketship. 22 | # This list contains the email addresses used by the satellite frame. 23 | self.satellite_emails=[] 24 | #create lists to attachments for all parties 25 | self.satellite_relevant_attachments = [] 26 | self.earth_relevant_attachments = [] 27 | 28 | # Build API call 29 | self.service = build('gmail', 'v1', credentials=self.creds) 30 | 31 | def pull_attachments(self, userID): 32 | 33 | #List all emails 34 | results = self.service.users().messages().list(userId=userID).execute() 35 | 36 | #get id of first 10 emails 37 | for idx,message in enumerate(results['messages'][0:11]): 38 | message_id=(message['id']) 39 | message_content=self.service.users().messages().get(userId=userID, id=message_id, format='full').execute() 40 | #assign default body text to empty 41 | data="" 42 | 43 | #find the sender 44 | for header_parts in message_content['payload']['headers']: 45 | if header_parts['name']== "From": 46 | sender=(header_parts['value']) 47 | 48 | for parts in message_content['payload']['parts']: 49 | #get text embedded in email content 50 | 51 | #if part has another part 52 | if "parts" in parts: 53 | data=(base64.urlsafe_b64decode((parts["parts"][0]["body"]["data"]).encode("ASCII")).decode("utf-8").replace('\r\n', '')) 54 | #remove text between brackets 55 | data=re.sub(r'\[.*?\]', ' ', data) 56 | 57 | #else if parts are not recursive 58 | elif parts['mimeType']=='text/plain': 59 | data=(base64.urlsafe_b64decode((parts["body"]["data"]).encode("ASCII")).decode("utf-8").replace('\r\n', '')) 60 | #remove text between brackets 61 | data=re.sub(r'\[.*?\]', ' ', data) 62 | 63 | #get attachment 64 | #avoid collecting useless attachments that have no name (logos and other stuff) 65 | if 'attachmentId' in parts['body'] and parts['filename']!="": 66 | att_id = parts['body']['attachmentId'] 67 | 68 | # create a list of relevant attachments for the earth frame 69 | if any(address in sender for address in self.satellite_emails): 70 | self.earth_relevant_attachments.append([message_id,att_id, data]) 71 | # create a list of relevant attachments for the satellite frame 72 | else: 73 | self.satellite_relevant_attachments.append([message_id,att_id, data]) 74 | 75 | def grab_first_image(self, initiator, userID): 76 | # initiator is either "satellite" or "earth" 77 | 78 | if initiator=="satellite_frame": 79 | #get first available attachment 80 | img_data = self.service.users().messages().attachments().get(userId=userID, messageId=self.satellite_relevant_attachments[0][0],id=self.satellite_relevant_attachments[0][1]).execute() 81 | img_data=img_data['data'].encode('UTF-8') 82 | output_text=self.satellite_relevant_attachments[0][2] 83 | 84 | elif initiator=="earth_frame": 85 | #get first available attachment 86 | img_data = self.service.users().messages().attachments().get(userId=userID, messageId=self.earth_relevant_attachments[0][0],id=self.earth_relevant_attachments[0][1]).execute() 87 | img_data=img_data['data'].encode('UTF-8') 88 | output_text=self.earth_relevant_attachments[0][2] 89 | 90 | #decode string 91 | file_data=base64.urlsafe_b64decode(img_data) 92 | 93 | #open as an image 94 | import io 95 | from PIL import Image 96 | image_to_send=Image.open(io.BytesIO(file_data)) 97 | 98 | # return image and body of first email relevant to the initiator 99 | return(image_to_send,output_text) 100 | 101 | -------------------------------------------------------------------------------- /server/main.py: -------------------------------------------------------------------------------- 1 | # -*- Code revised May 2023. Olivier Simard-Hanley. -*- 2 | # this is the main file for the DispatchPi flask app 3 | 4 | import os 5 | import flask 6 | from flask import send_file 7 | import requests 8 | import json 9 | from io import BytesIO 10 | 11 | #google libraries 12 | import google_auth_oauthlib.flow 13 | from google.oauth2.credentials import Credentials 14 | from google.auth.transport.requests import Request 15 | 16 | #local functions 17 | from eink_image import Image_transform 18 | from gmail_connector import GmailConnector 19 | 20 | 21 | # find script directory 22 | dir_path = os.path.dirname(os.path.realpath(__file__)) 23 | 24 | #Path to your API credentials file 25 | CLIENT_SECRETS_FILE = os.path.join(dir_path, "secrets/client_secret.json") 26 | #Path to your API Access token file 27 | TOKEN_FILE = os.path.join(dir_path, 'secrets/token.json') 28 | #Path to your Flask app key 29 | FLASK_KEY= os.path.join(dir_path, 'secrets/flask_key.json') 30 | 31 | ##AUTH 32 | 33 | # This OAuth 2.0 access scope allows to read emails 34 | SCOPES = ['https://www.googleapis.com/auth/gmail.readonly'] 35 | API_SERVICE_NAME = 'gmail' 36 | #API_VERSION = 'v3' 37 | 38 | ##FLASK APP 39 | app = flask.Flask(__name__) 40 | 41 | # Flask app key (so that session parameters work) 42 | with open(FLASK_KEY) as secrets_file: 43 | key_file = json.load(secrets_file) 44 | app.secret_key = key_file['SECRET_KEY'] 45 | 46 | 47 | def generate_credentials(): 48 | #if there are stored credentials, retrieve them 49 | credentials = Credentials.from_authorized_user_file(TOKEN_FILE, SCOPES) 50 | 51 | #if credentials are expired, refresh 52 | if not credentials.valid: 53 | credentials.refresh(Request()) 54 | print("Credentials refreshed!") 55 | 56 | #Save credentials to file if they were refreshed 57 | with open(TOKEN_FILE, 'w') as token: 58 | token.write(credentials.to_json()) 59 | print("Credentials saved to file!") 60 | 61 | return credentials 62 | 63 | def pull_and_display_image(target, creds): 64 | 65 | # initialize connector 66 | gmail_inbox = GmailConnector(creds=creds, length_of_queue = 10, satellite_emails = ["EMAIL_USED_BY_SATELLITE_FRAME"] ) 67 | 68 | # pull attachments 69 | gmail_inbox.pull_attachments(target=target) 70 | 71 | # get the image to send 72 | image_to_send, output_text = gmail_inbox.display_from_queue(target=target) 73 | 74 | #transform image into a low res format for the eink screen 75 | transformed_image = Image_transform(imported_image=image_to_send, fit="crop", message=output_text) 76 | transformed_image = transformed_image.render() 77 | output = BytesIO() 78 | transformed_image.save(output, "PNG") 79 | 80 | # display the image (don't cache it) 81 | # output.seek resets the pointer to the beginning of the file 82 | output.seek(0) 83 | return output 84 | 85 | 86 | # define the index 87 | @app.route('/') 88 | def index(): 89 | 90 | return ('' + 91 | "" + 92 | "" + 93 | '' + 94 | '' + 95 | '
See the satellite's frame
See the earth's frame
Test the auth flow directly. You will be sent back to the index
Revoke current credentials
') 96 | 97 | # define view for the satellite frame 98 | @app.route('/satellite_frame') 99 | def api_route_satellite_frame(): 100 | 101 | #update refresh token if we have a token file 102 | if os.path.exists(TOKEN_FILE): 103 | credentials = generate_credentials() 104 | 105 | #if there are no credentials, redirect to the authorization flow 106 | else: 107 | #create a session parameter to send the user to the right view after the auth flow 108 | flask.session['view']="satellite_frame" 109 | return flask.redirect('authorize') 110 | 111 | #pull and display image 112 | output = pull_and_display_image(target = "satellite_frame", creds = credentials) 113 | return send_file(output, mimetype="image/png") 114 | 115 | 116 | # define view for the earth frame 117 | @app.route('/earth_frame') 118 | def api_route_earth_frame(): 119 | 120 | #update refresh token if we have a token file 121 | if os.path.exists(TOKEN_FILE): 122 | credentials = generate_credentials() 123 | 124 | #if there are no credentials, redirect to the authorization flow 125 | else: 126 | #create a session parameter to send the user to the right view after the auth flow 127 | flask.session['view']="earth_frame" 128 | return flask.redirect('authorize') 129 | 130 | #pull and display image 131 | output = pull_and_display_image(target = "earth_frame", creds = credentials) 132 | return send_file(output, mimetype="image/png") 133 | 134 | 135 | # build the authorization flow 136 | @app.route('/authorize') 137 | def authorize(): 138 | 139 | #if testing the auth flow directly, send to the index 140 | if 'view' not in flask.session: 141 | flask.session['view']="index" 142 | 143 | #if we are just testing the auth flow and the credentials are expired, simply refresh them 144 | if os.path.exists(TOKEN_FILE): 145 | credentials = Credentials.from_authorized_user_file(TOKEN_FILE, SCOPES) 146 | if not credentials.valid: 147 | credentials.refresh(Request()) 148 | 149 | #Save credentials to file if they were refreshed 150 | with open(TOKEN_FILE, 'w') as token: 151 | token.write(credentials.to_json()) 152 | 153 | return flask.redirect(flask.url_for('index')) 154 | 155 | #otherwise fetch the full creds 156 | else: 157 | # Create flow instance to manage the OAuth 2.0 Authorization Grant Flow steps. 158 | flow = google_auth_oauthlib.flow.Flow.from_client_secrets_file( 159 | CLIENT_SECRETS_FILE, scopes=SCOPES) 160 | 161 | # The URI created here must exactly match one of the authorized redirect URIs 162 | # for the OAuth 2.0 client, which you configured in the API Console. If this 163 | # value doesn't match an authorized URI, you will get a 'redirect_uri_mismatch' 164 | # error. 165 | flow.redirect_uri = flask.url_for('oauth2callback', _external=True) 166 | 167 | authorization_url, state = flow.authorization_url( 168 | # Enable offline access so that you can refresh an access token without 169 | # re-prompting the user for permission. Recommended for web server apps. 170 | access_type='offline', 171 | # Enable incremental authorization. Recommended as a best practice. 172 | include_granted_scopes='false') 173 | 174 | # Store the state so the callback can verify the auth server response. 175 | flask.session['state'] = state 176 | 177 | return flask.redirect(authorization_url) 178 | 179 | # define the callback 180 | @app.route('/oauth2callback') 181 | def oauth2callback(): 182 | # Specify the state when creating the flow in the callback so that it can 183 | # verified in the authorization server response. 184 | state = flask.session['state'] 185 | 186 | flow = google_auth_oauthlib.flow.Flow.from_client_secrets_file( 187 | CLIENT_SECRETS_FILE, scopes=SCOPES, state=state) 188 | flow.redirect_uri = flask.url_for('oauth2callback', _external=True) 189 | 190 | # Use the authorization server's response to fetch the OAuth 2.0 tokens. 191 | authorization_response = flask.request.url 192 | flow.fetch_token(authorization_response=authorization_response) 193 | 194 | # Store credentials in the session. 195 | credentials = flow.credentials 196 | 197 | #save the credentials to file 198 | with open(TOKEN_FILE, 'w') as token: 199 | token.write(credentials.to_json()) 200 | 201 | return flask.redirect(flask.url_for('index')) 202 | 203 | #revoke the credentials : remove the app from authorized apps 204 | #this will reset the refresh token, you'll have to erase the token file to start over 205 | @app.route('/revoke') 206 | def revoke(): 207 | 208 | credentials = Credentials.from_authorized_user_file(TOKEN_FILE, SCOPES) 209 | 210 | revoke = requests.post('https://oauth2.googleapis.com/revoke', 211 | params={'token': credentials.token}, 212 | headers = {'content-type': 'application/x-www-form-urlencoded'}) 213 | 214 | status_code = getattr(revoke, 'status_code') 215 | if status_code == 200: 216 | return('Credentials successfully revoked.' + index()) 217 | 218 | else: 219 | return('An error occurred.' + index()) 220 | 221 | if __name__ == '__main__': 222 | # When running locally, disable OAuthlib's HTTPs verification. 223 | # When running in production *do not* leave this option enabled. 224 | os.environ['OAUTHLIB_INSECURE_TRANSPORT'] = '1' 225 | 226 | #Specify a hostname and port that are set as a valid redirect URI 227 | #for your API project in the Google API Console. 228 | #set debug to true when testing locally 229 | app.run('localhost', 8080, debug=True) 230 | 231 | else: 232 | # When running online, use HTTPS for URLs 233 | # The lines below should be disabled if you are testing the code locally 234 | # This is handled by the if name == main block above 235 | class ReverseProxied(object): 236 | def __init__(self, app): 237 | self.app = app 238 | 239 | def __call__(self, environ, start_response): 240 | scheme = environ.get('HTTP_X_FORWARDED_PROTO') 241 | if scheme: 242 | environ['wsgi.url_scheme'] = scheme 243 | return self.app(environ, start_response) 244 | 245 | app.wsgi_app = ReverseProxied(app.wsgi_app) 246 | 247 | -------------------------------------------------------------------------------- /server/requirements.txt: -------------------------------------------------------------------------------- 1 | pybase64 2 | gunicorn 3 | Requests==2.29.0 4 | Flask==2.2.2 5 | Pillow==9.4.0 6 | google-auth==2.3.3 7 | google-auth-httplib2==0.1.0 8 | google-api-python-client==2.33.0 9 | google-auth-oauthlib==0.4.6 10 | -------------------------------------------------------------------------------- /server/secrets/flask_key.json: -------------------------------------------------------------------------------- 1 | { 2 | "SECRET_KEY": "anykindofstring" 3 | } 4 | --------------------------------------------------------------------------------