nedo-vision-worker 1.1.2__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.
- nedo_vision_worker/__init__.py +1 -1
- nedo_vision_worker/cli.py +197 -168
- nedo_vision_worker/database/DatabaseManager.py +3 -3
- nedo_vision_worker/doctor.py +1066 -386
- nedo_vision_worker/models/ai_model.py +35 -2
- nedo_vision_worker/protos/AIModelService_pb2.py +12 -10
- nedo_vision_worker/protos/AIModelService_pb2_grpc.py +1 -1
- nedo_vision_worker/protos/DatasetSourceService_pb2.py +2 -2
- nedo_vision_worker/protos/DatasetSourceService_pb2_grpc.py +1 -1
- nedo_vision_worker/protos/HumanDetectionService_pb2.py +2 -2
- nedo_vision_worker/protos/HumanDetectionService_pb2_grpc.py +1 -1
- nedo_vision_worker/protos/PPEDetectionService_pb2.py +2 -2
- nedo_vision_worker/protos/PPEDetectionService_pb2_grpc.py +1 -1
- nedo_vision_worker/protos/VisionWorkerService_pb2.py +2 -2
- nedo_vision_worker/protos/VisionWorkerService_pb2_grpc.py +1 -1
- nedo_vision_worker/protos/WorkerSourcePipelineService_pb2.py +2 -2
- nedo_vision_worker/protos/WorkerSourcePipelineService_pb2_grpc.py +1 -1
- nedo_vision_worker/protos/WorkerSourceService_pb2.py +2 -2
- nedo_vision_worker/protos/WorkerSourceService_pb2_grpc.py +1 -1
- nedo_vision_worker/services/AIModelClient.py +184 -160
- nedo_vision_worker/services/DirectDeviceToRTMPStreamer.py +534 -0
- nedo_vision_worker/services/GrpcClientBase.py +142 -108
- nedo_vision_worker/services/PPEDetectionClient.py +0 -7
- nedo_vision_worker/services/RestrictedAreaClient.py +0 -5
- nedo_vision_worker/services/SharedDirectDeviceClient.py +278 -0
- nedo_vision_worker/services/SharedVideoStreamServer.py +315 -0
- nedo_vision_worker/services/SystemWideDeviceCoordinator.py +236 -0
- nedo_vision_worker/services/VideoSharingDaemon.py +832 -0
- nedo_vision_worker/services/VideoStreamClient.py +30 -13
- nedo_vision_worker/services/WorkerSourceClient.py +1 -1
- nedo_vision_worker/services/WorkerSourcePipelineClient.py +28 -6
- nedo_vision_worker/services/WorkerSourceUpdater.py +30 -3
- nedo_vision_worker/util/VideoProbeUtil.py +222 -15
- nedo_vision_worker/worker/DataSyncWorker.py +1 -0
- nedo_vision_worker/worker/PipelineImageWorker.py +1 -1
- nedo_vision_worker/worker/VideoStreamWorker.py +27 -3
- nedo_vision_worker/worker/WorkerManager.py +2 -29
- nedo_vision_worker/worker_service.py +24 -11
- {nedo_vision_worker-1.1.2.dist-info → nedo_vision_worker-1.2.0.dist-info}/METADATA +1 -3
- {nedo_vision_worker-1.1.2.dist-info → nedo_vision_worker-1.2.0.dist-info}/RECORD +43 -38
- {nedo_vision_worker-1.1.2.dist-info → nedo_vision_worker-1.2.0.dist-info}/WHEEL +0 -0
- {nedo_vision_worker-1.1.2.dist-info → nedo_vision_worker-1.2.0.dist-info}/entry_points.txt +0 -0
- {nedo_vision_worker-1.1.2.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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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("🚀
|
|
59
|
-
return
|
|
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
|
-
|
|
66
|
-
logger.error("⚠️
|
|
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))
|
|
70
|
-
logger.info("⏳
|
|
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("❌
|
|
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
|
-
|
|
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
|
-
|
|
98
|
-
The RPC response or None if an error occurs.
|
|
99
|
-
"""
|
|
82
|
+
def _close_channel(self) -> None:
|
|
100
83
|
try:
|
|
101
|
-
|
|
102
|
-
|
|
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
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
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
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
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
|
-
|
|
140
|
-
response: The RPC response.
|
|
118
|
+
getattr(logger, log_level)(f"{emoji} {description}: {error_message}")
|
|
141
119
|
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
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")
|
|
176
|
+
message = response.get("message", "Unknown error")
|
|
149
177
|
|
|
150
|
-
|
|
151
|
-
if message and (
|
|
152
|
-
logger.error("🔑
|
|
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()
|