nedo-vision-worker 1.1.3__py3-none-any.whl → 1.2.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.
Files changed (43) 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 +30 -13
  30. nedo_vision_worker/services/WorkerSourceClient.py +1 -1
  31. nedo_vision_worker/services/WorkerSourcePipelineClient.py +28 -6
  32. nedo_vision_worker/services/WorkerSourceUpdater.py +30 -3
  33. nedo_vision_worker/util/VideoProbeUtil.py +222 -15
  34. nedo_vision_worker/worker/DataSyncWorker.py +1 -0
  35. nedo_vision_worker/worker/PipelineImageWorker.py +1 -1
  36. nedo_vision_worker/worker/VideoStreamWorker.py +27 -3
  37. nedo_vision_worker/worker/WorkerManager.py +2 -29
  38. nedo_vision_worker/worker_service.py +22 -5
  39. {nedo_vision_worker-1.1.3.dist-info → nedo_vision_worker-1.2.0.dist-info}/METADATA +1 -3
  40. {nedo_vision_worker-1.1.3.dist-info → nedo_vision_worker-1.2.0.dist-info}/RECORD +43 -38
  41. {nedo_vision_worker-1.1.3.dist-info → nedo_vision_worker-1.2.0.dist-info}/WHEEL +0 -0
  42. {nedo_vision_worker-1.1.3.dist-info → nedo_vision_worker-1.2.0.dist-info}/entry_points.txt +0 -0
  43. {nedo_vision_worker-1.1.3.dist-info → nedo_vision_worker-1.2.0.dist-info}/top_level.txt +0 -0
@@ -2,154 +2,188 @@ import grpc
2
2
  import logging
3
3
  import time
4
4
  from grpc import StatusCode
5
+ from typing import Callable, Optional, Any, Dict
5
6
 
6
7
  logger = logging.getLogger(__name__)
7
8
 
8
- # Global callback for authentication failures
9
- _auth_failure_callback = None
9
+ _auth_failure_callback: Optional[Callable[[], None]] = None
10
10
 
11
- def set_auth_failure_callback(callback):
12
- """Set a global callback to be called when authentication failures occur."""
11
+ def set_auth_failure_callback(callback: Callable[[], None]) -> None:
13
12
  global _auth_failure_callback
14
13
  _auth_failure_callback = callback
15
14
 
16
- def _notify_auth_failure():
17
- """Notify the registered callback about authentication failure."""
18
- global _auth_failure_callback
15
+ def _notify_auth_failure() -> None:
19
16
  if _auth_failure_callback:
20
- _auth_failure_callback()
17
+ try:
18
+ _auth_failure_callback()
19
+ except Exception as e:
20
+ logger.error(f"❌ Auth callback error: {e}")
21
+
21
22
 
22
23
  class GrpcClientBase:
24
+ ERROR_HANDLERS = {
25
+ StatusCode.UNAVAILABLE: ("⚠️", "warning", "Server unavailable"),
26
+ StatusCode.DEADLINE_EXCEEDED: ("⏳", "error", "Request timeout"),
27
+ StatusCode.PERMISSION_DENIED: ("🚫", "error", "Permission denied"),
28
+ StatusCode.UNAUTHENTICATED: ("🔑", "error", "Authentication failed"),
29
+ StatusCode.INVALID_ARGUMENT: ("⚠️", "error", "Invalid argument"),
30
+ StatusCode.NOT_FOUND: ("🔍", "error", "Resource not found"),
31
+ StatusCode.INTERNAL: ("💥", "error", "Internal server error"),
32
+ StatusCode.CANCELLED: ("🛑", "warning", "Request cancelled"),
33
+ StatusCode.ALREADY_EXISTS: ("📁", "warning", "Resource exists"),
34
+ StatusCode.RESOURCE_EXHAUSTED: ("🔋", "error", "Resources exhausted"),
35
+ StatusCode.FAILED_PRECONDITION: ("⚡", "error", "Precondition failed"),
36
+ StatusCode.ABORTED: ("🔄", "error", "Request aborted"),
37
+ StatusCode.OUT_OF_RANGE: ("📏", "error", "Value out of range"),
38
+ StatusCode.UNIMPLEMENTED: ("🚧", "error", "Method not implemented"),
39
+ StatusCode.DATA_LOSS: ("💿", "critical", "Data loss detected"),
40
+ }
41
+
23
42
  def __init__(self, server_host: str, server_port: int = 50051, max_retries: int = 3):
24
- """
25
- Initialize the gRPC client base.
26
-
27
- Args:
28
- server_host (str): The server hostname or IP address.
29
- server_port (int): The server port. Default is 50051.
30
- max_retries (int): Maximum number of reconnection attempts.
31
- """
32
43
  self.server_address = f"{server_host}:{server_port}"
33
- self.channel = None
34
- self.stub = None
44
+ self.channel: Optional[grpc.Channel] = None
45
+ self.stub: Optional[Any] = None
35
46
  self.connected = False
36
47
  self.max_retries = max_retries
37
48
 
38
- def connect(self, stub_class, retry_interval: int = 2):
39
- """
40
- Create a gRPC channel and stub, with retry logic if the server is unavailable.
41
-
42
- Args:
43
- stub_class: The gRPC stub class for the service.
44
- retry_interval (int): Initial time in seconds between reconnection attempts.
45
- """
49
+ def connect(self, stub_class, retry_interval: int = 2) -> bool:
46
50
  attempts = 0
47
51
  while attempts < self.max_retries and not self.connected:
48
52
  try:
53
+ if self.channel:
54
+ self._close_channel()
55
+
49
56
  self.channel = grpc.insecure_channel(self.server_address)
57
+
50
58
  future = grpc.channel_ready_future(self.channel)
51
- try:
52
- future.result(timeout=30)
53
- except grpc.FutureTimeoutError:
54
- raise grpc.RpcError("gRPC connection timed out.")
59
+ future.result(timeout=30)
55
60
 
56
61
  self.stub = stub_class(self.channel)
57
62
  self.connected = True
58
- logger.info("🚀 [APP] Successfully connected to gRPC server at %s", self.server_address)
59
- return # Exit if successful
63
+ logger.info(f"🚀 Connected to gRPC server at {self.server_address}")
64
+ return True
60
65
 
61
- except grpc.RpcError as e:
66
+ except (grpc.RpcError, grpc.FutureTimeoutError, Exception) as e:
62
67
  attempts += 1
63
68
  self.connected = False
64
-
65
- error_message = getattr(e, "details", lambda: str(e))()
66
- logger.error("⚠️ [APP] Failed to connect (%d/%d): %s", attempts, self.max_retries, error_message)
69
+ error_msg = str(e)
70
+
71
+ logger.error(f"⚠️ Connection failed ({attempts}/{self.max_retries}): {error_msg}")
67
72
 
68
73
  if attempts < self.max_retries:
69
- sleep_time = retry_interval * (2 ** (attempts - 1)) # Exponential backoff
70
- logger.info("⏳ [APP] Retrying in %d seconds...", sleep_time)
74
+ sleep_time = retry_interval * (2 ** (attempts - 1))
75
+ logger.info(f"⏳ Retrying in {sleep_time}s...")
71
76
  time.sleep(sleep_time)
72
77
  else:
73
- logger.critical("❌ [APP] Maximum retries reached. Could not connect to gRPC server.")
74
-
75
- except Exception as e:
76
- logger.critical("🚨 [APP] Unexpected error during gRPC initialization: %s", str(e))
77
- break # Stop retrying if an unexpected error occurs
78
-
79
- def close(self):
80
- """
81
- Close the gRPC channel.
82
- """
83
- if self.channel:
84
- self.channel.close()
85
- self.connected = False
86
- logger.info("🔌 [APP] gRPC channel closed.")
87
-
88
- def handle_rpc(self, rpc_call, *args, **kwargs):
89
- """
90
- Handle an RPC call with error handling.
78
+ logger.critical("❌ Max retries reached. Connection failed.")
91
79
 
92
- Args:
93
- rpc_call: The RPC method to call.
94
- *args: Positional arguments for the RPC call.
95
- **kwargs: Keyword arguments for the RPC call.
80
+ return False
96
81
 
97
- Returns:
98
- The RPC response or None if an error occurs.
99
- """
82
+ def _close_channel(self) -> None:
100
83
  try:
101
- response = rpc_call(*args, **kwargs)
102
- return response
84
+ if self.channel:
85
+ self.channel.close()
86
+ except Exception as e:
87
+ logger.warning(f"⚠️ Error closing channel: {e}")
88
+ finally:
89
+ self.channel = None
90
+ self.stub = None
91
+
92
+ def close(self) -> None:
93
+ self._close_channel()
94
+ self.connected = False
95
+ logger.info("🔌 gRPC connection closed")
96
+
97
+ def handle_rpc(self, rpc_call: Callable, *args, **kwargs) -> Optional[Any]:
98
+ if not self.is_connected():
99
+ logger.error("❌ Not connected. Cannot make RPC call")
100
+ return None
103
101
 
102
+ try:
103
+ return rpc_call(*args, **kwargs)
104
104
  except grpc.RpcError as e:
105
- status_code = e.code()
106
-
107
- # Extract only the meaningful part of the error message
108
- error_message = getattr(e, "details", lambda: str(e))()
109
- error_clean = error_message.split("debug_error_string")[0].strip()
110
-
111
- self.connected = False # Mark as disconnected for reconnection
112
-
113
- if status_code == StatusCode.UNAVAILABLE:
114
- logger.warning("⚠️ [APP] Server unavailable. Attempting to reconnect... (Error: %s)", error_clean)
115
- self.connect(type(self.stub)) # Attempt to reconnect
116
- elif status_code == StatusCode.DEADLINE_EXCEEDED:
117
- logger.error("⏳ [APP] RPC timeout error. (Error: %s)", error_clean)
118
- elif status_code == StatusCode.PERMISSION_DENIED:
119
- logger.error("🚫 [APP] RPC call failed: Permission denied. (Error: %s)", error_clean)
120
- elif status_code == StatusCode.UNAUTHENTICATED:
121
- logger.error("🔑 [APP] Authentication failed. (Error: %s)", error_clean)
122
- _notify_auth_failure() # Notify about authentication failure
123
- elif status_code == StatusCode.INVALID_ARGUMENT:
124
- logger.error("⚠️ [APP] Invalid argument in RPC call. (Error: %s)", error_clean)
125
- elif status_code == StatusCode.NOT_FOUND:
126
- logger.error("🔍 [APP] Requested resource not found. (Error: %s)", error_clean)
127
- elif status_code == StatusCode.INTERNAL:
128
- logger.error("💥 [APP] Internal server error encountered. (Error: %s)", error_clean)
129
- else:
130
- logger.error("❌ [APP] Unhandled gRPC error: %s (Code: %s)", error_clean, status_code)
131
-
132
- return None # Ensure the caller handles the failure
105
+ return self._handle_grpc_error(e, rpc_call, *args, **kwargs)
106
+ except Exception as e:
107
+ logger.error(f"💥 Unexpected RPC error: {e}")
108
+ return None
133
109
 
134
- @staticmethod
135
- def get_error_message(response):
136
- """
137
- Extract only the meaningful part of the error message.
110
+ def _handle_grpc_error(self, e: grpc.RpcError, rpc_call: Callable, *args, **kwargs) -> Optional[Any]:
111
+ status_code = e.code()
112
+ error_message = self._extract_error_message(e)
113
+
114
+ emoji, log_level, description = self.ERROR_HANDLERS.get(
115
+ status_code, ("❌", "error", f"Unhandled error (Code: {status_code})")
116
+ )
138
117
 
139
- Args:
140
- response: The RPC response.
118
+ getattr(logger, log_level)(f"{emoji} {description}: {error_message}")
141
119
 
142
- Returns:
143
- str: The error message.
144
- """
145
- if response and response.get("success"):
120
+ if status_code == StatusCode.UNAVAILABLE:
121
+ return self._handle_unavailable(rpc_call, *args, **kwargs)
122
+ elif status_code in {StatusCode.UNAUTHENTICATED, StatusCode.PERMISSION_DENIED}:
123
+ self._handle_auth_error(error_message)
124
+
125
+ if status_code in {StatusCode.UNAVAILABLE, StatusCode.DEADLINE_EXCEEDED}:
126
+ self.connected = False
127
+
128
+ return None
129
+
130
+ def _handle_unavailable(self, rpc_call: Callable, *args, **kwargs) -> Optional[Any]:
131
+ self.connected = False
132
+
133
+ if self.stub:
134
+ stub_class = type(self.stub)
135
+ logger.info("🔄 Reconnecting...")
136
+
137
+ if self.connect(stub_class):
138
+ logger.info("✅ Reconnected. Retrying...")
139
+ try:
140
+ return rpc_call(*args, **kwargs)
141
+ except Exception as e:
142
+ logger.error(f"❌ Retry failed: {e}")
143
+
144
+ return None
145
+
146
+ def _handle_auth_error(self, error_message: str) -> None:
147
+ auth_keywords = ["authentication", "token", "unauthorized", "invalid token"]
148
+ if any(keyword in error_message.lower() for keyword in auth_keywords):
149
+ logger.error(f"🔑 Auth failure: {error_message}")
150
+ _notify_auth_failure()
151
+
152
+ def _extract_error_message(self, e: grpc.RpcError) -> str:
153
+ error_message = getattr(e, "details", lambda: str(e))()
154
+ return error_message.split("debug_error_string")[0].strip()
155
+
156
+ def is_connected(self) -> bool:
157
+ return self.connected and self.channel and self.stub
158
+
159
+ def get_connection_info(self) -> Dict[str, Any]:
160
+ return {
161
+ "server_address": self.server_address,
162
+ "connected": self.connected,
163
+ "max_retries": self.max_retries,
164
+ "has_channel": self.channel is not None,
165
+ "has_stub": self.stub is not None
166
+ }
167
+
168
+ @staticmethod
169
+ def get_error_message(response: Optional[Dict]) -> Optional[str]:
170
+ if not response:
171
+ return "Unknown error"
172
+
173
+ if response.get("success"):
146
174
  return None
147
175
 
148
- message = response.get("message", "Unknown error") if response else "Unknown error"
176
+ message = response.get("message", "Unknown error")
149
177
 
150
- # Check for authentication failure in the message
151
- if message and ("Invalid authentication token" in message or "authentication" in message.lower()):
152
- logger.error("🔑 [APP] Authentication failure detected in response: %s", message)
178
+ auth_keywords = ["Invalid authentication token", "authentication", "unauthorized"]
179
+ if message and any(keyword in message for keyword in auth_keywords):
180
+ logger.error(f"🔑 Auth failure in response: {message}")
153
181
  _notify_auth_failure()
154
182
 
155
- return message
183
+ return message
184
+
185
+ def __enter__(self):
186
+ return self
187
+
188
+ def __exit__(self, exc_type, exc_val, exc_tb):
189
+ self.close()
@@ -3,8 +3,6 @@ from .GrpcClientBase import GrpcClientBase
3
3
  from ..protos.PPEDetectionService_pb2_grpc import PPEDetectionGRPCServiceStub
4
4
  from ..protos.PPEDetectionService_pb2 import UpsertPPEDetectionBatchRequest, UpsertPPEDetectionRequest, PPEDetectionLabelRequest
5
5
  from ..repositories.PPEDetectionRepository import PPEDetectionRepository
6
- import json
7
- import os
8
6
 
9
7
  logger = logging.getLogger(__name__)
10
8
 
@@ -38,11 +36,6 @@ class PPEDetectionClient(GrpcClientBase):
38
36
  Returns:
39
37
  bytes: Binary content of the image.
40
38
  """
41
- from ..database.DatabaseManager import get_storage_path
42
- from pathlib import Path
43
- import os
44
- if not os.path.isabs(image_path):
45
- image_path = str(get_storage_path("files") / Path(image_path).relative_to("data/files"))
46
39
  with open(image_path, 'rb') as image_file:
47
40
  return image_file.read()
48
41
 
@@ -39,11 +39,6 @@ class RestrictedAreaClient(GrpcClientBase):
39
39
  Returns:
40
40
  bytes: Binary content of the image.
41
41
  """
42
- from ..database.DatabaseManager import get_storage_path
43
- from pathlib import Path
44
- import os
45
- if not os.path.isabs(image_path):
46
- image_path = str(get_storage_path("restricted_violations") / Path(image_path).relative_to("data/restricted_violations"))
47
42
  with open(image_path, 'rb') as image_file:
48
43
  return image_file.read()
49
44
 
@@ -0,0 +1,278 @@
1
+ import logging
2
+ import threading
3
+ import time
4
+ import cv2
5
+ import platform
6
+ import ffmpeg
7
+ from typing import Dict
8
+ from .SystemWideDeviceCoordinator import get_system_coordinator
9
+
10
+
11
+ class SharedDirectDeviceClient:
12
+ """
13
+ Client for accessing shared direct video devices.
14
+ Coordinates with other services to prevent 'device busy' errors.
15
+ """
16
+
17
+ _instance = None
18
+ _lock = threading.Lock()
19
+
20
+ def __new__(cls):
21
+ if cls._instance is None:
22
+ with cls._lock:
23
+ if cls._instance is None:
24
+ cls._instance = super().__new__(cls)
25
+ return cls._instance
26
+
27
+ def __init__(self):
28
+ if hasattr(self, '_initialized'):
29
+ return
30
+
31
+ self._initialized = True
32
+ self.active_devices: Dict[int, Dict] = {} # device_index -> device_info
33
+ self.device_locks: Dict[int, threading.Lock] = {}
34
+ self.main_lock = threading.Lock()
35
+
36
+ logging.info("SharedDirectDeviceClient initialized")
37
+
38
+ def _is_direct_device(self, url) -> tuple:
39
+ """Check if source is a direct video device and return device index."""
40
+ if isinstance(url, int):
41
+ return True, url
42
+ elif isinstance(url, str) and url.isdigit():
43
+ return True, int(url)
44
+ elif isinstance(url, str) and url.startswith('/dev/video'):
45
+ try:
46
+ device_index = int(url.replace('/dev/video', ''))
47
+ return True, device_index
48
+ except ValueError:
49
+ pass
50
+ return False, None
51
+
52
+ def _get_device_path(self, device_index: int) -> str:
53
+ """Get the appropriate device path based on the platform."""
54
+ system = platform.system().lower()
55
+
56
+ if system == "linux":
57
+ return f"/dev/video{device_index}"
58
+ elif system == "windows":
59
+ return f"video={device_index}"
60
+ elif system == "darwin":
61
+ return f"{device_index}"
62
+ else:
63
+ logging.warning(f"Unsupported platform: {system}, using default")
64
+ return str(device_index)
65
+
66
+ def get_video_properties(self, url) -> tuple:
67
+ """
68
+ Get video properties for a direct device using cv2 instead of ffmpeg probe.
69
+ This is safer for device access coordination.
70
+ """
71
+ is_device, device_index = self._is_direct_device(url)
72
+
73
+ if not is_device:
74
+ logging.error(f"URL {url} is not a direct video device")
75
+ return None, None, None, "rgb24"
76
+
77
+ with self.main_lock:
78
+ # Check if device is already being accessed locally
79
+ if device_index in self.active_devices:
80
+ device_info = self.active_devices[device_index]
81
+ return (
82
+ device_info['width'],
83
+ device_info['height'],
84
+ device_info['fps'],
85
+ device_info['pixel_format']
86
+ )
87
+
88
+ # Check if device is locked by another service
89
+ coordinator = get_system_coordinator()
90
+ if coordinator.is_device_locked(device_index):
91
+ lock_info = coordinator.get_device_lock_info(device_index)
92
+ service = lock_info.get('service', 'unknown') if lock_info else 'unknown'
93
+ logging.warning(f"⚠️ Device {device_index} is locked by {service}. Cannot probe properties.")
94
+ # Return default properties for locked devices
95
+ return 640, 480, 30.0, "rgb24"
96
+
97
+ # Probe the device safely
98
+ try:
99
+ cap = cv2.VideoCapture(device_index)
100
+
101
+ if not cap.isOpened():
102
+ logging.error(f"Failed to open direct video device: {device_index}")
103
+ return None, None, None, "rgb24"
104
+
105
+ width = int(cap.get(cv2.CAP_PROP_FRAME_WIDTH))
106
+ height = int(cap.get(cv2.CAP_PROP_FRAME_HEIGHT))
107
+ fps = float(cap.get(cv2.CAP_PROP_FPS))
108
+
109
+ if fps <= 0 or fps > 240:
110
+ fps = 30.0
111
+
112
+ cap.release()
113
+
114
+ # Store device info for future use
115
+ with self.main_lock:
116
+ self.active_devices[device_index] = {
117
+ 'width': width,
118
+ 'height': height,
119
+ 'fps': fps,
120
+ 'pixel_format': 'rgb24',
121
+ 'access_count': 0,
122
+ 'last_access': time.time()
123
+ }
124
+ self.device_locks[device_index] = threading.Lock()
125
+
126
+ logging.info(f"Probed device {device_index}: {width}x{height} @ {fps}fps")
127
+ return width, height, fps, "rgb24"
128
+
129
+ except Exception as e:
130
+ logging.error(f"Error probing direct video device {device_index}: {e}")
131
+ return None, None, None, "rgb24"
132
+
133
+ def create_ffmpeg_input(self, url, width: int, height: int, fps: float):
134
+ """
135
+ Create an ffmpeg input for a direct device with proper coordination.
136
+ """
137
+ is_device, device_index = self._is_direct_device(url)
138
+
139
+ if not is_device:
140
+ raise ValueError(f"URL {url} is not a direct video device")
141
+
142
+ # Increment access count
143
+ with self.main_lock:
144
+ if device_index not in self.active_devices:
145
+ # This shouldn't happen if get_video_properties was called first
146
+ logging.warning(f"Device {device_index} not in active devices, initializing...")
147
+ self.get_video_properties(url)
148
+
149
+ self.active_devices[device_index]['access_count'] += 1
150
+ self.active_devices[device_index]['last_access'] = time.time()
151
+
152
+ logging.info(f"Creating ffmpeg input for device {device_index} "
153
+ f"(access count: {self.active_devices[device_index]['access_count']})")
154
+
155
+ system = platform.system().lower()
156
+
157
+ try:
158
+ if system == "linux":
159
+ ffmpeg_input = (
160
+ ffmpeg
161
+ .input(f"/dev/video{device_index}", format="v4l2",
162
+ framerate=fps, video_size=f"{width}x{height}")
163
+ )
164
+ elif system == "windows":
165
+ ffmpeg_input = (
166
+ ffmpeg
167
+ .input(f"video={device_index}", format="dshow",
168
+ framerate=fps, video_size=f"{width}x{height}")
169
+ )
170
+ elif system == "darwin":
171
+ ffmpeg_input = (
172
+ ffmpeg
173
+ .input(f"{device_index}", format="avfoundation",
174
+ framerate=fps, video_size=f"{width}x{height}")
175
+ )
176
+ else:
177
+ raise ValueError(f"Unsupported platform for direct video streaming: {system}")
178
+
179
+ return ffmpeg_input
180
+
181
+ except Exception as e:
182
+ # Decrement access count on error
183
+ with self.main_lock:
184
+ if device_index in self.active_devices:
185
+ self.active_devices[device_index]['access_count'] -= 1
186
+ raise e
187
+
188
+ def release_device_access(self, url):
189
+ """
190
+ Release access to a direct device.
191
+ """
192
+ is_device, device_index = self._is_direct_device(url)
193
+
194
+ if not is_device:
195
+ return
196
+
197
+ with self.main_lock:
198
+ if device_index in self.active_devices:
199
+ self.active_devices[device_index]['access_count'] -= 1
200
+
201
+ logging.info(f"Released device {device_index} access "
202
+ f"(remaining count: {self.active_devices[device_index]['access_count']})")
203
+
204
+ # Clean up if no more access
205
+ if self.active_devices[device_index]['access_count'] <= 0:
206
+ del self.active_devices[device_index]
207
+ if device_index in self.device_locks:
208
+ del self.device_locks[device_index]
209
+ logging.info(f"Cleaned up device {device_index} resources")
210
+
211
+ def is_device_busy(self, url) -> bool:
212
+ """
213
+ Check if a device is currently being accessed.
214
+ """
215
+ is_device, device_index = self._is_direct_device(url)
216
+
217
+ if not is_device:
218
+ return False
219
+
220
+ with self.main_lock:
221
+ return (device_index in self.active_devices and
222
+ self.active_devices[device_index]['access_count'] > 0)
223
+
224
+ def get_device_access_count(self, url) -> int:
225
+ """
226
+ Get the current access count for a device.
227
+ """
228
+ is_device, device_index = self._is_direct_device(url)
229
+
230
+ if not is_device:
231
+ return 0
232
+
233
+ with self.main_lock:
234
+ if device_index in self.active_devices:
235
+ return self.active_devices[device_index]['access_count']
236
+ return 0
237
+
238
+ def get_all_devices_info(self) -> Dict[int, Dict]:
239
+ """Get information about all active devices."""
240
+ with self.main_lock:
241
+ return {
242
+ device_index: {
243
+ 'width': info['width'],
244
+ 'height': info['height'],
245
+ 'fps': info['fps'],
246
+ 'pixel_format': info['pixel_format'],
247
+ 'access_count': info['access_count'],
248
+ 'last_access': info['last_access'],
249
+ 'time_since_last_access': time.time() - info['last_access']
250
+ }
251
+ for device_index, info in self.active_devices.items()
252
+ }
253
+
254
+ def cleanup_stale_devices(self, max_idle_time: float = 300.0):
255
+ """
256
+ Clean up devices that haven't been accessed for a while.
257
+ """
258
+ current_time = time.time()
259
+ stale_devices = []
260
+
261
+ with self.main_lock:
262
+ for device_index, info in self.active_devices.items():
263
+ if (current_time - info['last_access']) > max_idle_time and info['access_count'] <= 0:
264
+ stale_devices.append(device_index)
265
+
266
+ for device_index in stale_devices:
267
+ del self.active_devices[device_index]
268
+ if device_index in self.device_locks:
269
+ del self.device_locks[device_index]
270
+ logging.info(f"Cleaned up stale device {device_index}")
271
+
272
+ def shutdown(self):
273
+ """Shutdown the client and clean up all resources."""
274
+ logging.info("Shutting down SharedDirectDeviceClient")
275
+
276
+ with self.main_lock:
277
+ self.active_devices.clear()
278
+ self.device_locks.clear()