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