This repository has no description
0

Configure Feed

Select the types of activity you want to include in your feed.

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