├── .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 ('
| See the satellite's frame | " + 92 | "
| See the earth's frame | " + 93 | '
| Test the auth flow directly. You will be sent back to the index | ' + 94 | '
| Revoke current credentials | ' + 95 | '