nedo-vision-worker-core 0.2.0__py3-none-any.whl → 0.3.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.

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 +24 -34
  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 +299 -14
  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 +267 -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.1.dist-info/METADATA +444 -0
  29. {nedo_vision_worker_core-0.2.0.dist-info → nedo_vision_worker_core-0.3.1.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.1.dist-info}/WHEEL +0 -0
  32. {nedo_vision_worker_core-0.2.0.dist-info → nedo_vision_worker_core-0.3.1.dist-info}/entry_points.txt +0 -0
  33. {nedo_vision_worker_core-0.2.0.dist-info → nedo_vision_worker_core-0.3.1.dist-info}/top_level.txt +0 -0
@@ -0,0 +1,257 @@
1
+ """
2
+ Video Sharing Daemon Manager for automatic daemon lifecycle management.
3
+ """
4
+
5
+ import logging
6
+ import threading
7
+ import time
8
+ import os
9
+ import json
10
+ from pathlib import Path
11
+ import tempfile
12
+ from typing import Dict, Optional, Set
13
+ from .VideoSharingDaemon import VideoSharingDaemon
14
+ from ..database.DatabaseManager import DatabaseManager
15
+
16
+
17
+ class VideoSharingDaemonManager:
18
+ """
19
+ Manages video sharing daemons for multiple devices automatically.
20
+ Integrates with CoreService to start/stop daemons as needed.
21
+ """
22
+
23
+ _instance = None
24
+ _lock = threading.Lock()
25
+
26
+ def __new__(cls):
27
+ if cls._instance is None:
28
+ with cls._lock:
29
+ if cls._instance is None:
30
+ cls._instance = super().__new__(cls)
31
+ return cls._instance
32
+
33
+ def __init__(self):
34
+ if hasattr(self, '_initialized'):
35
+ return
36
+
37
+ self._initialized = True
38
+ self.daemons = {} # Dict[int, VideoSharingDaemon]
39
+ self.daemon_threads: Dict[int, threading.Thread] = {}
40
+ self.managed_devices: Set[int] = set()
41
+ self.running = False
42
+ self.auto_start_enabled = True
43
+
44
+ logging.info("🔗 VideoSharingDaemonManager initialized")
45
+
46
+ def enable_auto_start(self, enabled: bool = True):
47
+ """Enable or disable automatic daemon starting."""
48
+ self.auto_start_enabled = enabled
49
+ logging.info(f"📋 Auto-start video sharing daemons: {'enabled' if enabled else 'disabled'}")
50
+
51
+ def is_daemon_running(self, device_index: int) -> bool:
52
+ """Check if daemon is running for a specific device."""
53
+ if not VideoSharingDaemon:
54
+ return False
55
+
56
+ try:
57
+ # Get storage path from DatabaseManager
58
+ storage_path = None
59
+ if DatabaseManager and hasattr(DatabaseManager, 'STORAGE_PATH') and DatabaseManager.STORAGE_PATH:
60
+ storage_path = str(DatabaseManager.STORAGE_PATH)
61
+
62
+ # Use the same socket path logic as the daemon
63
+ from nedo_vision_worker_core.services.VideoSharingDaemon import get_storage_socket_path
64
+ socket_path = get_storage_socket_path(device_index, storage_path)
65
+ info_file = socket_path.parent / f"vd{device_index}_info.json"
66
+
67
+ if info_file.exists():
68
+ with open(info_file, 'r') as f:
69
+ info = json.load(f)
70
+
71
+ pid = info.get('pid')
72
+ if pid:
73
+ try:
74
+ # Cross-platform process check
75
+ import platform
76
+ if platform.system() == 'Windows':
77
+ import subprocess
78
+ result = subprocess.run(['tasklist', '/FI', f'PID eq {pid}'],
79
+ capture_output=True, text=True)
80
+ return str(pid) in result.stdout
81
+ else:
82
+ os.kill(pid, 0) # Check if process exists
83
+ return True
84
+ except (OSError, ProcessLookupError, subprocess.SubprocessError):
85
+ # Process is dead, clean up
86
+ info_file.unlink()
87
+ except Exception:
88
+ pass
89
+ return False
90
+
91
+ def start_daemon_for_device(self, device_index: int) -> bool:
92
+ """Start a video sharing daemon for a specific device."""
93
+ if not VideoSharingDaemon:
94
+ logging.warning(f"Video sharing daemon not available, cannot start daemon for device {device_index}")
95
+ return False
96
+
97
+ if device_index in self.daemons:
98
+ logging.info(f"📹 Daemon for device {device_index} already managed")
99
+ return True
100
+
101
+ # Check if external daemon is already running
102
+ if self.is_daemon_running(device_index):
103
+ logging.info(f"📹 External daemon for device {device_index} already running")
104
+ return True
105
+
106
+ try:
107
+ # Get storage path from DatabaseManager
108
+ storage_path = None
109
+ if DatabaseManager and hasattr(DatabaseManager, 'STORAGE_PATH') and DatabaseManager.STORAGE_PATH:
110
+ storage_path = str(DatabaseManager.STORAGE_PATH)
111
+
112
+ # Create daemon instance
113
+ daemon = VideoSharingDaemon(device_index, storage_path=storage_path)
114
+
115
+ # Start daemon in separate thread
116
+ def daemon_runner():
117
+ try:
118
+ logging.info(f"🚀 Starting video sharing daemon for device {device_index}")
119
+ daemon.start_daemon()
120
+ except Exception as e:
121
+ logging.error(f"❌ Error running daemon for device {device_index}: {e}")
122
+ finally:
123
+ # Clean up when daemon stops
124
+ with self._lock:
125
+ if device_index in self.daemons:
126
+ del self.daemons[device_index]
127
+ if device_index in self.daemon_threads:
128
+ del self.daemon_threads[device_index]
129
+ if device_index in self.managed_devices:
130
+ self.managed_devices.remove(device_index)
131
+
132
+ daemon_thread = threading.Thread(
133
+ target=daemon_runner,
134
+ name=f"VideoSharingDaemon-{device_index}",
135
+ daemon=True
136
+ )
137
+
138
+ # Store references
139
+ self.daemons[device_index] = daemon
140
+ self.daemon_threads[device_index] = daemon_thread
141
+ self.managed_devices.add(device_index)
142
+
143
+ # Start the daemon
144
+ daemon_thread.start()
145
+
146
+ # Give daemon time to start
147
+ time.sleep(1)
148
+
149
+ # Verify daemon started successfully
150
+ if self.is_daemon_running(device_index):
151
+ logging.info(f"✅ Video sharing daemon started for device {device_index}")
152
+ return True
153
+ else:
154
+ logging.error(f"❌ Failed to start video sharing daemon for device {device_index}")
155
+ self.stop_daemon_for_device(device_index)
156
+ return False
157
+
158
+ except Exception as e:
159
+ logging.error(f"❌ Error starting daemon for device {device_index}: {e}")
160
+ return False
161
+
162
+ def stop_daemon_for_device(self, device_index: int):
163
+ """Stop the video sharing daemon for a specific device."""
164
+ try:
165
+ if device_index in self.daemons:
166
+ daemon = self.daemons[device_index]
167
+ logging.info(f"🛑 Stopping video sharing daemon for device {device_index}")
168
+ daemon.stop_daemon()
169
+
170
+ # Wait for thread to finish
171
+ if device_index in self.daemon_threads:
172
+ thread = self.daemon_threads[device_index]
173
+ thread.join(timeout=5)
174
+
175
+ # Clean up references
176
+ if device_index in self.daemons:
177
+ del self.daemons[device_index]
178
+ if device_index in self.daemon_threads:
179
+ del self.daemon_threads[device_index]
180
+ if device_index in self.managed_devices:
181
+ self.managed_devices.remove(device_index)
182
+
183
+ logging.info(f"✅ Video sharing daemon stopped for device {device_index}")
184
+
185
+ except Exception as e:
186
+ logging.error(f"❌ Error stopping daemon for device {device_index}: {e}")
187
+
188
+ def ensure_daemon_for_device(self, device_index: int) -> bool:
189
+ """
190
+ Ensure a daemon is running for the device.
191
+ Start one if auto-start is enabled and none is running.
192
+ """
193
+ if not self.auto_start_enabled:
194
+ return self.is_daemon_running(device_index)
195
+
196
+ # Check if daemon is already running (managed or external)
197
+ if self.is_daemon_running(device_index):
198
+ return True
199
+
200
+ # Try to start our own daemon
201
+ return self.start_daemon_for_device(device_index)
202
+
203
+ def start_all_managed_daemons(self):
204
+ """Start daemons for all currently managed devices."""
205
+ self.running = True
206
+ for device_index in list(self.managed_devices):
207
+ if not self.is_daemon_running(device_index):
208
+ self.start_daemon_for_device(device_index)
209
+
210
+ def stop_all_daemons(self):
211
+ """Stop all managed video sharing daemons."""
212
+ logging.info("🛑 Stopping all video sharing daemons...")
213
+ self.running = False
214
+
215
+ # Stop all managed daemons
216
+ device_indices = list(self.managed_devices)
217
+ for device_index in device_indices:
218
+ self.stop_daemon_for_device(device_index)
219
+
220
+ logging.info("✅ All video sharing daemons stopped")
221
+
222
+ def get_daemon_status(self) -> Dict[int, Dict]:
223
+ """Get status of all managed daemons."""
224
+ status = {}
225
+
226
+ for device_index in self.managed_devices:
227
+ is_running = self.is_daemon_running(device_index)
228
+ thread_alive = device_index in self.daemon_threads and self.daemon_threads[device_index].is_alive()
229
+
230
+ status[device_index] = {
231
+ 'managed': True,
232
+ 'daemon_running': is_running,
233
+ 'thread_alive': thread_alive,
234
+ 'auto_start': self.auto_start_enabled
235
+ }
236
+
237
+ return status
238
+
239
+ def get_device_info(self, device_index: int) -> Optional[Dict]:
240
+ """Get device information from daemon if available."""
241
+ try:
242
+ info_file = Path(tempfile.gettempdir()) / f"video_device_{device_index}_info.json"
243
+ if info_file.exists():
244
+ with open(info_file, 'r') as f:
245
+ return json.load(f)
246
+ except Exception:
247
+ pass
248
+ return None
249
+
250
+
251
+ # Singleton instance
252
+ daemon_manager = VideoSharingDaemonManager()
253
+
254
+
255
+ def get_daemon_manager() -> VideoSharingDaemonManager:
256
+ """Get the singleton daemon manager instance."""
257
+ return daemon_manager
@@ -0,0 +1,383 @@
1
+ import logging
2
+ import threading
3
+ import time
4
+ import cv2
5
+ from typing import Dict, Optional, Callable
6
+ from enum import Enum
7
+ from .VideoStream import VideoStream
8
+ from ..services.SharedVideoStreamServer import get_shared_stream_server
9
+ from ..services.VideoSharingDaemonManager import get_daemon_manager
10
+
11
+ try:
12
+ from nedo_vision_worker_core.services.VideoSharingDaemon import VideoSharingClient
13
+ except ImportError:
14
+ logging.warning("Video sharing daemon not available, falling back to SharedVideoStreamServer")
15
+ VideoSharingClient = None
16
+
17
+ try:
18
+ from ..database.DatabaseManager import DatabaseManager
19
+ except ImportError:
20
+ DatabaseManager = None
21
+
22
+
23
+ class DeviceAccessMode(Enum):
24
+ EXCLUSIVE = "exclusive"
25
+ SHARED = "shared"
26
+
27
+
28
+ class SharedVideoDeviceManager:
29
+ """
30
+ Manages shared access to direct video devices across multiple services.
31
+ Prevents 'device busy' errors by implementing a device sharing mechanism.
32
+ """
33
+
34
+ _instance = None
35
+ _lock = threading.Lock()
36
+
37
+ def __new__(cls):
38
+ if cls._instance is None:
39
+ with cls._lock:
40
+ if cls._instance is None:
41
+ cls._instance = super().__new__(cls)
42
+ return cls._instance
43
+
44
+ def __init__(self):
45
+ if hasattr(self, '_initialized'):
46
+ return
47
+
48
+ self._initialized = True
49
+ self.device_streams: Dict[int, VideoStream] = {} # device_index -> VideoStream (legacy)
50
+ self.device_subscribers: Dict[int, Dict[str, Callable]] = {} # device_index -> {subscriber_id: callback}
51
+ self.device_locks: Dict[int, threading.Lock] = {}
52
+ self.device_access_counts: Dict[int, int] = {}
53
+ self.main_lock = threading.Lock()
54
+
55
+ # Video sharing clients for cross-process access
56
+ self.video_clients = {} # device_index -> Video sharing client
57
+ self._use_video_sharing = VideoSharingClient is not None
58
+
59
+ if self._use_video_sharing:
60
+ logging.info("🔗 SharedVideoDeviceManager initialized with cross-process video sharing support")
61
+ else:
62
+ logging.info("⚠️ SharedVideoDeviceManager initialized with SharedVideoStreamServer fallback")
63
+
64
+ logging.info("SharedVideoDeviceManager initialized")
65
+
66
+ def _is_direct_device(self, source) -> tuple:
67
+ """Check if source is a direct video device and return device index."""
68
+ if isinstance(source, int):
69
+ return True, source
70
+ elif isinstance(source, str) and source.isdigit():
71
+ return True, int(source)
72
+ elif isinstance(source, str) and source.startswith('/dev/video'):
73
+ try:
74
+ device_index = int(source.replace('/dev/video', ''))
75
+ return True, device_index
76
+ except ValueError:
77
+ pass
78
+ return False, None
79
+
80
+ def subscribe_to_device(self, source, subscriber_id: str, callback: Callable[[cv2.Mat], None]) -> bool:
81
+ """
82
+ Subscribe to a direct video device.
83
+
84
+ Args:
85
+ source: Video source (device index or path)
86
+ subscriber_id: Unique identifier for the subscriber
87
+ callback: Function to call with new frames
88
+
89
+ Returns:
90
+ bool: True if subscription successful, False otherwise
91
+ """
92
+ is_device, device_index = self._is_direct_device(source)
93
+
94
+ if not is_device:
95
+ logging.warning(f"Source {source} is not a direct video device")
96
+ return False
97
+
98
+ with self.main_lock:
99
+ # Initialize device if not exists
100
+ if device_index not in self.device_streams:
101
+ if not self._initialize_device(device_index):
102
+ return False
103
+
104
+ # Add subscriber
105
+ if device_index not in self.device_subscribers:
106
+ self.device_subscribers[device_index] = {}
107
+
108
+ self.device_subscribers[device_index][subscriber_id] = callback
109
+ self.device_access_counts[device_index] = self.device_access_counts.get(device_index, 0) + 1
110
+
111
+ logging.info(f"Subscriber {subscriber_id} added to device {device_index}. "
112
+ f"Total subscribers: {self.device_access_counts[device_index]}")
113
+
114
+ return True
115
+
116
+ def unsubscribe_from_device(self, source, subscriber_id: str) -> bool:
117
+ """
118
+ Unsubscribe from a direct video device.
119
+
120
+ Args:
121
+ source: Video source (device index or path)
122
+ subscriber_id: Unique identifier for the subscriber
123
+
124
+ Returns:
125
+ bool: True if unsubscription successful, False otherwise
126
+ """
127
+ is_device, device_index = self._is_direct_device(source)
128
+
129
+ if not is_device:
130
+ return False
131
+
132
+ with self.main_lock:
133
+ if device_index not in self.device_subscribers:
134
+ return False
135
+
136
+ if subscriber_id in self.device_subscribers[device_index]:
137
+ del self.device_subscribers[device_index][subscriber_id]
138
+ self.device_access_counts[device_index] -= 1
139
+
140
+ logging.info(f"Subscriber {subscriber_id} removed from device {device_index}. "
141
+ f"Remaining subscribers: {self.device_access_counts[device_index]}")
142
+
143
+ # Clean up device if no more subscribers
144
+ if self.device_access_counts[device_index] <= 0:
145
+ self._cleanup_device(device_index)
146
+
147
+ return True
148
+
149
+ return False
150
+
151
+ def _initialize_device(self, device_index: int) -> bool:
152
+ """Initialize a direct video device with cross-process video sharing."""
153
+ try:
154
+ if self._use_video_sharing:
155
+ return self._initialize_device_with_video_sharing(device_index)
156
+ else:
157
+ return self._initialize_device_with_shared_server(device_index)
158
+ except Exception as e:
159
+ logging.error(f"❌ Failed to initialize device {device_index}: {e}")
160
+ return False
161
+
162
+ def _initialize_device_with_video_sharing(self, device_index: int) -> bool:
163
+ """Initialize device using VideoSharingClient for true cross-process access."""
164
+ try:
165
+ logging.info(f"Initializing cross-process access to video device {device_index}")
166
+
167
+ # Ensure daemon is running for this device
168
+ daemon_manager = get_daemon_manager()
169
+ if not daemon_manager.ensure_daemon_for_device(device_index):
170
+ logging.error(f"❌ Failed to ensure video sharing daemon for device {device_index}")
171
+ return False
172
+
173
+ # Get storage path from DatabaseManager
174
+ storage_path = None
175
+ if DatabaseManager and hasattr(DatabaseManager, 'STORAGE_PATH') and DatabaseManager.STORAGE_PATH:
176
+ storage_path = str(DatabaseManager.STORAGE_PATH)
177
+
178
+ # Create video sharing client
179
+ video_client = VideoSharingClient(device_index, storage_path=storage_path)
180
+
181
+ # Connect to the video sharing daemon
182
+ if not video_client.connect(lambda frame, timestamp: self._on_frame_received(device_index, frame, timestamp)):
183
+ logging.error(f"❌ Failed to connect to video sharing daemon for device {device_index}")
184
+ logging.info("💡 Daemon should be auto-started, but connection failed")
185
+ return False
186
+
187
+ # Store video client
188
+ self.video_clients[device_index] = video_client
189
+ self.device_locks[device_index] = threading.Lock()
190
+
191
+ # Store device info
192
+ self.device_streams[device_index] = {
193
+ 'video_client': video_client,
194
+ 'type': 'video_sharing'
195
+ }
196
+
197
+ logging.info(f"✅ Successfully initialized cross-process access to device {device_index}")
198
+ return True
199
+
200
+ except Exception as e:
201
+ logging.error(f"❌ Failed to initialize cross-process access for device {device_index}: {e}")
202
+ return False
203
+
204
+ def _initialize_device_with_shared_server(self, device_index: int) -> bool:
205
+ """Initialize device using SharedVideoStreamServer (fallback method)."""
206
+ try:
207
+ logging.info(f"Initializing shared access to video device {device_index}")
208
+
209
+ # Use shared stream server instead of exclusive device access
210
+ shared_server = get_shared_stream_server(device_index)
211
+
212
+ # Add this manager as a consumer
213
+ consumer_id = shared_server.add_consumer(
214
+ lambda frame, timestamp: self._on_frame_received(device_index, frame, timestamp),
215
+ f"worker-core-{device_index}"
216
+ )
217
+
218
+ # Store server reference and consumer ID
219
+ self.device_streams[device_index] = {
220
+ 'shared_server': shared_server,
221
+ 'consumer_id': consumer_id,
222
+ 'type': 'shared'
223
+ }
224
+ self.device_locks[device_index] = threading.Lock()
225
+
226
+ logging.info(f"✅ Successfully initialized shared access to device {device_index}")
227
+ return True
228
+
229
+ except Exception as e:
230
+ logging.error(f"❌ Failed to initialize shared access for device {device_index}: {e}")
231
+ return False
232
+
233
+ def _on_frame_received(self, device_index: int, frame, timestamp):
234
+ """Handle frame received from shared stream server."""
235
+ try:
236
+ # Distribute frame to all subscribers
237
+ with self.device_locks[device_index]:
238
+ subscribers = self.device_subscribers.get(device_index, {}).copy()
239
+
240
+ for subscriber_id, callback in subscribers.items():
241
+ try:
242
+ callback(frame)
243
+ except Exception as e:
244
+ logging.warning(f"Error delivering frame to subscriber {subscriber_id}: {e}")
245
+
246
+ except Exception as e:
247
+ logging.error(f"Error processing frame for device {device_index}: {e}")
248
+
249
+ logging.info(f"Successfully initialized shared access to device {device_index}")
250
+ return True
251
+
252
+ except Exception as e:
253
+ logging.error(f"Error initializing device {device_index}: {e}")
254
+ return False
255
+
256
+ def _cleanup_device(self, device_index: int):
257
+ """Clean up resources for a device and release shared stream access."""
258
+ logging.info(f"Cleaning up device {device_index}")
259
+
260
+ if device_index in self.device_streams:
261
+ device_info = self.device_streams.pop(device_index)
262
+
263
+ if isinstance(device_info, dict):
264
+ device_type = device_info.get('type')
265
+
266
+ if device_type == 'video_sharing':
267
+ # Disconnect from video sharing client
268
+ video_client = device_info.get('video_client')
269
+ if video_client:
270
+ video_client.disconnect()
271
+ logging.info(f"Disconnected from video sharing daemon for device {device_index}")
272
+
273
+ elif device_type == 'shared':
274
+ # Remove from shared stream server
275
+ shared_server = device_info.get('shared_server')
276
+ consumer_id = device_info.get('consumer_id')
277
+
278
+ if shared_server and consumer_id:
279
+ shared_server.remove_consumer(consumer_id)
280
+ logging.info(f"Removed consumer from shared stream server for device {device_index}")
281
+ else:
282
+ # Legacy cleanup for old-style streams
283
+ if hasattr(device_info, 'stop'):
284
+ device_info.stop()
285
+
286
+ # Clean up video client if exists
287
+ if device_index in self.video_clients:
288
+ video_client = self.video_clients.pop(device_index)
289
+ video_client.disconnect()
290
+ logging.info(f"Cleaned up video sharing client for device {device_index}")
291
+
292
+ if device_index in self.device_locks:
293
+ del self.device_locks[device_index]
294
+
295
+ if device_index in self.device_subscribers:
296
+ del self.device_subscribers[device_index]
297
+
298
+ if device_index in self.device_access_counts:
299
+ del self.device_access_counts[device_index]
300
+
301
+ def _start_frame_distributor(self, device_index: int):
302
+ """Start a thread to distribute frames to all subscribers."""
303
+ def distribute_frames():
304
+ stream = self.device_streams[device_index]
305
+
306
+ while stream.running and device_index in self.device_streams:
307
+ try:
308
+ frame = stream.get_frame()
309
+
310
+ if frame is not None:
311
+ # Distribute frame to all subscribers
312
+ with self.device_locks[device_index]:
313
+ subscribers = self.device_subscribers.get(device_index, {}).copy()
314
+
315
+ for subscriber_id, callback in subscribers.items():
316
+ try:
317
+ callback(frame.copy())
318
+ except Exception as e:
319
+ logging.error(f"Error calling callback for subscriber {subscriber_id}: {e}")
320
+
321
+ time.sleep(1.0 / 30.0) # 30 FPS distribution rate
322
+
323
+ except Exception as e:
324
+ logging.error(f"Error in frame distributor for device {device_index}: {e}")
325
+ time.sleep(0.1)
326
+
327
+ logging.info(f"Frame distributor for device {device_index} stopped")
328
+
329
+ distributor_thread = threading.Thread(
330
+ target=distribute_frames,
331
+ name=f"FrameDistributor-{device_index}",
332
+ daemon=True
333
+ )
334
+ distributor_thread.start()
335
+
336
+ def get_device_info(self, device_index: int) -> Optional[Dict]:
337
+ """Get information about a device."""
338
+ with self.main_lock:
339
+ if device_index not in self.device_streams:
340
+ return None
341
+
342
+ stream = self.device_streams[device_index]
343
+ return {
344
+ 'device_index': device_index,
345
+ 'is_connected': stream.is_connected(),
346
+ 'state': stream.get_state().value,
347
+ 'subscriber_count': self.device_access_counts.get(device_index, 0),
348
+ 'subscribers': list(self.device_subscribers.get(device_index, {}).keys())
349
+ }
350
+
351
+ def get_all_devices_info(self) -> Dict[int, Dict]:
352
+ """Get information about all managed devices."""
353
+ with self.main_lock:
354
+ return {
355
+ device_index: self.get_device_info(device_index)
356
+ for device_index in self.device_streams.keys()
357
+ }
358
+
359
+ def is_device_available(self, source) -> bool:
360
+ """Check if a direct video device is available."""
361
+ is_device, device_index = self._is_direct_device(source)
362
+
363
+ if not is_device:
364
+ return False
365
+
366
+ # Test if device can be opened
367
+ try:
368
+ test_cap = cv2.VideoCapture(device_index)
369
+ available = test_cap.isOpened()
370
+ test_cap.release()
371
+ return available
372
+ except Exception:
373
+ return False
374
+
375
+ def shutdown(self):
376
+ """Shutdown the manager and clean up all devices."""
377
+ logging.info("Shutting down SharedVideoDeviceManager")
378
+
379
+ with self.main_lock:
380
+ device_indices = list(self.device_streams.keys())
381
+
382
+ for device_index in device_indices:
383
+ self._cleanup_device(device_index)
@@ -29,8 +29,13 @@ class StreamSyncThread(threading.Thread):
29
29
  try:
30
30
  sources = self.worker_source_repo.get_worker_sources()
31
31
  db_sources = {
32
- source.id: (source.url if source.type_code == "live" else self._get_source_file_path(source.file_path), source.status_code) for source in sources
33
- } # Store latest sources
32
+ source.id: (
33
+ source.url if source.type_code == "live"
34
+ else source.url if source.type_code == "direct"
35
+ else self._get_source_file_path(source.file_path),
36
+ source.status_code
37
+ ) for source in sources
38
+ } # Store latest sources
34
39
  active_stream_ids = set(self.manager.get_active_stream_ids())
35
40
 
36
41
  # **1️⃣ Add new streams**
@@ -54,6 +59,9 @@ class StreamSyncThread(threading.Thread):
54
59
  if existing_url != url:
55
60
  logging.info(f"🟡 Updating stream {source_id}: New URL {url}")
56
61
  self.manager.remove_stream(source_id)
62
+ # Add a small delay for device cleanup
63
+ if self._is_direct_device(url) or self._is_direct_device(existing_url):
64
+ time.sleep(0.5) # Allow device to be properly released
57
65
  self.manager.add_stream(source_id, url)
58
66
 
59
67
  except Exception as e:
@@ -61,6 +69,12 @@ class StreamSyncThread(threading.Thread):
61
69
 
62
70
  time.sleep(self.polling_interval) # Poll every X seconds
63
71
 
72
+ def _is_direct_device(self, url) -> bool:
73
+ """Check if URL represents a direct video device."""
74
+ if isinstance(url, str):
75
+ return url.isdigit() or url.startswith('/dev/video')
76
+ return isinstance(url, int)
77
+
64
78
  def stop(self):
65
79
  """Stops the synchronization thread."""
66
80
  self.running = False