nedo-vision-worker 1.0.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 (92) hide show
  1. nedo_vision_worker/__init__.py +10 -0
  2. nedo_vision_worker/cli.py +195 -0
  3. nedo_vision_worker/config/ConfigurationManager.py +196 -0
  4. nedo_vision_worker/config/__init__.py +1 -0
  5. nedo_vision_worker/database/DatabaseManager.py +219 -0
  6. nedo_vision_worker/database/__init__.py +1 -0
  7. nedo_vision_worker/doctor.py +453 -0
  8. nedo_vision_worker/initializer/AppInitializer.py +78 -0
  9. nedo_vision_worker/initializer/__init__.py +1 -0
  10. nedo_vision_worker/models/__init__.py +15 -0
  11. nedo_vision_worker/models/ai_model.py +29 -0
  12. nedo_vision_worker/models/auth.py +14 -0
  13. nedo_vision_worker/models/config.py +9 -0
  14. nedo_vision_worker/models/dataset_source.py +30 -0
  15. nedo_vision_worker/models/logs.py +9 -0
  16. nedo_vision_worker/models/ppe_detection.py +39 -0
  17. nedo_vision_worker/models/ppe_detection_label.py +20 -0
  18. nedo_vision_worker/models/restricted_area_violation.py +20 -0
  19. nedo_vision_worker/models/user.py +10 -0
  20. nedo_vision_worker/models/worker_source.py +19 -0
  21. nedo_vision_worker/models/worker_source_pipeline.py +21 -0
  22. nedo_vision_worker/models/worker_source_pipeline_config.py +24 -0
  23. nedo_vision_worker/models/worker_source_pipeline_debug.py +15 -0
  24. nedo_vision_worker/models/worker_source_pipeline_detection.py +14 -0
  25. nedo_vision_worker/protos/AIModelService_pb2.py +46 -0
  26. nedo_vision_worker/protos/AIModelService_pb2_grpc.py +140 -0
  27. nedo_vision_worker/protos/DatasetSourceService_pb2.py +46 -0
  28. nedo_vision_worker/protos/DatasetSourceService_pb2_grpc.py +140 -0
  29. nedo_vision_worker/protos/HumanDetectionService_pb2.py +44 -0
  30. nedo_vision_worker/protos/HumanDetectionService_pb2_grpc.py +140 -0
  31. nedo_vision_worker/protos/PPEDetectionService_pb2.py +46 -0
  32. nedo_vision_worker/protos/PPEDetectionService_pb2_grpc.py +140 -0
  33. nedo_vision_worker/protos/VisionWorkerService_pb2.py +72 -0
  34. nedo_vision_worker/protos/VisionWorkerService_pb2_grpc.py +471 -0
  35. nedo_vision_worker/protos/WorkerSourcePipelineService_pb2.py +64 -0
  36. nedo_vision_worker/protos/WorkerSourcePipelineService_pb2_grpc.py +312 -0
  37. nedo_vision_worker/protos/WorkerSourceService_pb2.py +50 -0
  38. nedo_vision_worker/protos/WorkerSourceService_pb2_grpc.py +183 -0
  39. nedo_vision_worker/protos/__init__.py +1 -0
  40. nedo_vision_worker/repositories/AIModelRepository.py +44 -0
  41. nedo_vision_worker/repositories/DatasetSourceRepository.py +150 -0
  42. nedo_vision_worker/repositories/PPEDetectionRepository.py +112 -0
  43. nedo_vision_worker/repositories/RestrictedAreaRepository.py +88 -0
  44. nedo_vision_worker/repositories/WorkerSourcePipelineDebugRepository.py +90 -0
  45. nedo_vision_worker/repositories/WorkerSourcePipelineDetectionRepository.py +48 -0
  46. nedo_vision_worker/repositories/WorkerSourcePipelineRepository.py +174 -0
  47. nedo_vision_worker/repositories/WorkerSourceRepository.py +46 -0
  48. nedo_vision_worker/repositories/__init__.py +1 -0
  49. nedo_vision_worker/services/AIModelClient.py +362 -0
  50. nedo_vision_worker/services/ConnectionInfoClient.py +57 -0
  51. nedo_vision_worker/services/DatasetSourceClient.py +88 -0
  52. nedo_vision_worker/services/FileToRTMPServer.py +78 -0
  53. nedo_vision_worker/services/GrpcClientBase.py +155 -0
  54. nedo_vision_worker/services/GrpcClientManager.py +141 -0
  55. nedo_vision_worker/services/ImageUploadClient.py +82 -0
  56. nedo_vision_worker/services/PPEDetectionClient.py +108 -0
  57. nedo_vision_worker/services/RTSPtoRTMPStreamer.py +98 -0
  58. nedo_vision_worker/services/RestrictedAreaClient.py +100 -0
  59. nedo_vision_worker/services/SystemUsageClient.py +77 -0
  60. nedo_vision_worker/services/VideoStreamClient.py +161 -0
  61. nedo_vision_worker/services/WorkerSourceClient.py +215 -0
  62. nedo_vision_worker/services/WorkerSourcePipelineClient.py +393 -0
  63. nedo_vision_worker/services/WorkerSourceUpdater.py +134 -0
  64. nedo_vision_worker/services/WorkerStatusClient.py +65 -0
  65. nedo_vision_worker/services/__init__.py +1 -0
  66. nedo_vision_worker/util/HardwareID.py +104 -0
  67. nedo_vision_worker/util/ImageUploader.py +92 -0
  68. nedo_vision_worker/util/Networking.py +94 -0
  69. nedo_vision_worker/util/PlatformDetector.py +50 -0
  70. nedo_vision_worker/util/SystemMonitor.py +299 -0
  71. nedo_vision_worker/util/VideoProbeUtil.py +120 -0
  72. nedo_vision_worker/util/__init__.py +1 -0
  73. nedo_vision_worker/worker/CoreActionWorker.py +125 -0
  74. nedo_vision_worker/worker/DataSenderWorker.py +168 -0
  75. nedo_vision_worker/worker/DataSyncWorker.py +143 -0
  76. nedo_vision_worker/worker/DatasetFrameSender.py +208 -0
  77. nedo_vision_worker/worker/DatasetFrameWorker.py +412 -0
  78. nedo_vision_worker/worker/PPEDetectionManager.py +86 -0
  79. nedo_vision_worker/worker/PipelineActionWorker.py +129 -0
  80. nedo_vision_worker/worker/PipelineImageWorker.py +116 -0
  81. nedo_vision_worker/worker/RabbitMQListener.py +170 -0
  82. nedo_vision_worker/worker/RestrictedAreaManager.py +85 -0
  83. nedo_vision_worker/worker/SystemUsageManager.py +111 -0
  84. nedo_vision_worker/worker/VideoStreamWorker.py +139 -0
  85. nedo_vision_worker/worker/WorkerManager.py +155 -0
  86. nedo_vision_worker/worker/__init__.py +1 -0
  87. nedo_vision_worker/worker_service.py +264 -0
  88. nedo_vision_worker-1.0.0.dist-info/METADATA +563 -0
  89. nedo_vision_worker-1.0.0.dist-info/RECORD +92 -0
  90. nedo_vision_worker-1.0.0.dist-info/WHEEL +5 -0
  91. nedo_vision_worker-1.0.0.dist-info/entry_points.txt +2 -0
  92. nedo_vision_worker-1.0.0.dist-info/top_level.txt +1 -0
@@ -0,0 +1,208 @@
1
+ import os
2
+ import json
3
+ import logging
4
+ import time
5
+ from pathlib import Path
6
+ from typing import List, Dict, Optional
7
+ from ..services.DatasetSourceClient import DatasetSourceClient
8
+ from ..database.DatabaseManager import _get_storage_paths
9
+
10
+ logger = logging.getLogger(__name__)
11
+
12
+ class DatasetFrameSender:
13
+ """Handles batched sending of saved dataset frames to the backend."""
14
+
15
+ def __init__(self, server_host: str, token: str):
16
+ """
17
+ Initialize the Dataset Frame Sender.
18
+
19
+ Args:
20
+ server_host (str): Server host for sending frames
21
+ token (str): Authentication token
22
+ """
23
+ self.server_host = server_host
24
+ self.token = token
25
+ self.client = DatasetSourceClient(server_host)
26
+
27
+ # Get storage paths
28
+ storage_paths = _get_storage_paths()
29
+ self.dataset_frames_path = storage_paths["files"] / "dataset_frames"
30
+
31
+ # Track sent frames to avoid duplicates
32
+ self.sent_frames = set()
33
+
34
+ def send_pending_frames(self, max_batch_size: int = 10) -> Dict[str, int]:
35
+ """
36
+ Send pending dataset frames in batches.
37
+
38
+ Args:
39
+ max_batch_size (int): Maximum number of frames to send in one batch
40
+
41
+ Returns:
42
+ Dict[str, int]: Statistics of sent frames per dataset source
43
+ """
44
+ stats = {}
45
+
46
+ try:
47
+ if not self.dataset_frames_path.exists():
48
+ return stats
49
+
50
+ # Find all dataset source directories
51
+ for dataset_source_dir in self.dataset_frames_path.iterdir():
52
+ if not dataset_source_dir.is_dir():
53
+ continue
54
+
55
+ dataset_source_id = dataset_source_dir.name
56
+ sent_count = self._send_frames_for_dataset_source(
57
+ dataset_source_dir,
58
+ dataset_source_id,
59
+ max_batch_size
60
+ )
61
+
62
+ if sent_count > 0:
63
+ stats[dataset_source_id] = sent_count
64
+
65
+ except Exception as e:
66
+ logger.error(f"🚨 [APP] Error sending pending frames: {e}")
67
+
68
+ return stats
69
+
70
+ def _send_frames_for_dataset_source(self, dataset_source_dir: Path, dataset_source_id: str, max_batch_size: int) -> int:
71
+ """
72
+ Send frames for a specific dataset source.
73
+
74
+ Args:
75
+ dataset_source_dir (Path): Directory containing frames for this dataset source
76
+ dataset_source_id (str): ID of the dataset source
77
+ max_batch_size (int): Maximum frames to send in one batch
78
+
79
+ Returns:
80
+ int: Number of frames sent
81
+ """
82
+ sent_count = 0
83
+
84
+ try:
85
+ # Find all frame files (jpg) that haven't been sent yet
86
+ frame_files = []
87
+ for file_path in dataset_source_dir.glob("*.jpg"):
88
+ frame_uuid = file_path.stem.split('_')[0] # Extract UUID from filename
89
+
90
+ if frame_uuid not in self.sent_frames:
91
+ metadata_path = file_path.with_suffix('.json')
92
+ if metadata_path.exists():
93
+ frame_files.append((file_path, metadata_path, frame_uuid))
94
+
95
+ # Sort by timestamp (filename contains timestamp)
96
+ frame_files.sort(key=lambda x: x[0].name)
97
+
98
+ # Send frames in batches
99
+ for i in range(0, len(frame_files), max_batch_size):
100
+ batch = frame_files[i:i + max_batch_size]
101
+ if self._send_frame_batch(batch, dataset_source_id):
102
+ sent_count += len(batch)
103
+
104
+ except Exception as e:
105
+ logger.error(f"🚨 [APP] Error sending frames for dataset source {dataset_source_id}: {e}")
106
+
107
+ return sent_count
108
+
109
+ def _send_frame_batch(self, frame_batch: List[tuple], dataset_source_id: str) -> bool:
110
+ """
111
+ Send a batch of frames to the backend.
112
+
113
+ Args:
114
+ frame_batch (List[tuple]): List of (file_path, metadata_path, frame_uuid) tuples
115
+ dataset_source_id (str): ID of the dataset source
116
+
117
+ Returns:
118
+ bool: True if batch was sent successfully
119
+ """
120
+ try:
121
+ for file_path, metadata_path, frame_uuid in frame_batch:
122
+ # Read frame data
123
+ with open(file_path, 'rb') as f:
124
+ frame_bytes = f.read()
125
+
126
+ # Read metadata
127
+ with open(metadata_path, 'r') as f:
128
+ metadata = json.load(f)
129
+
130
+ # Send frame to backend
131
+ response = self.client.send_dataset_frame(
132
+ dataset_source_id=metadata["dataset_source_id"],
133
+ uuid=metadata["frame_uuid"],
134
+ image=frame_bytes,
135
+ timestamp=metadata["timestamp"],
136
+ token=self.token
137
+ )
138
+
139
+ if response and response.get("success"):
140
+ # Mark as sent and clean up local files
141
+ self.sent_frames.add(frame_uuid)
142
+ self._cleanup_sent_frame(file_path, metadata_path)
143
+ logger.debug(f"✅ [APP] Sent frame {frame_uuid} for dataset source {dataset_source_id}")
144
+ else:
145
+ error_message = response.get("message", "Unknown error") if response else "Unknown error"
146
+
147
+ # Handle specific error cases
148
+ if "DatasetSource not found" in error_message:
149
+ logger.warning(f"🗑️ [APP] Dataset source {dataset_source_id} not found, cleaning up orphaned frame {frame_uuid}")
150
+ # Mark as sent to avoid retry loops and clean up
151
+ self.sent_frames.add(frame_uuid)
152
+ self._cleanup_sent_frame(file_path, metadata_path)
153
+ else:
154
+ logger.error(f"❌ [APP] Failed to send frame {frame_uuid}: {error_message}")
155
+ return False
156
+
157
+ return True
158
+
159
+ except Exception as e:
160
+ logger.error(f"🚨 [APP] Error sending frame batch: {e}")
161
+ return False
162
+
163
+ def _cleanup_sent_frame(self, file_path: Path, metadata_path: Path):
164
+ """
165
+ Clean up local files after successful send.
166
+
167
+ Args:
168
+ file_path (Path): Path to the frame file
169
+ metadata_path (Path): Path to the metadata file
170
+ """
171
+ try:
172
+ # Remove frame file
173
+ if file_path.exists():
174
+ file_path.unlink()
175
+
176
+ # Remove metadata file
177
+ if metadata_path.exists():
178
+ metadata_path.unlink()
179
+
180
+ except Exception as e:
181
+ logger.error(f"🚨 [APP] Error cleaning up sent frame: {e}")
182
+
183
+ def get_pending_frame_count(self) -> int:
184
+ """
185
+ Get the total number of pending frames to be sent.
186
+
187
+ Returns:
188
+ int: Number of pending frames
189
+ """
190
+ pending_count = 0
191
+
192
+ try:
193
+ if not self.dataset_frames_path.exists():
194
+ return 0
195
+
196
+ for dataset_source_dir in self.dataset_frames_path.iterdir():
197
+ if not dataset_source_dir.is_dir():
198
+ continue
199
+
200
+ for file_path in dataset_source_dir.glob("*.jpg"):
201
+ frame_uuid = file_path.stem.split('_')[0]
202
+ if frame_uuid not in self.sent_frames:
203
+ pending_count += 1
204
+
205
+ except Exception as e:
206
+ logger.error(f"🚨 [APP] Error counting pending frames: {e}")
207
+
208
+ return pending_count
@@ -0,0 +1,412 @@
1
+ import threading
2
+ import time
3
+ import logging
4
+ import uuid
5
+ from datetime import datetime
6
+ from typing import Dict
7
+ from ..services.WorkerSourcePipelineClient import WorkerSourcePipelineClient
8
+ from ..services.GrpcClientManager import GrpcClientManager
9
+ from ..repositories.DatasetSourceRepository import DatasetSourceRepository
10
+
11
+ logger = logging.getLogger(__name__)
12
+
13
+ def safe_join_thread(thread, timeout=5):
14
+ """Safely join a thread, avoiding RuntimeError when joining current thread."""
15
+ if thread and thread != threading.current_thread():
16
+ thread.join(timeout=timeout)
17
+ elif thread == threading.current_thread():
18
+ logging.info("🛑 [APP] Thread stopping from within itself, skipping join.")
19
+
20
+ class DatasetSourceThread:
21
+ """Individual thread for handling a single dataset source."""
22
+
23
+ def __init__(self, dataset_source, pipeline_client, storage_path):
24
+ self.dataset_source = dataset_source
25
+ self.pipeline_client = pipeline_client
26
+ self.storage_path = storage_path
27
+ self.thread = None
28
+ self.stop_event = threading.Event()
29
+ self.last_frame_time = 0
30
+ self.lock = threading.Lock()
31
+
32
+ # Create storage directory for this dataset source
33
+ self.dataset_storage_path = storage_path / "dataset_frames" / dataset_source.id
34
+ self.dataset_storage_path.mkdir(parents=True, exist_ok=True)
35
+
36
+ # Track consecutive failures
37
+ self.consecutive_failures = 0
38
+ self.max_consecutive_failures = 5
39
+
40
+ def start(self):
41
+ """Start the dataset source thread."""
42
+ if self.thread and self.thread.is_alive():
43
+ logger.warning(f"⚠️ [APP] Thread for dataset source {self.dataset_source.id} is already running.")
44
+ return
45
+
46
+ self.stop_event.clear()
47
+ self.consecutive_failures = 0 # Reset failure counter
48
+ self.thread = threading.Thread(
49
+ target=self._run,
50
+ daemon=True,
51
+ name=f"DatasetSource-{self.dataset_source.id}"
52
+ )
53
+ self.thread.start()
54
+ logger.info(f"🚀 [APP] Started thread for dataset source {self.dataset_source.id} ({self.dataset_source.dataset_name})")
55
+
56
+ def stop(self):
57
+ """Stop the dataset source thread."""
58
+ if not self.thread or not self.thread.is_alive():
59
+ return
60
+
61
+ self.stop_event.set()
62
+ safe_join_thread(self.thread)
63
+ logger.info(f"🛑 [APP] Stopped thread for dataset source {self.dataset_source.id}")
64
+
65
+ def _run(self):
66
+ """Main loop for this dataset source."""
67
+ try:
68
+ while not self.stop_event.is_set():
69
+ current_time = time.time()
70
+
71
+ # Check if it's time to capture a frame
72
+ if (current_time - self.last_frame_time) >= self.dataset_source.sampling_interval:
73
+ success = self._capture_frame()
74
+
75
+ with self.lock:
76
+ self.last_frame_time = current_time
77
+ if success:
78
+ self.consecutive_failures = 0 # Reset on success
79
+ else:
80
+ self.consecutive_failures += 1
81
+
82
+ # If too many consecutive failures, log warning and pause
83
+ if self.consecutive_failures >= self.max_consecutive_failures:
84
+ logger.warning(f"⚠️ [APP] Dataset source {self.dataset_source.id} has {self.consecutive_failures} consecutive failures. Pausing for 30 seconds.")
85
+ time.sleep(30) # Pause for 30 seconds
86
+ self.consecutive_failures = 0 # Reset after pause
87
+
88
+ # Sleep for a shorter interval to be more responsive
89
+ time.sleep(min(1, self.dataset_source.sampling_interval / 10))
90
+
91
+ except Exception as e:
92
+ logger.error(f"🚨 [APP] Error in dataset source thread {self.dataset_source.id}: {e}", exc_info=True)
93
+
94
+ def _capture_frame(self):
95
+ """Capture and save a frame for this dataset source. Returns True if successful."""
96
+ try:
97
+ # Get frame from source
98
+ frame_bytes = self._get_frame_from_source(self.dataset_source.worker_source_url)
99
+
100
+ if frame_bytes:
101
+ # Generate unique filename
102
+ timestamp = int(time.time() * 1000)
103
+ frame_uuid = str(uuid.uuid4())
104
+ filename = f"{frame_uuid}_{timestamp}.jpg"
105
+ file_path = self.dataset_storage_path / filename
106
+
107
+ # Save frame to local storage
108
+ with open(file_path, 'wb') as f:
109
+ f.write(frame_bytes)
110
+
111
+ # Create metadata file
112
+ metadata = {
113
+ "dataset_source_id": self.dataset_source.id,
114
+ "dataset_id": self.dataset_source.dataset_id,
115
+ "worker_source_id": self.dataset_source.worker_source_id,
116
+ "dataset_name": self.dataset_source.dataset_name,
117
+ "worker_source_name": self.dataset_source.worker_source_name,
118
+ "worker_source_url": self.dataset_source.worker_source_url,
119
+ "frame_uuid": frame_uuid,
120
+ "timestamp": timestamp,
121
+ "captured_at": datetime.utcnow().isoformat()
122
+ }
123
+
124
+ metadata_path = file_path.with_suffix('.json')
125
+ import json
126
+ with open(metadata_path, 'w') as f:
127
+ json.dump(metadata, f, indent=2)
128
+
129
+ logger.info(f"📸 [APP] Captured frame for {self.dataset_source.dataset_name} (ID: {self.dataset_source.id})")
130
+ return True
131
+ else:
132
+ logger.warning(f"⚠️ [APP] Could not get frame from source {self.dataset_source.worker_source_url}")
133
+ return False
134
+
135
+ except Exception as e:
136
+ logger.error(f"🚨 [APP] Error capturing frame for {self.dataset_source.dataset_name}: {e}", exc_info=True)
137
+ return False
138
+
139
+ def _get_frame_from_source(self, source_url):
140
+ """Get a frame from the given source URL."""
141
+ try:
142
+ stream_type = self.pipeline_client._detect_stream_type(source_url)
143
+ if stream_type == "video_file":
144
+ logger.info(f"🎬 [APP] Capturing video frame from {source_url}")
145
+ elif stream_type == "image_file":
146
+ logger.info(f"🖼️ [APP] Capturing image frame from {source_url}")
147
+ elif stream_type in ["rtsp", "hls"]:
148
+ logger.info(f"📡 [APP] Capturing live stream frame from {source_url}")
149
+
150
+ frame_bytes = self.pipeline_client._get_single_frame_bytes(source_url)
151
+
152
+ if frame_bytes and stream_type == "video_file":
153
+ status = self.pipeline_client.get_video_positions_status()
154
+ for video_path, info in status.items():
155
+ if info["duration"]:
156
+ logger.info(f"📊 [APP] Video progress: {info['progress_percent']:.1f}% ({info['current_position']:.2f}s / {info['duration']:.2f}s)")
157
+
158
+ return frame_bytes
159
+ except Exception as e:
160
+ logger.error(f"🚨 [APP] Error getting frame from source {source_url}: {e}", exc_info=True)
161
+ return None
162
+
163
+ class DatasetFrameWorker:
164
+ def __init__(self, config: dict):
165
+ """
166
+ Initialize Dataset Frame Worker.
167
+
168
+ Args:
169
+ config (dict): Configuration object containing settings.
170
+ """
171
+ if not isinstance(config, dict):
172
+ raise ValueError("⚠️ [APP] config must be a dictionary.")
173
+
174
+ self.config = config
175
+ self.worker_id = self.config.get("worker_id")
176
+ self.server_host = self.config.get("server_host")
177
+ self.token = self.config.get("token")
178
+
179
+ if not self.worker_id:
180
+ raise ValueError("⚠️ [APP] Configuration is missing 'worker_id'.")
181
+ if not self.token:
182
+ raise ValueError("⚠️ [APP] Configuration is missing 'token'.")
183
+
184
+ self.dataset_source_repo = DatasetSourceRepository()
185
+
186
+ # Get shared client instance from the centralized manager
187
+ self.client_manager = GrpcClientManager.get_instance()
188
+ self.worker_source_pipeline_client = self.client_manager.get_client(WorkerSourcePipelineClient)
189
+
190
+ self.thread = None
191
+ self.stop_event = threading.Event()
192
+ self.lock = threading.Lock()
193
+
194
+ # Cache for dataset source threads
195
+ self.dataset_source_threads: Dict[str, DatasetSourceThread] = {}
196
+ self.last_sync_time = 0
197
+ self.sync_interval = 30 # Sync dataset sources every 30 seconds
198
+
199
+ # Thread for syncing dataset sources
200
+ self.sync_thread = None
201
+
202
+ # Sync lock to prevent multiple simultaneous sync operations
203
+ self.sync_lock = threading.Lock()
204
+
205
+ # Storage path for dataset frames
206
+ from ..database.DatabaseManager import get_storage_path
207
+ self.storage_path = get_storage_path("files")
208
+
209
+ def start(self):
210
+ """Start the Dataset Frame Worker."""
211
+ with self.lock:
212
+ if self.thread and self.thread.is_alive():
213
+ logger.warning("⚠️ [APP] Dataset Frame Worker is already running.")
214
+ return
215
+
216
+ self.stop_event.clear()
217
+
218
+ # Start sync thread
219
+ self.sync_thread = threading.Thread(target=self._sync_loop, daemon=True)
220
+ self.sync_thread.start()
221
+
222
+ # Start main worker thread
223
+ self.thread = threading.Thread(target=self._run, daemon=True)
224
+ self.thread.start()
225
+ logger.info(f"🚀 [APP] Dataset Frame Worker started (Device: {self.worker_id}).")
226
+
227
+ def stop(self):
228
+ """Stop the Dataset Frame Worker."""
229
+ with self.lock:
230
+ if not self.thread or not self.thread.is_alive():
231
+ logger.warning("⚠️ [APP] Dataset Frame Worker is not running.")
232
+ return
233
+
234
+ self.stop_event.set()
235
+
236
+ # Stop all dataset source threads
237
+ for thread in self.dataset_source_threads.values():
238
+ thread.stop()
239
+
240
+ # Wait for threads to stop
241
+ if self.thread:
242
+ safe_join_thread(self.thread)
243
+ if self.sync_thread:
244
+ safe_join_thread(self.sync_thread)
245
+
246
+ self.thread = None
247
+ self.sync_thread = None
248
+ logger.info(f"🛑 [APP] Dataset Frame Worker stopped (Device: {self.worker_id}).")
249
+
250
+ def _run(self):
251
+ """Main loop for managing dataset source threads."""
252
+ try:
253
+ while not self.stop_event.is_set():
254
+ self._manage_dataset_source_threads()
255
+ time.sleep(5) # Check every 5 seconds
256
+ except Exception as e:
257
+ logger.error("🚨 [APP] Unexpected error in Dataset Frame Worker main loop.", exc_info=True)
258
+
259
+ def _sync_loop(self):
260
+ """Background thread for syncing dataset sources."""
261
+ try:
262
+ while not self.stop_event.is_set():
263
+ self._sync_dataset_sources()
264
+ time.sleep(self.sync_interval)
265
+ except Exception as e:
266
+ logger.error("🚨 [APP] Error in dataset source sync loop.", exc_info=True)
267
+
268
+ def _sync_dataset_sources(self):
269
+ """Sync dataset sources from server."""
270
+ # Prevent multiple simultaneous sync operations
271
+ if not self.sync_lock.acquire(blocking=False):
272
+ logger.debug("🔄 [APP] Sync operation already in progress, skipping...")
273
+ return
274
+
275
+ try:
276
+ from ..services.DatasetSourceClient import DatasetSourceClient
277
+ # Use shared client instead of creating new instance
278
+ client = self.client_manager.get_client(DatasetSourceClient, "DatasetSourceClient")
279
+ response = client.get_dataset_source_list(self.token)
280
+
281
+ if response and response.get("success"):
282
+ dataset_sources_data = response.get("data", [])
283
+ self.dataset_source_repo.sync_dataset_sources(dataset_sources_data)
284
+ self.last_sync_time = time.time()
285
+ else:
286
+ error_message = response.get("message", "Unknown error") if response else "Unknown error"
287
+ logger.error(f"❌ [APP] Failed to sync dataset sources: {error_message}")
288
+
289
+ except Exception as e:
290
+ logger.error("🚨 [APP] Error syncing dataset sources.", exc_info=True)
291
+ finally:
292
+ self.sync_lock.release()
293
+
294
+ def _cleanup_orphaned_frames(self, deleted_dataset_source_ids):
295
+ """Clean up frames for deleted dataset sources."""
296
+ try:
297
+ for dataset_source_id in deleted_dataset_source_ids:
298
+ orphaned_frames_path = self.storage_path / "dataset_frames" / dataset_source_id
299
+ if orphaned_frames_path.exists():
300
+ import shutil
301
+ shutil.rmtree(orphaned_frames_path)
302
+ logger.info(f"🗑️ [APP] Cleaned up orphaned frames for deleted dataset source {dataset_source_id}")
303
+ except Exception as e:
304
+ logger.error(f"🚨 [APP] Error cleaning up orphaned frames: {e}", exc_info=True)
305
+
306
+ def _manage_dataset_source_threads(self):
307
+ """Manage dataset source threads based on current dataset sources."""
308
+ try:
309
+ # Get current dataset sources from local database
310
+ dataset_sources = self.dataset_source_repo.get_all_dataset_sources()
311
+ current_dataset_source_ids = {ds.id for ds in dataset_sources}
312
+
313
+ # Stop threads for dataset sources that no longer exist
314
+ threads_to_remove = []
315
+ deleted_dataset_source_ids = []
316
+ for dataset_source_id, thread in self.dataset_source_threads.items():
317
+ if dataset_source_id not in current_dataset_source_ids:
318
+ logger.info(f"🛑 [APP] Stopping thread for deleted dataset source {dataset_source_id}")
319
+ thread.stop()
320
+ threads_to_remove.append(dataset_source_id)
321
+ deleted_dataset_source_ids.append(dataset_source_id)
322
+
323
+ for dataset_source_id in threads_to_remove:
324
+ del self.dataset_source_threads[dataset_source_id]
325
+
326
+ # Clean up orphaned frames for deleted dataset sources
327
+ if deleted_dataset_source_ids:
328
+ self._cleanup_orphaned_frames(deleted_dataset_source_ids)
329
+
330
+ # Process current dataset sources
331
+ for dataset_source in dataset_sources:
332
+ if dataset_source.id not in self.dataset_source_threads:
333
+ # Create new thread for new dataset source
334
+ logger.info(f"🆕 [APP] Creating new thread for dataset source {dataset_source.id} ({dataset_source.dataset_name})")
335
+ thread = DatasetSourceThread(
336
+ dataset_source=dataset_source,
337
+ pipeline_client=self.worker_source_pipeline_client,
338
+ storage_path=self.storage_path
339
+ )
340
+ self.dataset_source_threads[dataset_source.id] = thread
341
+ thread.start()
342
+ else:
343
+ # Update existing thread with new dataset source data
344
+ existing_thread = self.dataset_source_threads[dataset_source.id]
345
+ if self._dataset_source_changed(existing_thread.dataset_source, dataset_source):
346
+ logger.info(f"🔄 [APP] Updating thread for dataset source {dataset_source.id} ({dataset_source.dataset_name})")
347
+ # Stop the old thread
348
+ existing_thread.stop()
349
+ # Create new thread with updated data
350
+ new_thread = DatasetSourceThread(
351
+ dataset_source=dataset_source,
352
+ pipeline_client=self.worker_source_pipeline_client,
353
+ storage_path=self.storage_path
354
+ )
355
+ self.dataset_source_threads[dataset_source.id] = new_thread
356
+ new_thread.start()
357
+
358
+ # Log current status
359
+ active_threads = len([t for t in self.dataset_source_threads.values() if t.thread and t.thread.is_alive()])
360
+ logger.debug(f"📊 [APP] Dataset Frame Worker status: {active_threads} active threads, {len(dataset_sources)} total dataset sources")
361
+
362
+ except Exception as e:
363
+ logger.error("🚨 [APP] Error managing dataset source threads.", exc_info=True)
364
+
365
+ def _dataset_source_changed(self, old_dataset_source, new_dataset_source):
366
+ """Check if dataset source data has changed significantly."""
367
+ try:
368
+ # Compare relevant fields that would affect thread behavior
369
+ fields_to_compare = [
370
+ 'worker_source_url',
371
+ 'sampling_interval',
372
+ 'dataset_name',
373
+ 'worker_source_name',
374
+ 'dataset_id',
375
+ 'worker_source_id'
376
+ ]
377
+
378
+ for field in fields_to_compare:
379
+ old_value = getattr(old_dataset_source, field, None)
380
+ new_value = getattr(new_dataset_source, field, None)
381
+ if old_value != new_value:
382
+ logger.debug(f"🔄 [APP] Dataset source {new_dataset_source.id} field '{field}' changed: {old_value} -> {new_value}")
383
+ return True
384
+
385
+ return False
386
+
387
+ except Exception as e:
388
+ logger.error(f"🚨 [APP] Error comparing dataset sources: {e}", exc_info=True)
389
+ return True # Assume changed if comparison fails
390
+
391
+ def get_status(self):
392
+ """Get current status of dataset frame worker."""
393
+ try:
394
+ dataset_sources = self.dataset_source_repo.get_all_dataset_sources()
395
+ active_threads = [t for t in self.dataset_source_threads.values() if t.thread and t.thread.is_alive()]
396
+
397
+ return {
398
+ "total_dataset_sources": len(dataset_sources),
399
+ "active_threads": len(active_threads),
400
+ "thread_details": [
401
+ {
402
+ "dataset_source_id": t.dataset_source.id,
403
+ "dataset_name": t.dataset_source.dataset_name,
404
+ "is_alive": t.thread.is_alive() if t.thread else False,
405
+ "consecutive_failures": t.consecutive_failures
406
+ }
407
+ for t in self.dataset_source_threads.values()
408
+ ]
409
+ }
410
+ except Exception as e:
411
+ logger.error(f"🚨 [APP] Error getting dataset frame worker status: {e}", exc_info=True)
412
+ return {"error": str(e)}