nedo-vision-worker-core 0.2.0__py3-none-any.whl → 0.3.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.

Potentially problematic release.


This version of nedo-vision-worker-core might be problematic. Click here for more details.

Files changed (33) hide show
  1. nedo_vision_worker_core/__init__.py +47 -12
  2. nedo_vision_worker_core/callbacks/DetectionCallbackManager.py +306 -0
  3. nedo_vision_worker_core/callbacks/DetectionCallbackTypes.py +150 -0
  4. nedo_vision_worker_core/callbacks/__init__.py +27 -0
  5. nedo_vision_worker_core/cli.py +47 -5
  6. nedo_vision_worker_core/core_service.py +121 -55
  7. nedo_vision_worker_core/database/DatabaseManager.py +2 -2
  8. nedo_vision_worker_core/detection/BaseDetector.py +2 -1
  9. nedo_vision_worker_core/detection/DetectionManager.py +2 -2
  10. nedo_vision_worker_core/detection/RFDETRDetector.py +23 -5
  11. nedo_vision_worker_core/detection/YOLODetector.py +18 -5
  12. nedo_vision_worker_core/detection/detection_processing/DetectionProcessor.py +1 -1
  13. nedo_vision_worker_core/detection/detection_processing/HumanDetectionProcessor.py +57 -3
  14. nedo_vision_worker_core/detection/detection_processing/PPEDetectionProcessor.py +173 -10
  15. nedo_vision_worker_core/models/ai_model.py +23 -2
  16. nedo_vision_worker_core/pipeline/PipelineProcessor.py +51 -8
  17. nedo_vision_worker_core/pipeline/PipelineSyncThread.py +32 -0
  18. nedo_vision_worker_core/repositories/PPEDetectionRepository.py +18 -15
  19. nedo_vision_worker_core/repositories/RestrictedAreaRepository.py +17 -13
  20. nedo_vision_worker_core/services/SharedVideoStreamServer.py +276 -0
  21. nedo_vision_worker_core/services/VideoSharingDaemon.py +808 -0
  22. nedo_vision_worker_core/services/VideoSharingDaemonManager.py +257 -0
  23. nedo_vision_worker_core/streams/SharedVideoDeviceManager.py +383 -0
  24. nedo_vision_worker_core/streams/StreamSyncThread.py +16 -2
  25. nedo_vision_worker_core/streams/VideoStream.py +208 -246
  26. nedo_vision_worker_core/streams/VideoStreamManager.py +158 -6
  27. nedo_vision_worker_core/tracker/TrackerManager.py +25 -31
  28. nedo_vision_worker_core-0.3.0.dist-info/METADATA +444 -0
  29. {nedo_vision_worker_core-0.2.0.dist-info → nedo_vision_worker_core-0.3.0.dist-info}/RECORD +32 -25
  30. nedo_vision_worker_core-0.2.0.dist-info/METADATA +0 -347
  31. {nedo_vision_worker_core-0.2.0.dist-info → nedo_vision_worker_core-0.3.0.dist-info}/WHEEL +0 -0
  32. {nedo_vision_worker_core-0.2.0.dist-info → nedo_vision_worker_core-0.3.0.dist-info}/entry_points.txt +0 -0
  33. {nedo_vision_worker_core-0.2.0.dist-info → nedo_vision_worker_core-0.3.0.dist-info}/top_level.txt +0 -0
@@ -0,0 +1,808 @@
1
+ #!/usr/bin/env python3
2
+ """
3
+ Cross-Platform Video Sharing Daemon - A system-wide service that captures from a video device
4
+ and shares frames with multiple consumers across different processes.
5
+
6
+ Supports Windows (Named Pipes), Linux/macOS (Unix Domain Sockets), and fallback TCP.
7
+ """
8
+
9
+ import cv2
10
+ import numpy as np
11
+ import socket
12
+ import struct
13
+ import threading
14
+ import time
15
+ import logging
16
+ import json
17
+ import os
18
+ import sys
19
+ import platform
20
+ from pathlib import Path
21
+ import tempfile
22
+ from ..database.DatabaseManager import DatabaseManager
23
+ from abc import ABC, abstractmethod
24
+
25
+ # Platform-specific imports
26
+ if platform.system() == 'Windows':
27
+ try:
28
+ import win32pipe
29
+ import win32file
30
+ import win32api
31
+ import win32con
32
+ import pywintypes
33
+ WINDOWS_SUPPORT = True
34
+ except ImportError:
35
+ WINDOWS_SUPPORT = False
36
+ logging.warning("pywin32 not available, falling back to TCP sockets")
37
+ else:
38
+ import signal
39
+ WINDOWS_SUPPORT = False
40
+
41
+ logging.basicConfig(level=logging.INFO, format='%(asctime)s - %(levelname)s - %(message)s')
42
+
43
+
44
+ def get_storage_socket_path(device_index, storage_path=None):
45
+ """Get storage-based socket path for video sharing daemon with length optimization."""
46
+ try:
47
+ if storage_path:
48
+ # Use explicitly provided storage path
49
+ socket_dir = Path(storage_path) / "sockets"
50
+ elif DatabaseManager and hasattr(DatabaseManager, 'STORAGE_PATH') and DatabaseManager.STORAGE_PATH:
51
+ # Use storage path from DatabaseManager if available
52
+ socket_dir = DatabaseManager.STORAGE_PATH / "sockets"
53
+ else:
54
+ # Fallback to temp directory with short path
55
+ return Path(tempfile.gettempdir()) / f"nvw_vd{device_index}.sock"
56
+
57
+ socket_dir.mkdir(parents=True, exist_ok=True)
58
+ socket_path = socket_dir / f"vd{device_index}.sock"
59
+
60
+ # Check if the path is too long for Unix domain sockets (limit ~100-108 chars)
61
+ if len(str(socket_path)) > 100:
62
+ # Try to use a shorter path by using relative path from current directory
63
+ try:
64
+ relative_path = socket_path.relative_to(Path.cwd())
65
+ if len(str(relative_path)) < len(str(socket_path)):
66
+ socket_path = relative_path
67
+ except ValueError:
68
+ # If relative path doesn't work, use temp directory fallback
69
+ logging.warning(f"Socket path too long ({len(str(socket_path))} chars), using temp directory")
70
+ return Path(tempfile.gettempdir()) / f"nvw_vd{device_index}.sock"
71
+
72
+ return socket_path
73
+
74
+ except Exception as e:
75
+ logging.debug(f"Could not use storage path: {e}")
76
+ # Fallback to temp directory with short path
77
+ return Path(tempfile.gettempdir()) / f"nvw_vd{device_index}.sock"
78
+
79
+
80
+ class IPCBackend(ABC):
81
+ """Abstract base class for IPC backends."""
82
+
83
+ @abstractmethod
84
+ def create_server(self, address):
85
+ """Create and return a server socket/pipe."""
86
+ pass
87
+
88
+ @abstractmethod
89
+ def accept_client(self, server):
90
+ """Accept a client connection."""
91
+ pass
92
+
93
+ @abstractmethod
94
+ def connect_client(self, address):
95
+ """Connect as a client."""
96
+ pass
97
+
98
+ @abstractmethod
99
+ def send_data(self, connection, data):
100
+ """Send data through connection."""
101
+ pass
102
+
103
+ @abstractmethod
104
+ def receive_data(self, connection, size):
105
+ """Receive data from connection."""
106
+ pass
107
+
108
+ @abstractmethod
109
+ def close_connection(self, connection):
110
+ """Close a connection."""
111
+ pass
112
+
113
+ @abstractmethod
114
+ def close_server(self, server):
115
+ """Close server and cleanup."""
116
+ pass
117
+
118
+
119
+ class UnixSocketBackend(IPCBackend):
120
+ """Unix Domain Socket backend for Linux/macOS."""
121
+
122
+ def __init__(self):
123
+ self.socket_paths = {}
124
+
125
+ def create_server(self, address):
126
+ """Create Unix domain socket server."""
127
+ # Remove existing socket file
128
+ try:
129
+ os.unlink(address)
130
+ except OSError:
131
+ pass
132
+
133
+ server_socket = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
134
+ server_socket.bind(address)
135
+ server_socket.listen(10)
136
+ self.socket_paths[server_socket] = address
137
+ return server_socket
138
+
139
+ def accept_client(self, server):
140
+ """Accept client connection."""
141
+ return server.accept()[0]
142
+
143
+ def connect_client(self, address):
144
+ """Connect as client."""
145
+ client_socket = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
146
+ client_socket.connect(address)
147
+ return client_socket
148
+
149
+ def send_data(self, connection, data):
150
+ """Send data through socket."""
151
+ connection.sendall(data)
152
+
153
+ def receive_data(self, connection, size):
154
+ """Receive data from socket."""
155
+ data = b''
156
+ while len(data) < size:
157
+ chunk = connection.recv(size - len(data))
158
+ if not chunk:
159
+ return None
160
+ data += chunk
161
+ return data
162
+
163
+ def close_connection(self, connection):
164
+ """Close socket connection."""
165
+ try:
166
+ connection.close()
167
+ except:
168
+ pass
169
+
170
+ def close_server(self, server):
171
+ """Close server and cleanup socket file."""
172
+ try:
173
+ socket_path = self.socket_paths.get(server)
174
+ server.close()
175
+ if socket_path:
176
+ os.unlink(socket_path)
177
+ del self.socket_paths[server]
178
+ except:
179
+ pass
180
+
181
+
182
+ class NamedPipeBackend(IPCBackend):
183
+ """Named Pipe backend for Windows."""
184
+
185
+ def __init__(self):
186
+ self.pipe_names = {}
187
+
188
+ def create_server(self, address):
189
+ """Create named pipe server."""
190
+ pipe_name = f"\\\\.\\pipe\\{address}"
191
+ self.pipe_names[pipe_name] = address
192
+ return pipe_name
193
+
194
+ def accept_client(self, server):
195
+ """Accept client connection on named pipe."""
196
+ try:
197
+ # Create named pipe
198
+ pipe_handle = win32pipe.CreateNamedPipe(
199
+ server,
200
+ win32pipe.PIPE_ACCESS_DUPLEX,
201
+ win32pipe.PIPE_TYPE_BYTE | win32pipe.PIPE_READMODE_BYTE | win32pipe.PIPE_WAIT,
202
+ win32pipe.PIPE_UNLIMITED_INSTANCES,
203
+ 65536, # Output buffer size
204
+ 65536, # Input buffer size
205
+ 0, # Default timeout
206
+ None # Default security
207
+ )
208
+
209
+ # Wait for client connection
210
+ win32pipe.ConnectNamedPipe(pipe_handle, None)
211
+ return pipe_handle
212
+
213
+ except pywintypes.error as e:
214
+ logging.error(f"Error creating named pipe: {e}")
215
+ return None
216
+
217
+ def connect_client(self, address):
218
+ """Connect as client to named pipe."""
219
+ pipe_name = f"\\\\.\\pipe\\{address}"
220
+ try:
221
+ pipe_handle = win32file.CreateFile(
222
+ pipe_name,
223
+ win32file.GENERIC_READ | win32file.GENERIC_WRITE,
224
+ 0,
225
+ None,
226
+ win32file.OPEN_EXISTING,
227
+ 0,
228
+ None
229
+ )
230
+ return pipe_handle
231
+ except pywintypes.error as e:
232
+ logging.error(f"Error connecting to named pipe: {e}")
233
+ return None
234
+
235
+ def send_data(self, connection, data):
236
+ """Send data through named pipe."""
237
+ try:
238
+ win32file.WriteFile(connection, data)
239
+ except pywintypes.error as e:
240
+ raise ConnectionError(f"Failed to send data: {e}")
241
+
242
+ def receive_data(self, connection, size):
243
+ """Receive data from named pipe."""
244
+ try:
245
+ data = b''
246
+ while len(data) < size:
247
+ result, chunk = win32file.ReadFile(connection, size - len(data))
248
+ if not chunk:
249
+ return None
250
+ data += chunk
251
+ return data
252
+ except pywintypes.error as e:
253
+ logging.error(f"Error reading from named pipe: {e}")
254
+ return None
255
+
256
+ def close_connection(self, connection):
257
+ """Close named pipe connection."""
258
+ try:
259
+ win32file.CloseHandle(connection)
260
+ except:
261
+ pass
262
+
263
+ def close_server(self, server):
264
+ """Close named pipe server."""
265
+ # Named pipe servers are handled per-connection
266
+ pass
267
+
268
+
269
+ class TCPSocketBackend(IPCBackend):
270
+ """TCP Socket backend (fallback for all platforms)."""
271
+
272
+ def __init__(self):
273
+ self.base_port = 19000
274
+ self.port_map = {}
275
+
276
+ def create_server(self, address):
277
+ """Create TCP server socket."""
278
+ # Convert address to port number
279
+ port = self.base_port + hash(address) % 1000
280
+
281
+ server_socket = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
282
+ server_socket.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
283
+ server_socket.bind(('127.0.0.1', port))
284
+ server_socket.listen(10)
285
+
286
+ self.port_map[server_socket] = port
287
+ logging.info(f"TCP server listening on 127.0.0.1:{port}")
288
+ return server_socket
289
+
290
+ def accept_client(self, server):
291
+ """Accept client connection."""
292
+ return server.accept()[0]
293
+
294
+ def connect_client(self, address):
295
+ """Connect as TCP client."""
296
+ port = self.base_port + hash(address) % 1000
297
+ client_socket = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
298
+ client_socket.connect(('127.0.0.1', port))
299
+ return client_socket
300
+
301
+ def send_data(self, connection, data):
302
+ """Send data through TCP socket."""
303
+ connection.sendall(data)
304
+
305
+ def receive_data(self, connection, size):
306
+ """Receive data from TCP socket."""
307
+ data = b''
308
+ while len(data) < size:
309
+ chunk = connection.recv(size - len(data))
310
+ if not chunk:
311
+ return None
312
+ data += chunk
313
+ return data
314
+
315
+ def close_connection(self, connection):
316
+ """Close TCP connection."""
317
+ try:
318
+ connection.close()
319
+ except:
320
+ pass
321
+
322
+ def close_server(self, server):
323
+ """Close TCP server."""
324
+ try:
325
+ server.close()
326
+ if server in self.port_map:
327
+ del self.port_map[server]
328
+ except:
329
+ pass
330
+
331
+
332
+ class VideoSharingDaemon:
333
+ """Video sharing daemon with automatic platform detection."""
334
+
335
+ def __init__(self, device_index=0, backend=None, storage_path=None):
336
+ self.device_index = device_index
337
+ self.socket_path = get_storage_socket_path(device_index, storage_path)
338
+ self.address = str(self.socket_path) if backend != 'tcp' else f"video_share_device_{device_index}"
339
+ self.info_file = self.socket_path.parent / f"vd{device_index}_info.json"
340
+
341
+ # Select IPC backend
342
+ self.backend = self._select_backend(backend)
343
+
344
+ self.cap = None
345
+ self.running = False
346
+ self.clients = []
347
+ self.clients_lock = threading.Lock()
348
+
349
+ # Video properties
350
+ self.width = 640
351
+ self.height = 480
352
+ self.fps = 30.0
353
+
354
+ # Server
355
+ self.server = None
356
+
357
+ def _select_backend(self, backend=None):
358
+ """Select appropriate IPC backend."""
359
+ if backend:
360
+ return backend
361
+
362
+ system = platform.system()
363
+
364
+ if system == 'Windows' and WINDOWS_SUPPORT:
365
+ logging.info("🖥️ Using Named Pipes backend (Windows)")
366
+ return NamedPipeBackend()
367
+ elif system in ['Linux', 'Darwin'] and hasattr(socket, 'AF_UNIX'):
368
+ logging.info(f"🐧 Using Unix Domain Sockets backend ({system})")
369
+ return UnixSocketBackend()
370
+ else:
371
+ logging.info("🌐 Using TCP Socket backend (fallback)")
372
+ return TCPSocketBackend()
373
+
374
+ def start_daemon(self):
375
+ """Start the video sharing daemon."""
376
+ try:
377
+ # Check if daemon is already running
378
+ if self._is_daemon_running():
379
+ logging.error(f"Video sharing daemon for device {self.device_index} is already running")
380
+ return False
381
+
382
+ # Open video device
383
+ self.cap = cv2.VideoCapture(self.device_index)
384
+ if not self.cap.isOpened():
385
+ logging.error(f"Cannot open video device {self.device_index}")
386
+ return False
387
+
388
+ # Get device properties
389
+ self.width = int(self.cap.get(cv2.CAP_PROP_FRAME_WIDTH))
390
+ self.height = int(self.cap.get(cv2.CAP_PROP_FRAME_HEIGHT))
391
+ self.fps = float(self.cap.get(cv2.CAP_PROP_FPS))
392
+
393
+ if self.fps <= 0:
394
+ self.fps = 30.0
395
+
396
+ logging.info(f"📹 Device {self.device_index} opened: {self.width}x{self.height} @ {self.fps}fps")
397
+
398
+ # Create server
399
+ self.server = self.backend.create_server(self.address)
400
+
401
+ # Write daemon info file
402
+ self._write_daemon_info()
403
+
404
+ # Start capture and server threads
405
+ self.running = True
406
+
407
+ capture_thread = threading.Thread(target=self._capture_loop, daemon=True)
408
+ server_thread = threading.Thread(target=self._server_loop, daemon=True)
409
+
410
+ capture_thread.start()
411
+ server_thread.start()
412
+
413
+ logging.info(f"🚀 Video sharing daemon started for device {self.device_index}")
414
+
415
+ # Setup shutdown handlers
416
+ self._setup_shutdown_handlers()
417
+
418
+ # Keep daemon running
419
+ while self.running:
420
+ time.sleep(1)
421
+
422
+ return True
423
+
424
+ except Exception as e:
425
+ logging.error(f"Failed to start daemon: {e}")
426
+ self.stop_daemon()
427
+ return False
428
+
429
+ def _setup_shutdown_handlers(self):
430
+ """Setup platform-appropriate shutdown handlers."""
431
+ try:
432
+ if platform.system() == 'Windows':
433
+ # Windows console control handler
434
+ if WINDOWS_SUPPORT:
435
+ try:
436
+ def ctrl_handler(ctrl_type):
437
+ if ctrl_type in (win32con.CTRL_C_EVENT, win32con.CTRL_CLOSE_EVENT):
438
+ logging.info("Received shutdown signal...")
439
+ self.stop_daemon()
440
+ return True
441
+ return False
442
+
443
+ win32api.SetConsoleCtrlHandler(ctrl_handler, True)
444
+ logging.info("📡 Windows console control handler registered")
445
+ except Exception as e:
446
+ logging.warning(f"Could not set up Windows control handler: {e}")
447
+ else:
448
+ # Unix signal handlers (only in main thread)
449
+ if threading.current_thread() is threading.main_thread():
450
+ signal.signal(signal.SIGTERM, self._signal_handler)
451
+ signal.signal(signal.SIGINT, self._signal_handler)
452
+ logging.info("📡 Signal handlers registered (main thread)")
453
+ else:
454
+ logging.info("⚠️ Skipping signal handlers (not main thread)")
455
+ except Exception as e:
456
+ logging.warning(f"⚠️ Could not set up shutdown handlers: {e}")
457
+
458
+ def _signal_handler(self, signum, frame):
459
+ """Handle Unix signals."""
460
+ logging.info(f"Received signal {signum}, shutting down...")
461
+ self.stop_daemon()
462
+
463
+ def _is_daemon_running(self):
464
+ """Check if daemon is already running for this device."""
465
+ try:
466
+ if self.info_file.exists():
467
+ with open(self.info_file, 'r') as f:
468
+ info = json.load(f)
469
+
470
+ pid = info.get('pid')
471
+ if pid:
472
+ try:
473
+ if platform.system() == 'Windows':
474
+ # Windows process check
475
+ import subprocess
476
+ result = subprocess.run(['tasklist', '/FI', f'PID eq {pid}'],
477
+ capture_output=True, text=True)
478
+ return str(pid) in result.stdout
479
+ else:
480
+ # Unix process check
481
+ os.kill(pid, 0)
482
+ return True
483
+ except (OSError, ProcessLookupError, subprocess.SubprocessError):
484
+ # Process is dead, clean up
485
+ self.info_file.unlink()
486
+ except Exception:
487
+ pass
488
+ return False
489
+
490
+ def _write_daemon_info(self):
491
+ """Write daemon information to file."""
492
+ info = {
493
+ 'device_index': self.device_index,
494
+ 'pid': os.getpid(),
495
+ 'address': self.address,
496
+ 'backend': self.backend.__class__.__name__,
497
+ 'platform': platform.system(),
498
+ 'width': self.width,
499
+ 'height': self.height,
500
+ 'fps': self.fps,
501
+ 'started_at': time.time()
502
+ }
503
+
504
+ with open(self.info_file, 'w') as f:
505
+ json.dump(info, f)
506
+
507
+ def _server_loop(self):
508
+ """Accept and handle client connections."""
509
+ while self.running:
510
+ try:
511
+ client = self.backend.accept_client(self.server)
512
+ if client:
513
+ logging.info(f"📱 New client connected")
514
+
515
+ with self.clients_lock:
516
+ self.clients.append(client)
517
+
518
+ # Handle client in separate thread
519
+ client_thread = threading.Thread(
520
+ target=self._handle_client,
521
+ args=(client,),
522
+ daemon=True
523
+ )
524
+ client_thread.start()
525
+
526
+ except Exception as e:
527
+ if self.running:
528
+ logging.error(f"Error accepting client: {e}")
529
+
530
+ def _handle_client(self, client):
531
+ """Handle individual client connection."""
532
+ try:
533
+ while self.running:
534
+ # Keep connection alive
535
+ time.sleep(1)
536
+ except Exception as e:
537
+ logging.warning(f"Client disconnected: {e}")
538
+ finally:
539
+ with self.clients_lock:
540
+ if client in self.clients:
541
+ self.clients.remove(client)
542
+ self.backend.close_connection(client)
543
+
544
+ def _capture_loop(self):
545
+ """Main video capture loop."""
546
+ frame_interval = 1.0 / self.fps
547
+ last_frame_time = 0
548
+
549
+ while self.running:
550
+ try:
551
+ current_time = time.time()
552
+
553
+ # Throttle frame rate
554
+ if current_time - last_frame_time < frame_interval:
555
+ time.sleep(0.001)
556
+ continue
557
+
558
+ ret, frame = self.cap.read()
559
+ if not ret:
560
+ logging.warning("Failed to read frame")
561
+ time.sleep(0.1)
562
+ continue
563
+
564
+ # Broadcast frame to all clients
565
+ self._broadcast_frame(frame)
566
+
567
+ last_frame_time = current_time
568
+
569
+ except Exception as e:
570
+ logging.error(f"Error in capture loop: {e}")
571
+ time.sleep(0.1)
572
+
573
+ def _broadcast_frame(self, frame):
574
+ """Broadcast frame to all connected clients."""
575
+ if not self.clients:
576
+ return
577
+
578
+ try:
579
+ # Serialize frame
580
+ frame_data = cv2.imencode('.jpg', frame, [cv2.IMWRITE_JPEG_QUALITY, 85])[1].tobytes()
581
+ frame_size = len(frame_data)
582
+
583
+ # Prepare message: [size][width][height][timestamp][frame_data]
584
+ message = struct.pack('!IIIf', frame_size, self.width, self.height, time.time())
585
+ message += frame_data
586
+
587
+ with self.clients_lock:
588
+ dead_clients = []
589
+
590
+ for client in self.clients:
591
+ try:
592
+ self.backend.send_data(client, message)
593
+ except Exception as e:
594
+ dead_clients.append(client)
595
+
596
+ # Remove dead clients
597
+ for client in dead_clients:
598
+ self.clients.remove(client)
599
+ self.backend.close_connection(client)
600
+
601
+ except Exception as e:
602
+ logging.error(f"Error broadcasting frame: {e}")
603
+
604
+ def stop_daemon(self):
605
+ """Stop the video sharing daemon."""
606
+ logging.info("🛑 Stopping video sharing daemon...")
607
+
608
+ self.running = False
609
+
610
+ # Close all client connections
611
+ with self.clients_lock:
612
+ for client in self.clients:
613
+ self.backend.close_connection(client)
614
+ self.clients.clear()
615
+
616
+ # Close server
617
+ if self.server:
618
+ self.backend.close_server(self.server)
619
+
620
+ # Close video capture
621
+ if self.cap:
622
+ self.cap.release()
623
+
624
+ # Clean up info file
625
+ try:
626
+ self.info_file.unlink()
627
+ except:
628
+ pass
629
+
630
+ logging.info("✅ Video sharing daemon stopped")
631
+
632
+
633
+ class VideoSharingClient:
634
+ """Video sharing client with automatic platform detection."""
635
+
636
+ def __init__(self, device_index=0, backend=None, storage_path=None):
637
+ self.device_index = device_index
638
+ self.socket_path = get_storage_socket_path(device_index, storage_path)
639
+ self.address = str(self.socket_path) if backend != 'tcp' else f"video_share_device_{device_index}"
640
+ self.info_file = self.socket_path.parent / f"vd{device_index}_info.json"
641
+
642
+ # Backend will be determined from daemon info
643
+ self.backend = backend
644
+ self.connection = None
645
+ self.running = False
646
+ self.frame_callback = None
647
+
648
+ # Device properties
649
+ self.width = 640
650
+ self.height = 480
651
+ self.fps = 30.0
652
+
653
+ def connect(self, frame_callback):
654
+ """Connect to video sharing daemon."""
655
+ try:
656
+ # Check if daemon is running and get info
657
+ if not self._load_daemon_info():
658
+ logging.error(f"Video sharing daemon for device {self.device_index} is not running")
659
+ return False
660
+
661
+ # Create appropriate backend if not provided
662
+ if not self.backend:
663
+ self.backend = self._create_backend_from_info()
664
+
665
+ # Connect to daemon
666
+ self.connection = self.backend.connect_client(self.address)
667
+ if not self.connection:
668
+ return False
669
+
670
+ self.frame_callback = frame_callback
671
+ self.running = True
672
+
673
+ # Start receiving thread
674
+ receive_thread = threading.Thread(target=self._receive_loop, daemon=True)
675
+ receive_thread.start()
676
+
677
+ logging.info(f"📱 Connected to video sharing daemon for device {self.device_index}")
678
+ return True
679
+
680
+ except Exception as e:
681
+ logging.error(f"Failed to connect to daemon: {e}")
682
+ return False
683
+
684
+ def _load_daemon_info(self):
685
+ """Load daemon information from file."""
686
+ try:
687
+ if not self.info_file.exists():
688
+ return False
689
+
690
+ with open(self.info_file, 'r') as f:
691
+ info = json.load(f)
692
+
693
+ self.width = info['width']
694
+ self.height = info['height']
695
+ self.fps = info['fps']
696
+ self.daemon_platform = info.get('platform', platform.system())
697
+ self.daemon_backend = info.get('backend', 'UnixSocketBackend')
698
+
699
+ return True
700
+
701
+ except Exception as e:
702
+ logging.error(f"Error loading daemon info: {e}")
703
+ return False
704
+
705
+ def _create_backend_from_info(self):
706
+ """Create backend based on daemon info."""
707
+ if self.daemon_backend == 'NamedPipeBackend':
708
+ return NamedPipeBackend()
709
+ elif self.daemon_backend == 'UnixSocketBackend':
710
+ return UnixSocketBackend()
711
+ else:
712
+ return TCPSocketBackend()
713
+
714
+ def _receive_loop(self):
715
+ """Receive frames from daemon."""
716
+ while self.running:
717
+ try:
718
+ # Receive header: [size][width][height][timestamp]
719
+ header = self.backend.receive_data(self.connection, 16) # 4 + 4 + 4 + 4 bytes
720
+ if not header:
721
+ break
722
+
723
+ frame_size, width, height, timestamp = struct.unpack('!IIIf', header)
724
+
725
+ # Receive frame data
726
+ frame_data = self.backend.receive_data(self.connection, frame_size)
727
+ if not frame_data:
728
+ break
729
+
730
+ # Decode frame
731
+ frame_array = np.frombuffer(frame_data, dtype=np.uint8)
732
+ frame = cv2.imdecode(frame_array, cv2.IMREAD_COLOR)
733
+
734
+ if frame is not None and self.frame_callback:
735
+ self.frame_callback(frame, timestamp)
736
+
737
+ except Exception as e:
738
+ if self.running:
739
+ logging.error(f"Error receiving frame: {e}")
740
+ break
741
+
742
+ def disconnect(self):
743
+ """Disconnect from daemon."""
744
+ self.running = False
745
+ if self.connection and self.backend:
746
+ self.backend.close_connection(self.connection)
747
+ logging.info("📱 Disconnected from video sharing daemon")
748
+
749
+ def get_device_properties(self):
750
+ """Get device properties."""
751
+ return self.width, self.height, self.fps, "bgr24"
752
+
753
+
754
+ def main():
755
+ """Main function to run daemon or test client."""
756
+ import argparse
757
+
758
+ parser = argparse.ArgumentParser(description='Video Sharing Daemon')
759
+ parser.add_argument('--device', type=int, default=0, help='Video device index')
760
+ parser.add_argument('--daemon', action='store_true', help='Run as daemon')
761
+ parser.add_argument('--test-client', action='store_true', help='Test client mode')
762
+ parser.add_argument('--backend', choices=['unix', 'namedpipe', 'tcp'],
763
+ help='Force specific backend')
764
+
765
+ args = parser.parse_args()
766
+
767
+ # Create backend if specified
768
+ backend = None
769
+ if args.backend:
770
+ if args.backend == 'unix':
771
+ backend = UnixSocketBackend()
772
+ elif args.backend == 'namedpipe':
773
+ backend = NamedPipeBackend()
774
+ elif args.backend == 'tcp':
775
+ backend = TCPSocketBackend()
776
+
777
+ if args.daemon:
778
+ # Run as daemon
779
+ daemon = VideoSharingDaemon(args.device, backend)
780
+ daemon.start_daemon()
781
+ elif args.test_client:
782
+ # Test client
783
+ def on_frame(frame, timestamp):
784
+ print(f"📹 Received frame: {frame.shape}, timestamp: {timestamp}")
785
+
786
+ client = VideoSharingClient(args.device, backend)
787
+ if client.connect(on_frame):
788
+ try:
789
+ while True:
790
+ time.sleep(1)
791
+ except KeyboardInterrupt:
792
+ client.disconnect()
793
+ else:
794
+ system = platform.system()
795
+ backend_info = "Named Pipes" if system == 'Windows' and WINDOWS_SUPPORT else \
796
+ "Unix Domain Sockets" if hasattr(socket, 'AF_UNIX') else "TCP Sockets"
797
+
798
+ print(f"Video Sharing Daemon")
799
+ print(f"Platform: {system}")
800
+ print(f"IPC Backend: {backend_info}")
801
+ print(f"Usage: python {sys.argv[0]} --daemon OR --test-client")
802
+
803
+ if system == 'Windows' and not WINDOWS_SUPPORT:
804
+ print("Note: Install pywin32 for optimal Windows support (pip install pywin32)")
805
+
806
+
807
+ if __name__ == "__main__":
808
+ main()