an eink camera running on an rpi zero 2 w
at main 15 kB view raw
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()