feat: Implement multi-series dongle support and improve app stability
This commit is contained in:
parent
d6c0b5eab5
commit
48acae9c74
@ -10,7 +10,39 @@ import kp
|
|||||||
import cv2
|
import cv2
|
||||||
import time
|
import time
|
||||||
from abc import ABC, abstractmethod
|
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):
|
class DataProcessor(ABC):
|
||||||
@ -222,17 +254,108 @@ class MultiDongle:
|
|||||||
except kp.ApiKPException as exception:
|
except kp.ApiKPException as exception:
|
||||||
raise Exception(f'Failed to connect devices: {str(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.
|
Initialize the MultiDongle class with support for both single and multi-series configurations.
|
||||||
: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 port_id: List of USB port IDs for single-series (legacy). If None and auto_detect=True, will auto-detect.
|
||||||
:param ncpu_fw_path: Path to the NCPU firmware file.
|
:param scpu_fw_path: Path to the SCPU firmware file for single-series (legacy).
|
||||||
:param model_path: Path to the model file.
|
:param ncpu_fw_path: Path to the NCPU firmware file for single-series (legacy).
|
||||||
:param upload_fw: Flag to indicate whether to upload firmware.
|
:param model_path: Path to the model file for single-series (legacy).
|
||||||
:param auto_detect: Flag to auto-detect and connect to available devices.
|
: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 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.auto_detect = auto_detect
|
||||||
self.connected_devices_info = []
|
self.connected_devices_info = []
|
||||||
|
|
||||||
@ -258,8 +381,8 @@ class MultiDongle:
|
|||||||
# generic_inference_input_descriptor will be prepared in initialize
|
# generic_inference_input_descriptor will be prepared in initialize
|
||||||
self.model_nef_descriptor = None
|
self.model_nef_descriptor = None
|
||||||
self.generic_inference_input_descriptor = None
|
self.generic_inference_input_descriptor = None
|
||||||
|
|
||||||
# Queues for data
|
# Queues for data
|
||||||
# Input queue for images to be sent
|
|
||||||
if max_queue_size > 0:
|
if max_queue_size > 0:
|
||||||
self._input_queue = queue.Queue(maxsize=max_queue_size)
|
self._input_queue = queue.Queue(maxsize=max_queue_size)
|
||||||
self._output_queue = queue.Queue(maxsize=max_queue_size)
|
self._output_queue = queue.Queue(maxsize=max_queue_size)
|
||||||
@ -270,9 +393,99 @@ class MultiDongle:
|
|||||||
# Threading attributes
|
# Threading attributes
|
||||||
self._send_thread = None
|
self._send_thread = None
|
||||||
self._receive_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
|
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):
|
def initialize(self):
|
||||||
"""
|
"""
|
||||||
|
|||||||
@ -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()
|
|
||||||
@ -115,6 +115,16 @@ class ExactModelNode(BaseNode):
|
|||||||
self.create_property('port_id', '')
|
self.create_property('port_id', '')
|
||||||
self.create_property('upload_fw', True)
|
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
|
# Original property options - exact match
|
||||||
self._property_options = {
|
self._property_options = {
|
||||||
'dongle_series': ['520', '720', '1080', 'Custom'],
|
'dongle_series': ['520', '720', '1080', 'Custom'],
|
||||||
@ -123,11 +133,25 @@ class ExactModelNode(BaseNode):
|
|||||||
'scpu_fw_path': {'type': 'file_path', 'filter': 'SCPU Firmware files (*.bin)'},
|
'scpu_fw_path': {'type': 'file_path', 'filter': 'SCPU Firmware files (*.bin)'},
|
||||||
'ncpu_fw_path': {'type': 'file_path', 'filter': 'NCPU Firmware files (*.bin)'},
|
'ncpu_fw_path': {'type': 'file_path', 'filter': 'NCPU Firmware files (*.bin)'},
|
||||||
'port_id': {'placeholder': 'e.g., 8080 or auto'},
|
'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
|
# Create custom properties dictionary for UI compatibility
|
||||||
self._populate_custom_properties()
|
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):
|
def _populate_custom_properties(self):
|
||||||
"""Populate the custom properties dictionary for UI compatibility."""
|
"""Populate the custom properties dictionary for UI compatibility."""
|
||||||
@ -166,8 +190,245 @@ class ExactModelNode(BaseNode):
|
|||||||
|
|
||||||
def get_display_properties(self):
|
def get_display_properties(self):
|
||||||
"""Return properties that should be displayed in the UI panel."""
|
"""Return properties that should be displayed in the UI panel."""
|
||||||
# Customize which properties appear for Model nodes
|
if not NODEGRAPH_AVAILABLE:
|
||||||
return ['model_path', 'scpu_fw_path', 'ncpu_fw_path', 'dongle_series', 'num_dongles', 'port_id', 'upload_fw']
|
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):
|
class ExactPreprocessNode(BaseNode):
|
||||||
|
|||||||
142
force_cleanup.py
Normal file
142
force_cleanup.py
Normal file
@ -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.")
|
||||||
121
gentle_cleanup.py
Normal file
121
gentle_cleanup.py
Normal file
@ -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'")
|
||||||
66
kill_app_processes.py
Normal file
66
kill_app_processes.py
Normal file
@ -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'")
|
||||||
247
main.py
247
main.py
@ -41,60 +41,194 @@ from ui.windows.login import DashboardLogin
|
|||||||
|
|
||||||
|
|
||||||
class SingleInstance:
|
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"):
|
def __init__(self, app_name="Cluster4NPU"):
|
||||||
self.app_name = app_name
|
self.app_name = app_name
|
||||||
self.shared_memory = QSharedMemory(app_name)
|
self.shared_memory = QSharedMemory(app_name)
|
||||||
self.lock_file = None
|
self.lock_file = None
|
||||||
self.lock_fd = None
|
self.lock_fd = None
|
||||||
|
self.process_check_enabled = True
|
||||||
|
|
||||||
def is_running(self):
|
def is_running(self):
|
||||||
"""Check if another instance is already running."""
|
"""Check if another instance is already running with recovery mechanisms."""
|
||||||
# Try to create shared memory
|
# First, try to detect and clean up stale instances
|
||||||
if self.shared_memory.attach():
|
if self._detect_and_cleanup_stale_instances():
|
||||||
# Another instance is already running
|
print("Cleaned up stale application instances")
|
||||||
|
|
||||||
|
# Try shared memory approach
|
||||||
|
if self._check_shared_memory():
|
||||||
return True
|
return True
|
||||||
|
|
||||||
# Try to create the shared memory
|
# Try file locking approach
|
||||||
if not self.shared_memory.create(1):
|
if self._check_file_lock():
|
||||||
# Failed to create, likely another instance exists
|
|
||||||
return True
|
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
|
return False
|
||||||
|
|
||||||
def cleanup(self):
|
def _detect_and_cleanup_stale_instances(self):
|
||||||
"""Clean up resources."""
|
"""Detect and clean up stale instances that might have crashed."""
|
||||||
if self.shared_memory.isAttached():
|
cleaned_up = False
|
||||||
self.shared_memory.detach()
|
|
||||||
|
try:
|
||||||
|
import psutil
|
||||||
|
|
||||||
if self.lock_fd:
|
# Check if there are any actual running processes
|
||||||
try:
|
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:
|
if HAS_FCNTL:
|
||||||
fcntl.lockf(self.lock_fd, fcntl.LOCK_UN)
|
fcntl.lockf(self.lock_fd, fcntl.LOCK_UN)
|
||||||
os.close(self.lock_fd)
|
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)
|
os.unlink(self.lock_file)
|
||||||
except:
|
except Exception as e:
|
||||||
pass
|
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():
|
def setup_application():
|
||||||
@ -125,6 +259,23 @@ def setup_application():
|
|||||||
|
|
||||||
def main():
|
def main():
|
||||||
"""Main application entry point."""
|
"""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
|
# Create a minimal QApplication first for the message box
|
||||||
temp_app = QApplication(sys.argv) if not QApplication.instance() else QApplication.instance()
|
temp_app = QApplication(sys.argv) if not QApplication.instance() else QApplication.instance()
|
||||||
|
|
||||||
@ -132,12 +283,32 @@ def main():
|
|||||||
single_instance = SingleInstance()
|
single_instance = SingleInstance()
|
||||||
|
|
||||||
if single_instance.is_running():
|
if single_instance.is_running():
|
||||||
QMessageBox.warning(
|
reply = QMessageBox.question(
|
||||||
None,
|
None,
|
||||||
"Application Already Running",
|
"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:
|
try:
|
||||||
# Setup the full application
|
# Setup the full application
|
||||||
|
|||||||
69
test_folder_selection.py
Normal file
69
test_folder_selection.py
Normal file
@ -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.")
|
||||||
203
test_multi_series_integration_final.py
Normal file
203
test_multi_series_integration_final.py
Normal file
@ -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)
|
||||||
170
test_multi_series_multidongle.py
Normal file
170
test_multi_series_multidongle.py
Normal file
@ -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()
|
||||||
@ -21,8 +21,12 @@ Usage:
|
|||||||
# Import utilities as they are implemented
|
# Import utilities as they are implemented
|
||||||
# from . import file_utils
|
# from . import file_utils
|
||||||
# from . import ui_utils
|
# from . import ui_utils
|
||||||
|
from .folder_dialog import select_folder, select_assets_folder, validate_assets_folder_structure
|
||||||
|
|
||||||
__all__ = [
|
__all__ = [
|
||||||
# "file_utils",
|
# "file_utils",
|
||||||
# "ui_utils"
|
# "ui_utils"
|
||||||
|
"select_folder",
|
||||||
|
"select_assets_folder",
|
||||||
|
"validate_assets_folder_structure"
|
||||||
]
|
]
|
||||||
217
utils/folder_dialog.py
Normal file
217
utils/folder_dialog.py
Normal file
@ -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}")
|
||||||
Loading…
x
Reference in New Issue
Block a user