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