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