an eink camera running on an rpi zero 2 w
1import RPi.GPIO as GPIO
2import time
3from picamera2 import Picamera2
4from datetime import datetime
5import os
6import logging
7import http.server
8import socketserver
9import threading
10import websockets
11import asyncio
12import json
13
14# Setup logging
15logger = logging.getLogger('camera_server')
16logger.setLevel(logging.INFO)
17formatter = logging.Formatter('%(asctime)s - %(name)s - %(levelname)s - %(message)s')
18file_handler = logging.FileHandler('/home/ink/camera_server.log')
19file_handler.setFormatter(formatter)
20stream_handler = logging.StreamHandler()
21stream_handler.setFormatter(formatter)
22logger.addHandler(file_handler)
23logger.addHandler(stream_handler)
24
25class Config:
26 BUTTON_PIN = 2
27 PHOTO_DIR = "/home/ink/photos"
28 WEB_PORT = 80
29 WS_PORT = 8765
30 PHOTO_RESOLUTION = (1280, 960)
31 CAMERA_SETTLE_TIME = 1
32 DEBOUNCE_DELAY = 0.2
33 POLL_INTERVAL = 0.01
34 ROTATION = 90
35
36def validate_photo_dir():
37 if not os.path.isabs(Config.PHOTO_DIR):
38 raise ValueError("PHOTO_DIR must be an absolute path")
39 if not os.access(Config.PHOTO_DIR, os.W_OK):
40 raise PermissionError(f"No write access to {Config.PHOTO_DIR}")
41
42# Ensure photo directory exists and is valid
43validate_photo_dir()
44os.makedirs(Config.PHOTO_DIR, exist_ok=True)
45
46# Set up GPIO
47GPIO.setmode(GPIO.BCM)
48GPIO.setup(Config.BUTTON_PIN, GPIO.IN)
49
50# WebSocket clients set
51connected_clients = set()
52
53# Create a simple HTML gallery template - using triple quotes properly and making sure to escape curly braces
54HTML_TEMPLATE = """<!DOCTYPE html>
55<html>
56<head>
57 <title>Inky: Gallery</title>
58 <meta name="viewport" content="width=device-width, initial-scale=1">
59 <style>
60 body {{ font-family: Arial; max-width: 800px; margin: 0 auto; padding: 20px; }}
61 h1 {{ text-align: center; }}
62 .gallery {{ display: flex; flex-wrap: wrap; gap: 10px; justify-content: center; }}
63 .photo {{ border: 1px solid #ddd; padding: 5px; flex: 0 1 200px; position: relative; display: flex; flex-direction: column }}
64 .photo img {{ width: 100%; height: 100%; object-fit: cover; }}
65 .photo .colored-img {{ position: absolute; top: 5px; left: 5px; opacity: 0; pointer-events: none; width: calc(100% - 10px); height: calc(100% - 10px); }}
66 .dithered-img:hover {{ opacity: 0; }}
67 .dithered-img:hover + .colored-img {{ opacity: 1; }}
68 .photo .actions {{ text-align: center; margin-top: 5px; }}
69 .photo .actions a {{ margin: 0 5px; }}
70 @keyframes fadeOut {{ from {{ opacity: 1; }} to {{ opacity: 0; }} }}
71 </style>
72 <script>
73 let ws;
74 const RECONNECT_DELAY = 1000;
75
76 function connect() {{
77 ws = new WebSocket('ws://' + window.location.hostname + ':8765');
78
79 ws.onmessage = function(event) {{
80 const data = JSON.parse(event.data);
81
82 if (data.action === 'new_photo') {{
83 addPhoto(data.filename, data.timestamp);
84 }} else if (data.action === 'delete_photo') {{
85 removePhoto(data.filename);
86 }}
87 }};
88
89 ws.onclose = function() {{
90 console.log('WebSocket connection closed. Reconnecting...');
91 setTimeout(connect, RECONNECT_DELAY);
92 }};
93
94 ws.onerror = function(err) {{
95 console.error('WebSocket error:', err);
96 ws.close();
97 }};
98 }}
99
100 connect();
101
102 function addPhoto(filename, timestamp) {{
103 const gallery = document.querySelector('.gallery');
104 const noPhotosMsg = gallery.querySelector('p');
105 if (noPhotosMsg) {{
106 noPhotosMsg.remove();
107 }}
108
109 const originalFilename = filename.replace('dithered_', 'photo_');
110 const isDithered = filename.startsWith('dithered_');
111
112 if (isDithered) {{
113 const photoDiv = document.createElement('div');
114 photoDiv.className = 'photo';
115 photoDiv.id = `photo-${{filename}}`;
116
117 photoDiv.innerHTML = `
118 <img class="dithered-img" src="/${{filename}}" alt="${{timestamp}}">
119 <img class="colored-img" src="/${{originalFilename}}" alt="${{timestamp}}">
120 <div class="actions">
121 <a href="/${{originalFilename}}" download>Download Color</a>
122 <a href="/${{filename}}" download>Download Dithered</a>
123 <a href="#" onclick="deletePhoto('${{filename}}', '${{originalFilename}}'); return false;">Delete</a>
124 </div>
125 `;
126
127 gallery.insertBefore(photoDiv, gallery.firstChild);
128 }}
129 }}
130
131 function removePhoto(filename) {{
132 const photoDiv = document.getElementById(`photo-${{filename}}`);
133 if (photoDiv) {{
134 setTimeout(() => {{
135 photoDiv.remove();
136 const gallery = document.querySelector('.gallery');
137 if (gallery.children.length === 0) {{
138 const noPhotosMsg = document.createElement('p');
139 noPhotosMsg.style = 'text-align: center;';
140 noPhotosMsg.textContent = 'No photos yet. Press the button to take a photo!';
141 gallery.appendChild(noPhotosMsg);
142 }}
143 }}, 100);
144 }}
145 }}
146
147 function deletePhoto(ditheredFilename, originalFilename) {{
148 if (confirm('Are you sure you want to delete this photo?')) {{
149 fetch('/delete/' + ditheredFilename, {{
150 method: 'POST'
151 }}).then(response => {{
152 if(response.ok) {{
153 return fetch('/delete/' + originalFilename, {{ method: 'POST' }});
154 }}
155 }}).then(response => {{
156 if(response.ok) {{
157 removePhoto(ditheredFilename);
158 }}
159 }});
160 }}
161 }}
162 </script>
163</head>
164<body>
165 <h1>Inky: Gallery</h1>
166 <div class="gallery">
167 {photo_items}
168 </div>
169</body>
170</html>
171"""
172
173class PhotoHandler(http.server.SimpleHTTPRequestHandler):
174 def __init__(self, *args, **kwargs):
175 super().__init__(*args, directory=Config.PHOTO_DIR, **kwargs)
176
177 def do_GET(self):
178 if self.path == '/':
179 self.send_response(200)
180 self.send_header('Content-type', 'text/html')
181 self.send_header('X-Content-Type-Options', 'nosniff')
182 self.send_header('X-Frame-Options', 'DENY')
183 self.send_header('X-XSS-Protection', '1; mode=block')
184 self.end_headers()
185
186 # Generate photo gallery HTML
187 photo_items = ""
188 try:
189 files = sorted(os.listdir(Config.PHOTO_DIR), reverse=True)
190 for filename in files:
191 if filename.lower().endswith(('.jpg', '.jpeg', '.png')) and filename.startswith('dithered_'):
192 originalFilename = filename.replace('dithered_', 'photo_')
193 timestamp = filename.replace('dithered_', '').replace('.jpg', '')
194 photo_items += f"""
195 <div class="photo" id="photo-{filename}">
196 <img class="dithered-img" src="/{filename}" alt="{timestamp}">
197 <img class="colored-img" src="/{originalFilename}" alt="{timestamp}">
198 <div class="actions">
199 <a href="/{originalFilename}" download>Download Color</a>
200 <a href="/{filename}" download>Download Dithered</a>
201 <a href="#" onclick="deletePhoto('{filename}', '{originalFilename}'); return false;">Delete</a>
202 </div>
203 </div>
204 """
205
206 if not photo_items:
207 photo_items = "<p style='grid-column: 1/-1; text-align: center;'>No photos yet. Press the button to take a photo!</p>"
208 except Exception as e:
209 logger.error(f"Error generating gallery: {str(e)}")
210 photo_items = f"<p>Error loading photos: {str(e)}</p>"
211
212 html = HTML_TEMPLATE.format(photo_items=photo_items)
213 self.wfile.write(html.encode())
214 else:
215 super().do_GET()
216
217 def do_POST(self):
218 if self.path.startswith('/delete/'):
219 filename = self.path[8:] # Remove '/delete/' prefix
220 file_path = os.path.join(Config.PHOTO_DIR, filename)
221
222 try:
223 if os.path.exists(file_path) and os.path.isfile(file_path):
224 os.remove(file_path)
225 logger.info(f"Deleted photo: {filename}")
226 self.send_response(200)
227 self.send_header('Content-type', 'text/plain')
228 self.end_headers()
229 self.wfile.write(b"File deleted successfully")
230 asyncio.run(notify_clients('delete_photo', {'filename': filename}))
231 else:
232 self.send_response(404)
233 self.send_header('Content-type', 'text/plain')
234 self.end_headers()
235 self.wfile.write(b"File not found")
236 except Exception as e:
237 logger.error(f"Error deleting file {filename}: {str(e)}")
238 self.send_response(500)
239 self.send_header('Content-type', 'text/plain')
240 self.end_headers()
241 self.wfile.write(b"Error deleting file")
242 else:
243 self.send_response(404)
244 self.end_headers()
245
246async def websocket_handler(websocket, path):
247 connected_clients.add(websocket)
248 try:
249 await websocket.wait_closed()
250 finally:
251 connected_clients.remove(websocket)
252
253async def notify_clients(action, data):
254 if connected_clients:
255 message = {
256 'action': action,
257 **data
258 }
259 await asyncio.gather(
260 *[client.send(json.dumps(message)) for client in connected_clients]
261 )
262
263def take_photo():
264 """
265 Captures a photo using the Raspberry Pi camera.
266
267 The photo is saved with a timestamp in the configured photo directory.
268 The camera is configured for still capture at the specified resolution.
269
270 Raises:
271 IOError: If there's an error accessing the camera or saving the file
272 """
273 try:
274 with Picamera2() as picam2:
275 config = picam2.create_still_configuration(main={"size": Config.PHOTO_RESOLUTION})
276 picam2.configure(config)
277 picam2.start()
278 time.sleep(Config.CAMERA_SETTLE_TIME)
279
280 timestamp = datetime.now().strftime("%Y-%m-%d_%H-%M-%S")
281 filename = f"photo_{timestamp}.jpg"
282 dithered_filename = f"dithered_{timestamp}.jpg"
283 filepath = os.path.join(Config.PHOTO_DIR, filename)
284 dithered_filepath = os.path.join(Config.PHOTO_DIR, dithered_filename)
285 logger.info(f"Taking photo: {filepath}")
286
287 picam2.capture_file(filepath)
288 logger.info("Photo taken successfully")
289
290 # Rotate the image using ImageMagick
291 os.system(f"convert {filepath} -rotate {Config.ROTATION} {filepath}")
292 logger.info("Photo rotated successfully")
293
294 # Create dithered version using ImageMagick
295 os.system(f"convert {filepath} -dither FloydSteinberg -define dither:diffusion-amount=100% -remap eink-4gray.png {dithered_filepath}")
296 logger.info("Dithered version created successfully")
297
298 # Notify websocket clients about both photos
299 asyncio.run(notify_clients('new_photo', {
300 'filename': dithered_filename,
301 'timestamp': timestamp
302 }))
303
304 except IOError as e:
305 logger.error(f"IO Error while taking photo: {str(e)}")
306 except Exception as e:
307 logger.error(f"Unexpected error while taking photo: {str(e)}")
308
309def run_server():
310 try:
311 handler = PhotoHandler
312 with socketserver.TCPServer(("", Config.WEB_PORT), handler) as httpd:
313 logger.info(f"Web server started at port {Config.WEB_PORT}")
314 httpd.serve_forever()
315 except Exception as e:
316 logger.error(f"Server error: {str(e)}")
317
318def cleanup():
319 try:
320 # Instead of getting/creating a new loop, we'll work with the running loop
321 loop = asyncio.get_running_loop()
322
323 # Create a new event loop for cleanup operations if needed
324 cleanup_loop = asyncio.new_event_loop()
325 asyncio.set_event_loop(cleanup_loop)
326
327 # Close all websocket connections
328 for websocket in connected_clients.copy():
329 cleanup_loop.run_until_complete(websocket.close())
330
331 # Cancel all tasks in the main loop
332 for task in asyncio.all_tasks(loop):
333 task.cancel()
334
335 cleanup_loop.close()
336
337 except RuntimeError:
338 # Handle case where there is no running loop
339 logger.info("No running event loop found during cleanup")
340 except Exception as e:
341 logger.error(f"Error during cleanup: {str(e)}")
342
343def main():
344 logger.info("Camera and web server starting")
345 server = None
346 ws_server = None
347 loop = None
348
349 try:
350 socketserver.TCPServer.allow_reuse_port = True
351
352 # Start HTTP server
353 server = socketserver.TCPServer(("", Config.WEB_PORT), PhotoHandler)
354 server_thread = threading.Thread(target=server.serve_forever, daemon=True)
355 server_thread.start()
356
357 # Create new event loop for websockets
358 loop = asyncio.new_event_loop()
359 asyncio.set_event_loop(loop)
360
361 # Start WebSocket server
362 ws_server = websockets.serve(websocket_handler, "0.0.0.0", Config.WS_PORT)
363 loop.run_until_complete(ws_server)
364 ws_thread = threading.Thread(
365 target=loop.run_forever,
366 daemon=True
367 )
368 ws_thread.start()
369
370 logger.info("Camera and web server started")
371
372 previous_state = GPIO.input(Config.BUTTON_PIN)
373 while True:
374 current_state = GPIO.input(Config.BUTTON_PIN)
375
376 if current_state == False and previous_state == True:
377 logger.info("Button press detected")
378 take_photo()
379 time.sleep(Config.DEBOUNCE_DELAY)
380
381 previous_state = current_state
382 time.sleep(Config.POLL_INTERVAL)
383
384 except KeyboardInterrupt:
385 logger.info("Program stopped by user")
386 except Exception as e:
387 logger.error(f"Unexpected error: {str(e)}")
388 finally:
389 if server:
390 server.shutdown()
391 server.server_close()
392 if loop:
393 loop.stop()
394 GPIO.cleanup()
395 logger.info("GPIO cleaned up")
396 cleanup()
397
398if __name__ == "__main__":
399 main()