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.
- nedo_vision_worker/__init__.py +1 -1
- nedo_vision_worker/cli.py +196 -167
- 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 +22 -5
- {nedo_vision_worker-1.1.3.dist-info → nedo_vision_worker-1.2.0.dist-info}/METADATA +1 -3
- {nedo_vision_worker-1.1.3.dist-info → nedo_vision_worker-1.2.0.dist-info}/RECORD +43 -38
- {nedo_vision_worker-1.1.3.dist-info → nedo_vision_worker-1.2.0.dist-info}/WHEEL +0 -0
- {nedo_vision_worker-1.1.3.dist-info → nedo_vision_worker-1.2.0.dist-info}/entry_points.txt +0 -0
- {nedo_vision_worker-1.1.3.dist-info → nedo_vision_worker-1.2.0.dist-info}/top_level.txt +0 -0
|
@@ -0,0 +1,315 @@
|
|
|
1
|
+
import logging
|
|
2
|
+
import threading
|
|
3
|
+
import time
|
|
4
|
+
import cv2
|
|
5
|
+
import queue
|
|
6
|
+
import uuid
|
|
7
|
+
from typing import Dict, Optional, Callable, List
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
import tempfile
|
|
10
|
+
import json
|
|
11
|
+
import os
|
|
12
|
+
import socket
|
|
13
|
+
import struct
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class SharedVideoStreamServer:
|
|
17
|
+
"""
|
|
18
|
+
A shared video stream server that captures from a video device once
|
|
19
|
+
and distributes frames to multiple consumers (both services).
|
|
20
|
+
"""
|
|
21
|
+
|
|
22
|
+
_instances: Dict[int, 'SharedVideoStreamServer'] = {}
|
|
23
|
+
_lock = threading.Lock()
|
|
24
|
+
|
|
25
|
+
def __new__(cls, device_index: int):
|
|
26
|
+
with cls._lock:
|
|
27
|
+
if device_index not in cls._instances:
|
|
28
|
+
cls._instances[device_index] = super().__new__(cls)
|
|
29
|
+
cls._instances[device_index]._initialized = False
|
|
30
|
+
return cls._instances[device_index]
|
|
31
|
+
|
|
32
|
+
def __init__(self, device_index: int):
|
|
33
|
+
if self._initialized:
|
|
34
|
+
return
|
|
35
|
+
|
|
36
|
+
self._initialized = True
|
|
37
|
+
self.device_index = device_index
|
|
38
|
+
self.cap = None
|
|
39
|
+
self.running = False
|
|
40
|
+
self.capture_thread = None
|
|
41
|
+
self.frame_lock = threading.Lock()
|
|
42
|
+
self.latest_frame = None
|
|
43
|
+
self.frame_timestamp = 0
|
|
44
|
+
|
|
45
|
+
# Consumer management
|
|
46
|
+
self.consumers: Dict[str, Dict] = {} # consumer_id -> {callback, last_frame_time}
|
|
47
|
+
self.consumers_lock = threading.Lock()
|
|
48
|
+
|
|
49
|
+
# Device properties
|
|
50
|
+
self.width = 640
|
|
51
|
+
self.height = 480
|
|
52
|
+
self.fps = 30.0
|
|
53
|
+
|
|
54
|
+
# Shared state file for cross-service coordination
|
|
55
|
+
self.state_file = self._get_state_file_path()
|
|
56
|
+
|
|
57
|
+
logging.info(f"SharedVideoStreamServer initialized for device {device_index}")
|
|
58
|
+
|
|
59
|
+
def _get_state_file_path(self) -> Path:
|
|
60
|
+
"""Get the path for the shared state file."""
|
|
61
|
+
temp_dir = tempfile.gettempdir()
|
|
62
|
+
state_dir = Path(temp_dir) / "nedo-vision-shared-streams"
|
|
63
|
+
state_dir.mkdir(exist_ok=True)
|
|
64
|
+
return state_dir / f"device_{self.device_index}_state.json"
|
|
65
|
+
|
|
66
|
+
def _update_state_file(self):
|
|
67
|
+
"""Update the shared state file with current process info."""
|
|
68
|
+
try:
|
|
69
|
+
state = {
|
|
70
|
+
'device_index': self.device_index,
|
|
71
|
+
'process_id': os.getpid(),
|
|
72
|
+
'width': self.width,
|
|
73
|
+
'height': self.height,
|
|
74
|
+
'fps': self.fps,
|
|
75
|
+
'running': self.running,
|
|
76
|
+
'consumer_count': len(self.consumers),
|
|
77
|
+
'last_update': time.time()
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
# Ensure directory exists
|
|
81
|
+
self.state_file.parent.mkdir(parents=True, exist_ok=True)
|
|
82
|
+
|
|
83
|
+
with open(self.state_file, 'w') as f:
|
|
84
|
+
json.dump(state, f)
|
|
85
|
+
|
|
86
|
+
except Exception as e:
|
|
87
|
+
logging.warning(f"Failed to update state file: {e}")
|
|
88
|
+
|
|
89
|
+
def _read_state_file(self) -> Optional[Dict]:
|
|
90
|
+
"""Read the shared state file."""
|
|
91
|
+
try:
|
|
92
|
+
if self.state_file.exists():
|
|
93
|
+
with open(self.state_file, 'r') as f:
|
|
94
|
+
return json.load(f)
|
|
95
|
+
except Exception:
|
|
96
|
+
pass
|
|
97
|
+
return None
|
|
98
|
+
|
|
99
|
+
def start_capture(self) -> bool:
|
|
100
|
+
"""Start capturing from the video device."""
|
|
101
|
+
if self.running:
|
|
102
|
+
logging.info(f"Device {self.device_index} capture already running")
|
|
103
|
+
return True
|
|
104
|
+
|
|
105
|
+
# First check if another process is already using this device
|
|
106
|
+
existing_process = self._check_existing_process()
|
|
107
|
+
if existing_process:
|
|
108
|
+
logging.info(f"Device {self.device_index} is already being used by another process (PID: {existing_process})")
|
|
109
|
+
# Try to connect to the existing process's stream server
|
|
110
|
+
return self._connect_to_existing_server()
|
|
111
|
+
|
|
112
|
+
try:
|
|
113
|
+
# Try to open the device
|
|
114
|
+
self.cap = cv2.VideoCapture(self.device_index)
|
|
115
|
+
|
|
116
|
+
if not self.cap.isOpened():
|
|
117
|
+
logging.error(f"Cannot open video device {self.device_index}")
|
|
118
|
+
return False
|
|
119
|
+
|
|
120
|
+
# Get device properties
|
|
121
|
+
self.width = int(self.cap.get(cv2.CAP_PROP_FRAME_WIDTH))
|
|
122
|
+
self.height = int(self.cap.get(cv2.CAP_PROP_FRAME_HEIGHT))
|
|
123
|
+
self.fps = float(self.cap.get(cv2.CAP_PROP_FPS))
|
|
124
|
+
|
|
125
|
+
if self.fps <= 0 or self.fps > 240:
|
|
126
|
+
self.fps = 30.0
|
|
127
|
+
|
|
128
|
+
logging.info(f"Device {self.device_index} opened: {self.width}x{self.height} @ {self.fps}fps")
|
|
129
|
+
|
|
130
|
+
# Start capture thread
|
|
131
|
+
self.running = True
|
|
132
|
+
self.capture_thread = threading.Thread(target=self._capture_loop, daemon=True)
|
|
133
|
+
self.capture_thread.start()
|
|
134
|
+
|
|
135
|
+
# Update state file to indicate this process is using the device
|
|
136
|
+
self._update_state_file()
|
|
137
|
+
|
|
138
|
+
return True
|
|
139
|
+
|
|
140
|
+
except Exception as e:
|
|
141
|
+
logging.error(f"Error starting capture for device {self.device_index}: {e}")
|
|
142
|
+
return False
|
|
143
|
+
|
|
144
|
+
def _check_existing_process(self) -> Optional[int]:
|
|
145
|
+
"""Check if another process is already using this device."""
|
|
146
|
+
try:
|
|
147
|
+
state = self._read_state_file()
|
|
148
|
+
if state and 'process_id' in state:
|
|
149
|
+
pid = state['process_id']
|
|
150
|
+
# Check if the process is still running
|
|
151
|
+
try:
|
|
152
|
+
os.kill(pid, 0) # Signal 0 just checks if process exists
|
|
153
|
+
return pid
|
|
154
|
+
except (OSError, ProcessLookupError):
|
|
155
|
+
# Process is dead, clean up the state file
|
|
156
|
+
try:
|
|
157
|
+
self.state_file.unlink()
|
|
158
|
+
except:
|
|
159
|
+
pass
|
|
160
|
+
except Exception:
|
|
161
|
+
pass
|
|
162
|
+
return None
|
|
163
|
+
|
|
164
|
+
def _connect_to_existing_server(self) -> bool:
|
|
165
|
+
"""Connect to an existing stream server in another process."""
|
|
166
|
+
# For now, return False to indicate we can't connect
|
|
167
|
+
# In a full implementation, you'd implement IPC (named pipes, sockets, etc.)
|
|
168
|
+
logging.warning(f"Device {self.device_index} is in use by another process. Cannot share across processes yet.")
|
|
169
|
+
return False
|
|
170
|
+
|
|
171
|
+
def stop_capture(self):
|
|
172
|
+
"""Stop capturing from the video device."""
|
|
173
|
+
if not self.running:
|
|
174
|
+
return
|
|
175
|
+
|
|
176
|
+
logging.info(f"Stopping capture for device {self.device_index}")
|
|
177
|
+
|
|
178
|
+
self.running = False
|
|
179
|
+
|
|
180
|
+
if self.capture_thread:
|
|
181
|
+
self.capture_thread.join(timeout=5)
|
|
182
|
+
|
|
183
|
+
if self.cap:
|
|
184
|
+
self.cap.release()
|
|
185
|
+
self.cap = None
|
|
186
|
+
|
|
187
|
+
# Clean up state file
|
|
188
|
+
try:
|
|
189
|
+
if self.state_file.exists():
|
|
190
|
+
self.state_file.unlink()
|
|
191
|
+
except Exception:
|
|
192
|
+
pass
|
|
193
|
+
|
|
194
|
+
# Remove from instances
|
|
195
|
+
with self._lock:
|
|
196
|
+
if self.device_index in self._instances:
|
|
197
|
+
del self._instances[self.device_index]
|
|
198
|
+
|
|
199
|
+
def _capture_loop(self):
|
|
200
|
+
"""Main capture loop that reads frames and distributes to consumers."""
|
|
201
|
+
frame_interval = 1.0 / self.fps
|
|
202
|
+
last_frame_time = 0
|
|
203
|
+
|
|
204
|
+
while self.running and self.cap and self.cap.isOpened():
|
|
205
|
+
try:
|
|
206
|
+
current_time = time.time()
|
|
207
|
+
|
|
208
|
+
# Throttle frame rate
|
|
209
|
+
if current_time - last_frame_time < frame_interval:
|
|
210
|
+
time.sleep(0.001)
|
|
211
|
+
continue
|
|
212
|
+
|
|
213
|
+
ret, frame = self.cap.read()
|
|
214
|
+
|
|
215
|
+
if not ret or frame is None:
|
|
216
|
+
logging.warning(f"Failed to read frame from device {self.device_index}")
|
|
217
|
+
time.sleep(0.1)
|
|
218
|
+
continue
|
|
219
|
+
|
|
220
|
+
# Update latest frame
|
|
221
|
+
with self.frame_lock:
|
|
222
|
+
self.latest_frame = frame.copy()
|
|
223
|
+
self.frame_timestamp = current_time
|
|
224
|
+
|
|
225
|
+
# Distribute frame to consumers
|
|
226
|
+
self._distribute_frame(frame, current_time)
|
|
227
|
+
|
|
228
|
+
last_frame_time = current_time
|
|
229
|
+
|
|
230
|
+
# Update state file periodically
|
|
231
|
+
if int(current_time) % 5 == 0: # Every 5 seconds
|
|
232
|
+
self._update_state_file()
|
|
233
|
+
|
|
234
|
+
except Exception as e:
|
|
235
|
+
logging.error(f"Error in capture loop for device {self.device_index}: {e}")
|
|
236
|
+
time.sleep(0.1)
|
|
237
|
+
|
|
238
|
+
logging.info(f"Capture loop ended for device {self.device_index}")
|
|
239
|
+
|
|
240
|
+
def _distribute_frame(self, frame, timestamp):
|
|
241
|
+
"""Distribute frame to all registered consumers."""
|
|
242
|
+
with self.consumers_lock:
|
|
243
|
+
dead_consumers = []
|
|
244
|
+
|
|
245
|
+
for consumer_id, consumer_info in self.consumers.items():
|
|
246
|
+
try:
|
|
247
|
+
callback = consumer_info['callback']
|
|
248
|
+
callback(frame, timestamp)
|
|
249
|
+
consumer_info['last_frame_time'] = timestamp
|
|
250
|
+
|
|
251
|
+
except Exception as e:
|
|
252
|
+
logging.warning(f"Error delivering frame to consumer {consumer_id}: {e}")
|
|
253
|
+
dead_consumers.append(consumer_id)
|
|
254
|
+
|
|
255
|
+
# Remove dead consumers
|
|
256
|
+
for consumer_id in dead_consumers:
|
|
257
|
+
del self.consumers[consumer_id]
|
|
258
|
+
logging.info(f"Removed dead consumer: {consumer_id}")
|
|
259
|
+
|
|
260
|
+
def add_consumer(self, callback: Callable, consumer_id: str = None) -> str:
|
|
261
|
+
"""Add a consumer that will receive frames."""
|
|
262
|
+
if consumer_id is None:
|
|
263
|
+
consumer_id = str(uuid.uuid4())
|
|
264
|
+
|
|
265
|
+
with self.consumers_lock:
|
|
266
|
+
self.consumers[consumer_id] = {
|
|
267
|
+
'callback': callback,
|
|
268
|
+
'added_time': time.time(),
|
|
269
|
+
'last_frame_time': 0
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
logging.info(f"Added consumer {consumer_id} for device {self.device_index}")
|
|
273
|
+
|
|
274
|
+
# Start capture if this is the first consumer
|
|
275
|
+
if len(self.consumers) == 1 and not self.running:
|
|
276
|
+
self.start_capture()
|
|
277
|
+
|
|
278
|
+
return consumer_id
|
|
279
|
+
|
|
280
|
+
def remove_consumer(self, consumer_id: str):
|
|
281
|
+
"""Remove a consumer."""
|
|
282
|
+
with self.consumers_lock:
|
|
283
|
+
if consumer_id in self.consumers:
|
|
284
|
+
del self.consumers[consumer_id]
|
|
285
|
+
logging.info(f"Removed consumer {consumer_id} for device {self.device_index}")
|
|
286
|
+
|
|
287
|
+
# Stop capture if no more consumers
|
|
288
|
+
if len(self.consumers) == 0 and self.running:
|
|
289
|
+
self.stop_capture()
|
|
290
|
+
|
|
291
|
+
def get_latest_frame(self):
|
|
292
|
+
"""Get the latest captured frame."""
|
|
293
|
+
with self.frame_lock:
|
|
294
|
+
if self.latest_frame is not None:
|
|
295
|
+
return self.latest_frame.copy(), self.frame_timestamp
|
|
296
|
+
return None, 0
|
|
297
|
+
|
|
298
|
+
def get_device_properties(self) -> tuple:
|
|
299
|
+
"""Get device properties."""
|
|
300
|
+
return self.width, self.height, self.fps, "rgb24"
|
|
301
|
+
|
|
302
|
+
def is_running(self) -> bool:
|
|
303
|
+
"""Check if the server is running."""
|
|
304
|
+
return self.running
|
|
305
|
+
|
|
306
|
+
def get_consumer_count(self) -> int:
|
|
307
|
+
"""Get the number of active consumers."""
|
|
308
|
+
with self.consumers_lock:
|
|
309
|
+
return len(self.consumers)
|
|
310
|
+
|
|
311
|
+
|
|
312
|
+
# Global function to get or create shared stream server
|
|
313
|
+
def get_shared_stream_server(device_index: int) -> SharedVideoStreamServer:
|
|
314
|
+
"""Get or create a shared stream server for a device."""
|
|
315
|
+
return SharedVideoStreamServer(device_index)
|
|
@@ -0,0 +1,236 @@
|
|
|
1
|
+
import os
|
|
2
|
+
import time
|
|
3
|
+
import logging
|
|
4
|
+
import threading
|
|
5
|
+
import platform
|
|
6
|
+
import tempfile
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
from typing import Dict, Optional
|
|
9
|
+
|
|
10
|
+
# Platform-specific imports
|
|
11
|
+
if platform.system().lower() == "windows":
|
|
12
|
+
import msvcrt
|
|
13
|
+
else:
|
|
14
|
+
import fcntl
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class SystemWideDeviceCoordinator:
|
|
18
|
+
"""
|
|
19
|
+
System-wide device coordinator that works across multiple processes/services.
|
|
20
|
+
Uses file locks to coordinate device access between different services.
|
|
21
|
+
"""
|
|
22
|
+
|
|
23
|
+
def __init__(self, lock_dir: str = None):
|
|
24
|
+
# Use platform-appropriate temporary directory if none specified
|
|
25
|
+
if lock_dir is None:
|
|
26
|
+
system_temp = tempfile.gettempdir()
|
|
27
|
+
lock_dir = os.path.join(system_temp, "nedo-vision-device-locks")
|
|
28
|
+
|
|
29
|
+
self.lock_dir = Path(lock_dir)
|
|
30
|
+
self.lock_dir.mkdir(exist_ok=True)
|
|
31
|
+
self.active_locks: Dict[int, any] = {}
|
|
32
|
+
self.lock = threading.Lock()
|
|
33
|
+
|
|
34
|
+
logging.info(f"SystemWideDeviceCoordinator initialized with lock directory: {self.lock_dir}")
|
|
35
|
+
|
|
36
|
+
def acquire_device_lock(self, device_index: int, timeout: float = 5.0) -> bool:
|
|
37
|
+
"""
|
|
38
|
+
Acquire exclusive lock for a video device across all services.
|
|
39
|
+
|
|
40
|
+
Args:
|
|
41
|
+
device_index: The video device index (e.g., 0 for /dev/video0)
|
|
42
|
+
timeout: Maximum time to wait for lock acquisition
|
|
43
|
+
|
|
44
|
+
Returns:
|
|
45
|
+
True if lock acquired successfully, False otherwise
|
|
46
|
+
"""
|
|
47
|
+
lock_file_path = self.lock_dir / f"video_device_{device_index}.lock"
|
|
48
|
+
|
|
49
|
+
try:
|
|
50
|
+
# Open lock file
|
|
51
|
+
lock_file = open(lock_file_path, 'w')
|
|
52
|
+
lock_file.write(f"pid:{os.getpid()}\nservice:worker-service\ntime:{time.time()}\n")
|
|
53
|
+
lock_file.flush()
|
|
54
|
+
|
|
55
|
+
# Platform-specific locking
|
|
56
|
+
if platform.system().lower() == "windows":
|
|
57
|
+
return self._acquire_lock_windows(lock_file, device_index, timeout)
|
|
58
|
+
else:
|
|
59
|
+
return self._acquire_lock_unix(lock_file, device_index, timeout)
|
|
60
|
+
|
|
61
|
+
except Exception as e:
|
|
62
|
+
logging.error(f"❌ Error acquiring device lock for device {device_index}: {e}")
|
|
63
|
+
return False
|
|
64
|
+
|
|
65
|
+
def _acquire_lock_unix(self, lock_file, device_index: int, timeout: float) -> bool:
|
|
66
|
+
"""Acquire lock using POSIX fcntl (Linux/macOS)."""
|
|
67
|
+
start_time = time.time()
|
|
68
|
+
while time.time() - start_time < timeout:
|
|
69
|
+
try:
|
|
70
|
+
fcntl.flock(lock_file.fileno(), fcntl.LOCK_EX | fcntl.LOCK_NB)
|
|
71
|
+
|
|
72
|
+
# Lock acquired successfully
|
|
73
|
+
with self.lock:
|
|
74
|
+
self.active_locks[device_index] = lock_file
|
|
75
|
+
|
|
76
|
+
logging.info(f"✅ Acquired system-wide lock for video device {device_index}")
|
|
77
|
+
return True
|
|
78
|
+
|
|
79
|
+
except BlockingIOError:
|
|
80
|
+
# Lock is held by another process, wait and retry
|
|
81
|
+
time.sleep(0.1)
|
|
82
|
+
continue
|
|
83
|
+
|
|
84
|
+
# Timeout reached
|
|
85
|
+
lock_file.close()
|
|
86
|
+
logging.warning(f"⏱️ Timeout acquiring lock for video device {device_index}")
|
|
87
|
+
return False
|
|
88
|
+
|
|
89
|
+
def _acquire_lock_windows(self, lock_file, device_index: int, timeout: float) -> bool:
|
|
90
|
+
"""Acquire lock using Windows msvcrt (Windows)."""
|
|
91
|
+
start_time = time.time()
|
|
92
|
+
while time.time() - start_time < timeout:
|
|
93
|
+
try:
|
|
94
|
+
# Try to lock the file
|
|
95
|
+
msvcrt.locking(lock_file.fileno(), msvcrt.LK_NBLCK, 1)
|
|
96
|
+
|
|
97
|
+
# Lock acquired successfully
|
|
98
|
+
with self.lock:
|
|
99
|
+
self.active_locks[device_index] = lock_file
|
|
100
|
+
|
|
101
|
+
logging.info(f"✅ Acquired system-wide lock for video device {device_index}")
|
|
102
|
+
return True
|
|
103
|
+
|
|
104
|
+
except OSError:
|
|
105
|
+
# Lock is held by another process, wait and retry
|
|
106
|
+
time.sleep(0.1)
|
|
107
|
+
continue
|
|
108
|
+
|
|
109
|
+
# Timeout reached
|
|
110
|
+
lock_file.close()
|
|
111
|
+
logging.warning(f"⏱️ Timeout acquiring lock for video device {device_index}")
|
|
112
|
+
return False
|
|
113
|
+
|
|
114
|
+
def release_device_lock(self, device_index: int):
|
|
115
|
+
"""Release the lock for a video device."""
|
|
116
|
+
with self.lock:
|
|
117
|
+
if device_index in self.active_locks:
|
|
118
|
+
try:
|
|
119
|
+
lock_file = self.active_locks[device_index]
|
|
120
|
+
|
|
121
|
+
# Platform-specific unlocking
|
|
122
|
+
if platform.system().lower() == "windows":
|
|
123
|
+
msvcrt.locking(lock_file.fileno(), msvcrt.LK_UNLCK, 1)
|
|
124
|
+
else:
|
|
125
|
+
fcntl.flock(lock_file.fileno(), fcntl.LOCK_UN)
|
|
126
|
+
|
|
127
|
+
lock_file.close()
|
|
128
|
+
del self.active_locks[device_index]
|
|
129
|
+
|
|
130
|
+
logging.info(f"🔓 Released system-wide lock for video device {device_index}")
|
|
131
|
+
|
|
132
|
+
except Exception as e:
|
|
133
|
+
logging.error(f"❌ Error releasing device lock for device {device_index}: {e}")
|
|
134
|
+
|
|
135
|
+
def is_device_locked(self, device_index: int) -> bool:
|
|
136
|
+
"""Check if a device is currently locked by any service."""
|
|
137
|
+
lock_file_path = self.lock_dir / f"video_device_{device_index}.lock"
|
|
138
|
+
|
|
139
|
+
if not lock_file_path.exists():
|
|
140
|
+
return False
|
|
141
|
+
|
|
142
|
+
try:
|
|
143
|
+
test_file = open(lock_file_path, 'r')
|
|
144
|
+
|
|
145
|
+
# Platform-specific lock testing
|
|
146
|
+
if platform.system().lower() == "windows":
|
|
147
|
+
try:
|
|
148
|
+
msvcrt.locking(test_file.fileno(), msvcrt.LK_NBLCK, 1)
|
|
149
|
+
msvcrt.locking(test_file.fileno(), msvcrt.LK_UNLCK, 1)
|
|
150
|
+
test_file.close()
|
|
151
|
+
return False # Lock is available
|
|
152
|
+
except OSError:
|
|
153
|
+
test_file.close()
|
|
154
|
+
return True # Lock is held by another process
|
|
155
|
+
else:
|
|
156
|
+
try:
|
|
157
|
+
fcntl.flock(test_file.fileno(), fcntl.LOCK_EX | fcntl.LOCK_NB)
|
|
158
|
+
fcntl.flock(test_file.fileno(), fcntl.LOCK_UN)
|
|
159
|
+
test_file.close()
|
|
160
|
+
return False # Lock is available
|
|
161
|
+
except BlockingIOError:
|
|
162
|
+
test_file.close()
|
|
163
|
+
return True # Lock is held by another process
|
|
164
|
+
|
|
165
|
+
except Exception:
|
|
166
|
+
return False # Assume available on error
|
|
167
|
+
|
|
168
|
+
def get_device_lock_info(self, device_index: int) -> Optional[Dict]:
|
|
169
|
+
"""Get information about who is holding the device lock."""
|
|
170
|
+
lock_file_path = self.lock_dir / f"video_device_{device_index}.lock"
|
|
171
|
+
|
|
172
|
+
if not lock_file_path.exists():
|
|
173
|
+
return None
|
|
174
|
+
|
|
175
|
+
try:
|
|
176
|
+
with open(lock_file_path, 'r') as f:
|
|
177
|
+
content = f.read().strip()
|
|
178
|
+
info = {}
|
|
179
|
+
for line in content.split('\n'):
|
|
180
|
+
if ':' in line:
|
|
181
|
+
key, value = line.split(':', 1)
|
|
182
|
+
info[key] = value
|
|
183
|
+
return info
|
|
184
|
+
except Exception:
|
|
185
|
+
return None
|
|
186
|
+
|
|
187
|
+
def cleanup_stale_locks(self, max_age: float = 300.0):
|
|
188
|
+
"""Clean up stale lock files older than max_age seconds."""
|
|
189
|
+
current_time = time.time()
|
|
190
|
+
|
|
191
|
+
for lock_file_path in self.lock_dir.glob("video_device_*.lock"):
|
|
192
|
+
try:
|
|
193
|
+
# Check if lock file is stale
|
|
194
|
+
if current_time - lock_file_path.stat().st_mtime > max_age:
|
|
195
|
+
# Try to acquire lock to see if it's really stale
|
|
196
|
+
try:
|
|
197
|
+
with open(lock_file_path, 'r') as f:
|
|
198
|
+
test_file = open(lock_file_path, 'r')
|
|
199
|
+
fcntl.flock(test_file.fileno(), fcntl.LOCK_EX | fcntl.LOCK_NB)
|
|
200
|
+
fcntl.flock(test_file.fileno(), fcntl.LOCK_UN)
|
|
201
|
+
test_file.close()
|
|
202
|
+
|
|
203
|
+
# Lock is available, remove stale file
|
|
204
|
+
lock_file_path.unlink()
|
|
205
|
+
logging.info(f"🧹 Cleaned up stale lock file: {lock_file_path}")
|
|
206
|
+
|
|
207
|
+
except BlockingIOError:
|
|
208
|
+
# Lock is still active, keep it
|
|
209
|
+
pass
|
|
210
|
+
|
|
211
|
+
except Exception as e:
|
|
212
|
+
logging.warning(f"⚠️ Error checking stale lock {lock_file_path}: {e}")
|
|
213
|
+
|
|
214
|
+
def shutdown(self):
|
|
215
|
+
"""Release all locks and cleanup."""
|
|
216
|
+
logging.info("🛑 Shutting down SystemWideDeviceCoordinator")
|
|
217
|
+
|
|
218
|
+
with self.lock:
|
|
219
|
+
for device_index in list(self.active_locks.keys()):
|
|
220
|
+
self.release_device_lock(device_index)
|
|
221
|
+
|
|
222
|
+
|
|
223
|
+
# Global instance
|
|
224
|
+
_system_coordinator = None
|
|
225
|
+
_coordinator_lock = threading.Lock()
|
|
226
|
+
|
|
227
|
+
def get_system_coordinator() -> SystemWideDeviceCoordinator:
|
|
228
|
+
"""Get the global system-wide device coordinator instance."""
|
|
229
|
+
global _system_coordinator
|
|
230
|
+
|
|
231
|
+
if _system_coordinator is None:
|
|
232
|
+
with _coordinator_lock:
|
|
233
|
+
if _system_coordinator is None:
|
|
234
|
+
_system_coordinator = SystemWideDeviceCoordinator()
|
|
235
|
+
|
|
236
|
+
return _system_coordinator
|