This repository has no description
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()