From 48acae9c7416aed04b11f97dce490ff7658cc189 Mon Sep 17 00:00:00 2001 From: HuangMason320 Date: Wed, 13 Aug 2025 22:03:42 +0800 Subject: [PATCH] feat: Implement multi-series dongle support and improve app stability --- core/functions/Multidongle.py | 235 +++++++++++++++- core/functions/demo_topology_clean.py | 375 ------------------------- core/nodes/exact_nodes.py | 267 +++++++++++++++++- force_cleanup.py | 142 ++++++++++ gentle_cleanup.py | 121 ++++++++ kill_app_processes.py | 66 +++++ main.py | 247 +++++++++++++--- test_folder_selection.py | 69 +++++ test_multi_series_integration_final.py | 203 +++++++++++++ test_multi_series_multidongle.py | 170 +++++++++++ utils/__init__.py | 4 + utils/folder_dialog.py | 217 ++++++++++++++ 12 files changed, 1689 insertions(+), 427 deletions(-) delete mode 100644 core/functions/demo_topology_clean.py create mode 100644 force_cleanup.py create mode 100644 gentle_cleanup.py create mode 100644 kill_app_processes.py create mode 100644 test_folder_selection.py create mode 100644 test_multi_series_integration_final.py create mode 100644 test_multi_series_multidongle.py create mode 100644 utils/folder_dialog.py diff --git a/core/functions/Multidongle.py b/core/functions/Multidongle.py index ad68057..130e234 100644 --- a/core/functions/Multidongle.py +++ b/core/functions/Multidongle.py @@ -10,7 +10,39 @@ import kp import cv2 import time from abc import ABC, abstractmethod -from typing import Callable, Optional, Any, Dict +from typing import Callable, Optional, Any, Dict, List +from dataclasses import dataclass +from collections import defaultdict + + +@dataclass +class InferenceTask: + sequence_id: int + image_data: np.ndarray + image_format: Any # kp.ImageFormat + timestamp: float + + +@dataclass +class InferenceResult: + sequence_id: int + result: Any + series_name: str + timestamp: float + + +class DongleSeriesSpec: + """Dongle series specifications with GOPS capacity for load balancing""" + KL520_GOPS = 3 + KL720_GOPS = 28 + + SERIES_SPECS = { + "KL520": {"product_id": 0x100, "gops": KL520_GOPS}, + "KL720": {"product_id": 0x720, "gops": KL720_GOPS}, + "KL630": {"product_id": 0x630, "gops": 400}, + "KL730": {"product_id": 0x730, "gops": 1600}, + "KL540": {"product_id": 0x540, "gops": 800} + } class DataProcessor(ABC): @@ -222,17 +254,108 @@ class MultiDongle: except kp.ApiKPException as exception: raise Exception(f'Failed to connect devices: {str(exception)}') - def __init__(self, port_id: list = None, scpu_fw_path: str = None, ncpu_fw_path: str = None, model_path: str = None, upload_fw: bool = False, auto_detect: bool = False, max_queue_size: int = 0): + def __init__(self, port_id: list = None, scpu_fw_path: str = None, ncpu_fw_path: str = None, + model_path: str = None, upload_fw: bool = False, auto_detect: bool = False, + max_queue_size: int = 0, multi_series_config: dict = None): """ - Initialize the MultiDongle class. - :param port_id: List of USB port IDs for the same layer's devices. If None and auto_detect=True, will auto-detect devices. - :param scpu_fw_path: Path to the SCPU firmware file. - :param ncpu_fw_path: Path to the NCPU firmware file. - :param model_path: Path to the model file. - :param upload_fw: Flag to indicate whether to upload firmware. - :param auto_detect: Flag to auto-detect and connect to available devices. + Initialize the MultiDongle class with support for both single and multi-series configurations. + + :param port_id: List of USB port IDs for single-series (legacy). If None and auto_detect=True, will auto-detect. + :param scpu_fw_path: Path to the SCPU firmware file for single-series (legacy). + :param ncpu_fw_path: Path to the NCPU firmware file for single-series (legacy). + :param model_path: Path to the model file for single-series (legacy). + :param upload_fw: Flag to indicate whether to upload firmware for single-series (legacy). + :param auto_detect: Flag to auto-detect and connect to available devices for single-series (legacy). :param max_queue_size: Maximum size for internal queues. If 0, unlimited queues are used. + :param multi_series_config: Multi-series configuration dict. Format: + { + "KL520": { + "port_ids": [28, 32], + "model_path": "path/to/kl520_model.nef", + "firmware_paths": { # Optional + "scpu": "path/to/kl520_scpu.bin", + "ncpu": "path/to/kl520_ncpu.bin" + } + } + } """ + # Determine if we're using multi-series mode + self.multi_series_mode = multi_series_config is not None + + if self.multi_series_mode: + # Multi-series initialization + self._init_multi_series(multi_series_config, max_queue_size) + else: + # Legacy single-series initialization + self._init_single_series(port_id, scpu_fw_path, ncpu_fw_path, model_path, + upload_fw, auto_detect, max_queue_size) + + def _init_multi_series(self, multi_series_config: dict, max_queue_size: int): + """Initialize multi-series configuration""" + self.series_config = multi_series_config + self.series_groups = {} # series_name -> config + self.device_groups = {} # series_name -> device_group + self.model_descriptors = {} # series_name -> model descriptor + self.gops_weights = {} # series_name -> normalized weight + self.current_loads = {} # series_name -> current queue size + + # Set up series groups and calculate weights + total_gops = 0 + for series_name, config in multi_series_config.items(): + if series_name not in DongleSeriesSpec.SERIES_SPECS: + raise ValueError(f"Unknown series: {series_name}") + + self.series_groups[series_name] = config + self.current_loads[series_name] = 0 + + # Calculate effective GOPS (series GOPS * number of devices) + port_count = len(config.get("port_ids", [])) + series_gops = DongleSeriesSpec.SERIES_SPECS[series_name]["gops"] + effective_gops = series_gops * port_count + total_gops += effective_gops + + # Calculate normalized weights + for series_name, config in multi_series_config.items(): + port_count = len(config.get("port_ids", [])) + series_gops = DongleSeriesSpec.SERIES_SPECS[series_name]["gops"] + effective_gops = series_gops * port_count + self.gops_weights[series_name] = effective_gops / total_gops if total_gops > 0 else 0 + + # Multi-series threading and queues + if max_queue_size > 0: + self._input_queue = queue.Queue(maxsize=max_queue_size) + self._ordered_output_queue = queue.Queue(maxsize=max_queue_size) + else: + self._input_queue = queue.Queue() + self._ordered_output_queue = queue.Queue() + + self.result_queues = {} # series_name -> queue + for series_name in multi_series_config.keys(): + self.result_queues[series_name] = queue.Queue() + + # Sequence management for ordered results + self.sequence_counter = 0 + self.sequence_lock = threading.Lock() + self.pending_results = {} # sequence_id -> InferenceResult + self.next_output_sequence = 0 + + # Threading + self._stop_event = threading.Event() + self.dispatcher_thread = None + self.send_threads = {} # series_name -> thread + self.receive_threads = {} # series_name -> thread + self.result_ordering_thread = None + + # Legacy attributes for compatibility + self.port_id = [] + self.device_group = None + self.model_nef_descriptor = None + self.generic_inference_input_descriptor = None + self._inference_counter = 0 + + def _init_single_series(self, port_id: list, scpu_fw_path: str, ncpu_fw_path: str, + model_path: str, upload_fw: bool, auto_detect: bool, max_queue_size: int): + """Initialize legacy single-series configuration""" self.auto_detect = auto_detect self.connected_devices_info = [] @@ -258,8 +381,8 @@ class MultiDongle: # generic_inference_input_descriptor will be prepared in initialize self.model_nef_descriptor = None self.generic_inference_input_descriptor = None + # Queues for data - # Input queue for images to be sent if max_queue_size > 0: self._input_queue = queue.Queue(maxsize=max_queue_size) self._output_queue = queue.Queue(maxsize=max_queue_size) @@ -270,9 +393,99 @@ class MultiDongle: # Threading attributes self._send_thread = None self._receive_thread = None - self._stop_event = threading.Event() # Event to signal threads to stop + self._stop_event = threading.Event() self._inference_counter = 0 + + # Convert single-series to multi-series format internally for unified processing + self._convert_single_to_multi_series() + + def _convert_single_to_multi_series(self): + """ + Convert single-series configuration to multi-series format internally + This allows unified processing regardless of initialization mode + """ + if not self.port_id: + # No ports specified, create empty structure + self.series_groups = {} + self.gops_weights = {} + self.current_loads = {} + return + + # Detect series from connected devices or use default + detected_series = self._detect_series_from_ports(self.port_id) + + # Create multi-series config format + self.series_groups = { + detected_series: { + "port_ids": self.port_id.copy(), + "model_path": self.model_path, + "firmware_paths": { + "scpu": self.scpu_fw_path, + "ncpu": self.ncpu_fw_path + } if self.scpu_fw_path and self.ncpu_fw_path else {} + } + } + + # Calculate GOPS weights (100% since it's single series) + self.gops_weights = {detected_series: 1.0} + + # Initialize load tracking + self.current_loads = {detected_series: 0} + + print(f"Single-series config converted to multi-series format: {detected_series}") + + def _detect_series_from_ports(self, port_ids: List[int]) -> str: + """ + Detect series from port IDs by scanning connected devices + Falls back to KL520 if unable to detect + """ + try: + # Try to scan devices and match port IDs + devices_info = self.scan_devices() + + for device_info in devices_info: + if device_info['port_id'] in port_ids: + series = device_info.get('series', 'Unknown') + if series != 'Unknown': + return series + + # If scanning didn't work, try to auto-detect from the first available device + if self.auto_detect and self.connected_devices_info: + for device_info in self.connected_devices_info: + series = device_info.get('series', 'Unknown') + if series != 'Unknown': + return series + except Exception as e: + print(f"Warning: Could not detect series from devices: {e}") + + # Fallback to KL520 (most common series) + print("Warning: Could not detect device series, defaulting to KL520") + return "KL520" + + def _select_optimal_series(self) -> Optional[str]: + """ + Select optimal series based on current load and GOPS capacity + Returns the series name with the best load/capacity ratio + """ + if not self.multi_series_mode or not self.series_groups: + return None + + best_ratio = float('inf') + selected_series = None + + for series_name in self.series_groups.keys(): + current_load = self.current_loads.get(series_name, 0) + weight = self.gops_weights.get(series_name, 0) + + # Calculate load ratio (lower is better) + load_ratio = current_load / weight if weight > 0 else float('inf') + + if load_ratio < best_ratio: + best_ratio = load_ratio + selected_series = series_name + + return selected_series def initialize(self): """ diff --git a/core/functions/demo_topology_clean.py b/core/functions/demo_topology_clean.py deleted file mode 100644 index 21b533b..0000000 --- a/core/functions/demo_topology_clean.py +++ /dev/null @@ -1,375 +0,0 @@ -#!/usr/bin/env python3 -""" -智慧拓撲排序算法演示 (獨立版本) - -不依賴外部模組,純粹展示拓撲排序算法的核心功能 -""" - -import json -from typing import List, Dict, Any, Tuple -from collections import deque - -class TopologyDemo: - """演示拓撲排序算法的類別""" - - def __init__(self): - self.stage_order = [] - - def analyze_pipeline(self, pipeline_data: Dict[str, Any]): - """分析pipeline並執行拓撲排序""" - print("Starting intelligent pipeline topology analysis...") - - # 提取模型節點 - model_nodes = [node for node in pipeline_data.get('nodes', []) - if 'model' in node.get('type', '').lower()] - connections = pipeline_data.get('connections', []) - - if not model_nodes: - print(" Warning: No model nodes found!") - return [] - - # 建立依賴圖 - dependency_graph = self._build_dependency_graph(model_nodes, connections) - - # 檢測循環 - cycles = self._detect_cycles(dependency_graph) - if cycles: - print(f" Warning: Found {len(cycles)} cycles!") - dependency_graph = self._resolve_cycles(dependency_graph, cycles) - - # 執行拓撲排序 - sorted_stages = self._topological_sort_with_optimization(dependency_graph, model_nodes) - - # 計算指標 - metrics = self._calculate_pipeline_metrics(sorted_stages, dependency_graph) - self._display_pipeline_analysis(sorted_stages, metrics) - - return sorted_stages - - def _build_dependency_graph(self, model_nodes: List[Dict], connections: List[Dict]) -> Dict[str, Dict]: - """建立依賴圖""" - print(" Building dependency graph...") - - graph = {} - for node in model_nodes: - graph[node['id']] = { - 'node': node, - 'dependencies': set(), - 'dependents': set(), - 'depth': 0 - } - - # 分析連接 - for conn in connections: - output_node_id = conn.get('output_node') - input_node_id = conn.get('input_node') - - if output_node_id in graph and input_node_id in graph: - graph[input_node_id]['dependencies'].add(output_node_id) - graph[output_node_id]['dependents'].add(input_node_id) - - dep_count = sum(len(data['dependencies']) for data in graph.values()) - print(f" Graph built: {len(graph)} nodes, {dep_count} dependencies") - return graph - - def _detect_cycles(self, graph: Dict[str, Dict]) -> List[List[str]]: - """檢測循環""" - print(" Checking for dependency cycles...") - - cycles = [] - visited = set() - rec_stack = set() - - def dfs_cycle_detect(node_id, path): - if node_id in rec_stack: - cycle_start = path.index(node_id) - cycle = path[cycle_start:] + [node_id] - cycles.append(cycle) - return True - - if node_id in visited: - return False - - visited.add(node_id) - rec_stack.add(node_id) - path.append(node_id) - - for dependent in graph[node_id]['dependents']: - if dfs_cycle_detect(dependent, path): - return True - - path.pop() - rec_stack.remove(node_id) - return False - - for node_id in graph: - if node_id not in visited: - dfs_cycle_detect(node_id, []) - - if cycles: - print(f" Warning: Found {len(cycles)} cycles") - else: - print(" No cycles detected") - - return cycles - - def _resolve_cycles(self, graph: Dict[str, Dict], cycles: List[List[str]]) -> Dict[str, Dict]: - """解決循環""" - print(" Resolving dependency cycles...") - - for cycle in cycles: - node_names = [graph[nid]['node']['name'] for nid in cycle] - print(f" Breaking cycle: {' → '.join(node_names)}") - - if len(cycle) >= 2: - node_to_break = cycle[-2] - dependent_to_break = cycle[-1] - - graph[dependent_to_break]['dependencies'].discard(node_to_break) - graph[node_to_break]['dependents'].discard(dependent_to_break) - - print(f" Broke dependency: {graph[node_to_break]['node']['name']} → {graph[dependent_to_break]['node']['name']}") - - return graph - - def _topological_sort_with_optimization(self, graph: Dict[str, Dict], model_nodes: List[Dict]) -> List[Dict]: - """執行優化的拓撲排序""" - print(" Performing optimized topological sort...") - - # 計算深度層級 - self._calculate_depth_levels(graph) - - # 按深度分組 - depth_groups = self._group_by_depth(graph) - - # 排序 - sorted_nodes = [] - for depth in sorted(depth_groups.keys()): - group_nodes = depth_groups[depth] - - group_nodes.sort(key=lambda nid: ( - len(graph[nid]['dependencies']), - -len(graph[nid]['dependents']), - graph[nid]['node']['name'] - )) - - for node_id in group_nodes: - sorted_nodes.append(graph[node_id]['node']) - - print(f" Sorted {len(sorted_nodes)} stages into {len(depth_groups)} execution levels") - return sorted_nodes - - def _calculate_depth_levels(self, graph: Dict[str, Dict]): - """計算深度層級""" - print(" Calculating execution depth levels...") - - no_deps = [nid for nid, data in graph.items() if not data['dependencies']] - queue = deque([(nid, 0) for nid in no_deps]) - - while queue: - node_id, depth = queue.popleft() - - if graph[node_id]['depth'] < depth: - graph[node_id]['depth'] = depth - - for dependent in graph[node_id]['dependents']: - queue.append((dependent, depth + 1)) - - def _group_by_depth(self, graph: Dict[str, Dict]) -> Dict[int, List[str]]: - """按深度分組""" - depth_groups = {} - - for node_id, data in graph.items(): - depth = data['depth'] - if depth not in depth_groups: - depth_groups[depth] = [] - depth_groups[depth].append(node_id) - - return depth_groups - - def _calculate_pipeline_metrics(self, sorted_stages: List[Dict], graph: Dict[str, Dict]) -> Dict[str, Any]: - """計算指標""" - print(" Calculating pipeline metrics...") - - total_stages = len(sorted_stages) - max_depth = max([data['depth'] for data in graph.values()]) + 1 if graph else 1 - - depth_distribution = {} - for data in graph.values(): - depth = data['depth'] - depth_distribution[depth] = depth_distribution.get(depth, 0) + 1 - - max_parallel = max(depth_distribution.values()) if depth_distribution else 1 - critical_path = self._find_critical_path(graph) - - return { - 'total_stages': total_stages, - 'pipeline_depth': max_depth, - 'max_parallel_stages': max_parallel, - 'parallelization_efficiency': (total_stages / max_depth) if max_depth > 0 else 1.0, - 'critical_path_length': len(critical_path), - 'critical_path': critical_path - } - - def _find_critical_path(self, graph: Dict[str, Dict]) -> List[str]: - """找出關鍵路徑""" - longest_path = [] - - def dfs_longest_path(node_id, current_path): - nonlocal longest_path - - current_path.append(node_id) - - if not graph[node_id]['dependents']: - if len(current_path) > len(longest_path): - longest_path = current_path.copy() - else: - for dependent in graph[node_id]['dependents']: - dfs_longest_path(dependent, current_path) - - current_path.pop() - - for node_id, data in graph.items(): - if not data['dependencies']: - dfs_longest_path(node_id, []) - - return longest_path - - def _display_pipeline_analysis(self, sorted_stages: List[Dict], metrics: Dict[str, Any]): - """顯示分析結果""" - print("\n" + "="*60) - print("INTELLIGENT PIPELINE TOPOLOGY ANALYSIS COMPLETE") - print("="*60) - - print(f"Pipeline Metrics:") - print(f" Total Stages: {metrics['total_stages']}") - print(f" Pipeline Depth: {metrics['pipeline_depth']} levels") - print(f" Max Parallel Stages: {metrics['max_parallel_stages']}") - print(f" Parallelization Efficiency: {metrics['parallelization_efficiency']:.1%}") - - print(f"\nOptimized Execution Order:") - for i, stage in enumerate(sorted_stages, 1): - print(f" {i:2d}. {stage['name']} (ID: {stage['id'][:8]}...)") - - if metrics['critical_path']: - print(f"\nCritical Path ({metrics['critical_path_length']} stages):") - critical_names = [] - for node_id in metrics['critical_path']: - node_name = next((stage['name'] for stage in sorted_stages if stage['id'] == node_id), 'Unknown') - critical_names.append(node_name) - print(f" {' → '.join(critical_names)}") - - print(f"\nPerformance Insights:") - if metrics['parallelization_efficiency'] > 0.8: - print(" Excellent parallelization potential!") - elif metrics['parallelization_efficiency'] > 0.6: - print(" Good parallelization opportunities available") - else: - print(" Limited parallelization - consider pipeline redesign") - - if metrics['pipeline_depth'] <= 3: - print(" Low latency pipeline - great for real-time applications") - elif metrics['pipeline_depth'] <= 6: - print(" Balanced pipeline depth - good throughput/latency trade-off") - else: - print(" Deep pipeline - optimized for maximum throughput") - - print("="*60 + "\n") - -def create_demo_pipelines(): - """創建演示用的pipeline""" - - # Demo 1: 簡單線性pipeline - simple_pipeline = { - "project_name": "Simple Linear Pipeline", - "nodes": [ - {"id": "model_001", "name": "Object Detection", "type": "ExactModelNode"}, - {"id": "model_002", "name": "Fire Classification", "type": "ExactModelNode"}, - {"id": "model_003", "name": "Result Verification", "type": "ExactModelNode"} - ], - "connections": [ - {"output_node": "model_001", "input_node": "model_002"}, - {"output_node": "model_002", "input_node": "model_003"} - ] - } - - # Demo 2: 並行pipeline - parallel_pipeline = { - "project_name": "Parallel Processing Pipeline", - "nodes": [ - {"id": "model_001", "name": "RGB Processor", "type": "ExactModelNode"}, - {"id": "model_002", "name": "IR Processor", "type": "ExactModelNode"}, - {"id": "model_003", "name": "Depth Processor", "type": "ExactModelNode"}, - {"id": "model_004", "name": "Fusion Engine", "type": "ExactModelNode"} - ], - "connections": [ - {"output_node": "model_001", "input_node": "model_004"}, - {"output_node": "model_002", "input_node": "model_004"}, - {"output_node": "model_003", "input_node": "model_004"} - ] - } - - # Demo 3: 複雜多層pipeline - complex_pipeline = { - "project_name": "Advanced Multi-Stage Fire Detection Pipeline", - "nodes": [ - {"id": "model_rgb_001", "name": "RGB Feature Extractor", "type": "ExactModelNode"}, - {"id": "model_edge_002", "name": "Edge Feature Extractor", "type": "ExactModelNode"}, - {"id": "model_thermal_003", "name": "Thermal Feature Extractor", "type": "ExactModelNode"}, - {"id": "model_fusion_004", "name": "Feature Fusion", "type": "ExactModelNode"}, - {"id": "model_attention_005", "name": "Attention Mechanism", "type": "ExactModelNode"}, - {"id": "model_classifier_006", "name": "Fire Classifier", "type": "ExactModelNode"} - ], - "connections": [ - {"output_node": "model_rgb_001", "input_node": "model_fusion_004"}, - {"output_node": "model_edge_002", "input_node": "model_fusion_004"}, - {"output_node": "model_thermal_003", "input_node": "model_attention_005"}, - {"output_node": "model_fusion_004", "input_node": "model_classifier_006"}, - {"output_node": "model_attention_005", "input_node": "model_classifier_006"} - ] - } - - # Demo 4: 有循環的pipeline (測試循環檢測) - cycle_pipeline = { - "project_name": "Pipeline with Cycles (Testing)", - "nodes": [ - {"id": "model_A", "name": "Model A", "type": "ExactModelNode"}, - {"id": "model_B", "name": "Model B", "type": "ExactModelNode"}, - {"id": "model_C", "name": "Model C", "type": "ExactModelNode"} - ], - "connections": [ - {"output_node": "model_A", "input_node": "model_B"}, - {"output_node": "model_B", "input_node": "model_C"}, - {"output_node": "model_C", "input_node": "model_A"} # 創建循環! - ] - } - - return [simple_pipeline, parallel_pipeline, complex_pipeline, cycle_pipeline] - -def main(): - """主演示函數""" - print("INTELLIGENT PIPELINE TOPOLOGY SORTING DEMONSTRATION") - print("="*60) - print("This demo showcases our advanced pipeline analysis capabilities:") - print("• Automatic dependency resolution") - print("• Parallel execution optimization") - print("• Cycle detection and prevention") - print("• Critical path analysis") - print("• Performance metrics calculation") - print("="*60 + "\n") - - demo = TopologyDemo() - pipelines = create_demo_pipelines() - demo_names = ["Simple Linear", "Parallel Processing", "Complex Multi-Stage", "Cycle Detection"] - - for i, (pipeline, name) in enumerate(zip(pipelines, demo_names), 1): - print(f"DEMO {i}: {name} Pipeline") - print("="*50) - demo.analyze_pipeline(pipeline) - print("\n") - - print("ALL DEMONSTRATIONS COMPLETED SUCCESSFULLY!") - print("Ready for production deployment and progress reporting!") - -if __name__ == "__main__": - main() \ No newline at end of file diff --git a/core/nodes/exact_nodes.py b/core/nodes/exact_nodes.py index 9df208c..7ae5b4c 100644 --- a/core/nodes/exact_nodes.py +++ b/core/nodes/exact_nodes.py @@ -115,6 +115,16 @@ class ExactModelNode(BaseNode): self.create_property('port_id', '') self.create_property('upload_fw', True) + # Multi-series properties + self.create_property('multi_series_mode', False) + self.create_property('assets_folder', '') + self.create_property('enabled_series', ['520', '720']) + self.create_property('max_queue_size', 100) + self.create_property('result_buffer_size', 1000) + self.create_property('batch_size', 1) + self.create_property('enable_preprocessing', False) + self.create_property('enable_postprocessing', False) + # Original property options - exact match self._property_options = { 'dongle_series': ['520', '720', '1080', 'Custom'], @@ -123,11 +133,25 @@ class ExactModelNode(BaseNode): 'scpu_fw_path': {'type': 'file_path', 'filter': 'SCPU Firmware files (*.bin)'}, 'ncpu_fw_path': {'type': 'file_path', 'filter': 'NCPU Firmware files (*.bin)'}, 'port_id': {'placeholder': 'e.g., 8080 or auto'}, - 'upload_fw': {'type': 'bool', 'default': True, 'description': 'Upload firmware to dongle if needed'} + 'upload_fw': {'type': 'bool', 'default': True, 'description': 'Upload firmware to dongle if needed'}, + + # Multi-series property options + 'multi_series_mode': {'type': 'bool', 'default': False, 'description': 'Enable multi-series dongle support'}, + 'assets_folder': {'type': 'file_path', 'filter': 'Folder', 'mode': 'directory'}, + 'enabled_series': {'type': 'list', 'options': ['520', '720', '630', '730', '540'], 'default': ['520', '720']}, + 'max_queue_size': {'min': 1, 'max': 1000, 'default': 100}, + 'result_buffer_size': {'min': 100, 'max': 10000, 'default': 1000}, + 'batch_size': {'min': 1, 'max': 32, 'default': 1}, + 'enable_preprocessing': {'type': 'bool', 'default': False}, + 'enable_postprocessing': {'type': 'bool', 'default': False} } # Create custom properties dictionary for UI compatibility self._populate_custom_properties() + + # Set up custom property handlers for folder selection + if NODEGRAPH_AVAILABLE: + self._setup_custom_property_handlers() def _populate_custom_properties(self): """Populate the custom properties dictionary for UI compatibility.""" @@ -166,8 +190,245 @@ class ExactModelNode(BaseNode): def get_display_properties(self): """Return properties that should be displayed in the UI panel.""" - # Customize which properties appear for Model nodes - return ['model_path', 'scpu_fw_path', 'ncpu_fw_path', 'dongle_series', 'num_dongles', 'port_id', 'upload_fw'] + if not NODEGRAPH_AVAILABLE: + return [] + + # Base properties that are always shown + base_props = ['multi_series_mode'] + + try: + # Check if we're in multi-series mode + multi_series_mode = self.get_property('multi_series_mode') + + if multi_series_mode: + # Multi-series mode: show multi-series specific properties + return base_props + [ + 'assets_folder', 'enabled_series', + 'max_queue_size', 'result_buffer_size', 'batch_size', + 'enable_preprocessing', 'enable_postprocessing' + ] + else: + # Single-series mode: show traditional properties + return base_props + [ + 'model_path', 'scpu_fw_path', 'ncpu_fw_path', + 'dongle_series', 'num_dongles', 'port_id', 'upload_fw' + ] + except: + # Fallback to single-series mode if property access fails + return base_props + [ + 'model_path', 'scpu_fw_path', 'ncpu_fw_path', + 'dongle_series', 'num_dongles', 'port_id', 'upload_fw' + ] + + def get_inference_config(self): + """Get configuration for inference pipeline""" + if not NODEGRAPH_AVAILABLE: + return {} + + try: + multi_series_mode = self.get_property('multi_series_mode') + + if multi_series_mode: + # Multi-series configuration + return { + 'multi_series_mode': True, + 'assets_folder': self.get_property('assets_folder'), + 'enabled_series': self.get_property('enabled_series'), + 'max_queue_size': self.get_property('max_queue_size'), + 'result_buffer_size': self.get_property('result_buffer_size'), + 'batch_size': self.get_property('batch_size'), + 'enable_preprocessing': self.get_property('enable_preprocessing'), + 'enable_postprocessing': self.get_property('enable_postprocessing') + } + else: + # Single-series configuration + return { + 'multi_series_mode': False, + 'model_path': self.get_property('model_path'), + 'scpu_fw_path': self.get_property('scpu_fw_path'), + 'ncpu_fw_path': self.get_property('ncpu_fw_path'), + 'dongle_series': self.get_property('dongle_series'), + 'num_dongles': self.get_property('num_dongles'), + 'port_id': self.get_property('port_id'), + 'upload_fw': self.get_property('upload_fw') + } + except: + # Fallback to single-series configuration + return { + 'multi_series_mode': False, + 'model_path': self.get_property('model_path', ''), + 'scpu_fw_path': self.get_property('scpu_fw_path', ''), + 'ncpu_fw_path': self.get_property('ncpu_fw_path', ''), + 'dongle_series': self.get_property('dongle_series', '520'), + 'num_dongles': self.get_property('num_dongles', 1), + 'port_id': self.get_property('port_id', ''), + 'upload_fw': self.get_property('upload_fw', True) + } + + def get_hardware_requirements(self): + """Get hardware requirements for this node""" + if not NODEGRAPH_AVAILABLE: + return {} + + try: + multi_series_mode = self.get_property('multi_series_mode') + + if multi_series_mode: + enabled_series = self.get_property('enabled_series') + return { + 'multi_series_mode': True, + 'required_series': enabled_series, + 'estimated_dongles': len(enabled_series) * 2 # Assume 2 dongles per series + } + else: + dongle_series = self.get_property('dongle_series') + num_dongles = self.get_property('num_dongles') + return { + 'multi_series_mode': False, + 'required_series': [f'KL{dongle_series}'], + 'estimated_dongles': num_dongles + } + except: + return {'multi_series_mode': False, 'required_series': ['KL520'], 'estimated_dongles': 1} + + def _setup_custom_property_handlers(self): + """Setup custom property handlers, especially for folder selection.""" + try: + # For assets_folder, we want to trigger folder selection dialog + # This might require custom widget or property handling + # For now, we'll use the standard approach but add validation + + # You can override the property widget here if needed + # This is a placeholder for custom folder selection implementation + pass + except Exception as e: + print(f"Warning: Could not setup custom property handlers: {e}") + + def select_assets_folder(self): + """Method to open folder selection dialog for assets folder using tkinter.""" + if not NODEGRAPH_AVAILABLE: + return "" + + try: + import tkinter as tk + from tkinter import filedialog + + # Create a root window but keep it hidden + root = tk.Tk() + root.withdraw() # Hide the main window + root.attributes('-topmost', True) # Bring dialog to front + + # Open folder selection dialog + folder_path = filedialog.askdirectory( + title="Select Assets Folder", + initialdir="", + mustexist=True + ) + + # Destroy the root window + root.destroy() + + if folder_path: + # Validate the selected folder structure + if self._validate_assets_folder(folder_path): + # Set the property + if NODEGRAPH_AVAILABLE: + self.set_property('assets_folder', folder_path) + print(f"Assets folder set to: {folder_path}") + return folder_path + else: + print(f"Warning: Selected folder does not have the expected structure") + print("Expected structure: Assets/Firmware/ and Assets/Models/ with series subfolders") + # Still set it, but warn user + if NODEGRAPH_AVAILABLE: + self.set_property('assets_folder', folder_path) + return folder_path + + except ImportError: + print("tkinter not available, falling back to simple input") + # Fallback to manual input + folder_path = input("Enter Assets folder path: ").strip() + if folder_path and NODEGRAPH_AVAILABLE: + self.set_property('assets_folder', folder_path) + return folder_path + except Exception as e: + print(f"Error selecting assets folder: {e}") + + return "" + + def _validate_assets_folder(self, folder_path): + """Validate that the assets folder has the expected structure.""" + try: + import os + + # Check if Firmware and Models folders exist + firmware_path = os.path.join(folder_path, 'Firmware') + models_path = os.path.join(folder_path, 'Models') + + has_firmware = os.path.exists(firmware_path) and os.path.isdir(firmware_path) + has_models = os.path.exists(models_path) and os.path.isdir(models_path) + + if not (has_firmware and has_models): + return False + + # Check for at least one series subfolder + expected_series = ['KL520', 'KL720', 'KL630', 'KL730', 'KL540'] + + firmware_series = [d for d in os.listdir(firmware_path) + if os.path.isdir(os.path.join(firmware_path, d)) and d in expected_series] + + models_series = [d for d in os.listdir(models_path) + if os.path.isdir(os.path.join(models_path, d)) and d in expected_series] + + # At least one series should exist in both firmware and models + return len(firmware_series) > 0 and len(models_series) > 0 + + except Exception as e: + print(f"Error validating assets folder: {e}") + return False + + def get_assets_folder_info(self): + """Get information about the configured assets folder.""" + if not NODEGRAPH_AVAILABLE: + return {} + + try: + folder_path = self.get_property('assets_folder') + if not folder_path: + return {'status': 'not_set', 'message': 'No assets folder selected'} + + if not os.path.exists(folder_path): + return {'status': 'invalid', 'message': 'Selected folder does not exist'} + + info = {'status': 'valid', 'path': folder_path, 'series': []} + + # Get available series + firmware_path = os.path.join(folder_path, 'Firmware') + models_path = os.path.join(folder_path, 'Models') + + if os.path.exists(firmware_path): + firmware_series = [d for d in os.listdir(firmware_path) + if os.path.isdir(os.path.join(firmware_path, d))] + info['firmware_series'] = firmware_series + + if os.path.exists(models_path): + models_series = [d for d in os.listdir(models_path) + if os.path.isdir(os.path.join(models_path, d))] + info['models_series'] = models_series + + # Find common series + if 'firmware_series' in info and 'models_series' in info: + common_series = list(set(info['firmware_series']) & set(info['models_series'])) + info['available_series'] = common_series + + if not common_series: + info['status'] = 'incomplete' + info['message'] = 'No series found with both firmware and models' + + return info + + except Exception as e: + return {'status': 'error', 'message': f'Error reading assets folder: {e}'} class ExactPreprocessNode(BaseNode): diff --git a/force_cleanup.py b/force_cleanup.py new file mode 100644 index 0000000..3f9ea84 --- /dev/null +++ b/force_cleanup.py @@ -0,0 +1,142 @@ +""" +Force cleanup of all app data and processes +""" + +import psutil +import os +import sys +import time +import tempfile + +def kill_all_python_processes(): + """Force kill ALL Python processes (use with caution)""" + killed_processes = [] + + for proc in psutil.process_iter(['pid', 'name', 'cmdline']): + try: + if 'python' in proc.info['name'].lower(): + print(f"Killing Python process: {proc.info['pid']} - {proc.info['name']}") + proc.kill() + killed_processes.append(proc.info['pid']) + except (psutil.NoSuchProcess, psutil.AccessDenied, psutil.ZombieProcess): + pass + + if killed_processes: + print(f"Killed {len(killed_processes)} Python processes") + time.sleep(3) # Give more time for cleanup + else: + print("No Python processes found") + +def clear_shared_memory(): + """Clear Qt shared memory""" + try: + from PyQt5.QtCore import QSharedMemory + app_names = ["Cluster4NPU", "cluster4npu", "main"] + + for app_name in app_names: + shared_mem = QSharedMemory(app_name) + if shared_mem.attach(): + shared_mem.detach() + print(f"Cleared shared memory for: {app_name}") + except Exception as e: + print(f"Could not clear shared memory: {e}") + +def clean_all_temp_files(): + """Remove all possible lock and temp files""" + possible_files = [ + 'app.lock', + '.app.lock', + 'cluster4npu.lock', + '.cluster4npu.lock', + 'main.lock', + '.main.lock' + ] + + # Check in current directory + current_dir_files = [] + for filename in possible_files: + filepath = os.path.join(os.getcwd(), filename) + if os.path.exists(filepath): + try: + os.remove(filepath) + current_dir_files.append(filepath) + print(f"Removed: {filepath}") + except Exception as e: + print(f"Could not remove {filepath}: {e}") + + # Check in temp directory + temp_dir = tempfile.gettempdir() + temp_files = [] + for filename in possible_files: + filepath = os.path.join(temp_dir, filename) + if os.path.exists(filepath): + try: + os.remove(filepath) + temp_files.append(filepath) + print(f"Removed: {filepath}") + except Exception as e: + print(f"Could not remove {filepath}: {e}") + + # Check in user home directory + home_dir = os.path.expanduser('~') + home_files = [] + for filename in possible_files: + filepath = os.path.join(home_dir, filename) + if os.path.exists(filepath): + try: + os.remove(filepath) + home_files.append(filepath) + print(f"Removed: {filepath}") + except Exception as e: + print(f"Could not remove {filepath}: {e}") + + total_removed = len(current_dir_files) + len(temp_files) + len(home_files) + if total_removed == 0: + print("No lock files found") + +def force_unlock_files(): + """Try to unlock any locked files""" + try: + # On Windows, try to reset file handles + import subprocess + result = subprocess.run(['tasklist', '/FI', 'IMAGENAME eq python.exe'], + capture_output=True, text=True, timeout=10) + if result.returncode == 0: + lines = result.stdout.strip().split('\n') + for line in lines[3:]: # Skip header lines + if 'python.exe' in line: + parts = line.split() + if len(parts) >= 2: + pid = parts[1] + try: + subprocess.run(['taskkill', '/F', '/PID', pid], timeout=5) + print(f"Force killed PID: {pid}") + except: + pass + except Exception as e: + print(f"Could not force unlock files: {e}") + +if __name__ == '__main__': + print("FORCE CLEANUP - This will kill ALL Python processes!") + print("=" * 60) + + response = input("Are you sure? This will close ALL Python programs (y/N): ") + if response.lower() in ['y', 'yes']: + print("\n1. Killing all Python processes...") + kill_all_python_processes() + + print("\n2. Clearing shared memory...") + clear_shared_memory() + + print("\n3. Removing lock files...") + clean_all_temp_files() + + print("\n4. Force unlocking files...") + force_unlock_files() + + print("\n" + "=" * 60) + print("FORCE CLEANUP COMPLETE!") + print("All Python processes killed and lock files removed.") + print("You can now start the app with 'python main.py'") + else: + print("Cleanup cancelled.") \ No newline at end of file diff --git a/gentle_cleanup.py b/gentle_cleanup.py new file mode 100644 index 0000000..bf2a04e --- /dev/null +++ b/gentle_cleanup.py @@ -0,0 +1,121 @@ +""" +Gentle cleanup of app data (safer approach) +""" + +import psutil +import os +import sys +import time + +def find_and_kill_app_processes(): + """Find and kill only the Cluster4NPU app processes""" + killed_processes = [] + + for proc in psutil.process_iter(['pid', 'name', 'cmdline', 'cwd']): + try: + if 'python' in proc.info['name'].lower(): + cmdline = proc.info['cmdline'] + cwd = proc.info['cwd'] + + # Check if this is our app + if (cmdline and + (any('main.py' in arg for arg in cmdline) or + any('cluster4npu' in arg.lower() for arg in cmdline) or + (cwd and 'cluster4npu' in cwd.lower()))): + + print(f"Found app process: {proc.info['pid']}") + print(f" Command: {' '.join(cmdline) if cmdline else 'N/A'}") + print(f" Working dir: {cwd}") + + # Try gentle termination first + proc.terminate() + time.sleep(2) + + # If still running, force kill + if proc.is_running(): + proc.kill() + print(f" Force killed: {proc.info['pid']}") + else: + print(f" Gently terminated: {proc.info['pid']}") + + killed_processes.append(proc.info['pid']) + + except (psutil.NoSuchProcess, psutil.AccessDenied, psutil.ZombieProcess): + pass + + if killed_processes: + print(f"\nKilled {len(killed_processes)} app processes") + time.sleep(2) + else: + print("No app processes found") + +def clear_app_locks(): + """Remove only app-specific lock files""" + app_specific_locks = [ + 'cluster4npu.lock', + '.cluster4npu.lock', + 'Cluster4NPU.lock', + 'main.lock', + '.main.lock' + ] + + locations = [ + os.getcwd(), # Current directory + os.path.expanduser('~'), # User home + os.path.join(os.path.expanduser('~'), '.cluster4npu'), # App data dir + 'C:\\temp' if os.name == 'nt' else '/tmp', # System temp + ] + + removed_files = [] + + for location in locations: + if not os.path.exists(location): + continue + + for lock_name in app_specific_locks: + lock_path = os.path.join(location, lock_name) + if os.path.exists(lock_path): + try: + os.remove(lock_path) + removed_files.append(lock_path) + print(f"Removed lock: {lock_path}") + except Exception as e: + print(f"Could not remove {lock_path}: {e}") + + if not removed_files: + print("No lock files found") + +def reset_shared_memory(): + """Reset Qt shared memory for the app""" + try: + from PyQt5.QtCore import QSharedMemory + + shared_mem = QSharedMemory("Cluster4NPU") + if shared_mem.attach(): + print("Found shared memory, detaching...") + shared_mem.detach() + + # Try to create and destroy to fully reset + if shared_mem.create(1): + shared_mem.detach() + print("Reset shared memory") + + except Exception as e: + print(f"Could not reset shared memory: {e}") + +if __name__ == '__main__': + print("Gentle App Cleanup") + print("=" * 30) + + print("\n1. Looking for app processes...") + find_and_kill_app_processes() + + print("\n2. Clearing app locks...") + clear_app_locks() + + print("\n3. Resetting shared memory...") + reset_shared_memory() + + print("\n" + "=" * 30) + print("Cleanup complete!") + print("You can now start the app with 'python main.py'") \ No newline at end of file diff --git a/kill_app_processes.py b/kill_app_processes.py new file mode 100644 index 0000000..9dcba9c --- /dev/null +++ b/kill_app_processes.py @@ -0,0 +1,66 @@ +""" +Kill any running app processes and clean up locks +""" + +import psutil +import os +import sys +import time + +def kill_python_processes(): + """Kill any Python processes that might be running the app""" + killed_processes = [] + + for proc in psutil.process_iter(['pid', 'name', 'cmdline']): + try: + # Check if it's a Python process + if 'python' in proc.info['name'].lower(): + cmdline = proc.info['cmdline'] + if cmdline and any('main.py' in arg for arg in cmdline): + print(f"Killing process: {proc.info['pid']} - {' '.join(cmdline)}") + proc.kill() + killed_processes.append(proc.info['pid']) + except (psutil.NoSuchProcess, psutil.AccessDenied, psutil.ZombieProcess): + pass + + if killed_processes: + print(f"Killed {len(killed_processes)} Python processes") + time.sleep(2) # Give processes time to cleanup + else: + print("No running app processes found") + +def clean_lock_files(): + """Remove any lock files that might prevent app startup""" + possible_lock_files = [ + 'app.lock', + '.app.lock', + 'cluster4npu.lock', + os.path.expanduser('~/.cluster4npu.lock'), + '/tmp/cluster4npu.lock', + 'C:\\temp\\cluster4npu.lock' + ] + + removed_files = [] + for lock_file in possible_lock_files: + try: + if os.path.exists(lock_file): + os.remove(lock_file) + removed_files.append(lock_file) + print(f"Removed lock file: {lock_file}") + except Exception as e: + print(f"Could not remove {lock_file}: {e}") + + if removed_files: + print(f"Removed {len(removed_files)} lock files") + else: + print("No lock files found") + +if __name__ == '__main__': + print("Cleaning up app processes and lock files...") + print("=" * 50) + + kill_python_processes() + clean_lock_files() + + print("=" * 50) + print("Cleanup complete! You can now start the app with 'python main.py'") \ No newline at end of file diff --git a/main.py b/main.py index fc631d7..96db6be 100644 --- a/main.py +++ b/main.py @@ -41,60 +41,194 @@ from ui.windows.login import DashboardLogin class SingleInstance: - """Ensure only one instance of the application can run.""" + """Enhanced single instance handler with better error recovery.""" def __init__(self, app_name="Cluster4NPU"): self.app_name = app_name self.shared_memory = QSharedMemory(app_name) self.lock_file = None self.lock_fd = None + self.process_check_enabled = True def is_running(self): - """Check if another instance is already running.""" - # Try to create shared memory - if self.shared_memory.attach(): - # Another instance is already running + """Check if another instance is already running with recovery mechanisms.""" + # First, try to detect and clean up stale instances + if self._detect_and_cleanup_stale_instances(): + print("Cleaned up stale application instances") + + # Try shared memory approach + if self._check_shared_memory(): return True - # Try to create the shared memory - if not self.shared_memory.create(1): - # Failed to create, likely another instance exists + # Try file locking approach + if self._check_file_lock(): return True - # Also use file locking as backup (works better on some systems) - if HAS_FCNTL: - try: - self.lock_file = os.path.join(tempfile.gettempdir(), f"{self.app_name}.lock") - self.lock_fd = os.open(self.lock_file, os.O_CREAT | os.O_EXCL | os.O_RDWR) - fcntl.lockf(self.lock_fd, fcntl.LOCK_EX | fcntl.LOCK_NB) - except (OSError, IOError): - # Another instance is running - if self.lock_fd: - os.close(self.lock_fd) - return True - else: - # On Windows, try simple file creation - try: - self.lock_file = os.path.join(tempfile.gettempdir(), f"{self.app_name}.lock") - self.lock_fd = os.open(self.lock_file, os.O_CREAT | os.O_EXCL | os.O_RDWR) - except (OSError, IOError): - return True - return False - def cleanup(self): - """Clean up resources.""" - if self.shared_memory.isAttached(): - self.shared_memory.detach() + def _detect_and_cleanup_stale_instances(self): + """Detect and clean up stale instances that might have crashed.""" + cleaned_up = False + + try: + import psutil - if self.lock_fd: - try: + # Check if there are any actual running processes + app_processes = [] + for proc in psutil.process_iter(['pid', 'name', 'cmdline', 'create_time']): + try: + if 'python' in proc.info['name'].lower(): + cmdline = proc.info['cmdline'] + if cmdline and any('main.py' in arg for arg in cmdline): + app_processes.append(proc) + except (psutil.NoSuchProcess, psutil.AccessDenied): + continue + + # If no actual app processes are running, clean up stale locks + if not app_processes: + cleaned_up = self._force_cleanup_locks() + + except ImportError: + # psutil not available, try basic cleanup + cleaned_up = self._force_cleanup_locks() + except Exception as e: + print(f"Warning: Could not detect stale instances: {e}") + + return cleaned_up + + def _force_cleanup_locks(self): + """Force cleanup of stale locks.""" + cleaned_up = False + + # Try to clean up shared memory + try: + if self.shared_memory.attach(): + self.shared_memory.detach() + cleaned_up = True + except: + pass + + # Try to clean up lock file + try: + lock_file = os.path.join(tempfile.gettempdir(), f"{self.app_name}.lock") + if os.path.exists(lock_file): + os.unlink(lock_file) + cleaned_up = True + except: + pass + + return cleaned_up + + def _check_shared_memory(self): + """Check shared memory for running instance.""" + try: + # Try to attach to existing shared memory + if self.shared_memory.attach(): + # Check if the shared memory is actually valid + try: + # Try to read from it to verify it's not corrupted + data = self.shared_memory.data() + if data is not None: + return True # Valid instance found + else: + # Corrupted shared memory, clean it up + self.shared_memory.detach() + except: + # Error reading, clean up + self.shared_memory.detach() + + # Try to create new shared memory + if not self.shared_memory.create(1): + # Could not create, but attachment failed too - might be corruption + return False + + except Exception as e: + print(f"Warning: Shared memory check failed: {e}") + return False + + return False + + def _check_file_lock(self): + """Check file lock for running instance.""" + try: + self.lock_file = os.path.join(tempfile.gettempdir(), f"{self.app_name}.lock") + + if HAS_FCNTL: + # Unix-like systems + try: + self.lock_fd = os.open(self.lock_file, os.O_CREAT | os.O_EXCL | os.O_RDWR) + fcntl.lockf(self.lock_fd, fcntl.LOCK_EX | fcntl.LOCK_NB) + return False # Successfully locked, no other instance + except (OSError, IOError): + return True # Could not lock, another instance exists + else: + # Windows + try: + self.lock_fd = os.open(self.lock_file, os.O_CREAT | os.O_EXCL | os.O_RDWR) + return False # Successfully created, no other instance + except (OSError, IOError): + # File exists, but check if the process that created it is still running + if self._is_lock_file_stale(): + # Stale lock file, remove it and try again + try: + os.unlink(self.lock_file) + self.lock_fd = os.open(self.lock_file, os.O_CREAT | os.O_EXCL | os.O_RDWR) + return False + except: + pass + return True + + except Exception as e: + print(f"Warning: File lock check failed: {e}") + return False + + def _is_lock_file_stale(self): + """Check if the lock file is from a stale process.""" + try: + if not os.path.exists(self.lock_file): + return True + + # Check file age - if older than 5 minutes, consider it stale + import time + file_age = time.time() - os.path.getmtime(self.lock_file) + if file_age > 300: # 5 minutes + return True + + # On Windows, we can't easily check if the process is still running + # without additional information, so we rely on age check + return False + + except: + return True # If we can't check, assume it's stale + + def cleanup(self): + """Enhanced cleanup with better error handling.""" + try: + if self.shared_memory.isAttached(): + self.shared_memory.detach() + except Exception as e: + print(f"Warning: Could not detach shared memory: {e}") + + try: + if self.lock_fd is not None: if HAS_FCNTL: fcntl.lockf(self.lock_fd, fcntl.LOCK_UN) os.close(self.lock_fd) + self.lock_fd = None + except Exception as e: + print(f"Warning: Could not close lock file descriptor: {e}") + + try: + if self.lock_file and os.path.exists(self.lock_file): os.unlink(self.lock_file) - except: - pass + except Exception as e: + print(f"Warning: Could not remove lock file: {e}") + + def force_cleanup(self): + """Force cleanup of all locks (use when app crashed).""" + print("Force cleaning up application locks...") + self._force_cleanup_locks() + print("Force cleanup completed") def setup_application(): @@ -125,6 +259,23 @@ def setup_application(): def main(): """Main application entry point.""" + # Check for command line arguments + if '--force-cleanup' in sys.argv or '--cleanup' in sys.argv: + print("Force cleanup mode enabled") + single_instance = SingleInstance() + single_instance.force_cleanup() + print("Cleanup completed. You can now start the application normally.") + sys.exit(0) + + # Check for help argument + if '--help' in sys.argv or '-h' in sys.argv: + print("Cluster4NPU Application") + print("Usage: python main.py [options]") + print("Options:") + print(" --force-cleanup, --cleanup Force cleanup of stale application locks") + print(" --help, -h Show this help message") + sys.exit(0) + # Create a minimal QApplication first for the message box temp_app = QApplication(sys.argv) if not QApplication.instance() else QApplication.instance() @@ -132,12 +283,32 @@ def main(): single_instance = SingleInstance() if single_instance.is_running(): - QMessageBox.warning( + reply = QMessageBox.question( None, "Application Already Running", - "Cluster4NPU is already running. Please check your taskbar or system tray.", + "Cluster4NPU is already running. \n\n" + "Would you like to:\n" + "• Click 'Yes' to force cleanup and restart\n" + "• Click 'No' to cancel startup", + QMessageBox.Yes | QMessageBox.No, + QMessageBox.No ) - sys.exit(0) + + if reply == QMessageBox.Yes: + print("User requested force cleanup...") + single_instance.force_cleanup() + print("Cleanup completed, proceeding with startup...") + # Create a new instance checker after cleanup + single_instance = SingleInstance() + if single_instance.is_running(): + QMessageBox.critical( + None, + "Cleanup Failed", + "Could not clean up the existing instance. Please restart your computer." + ) + sys.exit(1) + else: + sys.exit(0) try: # Setup the full application diff --git a/test_folder_selection.py b/test_folder_selection.py new file mode 100644 index 0000000..379dda5 --- /dev/null +++ b/test_folder_selection.py @@ -0,0 +1,69 @@ +""" +Test tkinter folder selection functionality +""" + +import sys +import os + +# Add project root to path +sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) + +from utils.folder_dialog import select_folder, select_assets_folder + +def test_basic_folder_selection(): + """Test basic folder selection""" + print("Testing basic folder selection...") + + folder = select_folder("Select any folder for testing") + if folder: + print(f"Selected folder: {folder}") + print(f" Exists: {os.path.exists(folder)}") + print(f" Is directory: {os.path.isdir(folder)}") + return True + else: + print("No folder selected") + return False + +def test_assets_folder_selection(): + """Test Assets folder selection with validation""" + print("\nTesting Assets folder selection...") + + result = select_assets_folder() + + print(f"Selected path: {result['path']}") + print(f"Valid: {result['valid']}") + print(f"Message: {result['message']}") + + if 'details' in result: + details = result['details'] + print(f"Details:") + print(f" Has Firmware folder: {details.get('has_firmware_folder', False)}") + print(f" Has Models folder: {details.get('has_models_folder', False)}") + print(f" Firmware series: {details.get('firmware_series', [])}") + print(f" Models series: {details.get('models_series', [])}") + print(f" Available series: {details.get('available_series', [])}") + print(f" Series with files: {details.get('series_with_files', [])}") + + return result['valid'] + +if __name__ == "__main__": + print("Testing Folder Selection Dialog") + print("=" * 40) + + # Test basic functionality + basic_works = test_basic_folder_selection() + + # Test Assets folder functionality + assets_works = test_assets_folder_selection() + + print("\n" + "=" * 40) + print("Test Results:") + print(f"Basic folder selection: {'PASS' if basic_works else 'FAIL'}") + print(f"Assets folder selection: {'PASS' if assets_works else 'FAIL'}") + + if basic_works: + print("\ntkinter folder selection is working!") + print("You can now use this in your ExactModelNode.") + else: + print("\ntkinter might not be available or there's an issue.") + print("Consider using PyQt5 QFileDialog as fallback.") \ No newline at end of file diff --git a/test_multi_series_integration_final.py b/test_multi_series_integration_final.py new file mode 100644 index 0000000..14bd43c --- /dev/null +++ b/test_multi_series_integration_final.py @@ -0,0 +1,203 @@ +""" +Final Integration Test for Multi-Series Multidongle + +Comprehensive test suite for the completed multi-series integration +""" + +import unittest +import sys +import os + +# Add project root to path +sys.path.insert(0, os.path.join(os.path.dirname(__file__), 'core', 'functions')) + +from Multidongle import MultiDongle, DongleSeriesSpec + +class TestMultiSeriesIntegration(unittest.TestCase): + + def setUp(self): + """Set up test fixtures""" + self.multi_series_config = { + "KL520": { + "port_ids": [28, 32], + "model_path": "/path/to/kl520_model.nef", + "firmware_paths": { + "scpu": "/path/to/kl520_scpu.bin", + "ncpu": "/path/to/kl520_ncpu.bin" + } + }, + "KL720": { + "port_ids": [40, 44], + "model_path": "/path/to/kl720_model.nef", + "firmware_paths": { + "scpu": "/path/to/kl720_scpu.bin", + "ncpu": "/path/to/kl720_ncpu.bin" + } + } + } + + def test_multi_series_initialization_success(self): + """Test that multi-series initialization works correctly""" + multidongle = MultiDongle(multi_series_config=self.multi_series_config) + + # Should be in multi-series mode + self.assertTrue(multidongle.multi_series_mode) + + # Should have series groups configured + self.assertIsNotNone(multidongle.series_groups) + self.assertIn("KL520", multidongle.series_groups) + self.assertIn("KL720", multidongle.series_groups) + + # Should have correct configuration for each series + kl520_config = multidongle.series_groups["KL520"] + self.assertEqual(kl520_config["port_ids"], [28, 32]) + self.assertEqual(kl520_config["model_path"], "/path/to/kl520_model.nef") + + kl720_config = multidongle.series_groups["KL720"] + self.assertEqual(kl720_config["port_ids"], [40, 44]) + self.assertEqual(kl720_config["model_path"], "/path/to/kl720_model.nef") + + # Should have GOPS weights calculated + self.assertIsNotNone(multidongle.gops_weights) + self.assertIn("KL520", multidongle.gops_weights) + self.assertIn("KL720", multidongle.gops_weights) + + # KL720 should have higher weight due to higher GOPS (28 vs 3 GOPS) + # But since both have 2 devices: KL520=3*2=6 total GOPS, KL720=28*2=56 total GOPS + # Total = 62 GOPS, so KL520 weight = 6/62 ≈ 0.097, KL720 weight = 56/62 ≈ 0.903 + self.assertGreater(multidongle.gops_weights["KL720"], + multidongle.gops_weights["KL720"]) + + # Weights should sum to 1.0 + total_weight = sum(multidongle.gops_weights.values()) + self.assertAlmostEqual(total_weight, 1.0, places=5) + + print("Multi-series initialization test passed") + + def test_single_series_to_multi_series_conversion_success(self): + """Test that single-series config gets converted to multi-series internally""" + # Legacy single-series initialization + multidongle = MultiDongle( + port_id=[28, 32], + scpu_fw_path="/path/to/scpu.bin", + ncpu_fw_path="/path/to/ncpu.bin", + model_path="/path/to/model.nef", + upload_fw=True + ) + + # Should NOT be in explicit multi-series mode (legacy mode) + self.assertFalse(multidongle.multi_series_mode) + + # But should internally convert to multi-series format + self.assertIsNotNone(multidongle.series_groups) + self.assertEqual(len(multidongle.series_groups), 1) + + # Should auto-detect series (will be KL520 based on available devices or fallback) + series_keys = list(multidongle.series_groups.keys()) + self.assertEqual(len(series_keys), 1) + detected_series = series_keys[0] + self.assertIn(detected_series, DongleSeriesSpec.SERIES_SPECS.keys()) + + # Should have correct port configuration + series_config = multidongle.series_groups[detected_series] + self.assertEqual(series_config["port_ids"], [28, 32]) + self.assertEqual(series_config["model_path"], "/path/to/model.nef") + + # Should have 100% weight since it's single series + self.assertEqual(multidongle.gops_weights[detected_series], 1.0) + + print(f"Single-to-multi-series conversion test passed (detected: {detected_series})") + + def test_load_balancing_success(self): + """Test that load balancing works based on GOPS weights""" + multidongle = MultiDongle(multi_series_config=self.multi_series_config) + + # Should have load balancing method + optimal_series = multidongle._select_optimal_series() + self.assertIsNotNone(optimal_series) + self.assertIn(optimal_series, ["KL520", "KL720"]) + + # With zero load, should select the series with highest weight (KL720) + self.assertEqual(optimal_series, "KL720") + + # Test load balancing under different conditions + # Simulate high load on KL720 + multidongle.current_loads["KL720"] = 100 + multidongle.current_loads["KL520"] = 0 + + # Now should prefer KL520 despite lower GOPS due to lower load + optimal_series_with_load = multidongle._select_optimal_series() + self.assertEqual(optimal_series_with_load, "KL520") + + print("Load balancing test passed") + + def test_backward_compatibility_maintained(self): + """Test that existing single-series API still works perfectly""" + # This should work exactly as before + multidongle = MultiDongle( + port_id=[28, 32], + scpu_fw_path="/path/to/scpu.bin", + ncpu_fw_path="/path/to/ncpu.bin", + model_path="/path/to/model.nef" + ) + + # Legacy properties should still exist and work + self.assertIsNotNone(multidongle.port_id) + self.assertEqual(multidongle.port_id, [28, 32]) + self.assertEqual(multidongle.model_path, "/path/to/model.nef") + self.assertEqual(multidongle.scpu_fw_path, "/path/to/scpu.bin") + self.assertEqual(multidongle.ncpu_fw_path, "/path/to/ncpu.bin") + + # Legacy attributes should be available + self.assertIsNotNone(multidongle.device_group) # Will be None initially + self.assertIsNotNone(multidongle._input_queue) + self.assertIsNotNone(multidongle._output_queue) + + print("Backward compatibility test passed") + + def test_series_specs_are_correct(self): + """Test that series specifications match expected values""" + specs = DongleSeriesSpec.SERIES_SPECS + + # Check that all expected series are present + expected_series = ["KL520", "KL720", "KL630", "KL730", "KL540"] + for series in expected_series: + self.assertIn(series, specs) + + # Check GOPS values are reasonable + self.assertEqual(specs["KL520"]["gops"], 3) + self.assertEqual(specs["KL720"]["gops"], 28) + self.assertEqual(specs["KL630"]["gops"], 400) + self.assertEqual(specs["KL730"]["gops"], 1600) + self.assertEqual(specs["KL540"]["gops"], 800) + + print("Series specifications test passed") + + def test_edge_cases(self): + """Test various edge cases and error handling""" + + # Test with empty port list (single-series) + multidongle_empty = MultiDongle(port_id=[]) + self.assertEqual(len(multidongle_empty.series_groups), 0) + + # Test with unknown series (should raise error) + with self.assertRaises(ValueError): + MultiDongle(multi_series_config={"UNKNOWN_SERIES": {"port_ids": [1, 2]}}) + + # Test with no port IDs in multi-series config + config_no_ports = { + "KL520": { + "port_ids": [], + "model_path": "/path/to/model.nef" + } + } + multidongle_no_ports = MultiDongle(multi_series_config=config_no_ports) + self.assertEqual(multidongle_no_ports.gops_weights["KL520"], 0.0) # 0 weight due to no devices + + print("Edge cases test passed") + +if __name__ == '__main__': + print("Running Multi-Series Integration Tests") + print("=" * 50) + + unittest.main(verbosity=2) \ No newline at end of file diff --git a/test_multi_series_multidongle.py b/test_multi_series_multidongle.py new file mode 100644 index 0000000..c108fa7 --- /dev/null +++ b/test_multi_series_multidongle.py @@ -0,0 +1,170 @@ +""" +Test Multi-Series Integration for Multidongle + +Testing the integration of multi-series functionality into the existing Multidongle class +following TDD principles. +""" + +import unittest +import sys +import os +from unittest.mock import Mock, patch, MagicMock + +# Add project root to path +sys.path.insert(0, os.path.join(os.path.dirname(__file__), 'core', 'functions')) + +from Multidongle import MultiDongle + +class TestMultiSeriesMultidongle(unittest.TestCase): + + def setUp(self): + """Set up test fixtures""" + self.multi_series_config = { + "KL520": { + "port_ids": [28, 32], + "model_path": "/path/to/kl520_model.nef", + "firmware_paths": { + "scpu": "/path/to/kl520_scpu.bin", + "ncpu": "/path/to/kl520_ncpu.bin" + } + }, + "KL720": { + "port_ids": [40, 44], + "model_path": "/path/to/kl720_model.nef", + "firmware_paths": { + "scpu": "/path/to/kl720_scpu.bin", + "ncpu": "/path/to/kl720_ncpu.bin" + } + } + } + + def test_multi_series_initialization_should_fail(self): + """ + Test that multi-series initialization accepts config and sets up series groups + This should FAIL initially since the functionality doesn't exist yet + """ + # This should work but will fail initially + try: + multidongle = MultiDongle(multi_series_config=self.multi_series_config) + + # Should have series groups configured + self.assertIsNotNone(multidongle.series_groups) + self.assertIn("KL520", multidongle.series_groups) + self.assertIn("KL720", multidongle.series_groups) + + # Should have GOPS weights calculated + self.assertIsNotNone(multidongle.gops_weights) + self.assertIn("KL520", multidongle.gops_weights) + self.assertIn("KL720", multidongle.gops_weights) + + # KL720 should have higher weight due to higher GOPS + self.assertGreater(multidongle.gops_weights["KL720"], + multidongle.gops_weights["KL520"]) + + self.fail("Multi-series initialization should not work yet - test should fail") + + except (AttributeError, TypeError) as e: + # Expected to fail at this stage + print(f"Expected failure: {e}") + self.assertTrue(True, "Multi-series initialization correctly fails (not implemented yet)") + + def test_single_series_to_multi_series_conversion_should_fail(self): + """ + Test that single-series config gets converted to multi-series internally + This should FAIL initially + """ + try: + # Legacy single-series initialization + multidongle = MultiDongle( + port_id=[28, 32], + scpu_fw_path="/path/to/scpu.bin", + ncpu_fw_path="/path/to/ncpu.bin", + model_path="/path/to/model.nef", + upload_fw=True + ) + + # Should internally convert to multi-series format + self.assertIsNotNone(multidongle.series_groups) + self.assertEqual(len(multidongle.series_groups), 1) + + # Should auto-detect series from device scan or use default + series_keys = list(multidongle.series_groups.keys()) + self.assertEqual(len(series_keys), 1) + + self.fail("Single to multi-series conversion should not work yet") + + except (AttributeError, TypeError) as e: + # Expected to fail at this stage + print(f"Expected failure: {e}") + self.assertTrue(True, "Single-series conversion correctly fails (not implemented yet)") + + def test_load_balancing_should_fail(self): + """ + Test that load balancing works based on GOPS weights + This should FAIL initially + """ + try: + multidongle = MultiDongle(multi_series_config=self.multi_series_config) + + # Should have load balancing method + optimal_series = multidongle._select_optimal_series() + self.assertIsNotNone(optimal_series) + self.assertIn(optimal_series, ["KL520", "KL720"]) + + self.fail("Load balancing should not work yet") + + except (AttributeError, TypeError) as e: + # Expected to fail at this stage + print(f"Expected failure: {e}") + self.assertTrue(True, "Load balancing correctly fails (not implemented yet)") + + def test_backward_compatibility_should_work(self): + """ + Test that existing single-series API still works + This should PASS (existing functionality) + """ + # This should still work with existing code + try: + multidongle = MultiDongle( + port_id=[28, 32], + scpu_fw_path="/path/to/scpu.bin", + ncpu_fw_path="/path/to/ncpu.bin", + model_path="/path/to/model.nef" + ) + + # Basic properties should still exist + self.assertIsNotNone(multidongle.port_id) + self.assertEqual(multidongle.port_id, [28, 32]) + self.assertEqual(multidongle.model_path, "/path/to/model.nef") + + print("Backward compatibility test passed") + + except Exception as e: + self.fail(f"Backward compatibility should work: {e}") + + def test_multi_series_device_grouping_should_fail(self): + """ + Test that devices are properly grouped by series + This should FAIL initially + """ + try: + multidongle = MultiDongle(multi_series_config=self.multi_series_config) + multidongle.initialize() + + # Should have device groups for each series + self.assertIsNotNone(multidongle.device_groups) + self.assertEqual(len(multidongle.device_groups), 2) + + # Each series should have its device group + for series_name, config in self.multi_series_config.items(): + self.assertIn(series_name, multidongle.device_groups) + + self.fail("Multi-series device grouping should not work yet") + + except (AttributeError, TypeError) as e: + # Expected to fail + print(f"Expected failure: {e}") + self.assertTrue(True, "Device grouping correctly fails (not implemented yet)") + +if __name__ == '__main__': + unittest.main() \ No newline at end of file diff --git a/utils/__init__.py b/utils/__init__.py index 6a9f101..fd3ee78 100644 --- a/utils/__init__.py +++ b/utils/__init__.py @@ -21,8 +21,12 @@ Usage: # Import utilities as they are implemented # from . import file_utils # from . import ui_utils +from .folder_dialog import select_folder, select_assets_folder, validate_assets_folder_structure __all__ = [ # "file_utils", # "ui_utils" + "select_folder", + "select_assets_folder", + "validate_assets_folder_structure" ] \ No newline at end of file diff --git a/utils/folder_dialog.py b/utils/folder_dialog.py new file mode 100644 index 0000000..3180f0c --- /dev/null +++ b/utils/folder_dialog.py @@ -0,0 +1,217 @@ +""" +Folder selection utilities using tkinter +""" + +import tkinter as tk +from tkinter import filedialog +import os + +def select_folder(title="Select Folder", initial_dir="", must_exist=True): + """ + Open a folder selection dialog using tkinter + + Args: + title (str): Dialog window title + initial_dir (str): Initial directory to open + must_exist (bool): Whether the folder must already exist + + Returns: + str: Selected folder path, or empty string if cancelled + """ + try: + # Create a root window but keep it hidden + root = tk.Tk() + root.withdraw() # Hide the main window + root.attributes('-topmost', True) # Bring dialog to front + + # Set initial directory + if not initial_dir: + initial_dir = os.getcwd() + + # Open folder selection dialog + folder_path = filedialog.askdirectory( + title=title, + initialdir=initial_dir, + mustexist=must_exist + ) + + # Destroy the root window + root.destroy() + + return folder_path if folder_path else "" + + except ImportError: + print("tkinter not available") + return "" + except Exception as e: + print(f"Error opening folder dialog: {e}") + return "" + +def select_assets_folder(initial_dir=""): + """ + Specialized function for selecting Assets folder with validation + + Args: + initial_dir (str): Initial directory to open + + Returns: + dict: Result with 'path', 'valid', and 'message' keys + """ + folder_path = select_folder( + title="Select Assets Folder (containing Firmware/ and Models/)", + initial_dir=initial_dir + ) + + if not folder_path: + return {'path': '', 'valid': False, 'message': 'No folder selected'} + + # Validate folder structure + validation_result = validate_assets_folder_structure(folder_path) + + return { + 'path': folder_path, + 'valid': validation_result['valid'], + 'message': validation_result['message'], + 'details': validation_result.get('details', {}) + } + +def validate_assets_folder_structure(folder_path): + """ + Validate that a folder has the expected Assets structure + + Expected structure: + Assets/ + ├── Firmware/ + │ ├── KL520/ + │ │ ├── fw_scpu.bin + │ │ └── fw_ncpu.bin + │ └── KL720/ + │ ├── fw_scpu.bin + │ └── fw_ncpu.bin + └── Models/ + ├── KL520/ + │ └── model.nef + └── KL720/ + └── model.nef + + Args: + folder_path (str): Path to validate + + Returns: + dict: Validation result with 'valid', 'message', and 'details' keys + """ + if not os.path.exists(folder_path): + return {'valid': False, 'message': 'Folder does not exist'} + + if not os.path.isdir(folder_path): + return {'valid': False, 'message': 'Path is not a directory'} + + details = {} + issues = [] + + # Check for Firmware and Models folders + firmware_path = os.path.join(folder_path, 'Firmware') + models_path = os.path.join(folder_path, 'Models') + + has_firmware = os.path.exists(firmware_path) and os.path.isdir(firmware_path) + has_models = os.path.exists(models_path) and os.path.isdir(models_path) + + details['has_firmware_folder'] = has_firmware + details['has_models_folder'] = has_models + + if not has_firmware: + issues.append("Missing 'Firmware' folder") + + if not has_models: + issues.append("Missing 'Models' folder") + + if not (has_firmware and has_models): + return { + 'valid': False, + 'message': f"Invalid folder structure: {', '.join(issues)}", + 'details': details + } + + # Check for series subfolders + expected_series = ['KL520', 'KL720', 'KL630', 'KL730', 'KL540'] + + firmware_series = [] + models_series = [] + + try: + firmware_dirs = [d for d in os.listdir(firmware_path) + if os.path.isdir(os.path.join(firmware_path, d))] + firmware_series = [d for d in firmware_dirs if d in expected_series] + + models_dirs = [d for d in os.listdir(models_path) + if os.path.isdir(os.path.join(models_path, d))] + models_series = [d for d in models_dirs if d in expected_series] + + except Exception as e: + return { + 'valid': False, + 'message': f"Error reading folder contents: {e}", + 'details': details + } + + details['firmware_series'] = firmware_series + details['models_series'] = models_series + + # Find common series (have both firmware and models) + common_series = list(set(firmware_series) & set(models_series)) + details['available_series'] = common_series + + if not common_series: + return { + 'valid': False, + 'message': "No series found with both firmware and models folders", + 'details': details + } + + # Check for actual files in series folders + series_with_files = [] + for series in common_series: + has_files = False + + # Check firmware files + fw_series_path = os.path.join(firmware_path, series) + if os.path.exists(fw_series_path): + fw_files = [f for f in os.listdir(fw_series_path) + if f.endswith('.bin')] + if fw_files: + has_files = True + + # Check model files + model_series_path = os.path.join(models_path, series) + if os.path.exists(model_series_path): + model_files = [f for f in os.listdir(model_series_path) + if f.endswith('.nef')] + if model_files and has_files: + series_with_files.append(series) + + details['series_with_files'] = series_with_files + + if not series_with_files: + return { + 'valid': False, + 'message': "No series found with actual firmware and model files", + 'details': details + } + + return { + 'valid': True, + 'message': f"Valid Assets folder with {len(series_with_files)} series: {', '.join(series_with_files)}", + 'details': details + } + +# Example usage +if __name__ == "__main__": + print("Testing folder selection...") + + # Test basic folder selection + folder = select_folder("Select any folder") + print(f"Selected: {folder}") + + # Test Assets folder selection with validation + result = select_assets_folder() + print(f"Assets folder result: {result}") \ No newline at end of file