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