Update GUI 2.3.0: JSON in Serial
This commit is contained in:
+270
-82
@@ -12,6 +12,10 @@ import socket
|
|||||||
import sys
|
import sys
|
||||||
import os
|
import os
|
||||||
import traceback
|
import traceback
|
||||||
|
try:
|
||||||
|
import serial
|
||||||
|
except ImportError:
|
||||||
|
serial = None
|
||||||
from pymavlink import mavutil
|
from pymavlink import mavutil
|
||||||
from geometry_msgs.msg import Point, Vector3, Vector3Stamped, PoseWithCovarianceStamped
|
from geometry_msgs.msg import Point, Vector3, Vector3Stamped, PoseWithCovarianceStamped
|
||||||
from sensor_msgs.msg import BatteryState, NavSatFix, Imu
|
from sensor_msgs.msg import BatteryState, NavSatFix, Imu
|
||||||
@@ -61,6 +65,153 @@ except ImportError as e:
|
|||||||
class DroneSignals(QObject):
|
class DroneSignals(QObject):
|
||||||
update_signal = pyqtSignal(str, str, object) # (msg_type, drone_id, data)
|
update_signal = pyqtSignal(str, str, object) # (msg_type, drone_id, data)
|
||||||
|
|
||||||
|
class JsonTelemetryProcessor:
|
||||||
|
"""共用 WebSocket JSON telemetry 轉換器。
|
||||||
|
|
||||||
|
Canonical JSON fields:
|
||||||
|
{
|
||||||
|
"system_id": 1,
|
||||||
|
"mode": "GUIDED",
|
||||||
|
"battery": 85,
|
||||||
|
"position": {"lat": 24.0, "lon": 120.0},
|
||||||
|
"heading": 90
|
||||||
|
}
|
||||||
|
Serial JSON uses this same shape; only the transport/framing differs.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def _emit_json_connection_type(self, drone_id):
|
||||||
|
self.signals.update_signal.emit('connection_type', drone_id, {
|
||||||
|
'type': self.source_type
|
||||||
|
})
|
||||||
|
|
||||||
|
def process_json_telemetry_message(self, data):
|
||||||
|
"""處理 WebSocket JSON 格式的遙測資料。"""
|
||||||
|
try:
|
||||||
|
if isinstance(data, list):
|
||||||
|
for item in data:
|
||||||
|
if isinstance(item, dict):
|
||||||
|
self.process_json_telemetry_message(item)
|
||||||
|
return
|
||||||
|
|
||||||
|
if not isinstance(data, dict):
|
||||||
|
return
|
||||||
|
|
||||||
|
system_id = data.get('system_id', data.get('sysid'))
|
||||||
|
if system_id is None:
|
||||||
|
return
|
||||||
|
|
||||||
|
drone_id = f"s{self.socket_id}_{system_id}"
|
||||||
|
self._emit_json_connection_type(drone_id)
|
||||||
|
|
||||||
|
mode = data.get('mode', data.get('mode_name'))
|
||||||
|
state = {}
|
||||||
|
if mode is not None:
|
||||||
|
state['mode'] = mode
|
||||||
|
if 'armed' in data:
|
||||||
|
state['armed'] = data.get('armed')
|
||||||
|
if state:
|
||||||
|
self.signals.update_signal.emit('state', drone_id, state)
|
||||||
|
|
||||||
|
if 'battery' in data:
|
||||||
|
battery = data['battery']
|
||||||
|
if isinstance(battery, dict):
|
||||||
|
battery_data = {}
|
||||||
|
if 'percentage' in battery:
|
||||||
|
battery_data['percentage'] = battery.get('percentage')
|
||||||
|
elif 'percent' in battery:
|
||||||
|
battery_data['percentage'] = battery.get('percent')
|
||||||
|
if 'voltage' in battery:
|
||||||
|
battery_data['voltage'] = battery.get('voltage')
|
||||||
|
elif 'voltage_v' in battery:
|
||||||
|
battery_data['voltage'] = battery.get('voltage_v')
|
||||||
|
if battery_data:
|
||||||
|
self.signals.update_signal.emit('battery', drone_id, battery_data)
|
||||||
|
else:
|
||||||
|
self.signals.update_signal.emit('battery', drone_id, {
|
||||||
|
'percentage': battery
|
||||||
|
})
|
||||||
|
elif 'battery_percentage' in data or 'battery_voltage' in data:
|
||||||
|
battery_data = {}
|
||||||
|
if 'battery_percentage' in data:
|
||||||
|
battery_data['percentage'] = data.get('battery_percentage')
|
||||||
|
if 'battery_voltage' in data:
|
||||||
|
battery_data['voltage'] = data.get('battery_voltage')
|
||||||
|
self.signals.update_signal.emit('battery', drone_id, battery_data)
|
||||||
|
|
||||||
|
pos = data.get('position')
|
||||||
|
if isinstance(pos, dict):
|
||||||
|
gps_data = {
|
||||||
|
'lat': pos.get('lat', pos.get('latitude', 0)),
|
||||||
|
'lon': pos.get('lon', pos.get('longitude', 0)),
|
||||||
|
'alt': pos.get('alt', pos.get('altitude', 0))
|
||||||
|
}
|
||||||
|
self.signals.update_signal.emit('gps', drone_id, gps_data)
|
||||||
|
elif 'lat' in data or 'latitude' in data:
|
||||||
|
self.signals.update_signal.emit('gps', drone_id, {
|
||||||
|
'lat': data.get('lat', data.get('latitude', 0)),
|
||||||
|
'lon': data.get('lon', data.get('longitude', 0)),
|
||||||
|
'alt': data.get('alt', data.get('altitude', 0))
|
||||||
|
})
|
||||||
|
|
||||||
|
local = data.get('local_position', data.get('local_pose', data.get('local')))
|
||||||
|
if isinstance(local, dict):
|
||||||
|
x = local.get('x', 0.0)
|
||||||
|
y = local.get('y', 0.0)
|
||||||
|
z = local.get('z', 0.0)
|
||||||
|
self.signals.update_signal.emit('local_pose', drone_id, {
|
||||||
|
'x': x,
|
||||||
|
'y': y,
|
||||||
|
'z': z
|
||||||
|
})
|
||||||
|
self.signals.update_signal.emit('altitude', drone_id, {
|
||||||
|
'altitude': z
|
||||||
|
})
|
||||||
|
elif isinstance(pos, dict):
|
||||||
|
alt = pos.get('alt', pos.get('altitude', 0.0))
|
||||||
|
self.signals.update_signal.emit('local_pose', drone_id, {
|
||||||
|
'x': 0.0,
|
||||||
|
'y': 0.0,
|
||||||
|
'z': alt
|
||||||
|
})
|
||||||
|
self.signals.update_signal.emit('altitude', drone_id, {
|
||||||
|
'altitude': alt
|
||||||
|
})
|
||||||
|
|
||||||
|
velocity = data.get('velocity')
|
||||||
|
if isinstance(velocity, dict):
|
||||||
|
self.signals.update_signal.emit('velocity', drone_id, {
|
||||||
|
'vx': velocity.get('vx', velocity.get('x', 0.0)),
|
||||||
|
'vy': velocity.get('vy', velocity.get('y', 0.0)),
|
||||||
|
'vz': velocity.get('vz', velocity.get('z', 0.0))
|
||||||
|
})
|
||||||
|
|
||||||
|
attitude = data.get('attitude')
|
||||||
|
if isinstance(attitude, dict):
|
||||||
|
self.signals.update_signal.emit('attitude', drone_id, {
|
||||||
|
'roll': attitude.get('roll', 0.0),
|
||||||
|
'pitch': attitude.get('pitch', 0.0),
|
||||||
|
'yaw': attitude.get('yaw', 0.0),
|
||||||
|
'rates': attitude.get('rates', (0.0, 0.0, 0.0))
|
||||||
|
})
|
||||||
|
|
||||||
|
hud = data.get('hud', {})
|
||||||
|
if not isinstance(hud, dict):
|
||||||
|
hud = {}
|
||||||
|
if 'heading' in data:
|
||||||
|
hud['heading'] = data.get('heading')
|
||||||
|
if hud:
|
||||||
|
self.signals.update_signal.emit('hud', drone_id, {
|
||||||
|
'heading': hud.get('heading', 0.0),
|
||||||
|
'groundspeed': hud.get('groundspeed', 0.0),
|
||||||
|
'airspeed': hud.get('airspeed', 0.0),
|
||||||
|
'throttle': hud.get('throttle', 0.0),
|
||||||
|
'alt': hud.get('alt', hud.get('altitude', 0.0)),
|
||||||
|
'climb': hud.get('climb', 0.0)
|
||||||
|
})
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
print(f"{self.source_type} JSON telemetry processing error: {e}")
|
||||||
|
|
||||||
class UDPMavlinkReceiver(threading.Thread):
|
class UDPMavlinkReceiver(threading.Thread):
|
||||||
"""UDP MAVLink 接收器"""
|
"""UDP MAVLink 接收器"""
|
||||||
def __init__(self, ip, port, signals, connection_name, monitor=None):
|
def __init__(self, ip, port, signals, connection_name, monitor=None):
|
||||||
@@ -184,8 +335,8 @@ class UDPMavlinkReceiver(threading.Thread):
|
|||||||
"""停止接收器"""
|
"""停止接收器"""
|
||||||
self.running = False
|
self.running = False
|
||||||
|
|
||||||
class SerialMavlinkReceiver(threading.Thread):
|
class SerialMavlinkReceiver(threading.Thread, JsonTelemetryProcessor):
|
||||||
"""串口 MAVLink 接收器"""
|
"""串口遙測接收器,可自動處理 MAVLink 或 WebSocket 格式 JSON。"""
|
||||||
def __init__(self, port, baudrate, signals, connection_name, monitor=None):
|
def __init__(self, port, baudrate, signals, connection_name, monitor=None):
|
||||||
super().__init__(daemon=True)
|
super().__init__(daemon=True)
|
||||||
self.port = port
|
self.port = port
|
||||||
@@ -194,47 +345,125 @@ class SerialMavlinkReceiver(threading.Thread):
|
|||||||
self.connection_name = connection_name
|
self.connection_name = connection_name
|
||||||
self.monitor = monitor # 保存 monitor 引用
|
self.monitor = monitor # 保存 monitor 引用
|
||||||
self.socket_id = monitor.get_next_socket_id() if monitor else 0
|
self.socket_id = monitor.get_next_socket_id() if monitor else 0
|
||||||
|
self.source_type = 'Serial'
|
||||||
self.running = False
|
self.running = False
|
||||||
self.mav = None
|
self.serial_conn = None
|
||||||
|
self.mav_parser = mavutil.mavlink.MAVLink(None)
|
||||||
|
self.mav_parser.robust_parsing = True
|
||||||
|
self.json_buffer = []
|
||||||
|
self.json_depth = 0
|
||||||
|
self.json_in_string = False
|
||||||
|
self.json_escape = False
|
||||||
|
self._detected_protocols = set()
|
||||||
|
|
||||||
def run(self):
|
def run(self):
|
||||||
"""執行串口接收循環"""
|
"""執行串口接收循環。"""
|
||||||
self.running = True
|
self.running = True
|
||||||
try:
|
try:
|
||||||
print(f"Serial MAVLink receiver started on {self.port} at {self.baudrate} baud")
|
print(f"Serial receiver started on {self.port} at {self.baudrate} baud (MAVLink/JSON auto detect)")
|
||||||
|
if serial is None:
|
||||||
# 創建 MAVLink 串口連接
|
raise RuntimeError("pyserial 未安裝,無法啟動 Serial 連線")
|
||||||
self.mav = mavutil.mavlink_connection(
|
|
||||||
|
self.serial_conn = serial.Serial(
|
||||||
self.port,
|
self.port,
|
||||||
baud=self.baudrate,
|
self.baudrate,
|
||||||
source_system=255
|
timeout=0.2
|
||||||
)
|
)
|
||||||
|
|
||||||
print(f"Waiting for heartbeat from {self.port}...")
|
|
||||||
self.mav.wait_heartbeat()
|
|
||||||
print(f"Heartbeat received from system {self.mav.target_system}, component {self.mav.target_component}")
|
|
||||||
|
|
||||||
while self.running:
|
while self.running:
|
||||||
try:
|
try:
|
||||||
msg = self.mav.recv_match(blocking=True, timeout=1.0)
|
chunk = self.serial_conn.read(256)
|
||||||
if msg is None:
|
if not chunk:
|
||||||
continue
|
continue
|
||||||
|
|
||||||
self.process_mavlink_message(msg)
|
for raw_byte in chunk:
|
||||||
|
byte = bytes([raw_byte])
|
||||||
|
self._process_json_byte(byte)
|
||||||
|
self._process_mavlink_byte(byte)
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
if self.running:
|
if self.running:
|
||||||
print(f"Error receiving MAVLink message from serial: {e}")
|
print(f"Error receiving serial telemetry: {e}")
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
print(f"Serial receiver error: {e}")
|
print(f"Serial receiver error: {e}")
|
||||||
finally:
|
finally:
|
||||||
if self.mav:
|
if self.serial_conn:
|
||||||
try:
|
try:
|
||||||
self.mav.close()
|
self.serial_conn.close()
|
||||||
except:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
|
|
||||||
|
def _process_mavlink_byte(self, byte):
|
||||||
|
try:
|
||||||
|
msg = self.mav_parser.parse_char(byte)
|
||||||
|
if msg is None:
|
||||||
|
return
|
||||||
|
if 'MAVLink' not in self._detected_protocols:
|
||||||
|
print(f"Serial {self.connection_name} detected MAVLink")
|
||||||
|
self._detected_protocols.add('MAVLink')
|
||||||
|
self.process_mavlink_message(msg)
|
||||||
|
except Exception:
|
||||||
|
# MAVLink parser is deliberately fed the whole stream, including JSON bytes.
|
||||||
|
return
|
||||||
|
|
||||||
|
def _process_json_byte(self, byte):
|
||||||
|
try:
|
||||||
|
char = byte.decode('utf-8')
|
||||||
|
except UnicodeDecodeError:
|
||||||
|
if self.json_buffer:
|
||||||
|
self._reset_json_framing()
|
||||||
|
return
|
||||||
|
|
||||||
|
if not self.json_buffer:
|
||||||
|
if char.isspace():
|
||||||
|
return
|
||||||
|
if char not in ('{', '['):
|
||||||
|
return
|
||||||
|
self.json_depth = 1
|
||||||
|
self.json_in_string = False
|
||||||
|
self.json_escape = False
|
||||||
|
self.json_buffer = [char]
|
||||||
|
return
|
||||||
|
|
||||||
|
self.json_buffer.append(char)
|
||||||
|
|
||||||
|
if self.json_in_string:
|
||||||
|
if self.json_escape:
|
||||||
|
self.json_escape = False
|
||||||
|
elif char == '\\':
|
||||||
|
self.json_escape = True
|
||||||
|
elif char == '"':
|
||||||
|
self.json_in_string = False
|
||||||
|
return
|
||||||
|
|
||||||
|
if char == '"':
|
||||||
|
self.json_in_string = True
|
||||||
|
elif char in ('{', '['):
|
||||||
|
self.json_depth += 1
|
||||||
|
elif char in ('}', ']'):
|
||||||
|
self.json_depth -= 1
|
||||||
|
|
||||||
|
if self.json_depth > 0:
|
||||||
|
return
|
||||||
|
|
||||||
|
payload = ''.join(self.json_buffer)
|
||||||
|
self._reset_json_framing()
|
||||||
|
try:
|
||||||
|
data = json.loads(payload)
|
||||||
|
if 'JSON' not in self._detected_protocols:
|
||||||
|
print(f"Serial {self.connection_name} detected JSON")
|
||||||
|
self._detected_protocols.add('JSON')
|
||||||
|
self.process_json_telemetry_message(data)
|
||||||
|
except json.JSONDecodeError as e:
|
||||||
|
print(f"Serial {self.connection_name} JSON decode error: {e}")
|
||||||
|
|
||||||
|
def _reset_json_framing(self):
|
||||||
|
self.json_buffer = []
|
||||||
|
self.json_depth = 0
|
||||||
|
self.json_in_string = False
|
||||||
|
self.json_escape = False
|
||||||
|
|
||||||
def process_mavlink_message(self, msg):
|
def process_mavlink_message(self, msg):
|
||||||
"""處理 MAVLink 訊息"""
|
"""處理 MAVLink 訊息"""
|
||||||
try:
|
try:
|
||||||
@@ -317,15 +546,23 @@ class SerialMavlinkReceiver(threading.Thread):
|
|||||||
def stop(self):
|
def stop(self):
|
||||||
"""停止接收器"""
|
"""停止接收器"""
|
||||||
self.running = False
|
self.running = False
|
||||||
|
if self.serial_conn:
|
||||||
|
try:
|
||||||
|
self.serial_conn.close()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
class WebSocketMavlinkReceiver(threading.Thread):
|
class WebSocketMavlinkReceiver(threading.Thread, JsonTelemetryProcessor):
|
||||||
"""WebSocket MAVLink 接收器"""
|
"""WebSocket MAVLink 接收器"""
|
||||||
def __init__(self, url, signals, connection_name, monitor=None):
|
def __init__(self, url, signals, connection_name, monitor=None):
|
||||||
super().__init__(daemon=True)
|
super().__init__(daemon=True)
|
||||||
self.url = url
|
self.url = url
|
||||||
self.signals = signals
|
self.signals = signals
|
||||||
self.connection_name = connection_name
|
self.connection_name = connection_name
|
||||||
self.monitor = monitor # 保存 monitor 引用 self.socket_id = monitor.get_next_socket_id() if monitor else 0 # 一次性分配 socket_id self.running = False
|
self.monitor = monitor # 保存 monitor 引用
|
||||||
|
self.socket_id = monitor.get_next_socket_id() if monitor else 0 # 一次性分配 socket_id
|
||||||
|
self.source_type = 'WS'
|
||||||
|
self.running = False
|
||||||
self.max_retries = 5
|
self.max_retries = 5
|
||||||
self.base_delay = 1.0
|
self.base_delay = 1.0
|
||||||
|
|
||||||
@@ -353,7 +590,8 @@ class WebSocketMavlinkReceiver(threading.Thread):
|
|||||||
|
|
||||||
try:
|
try:
|
||||||
data = json.loads(message)
|
data = json.loads(message)
|
||||||
data['_connection_source'] = self.connection_name
|
if isinstance(data, dict):
|
||||||
|
data['_connection_source'] = self.connection_name
|
||||||
self.process_websocket_message(data)
|
self.process_websocket_message(data)
|
||||||
except json.JSONDecodeError as e:
|
except json.JSONDecodeError as e:
|
||||||
print(f"WebSocket {self.connection_name} JSON decode error: {e}")
|
print(f"WebSocket {self.connection_name} JSON decode error: {e}")
|
||||||
@@ -383,57 +621,7 @@ class WebSocketMavlinkReceiver(threading.Thread):
|
|||||||
|
|
||||||
def process_websocket_message(self, data):
|
def process_websocket_message(self, data):
|
||||||
"""處理 WebSocket 訊息"""
|
"""處理 WebSocket 訊息"""
|
||||||
try:
|
self.process_json_telemetry_message(data)
|
||||||
system_id = data.get('system_id')
|
|
||||||
if not system_id:
|
|
||||||
return
|
|
||||||
drone_id = f"s{self.socket_id}_{system_id}"
|
|
||||||
|
|
||||||
# 模式
|
|
||||||
if 'mode' in data:
|
|
||||||
# 先發送連接類型資訊
|
|
||||||
self.signals.update_signal.emit('connection_type', drone_id, {
|
|
||||||
'type': 'WS'
|
|
||||||
})
|
|
||||||
self.signals.update_signal.emit('state', drone_id, {
|
|
||||||
'mode': data['mode'],
|
|
||||||
})
|
|
||||||
|
|
||||||
# 電池
|
|
||||||
if 'battery' in data:
|
|
||||||
self.signals.update_signal.emit('battery', drone_id, {
|
|
||||||
'percentage': data['battery']
|
|
||||||
})
|
|
||||||
|
|
||||||
# 位置
|
|
||||||
if 'position' in data:
|
|
||||||
pos = data['position']
|
|
||||||
self.signals.update_signal.emit('gps', drone_id, {
|
|
||||||
'lat': pos.get('lat', 0),
|
|
||||||
'lon': pos.get('lon', 0)
|
|
||||||
})
|
|
||||||
|
|
||||||
# Local position - 設定 x, y 為 0.0
|
|
||||||
self.signals.update_signal.emit('local_pose', drone_id, {
|
|
||||||
'x': 0.0,
|
|
||||||
'y': 0.0,
|
|
||||||
'z': 0.0
|
|
||||||
})
|
|
||||||
|
|
||||||
# Altitude - 設定為 0.0
|
|
||||||
self.signals.update_signal.emit('altitude', drone_id, {
|
|
||||||
'altitude': 0.0
|
|
||||||
})
|
|
||||||
|
|
||||||
# 航向
|
|
||||||
if 'heading' in data:
|
|
||||||
self.signals.update_signal.emit('hud', drone_id, {
|
|
||||||
'heading': data['heading'],
|
|
||||||
'groundspeed': 0.0
|
|
||||||
})
|
|
||||||
|
|
||||||
except Exception as e:
|
|
||||||
print(f"WebSocket message processing error: {e}")
|
|
||||||
|
|
||||||
def stop(self):
|
def stop(self):
|
||||||
"""停止接收器"""
|
"""停止接收器"""
|
||||||
@@ -1101,10 +1289,10 @@ class DroneMonitor(Node):
|
|||||||
}
|
}
|
||||||
|
|
||||||
def start_serial_connection(self, port='/dev/ttyUSB0', baudrate=57600):
|
def start_serial_connection(self, port='/dev/ttyUSB0', baudrate=57600):
|
||||||
"""啟動串口 MAVLink 連接"""
|
"""啟動串口遙測連接(自動辨識 MAVLink / JSON)"""
|
||||||
connection_name = f"Serial_{port.replace('/', '_')}"
|
connection_name = f"Serial_{port.replace('/', '_')}"
|
||||||
receiver = SerialMavlinkReceiver(port, baudrate, self.signals, connection_name, self)
|
receiver = SerialMavlinkReceiver(port, baudrate, self.signals, connection_name, self)
|
||||||
receiver.start()
|
receiver.start()
|
||||||
self.serial_receivers.append(receiver)
|
self.serial_receivers.append(receiver)
|
||||||
_log("INFO", f"已啟動 Serial 連線: {port} @ {baudrate} baud")
|
_log("INFO", f"已啟動 Serial 連線: {port} @ {baudrate} baud (MAVLink/JSON)")
|
||||||
return receiver
|
return receiver
|
||||||
|
|||||||
+303
-184
@@ -14,6 +14,8 @@ import subprocess
|
|||||||
import time
|
import time
|
||||||
import traceback
|
import traceback
|
||||||
import re
|
import re
|
||||||
|
import threading
|
||||||
|
from concurrent.futures import ThreadPoolExecutor
|
||||||
|
|
||||||
|
|
||||||
def _log(level, message):
|
def _log(level, message):
|
||||||
@@ -143,7 +145,9 @@ class ToggleSwitch(QWidget):
|
|||||||
|
|
||||||
|
|
||||||
class ControlStationUI(QMainWindow):
|
class ControlStationUI(QMainWindow):
|
||||||
VERSION = '2.2.0'
|
planning_finished = pyqtSignal(object)
|
||||||
|
|
||||||
|
VERSION = '2.3.0'
|
||||||
FONT_SCALE_MIN = 70
|
FONT_SCALE_MIN = 70
|
||||||
FONT_SCALE_MAX = 180
|
FONT_SCALE_MAX = 180
|
||||||
FONT_SCALE_DEFAULT = 100
|
FONT_SCALE_DEFAULT = 100
|
||||||
@@ -152,6 +156,7 @@ class ControlStationUI(QMainWindow):
|
|||||||
super().__init__()
|
super().__init__()
|
||||||
self.setWindowTitle(f'GCS v{self.VERSION}')
|
self.setWindowTitle(f'GCS v{self.VERSION}')
|
||||||
self.resize(1400, 900)
|
self.resize(1400, 900)
|
||||||
|
self._closing = False
|
||||||
|
|
||||||
# 初始化消息隊列(用於線程安全的 GUI 更新)
|
# 初始化消息隊列(用於線程安全的 GUI 更新)
|
||||||
import queue
|
import queue
|
||||||
@@ -168,10 +173,16 @@ class ControlStationUI(QMainWindow):
|
|||||||
# 將執行器註冊到 DroneMonitor,以便動態創建的 CommandLongClient 能被添加
|
# 將執行器註冊到 DroneMonitor,以便動態創建的 CommandLongClient 能被添加
|
||||||
self.monitor.executor = self.executor
|
self.monitor.executor = self.executor
|
||||||
|
|
||||||
# 定时处理ROS事件
|
# 在背景執行緒處理 ROS2 spin,避免佔用 Qt 主執行緒時間
|
||||||
self.timer = QTimer()
|
self.ros_thread_running = True
|
||||||
self.timer.timeout.connect(self.spin_ros)
|
self.ros_thread = threading.Thread(target=self._ros_spin_thread, daemon=True)
|
||||||
self.timer.start(10)
|
self.ros_thread.start()
|
||||||
|
|
||||||
|
# 任務規劃移到 worker thread,避免大量航點計算卡住 Qt 主執行緒
|
||||||
|
self.planner_pool = ThreadPoolExecutor(max_workers=2)
|
||||||
|
self._plan_request_counter = 0
|
||||||
|
self._latest_plan_request_by_group = {}
|
||||||
|
self.planning_finished.connect(self._on_plan_done)
|
||||||
|
|
||||||
# 驅動 asyncio 事件循環的定時器(新增 - 關鍵!)
|
# 驅動 asyncio 事件循環的定時器(新增 - 關鍵!)
|
||||||
# 這允許異步任務(如 arm_drone)能夠執行
|
# 這允許異步任務(如 arm_drone)能夠執行
|
||||||
@@ -179,7 +190,7 @@ class ControlStationUI(QMainWindow):
|
|||||||
self.asyncio_timer.timeout.connect(self._spin_asyncio)
|
self.asyncio_timer.timeout.connect(self._spin_asyncio)
|
||||||
self.asyncio_timer.start(10) # 每 10ms 驅動一次 asyncio
|
self.asyncio_timer.start(10) # 每 10ms 驅動一次 asyncio
|
||||||
|
|
||||||
# 初始化 panel 和 map 更新(10Hz)
|
# 初始化 panel 更新(10Hz)
|
||||||
self.panel_map_timer = QTimer()
|
self.panel_map_timer = QTimer()
|
||||||
self.panel_map_timer.timeout.connect(self._update_panel_and_map)
|
self.panel_map_timer.timeout.connect(self._update_panel_and_map)
|
||||||
self.panel_map_timer.start(100) # 10Hz
|
self.panel_map_timer.start(100) # 10Hz
|
||||||
@@ -191,6 +202,7 @@ class ControlStationUI(QMainWindow):
|
|||||||
|
|
||||||
# 快取消息數據,以便在沒有新消息時使用上一次的值
|
# 快取消息數據,以便在沒有新消息時使用上一次的值
|
||||||
self._message_cache = {}
|
self._message_cache = {}
|
||||||
|
self._overview_cache = {}
|
||||||
self.message_history = []
|
self.message_history = []
|
||||||
self.max_message_history = 500
|
self.max_message_history = 500
|
||||||
|
|
||||||
@@ -217,6 +229,17 @@ class ControlStationUI(QMainWindow):
|
|||||||
self.drone_headings = {}
|
self.drone_headings = {}
|
||||||
# 初始化地圖
|
# 初始化地圖
|
||||||
self.drone_map = DroneMap()
|
self.drone_map = DroneMap()
|
||||||
|
|
||||||
|
# 地圖更新獨立降頻(5Hz),避免 WebEngine / JS map 拖慢 panel 更新
|
||||||
|
self.map_timer = QTimer()
|
||||||
|
self.map_timer.timeout.connect(self._update_map_only)
|
||||||
|
self.map_timer.start(200)
|
||||||
|
|
||||||
|
# Overview table 獨立批次更新(5Hz),避免每筆資料都重繪 Qt table
|
||||||
|
self.overview_timer = QTimer()
|
||||||
|
self.overview_timer.timeout.connect(self._flush_overview_table)
|
||||||
|
self.overview_timer.start(200)
|
||||||
|
|
||||||
# 初始化連接列表
|
# 初始化連接列表
|
||||||
self.udp_receivers = []
|
self.udp_receivers = []
|
||||||
self.udp_connections = []
|
self.udp_connections = []
|
||||||
@@ -726,7 +749,7 @@ class ControlStationUI(QMainWindow):
|
|||||||
self.message_history = self.message_history[-self.max_message_history:]
|
self.message_history = self.message_history[-self.max_message_history:]
|
||||||
|
|
||||||
if hasattr(self, 'message_history_view'):
|
if hasattr(self, 'message_history_view'):
|
||||||
self.message_history_view.setPlainText("\n".join(self.message_history))
|
self.message_history_view.appendPlainText(entry)
|
||||||
scrollbar = self.message_history_view.verticalScrollBar()
|
scrollbar = self.message_history_view.verticalScrollBar()
|
||||||
scrollbar.setValue(scrollbar.maximum())
|
scrollbar.setValue(scrollbar.maximum())
|
||||||
|
|
||||||
@@ -800,7 +823,7 @@ class ControlStationUI(QMainWindow):
|
|||||||
status_label.setToolTip("已停止")
|
status_label.setToolTip("已停止")
|
||||||
self.statusBar().showMessage(f"已停止 WebSocket 連接: {conn['url']}", 3000)
|
self.statusBar().showMessage(f"已停止 WebSocket 連接: {conn['url']}", 3000)
|
||||||
else:
|
else:
|
||||||
receiver = WebSocketMavlinkReceiver(conn['url'], self.monitor.signals, conn['name'])
|
receiver = WebSocketMavlinkReceiver(conn['url'], self.monitor.signals, conn['name'], self.monitor)
|
||||||
receiver.start()
|
receiver.start()
|
||||||
self.monitor.ws_receivers.append(receiver)
|
self.monitor.ws_receivers.append(receiver)
|
||||||
conn['receiver'] = receiver
|
conn['receiver'] = receiver
|
||||||
@@ -832,7 +855,7 @@ class ControlStationUI(QMainWindow):
|
|||||||
status_label.setToolTip("已停止")
|
status_label.setToolTip("已停止")
|
||||||
self.statusBar().showMessage(f"已停止 UDP 連接: {conn['ip']}:{conn['port']}", 3000)
|
self.statusBar().showMessage(f"已停止 UDP 連接: {conn['ip']}:{conn['port']}", 3000)
|
||||||
else:
|
else:
|
||||||
receiver = UDPMavlinkReceiver(conn['ip'], conn['port'], self.monitor.signals, conn['name'])
|
receiver = UDPMavlinkReceiver(conn['ip'], conn['port'], self.monitor.signals, conn['name'], self.monitor)
|
||||||
receiver.start()
|
receiver.start()
|
||||||
self.udp_receivers.append(receiver)
|
self.udp_receivers.append(receiver)
|
||||||
conn['receiver'] = receiver
|
conn['receiver'] = receiver
|
||||||
@@ -1733,6 +1756,141 @@ class ControlStationUI(QMainWindow):
|
|||||||
"""取得群組的無人機 ID 列表(排序後)"""
|
"""取得群組的無人機 ID 列表(排序後)"""
|
||||||
return sorted(group.selected_drone_ids, key=lambda x: (x.split('_')[0], int(x.split('_')[1])))
|
return sorted(group.selected_drone_ids, key=lambda x: (x.split('_')[0], int(x.split('_')[1])))
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _run_plan_formation_mission(planner_config, drone_gps_positions,
|
||||||
|
target_gps, mission_type, params):
|
||||||
|
planner = FormationPlanner(**planner_config)
|
||||||
|
return planner.plan_formation_mission(
|
||||||
|
drone_gps_positions, target_gps, mission_type, params=params
|
||||||
|
)
|
||||||
|
|
||||||
|
def _submit_mission_plan(self, group, selected_drones, drone_gps_positions,
|
||||||
|
target_gps, mission_type, params, context):
|
||||||
|
self._plan_request_counter += 1
|
||||||
|
request_id = self._plan_request_counter
|
||||||
|
self._latest_plan_request_by_group[group.group_id] = request_id
|
||||||
|
|
||||||
|
planner_config = {
|
||||||
|
'spacing': self.mission_planner.spacing,
|
||||||
|
'base_altitude': self.mission_planner.base_altitude,
|
||||||
|
'altitude_diff': self.mission_planner.altitude_diff,
|
||||||
|
}
|
||||||
|
params = dict(params)
|
||||||
|
future = self.planner_pool.submit(
|
||||||
|
self._run_plan_formation_mission,
|
||||||
|
planner_config,
|
||||||
|
list(drone_gps_positions),
|
||||||
|
tuple(target_gps),
|
||||||
|
mission_type,
|
||||||
|
params,
|
||||||
|
)
|
||||||
|
future.add_done_callback(
|
||||||
|
lambda f: self._emit_plan_result(
|
||||||
|
f, request_id, group.group_id, list(selected_drones),
|
||||||
|
list(drone_gps_positions), tuple(target_gps), context
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
def _emit_plan_result(self, future, request_id, group_id, selected_drones,
|
||||||
|
drone_gps_positions, target_gps, context):
|
||||||
|
if getattr(self, '_closing', False):
|
||||||
|
return
|
||||||
|
try:
|
||||||
|
waypoints_per_drone, center_origin, rendezvous_indices = future.result()
|
||||||
|
payload = {
|
||||||
|
'ok': True,
|
||||||
|
'request_id': request_id,
|
||||||
|
'group_id': group_id,
|
||||||
|
'selected_drones': selected_drones,
|
||||||
|
'drone_gps_positions': drone_gps_positions,
|
||||||
|
'target_gps': target_gps,
|
||||||
|
'waypoints_per_drone': waypoints_per_drone,
|
||||||
|
'center_origin': center_origin,
|
||||||
|
'rendezvous_indices': rendezvous_indices,
|
||||||
|
'context': context,
|
||||||
|
}
|
||||||
|
except Exception as e:
|
||||||
|
payload = {
|
||||||
|
'ok': False,
|
||||||
|
'request_id': request_id,
|
||||||
|
'group_id': group_id,
|
||||||
|
'error': str(e),
|
||||||
|
'traceback': traceback.format_exc(),
|
||||||
|
'context': context,
|
||||||
|
}
|
||||||
|
self.planning_finished.emit(payload)
|
||||||
|
|
||||||
|
def _on_plan_done(self, payload):
|
||||||
|
if getattr(self, '_closing', False):
|
||||||
|
return
|
||||||
|
group_id = payload['group_id']
|
||||||
|
context = payload.get('context', {})
|
||||||
|
if self._latest_plan_request_by_group.get(group_id) != payload['request_id']:
|
||||||
|
return
|
||||||
|
|
||||||
|
group = self.mission_groups.get(group_id)
|
||||||
|
if not group:
|
||||||
|
return
|
||||||
|
|
||||||
|
if not payload['ok']:
|
||||||
|
self.statusBar().showMessage(
|
||||||
|
f"Group {group_id}: {context.get('failure_label', '規劃')}失敗: {payload['error']}", 5000
|
||||||
|
)
|
||||||
|
_log("ERROR", payload.get('traceback', ''))
|
||||||
|
return
|
||||||
|
|
||||||
|
selected_drones = payload['selected_drones']
|
||||||
|
drone_gps_positions = payload['drone_gps_positions']
|
||||||
|
target_gps = payload['target_gps']
|
||||||
|
waypoints_per_drone = payload['waypoints_per_drone']
|
||||||
|
center_origin = payload['center_origin']
|
||||||
|
rendezvous_indices = payload['rendezvous_indices']
|
||||||
|
expected_mission_type = context.get('expected_mission_type')
|
||||||
|
|
||||||
|
if set(group.selected_drone_ids) != set(selected_drones):
|
||||||
|
self.statusBar().showMessage(
|
||||||
|
f"Group {group_id}: 無人機分配已變更,忽略舊規劃結果", 3000
|
||||||
|
)
|
||||||
|
return
|
||||||
|
if expected_mission_type and group.mission_type != expected_mission_type:
|
||||||
|
self.statusBar().showMessage(
|
||||||
|
f"Group {group_id}: 任務模式已變更,忽略舊規劃結果", 3000
|
||||||
|
)
|
||||||
|
return
|
||||||
|
|
||||||
|
group.planned_waypoints = {
|
||||||
|
'drone_ids': selected_drones,
|
||||||
|
'waypoints': waypoints_per_drone,
|
||||||
|
'rendezvous_indices': rendezvous_indices,
|
||||||
|
}
|
||||||
|
group.center_origin = center_origin
|
||||||
|
self.show_planned_waypoints(group)
|
||||||
|
|
||||||
|
center_lat, center_lon, _ = center_origin
|
||||||
|
target_lat, target_lon = context['draw_target']
|
||||||
|
self.drone_map.draw_mission_plan_for_group(
|
||||||
|
group_id, group.color,
|
||||||
|
center_lat, center_lon, target_lat, target_lon
|
||||||
|
)
|
||||||
|
|
||||||
|
self._launch_verification(
|
||||||
|
context['verification_type'], drone_gps_positions, selected_drones,
|
||||||
|
waypoints_per_drone, center_origin,
|
||||||
|
target_gps=target_gps if context.get('use_target_gps') else None,
|
||||||
|
rect_corners=context.get('rect_corners'),
|
||||||
|
route_waypoints=context.get('route_waypoints'),
|
||||||
|
)
|
||||||
|
|
||||||
|
panel = self.group_panels.get(group_id)
|
||||||
|
if panel:
|
||||||
|
panel.update_status()
|
||||||
|
panel.update_mission_info(center_lat, center_lon, target_lat, target_lon)
|
||||||
|
|
||||||
|
total_wps = sum(len(wps) for wps in waypoints_per_drone)
|
||||||
|
self.statusBar().showMessage(
|
||||||
|
f"Group {group_id}: {context['success_label']},{len(selected_drones)} 台,共 {total_wps} 個航點", 5000
|
||||||
|
)
|
||||||
|
|
||||||
def handle_map_click(self, lat, lon):
|
def handle_map_click(self, lat, lon):
|
||||||
"""處理地圖點擊事件 — 根據 active group 的任務類型規劃"""
|
"""處理地圖點擊事件 — 根據 active group 的任務類型規劃"""
|
||||||
group = self._get_active_group()
|
group = self._get_active_group()
|
||||||
@@ -1761,46 +1919,22 @@ class ControlStationUI(QMainWindow):
|
|||||||
self.statusBar().showMessage(
|
self.statusBar().showMessage(
|
||||||
f"Group {group.group_id}: 正在規劃 {group.mission_type} ({len(selected_drones)} 台)", 2000)
|
f"Group {group.group_id}: 正在規劃 {group.mission_type} ({len(selected_drones)} 台)", 2000)
|
||||||
|
|
||||||
try:
|
drone_gps_positions = self._collect_drone_gps(selected_drones, base_alt)
|
||||||
drone_gps_positions = self._collect_drone_gps(selected_drones, base_alt)
|
if drone_gps_positions is None:
|
||||||
if drone_gps_positions is None:
|
return
|
||||||
return
|
|
||||||
|
|
||||||
waypoints_per_drone, center_origin, rendezvous_indices = self.mission_planner.plan_formation_mission(
|
context = {
|
||||||
drone_gps_positions, target_gps, mission_type, params=params
|
'success_label': f"{group.mission_type} 規劃完成",
|
||||||
)
|
'failure_label': '規劃',
|
||||||
|
'verification_type': group.mission_type,
|
||||||
group.planned_waypoints = {
|
'draw_target': (lat, lon),
|
||||||
'drone_ids': selected_drones,
|
'use_target_gps': True,
|
||||||
'waypoints': waypoints_per_drone,
|
'expected_mission_type': group.mission_type,
|
||||||
'rendezvous_indices': rendezvous_indices,
|
}
|
||||||
}
|
self._submit_mission_plan(
|
||||||
group.center_origin = center_origin
|
group, selected_drones, drone_gps_positions,
|
||||||
self.show_planned_waypoints(group)
|
target_gps, mission_type, params, context
|
||||||
|
)
|
||||||
center_lat, center_lon, _ = center_origin
|
|
||||||
self.drone_map.draw_mission_plan_for_group(
|
|
||||||
group.group_id, group.color,
|
|
||||||
center_lat, center_lon, lat, lon
|
|
||||||
)
|
|
||||||
|
|
||||||
self._launch_verification(
|
|
||||||
group.mission_type, drone_gps_positions, selected_drones,
|
|
||||||
waypoints_per_drone, center_origin, target_gps=target_gps
|
|
||||||
)
|
|
||||||
|
|
||||||
panel = self.group_panels.get(group.group_id)
|
|
||||||
if panel:
|
|
||||||
panel.update_status()
|
|
||||||
panel.update_mission_info(center_lat, center_lon, lat, lon)
|
|
||||||
|
|
||||||
total_wps = sum(len(wps) for wps in waypoints_per_drone)
|
|
||||||
self.statusBar().showMessage(
|
|
||||||
f"Group {group.group_id}: {group.mission_type} 規劃完成,{len(selected_drones)} 台,共 {total_wps} 個航點", 5000
|
|
||||||
)
|
|
||||||
except Exception as e:
|
|
||||||
self.statusBar().showMessage(f"Group {group.group_id}: 規劃失敗: {str(e)}", 5000)
|
|
||||||
traceback.print_exc()
|
|
||||||
|
|
||||||
# ================================================================================
|
# ================================================================================
|
||||||
# 任務規劃 — 矩形選取 (Grid Sweep)
|
# 任務規劃 — 矩形選取 (Grid Sweep)
|
||||||
@@ -1831,51 +1965,27 @@ class ControlStationUI(QMainWindow):
|
|||||||
base_alt = params.get('altitude', 10.0)
|
base_alt = params.get('altitude', 10.0)
|
||||||
self.statusBar().showMessage(
|
self.statusBar().showMessage(
|
||||||
f"Group {group.group_id}: 正在規劃 Grid Sweep ({len(selected_drones)} 台)", 2000)
|
f"Group {group.group_id}: 正在規劃 Grid Sweep ({len(selected_drones)} 台)", 2000)
|
||||||
try:
|
drone_gps_positions = self._collect_drone_gps(selected_drones, base_alt)
|
||||||
drone_gps_positions = self._collect_drone_gps(selected_drones, base_alt)
|
if drone_gps_positions is None:
|
||||||
if drone_gps_positions is None:
|
return
|
||||||
return
|
|
||||||
|
|
||||||
target_lat = sum(c[0] for c in rect_corners) / 4
|
target_lat = sum(c[0] for c in rect_corners) / 4
|
||||||
target_lon = sum(c[1] for c in rect_corners) / 4
|
target_lon = sum(c[1] for c in rect_corners) / 4
|
||||||
target_gps = (target_lat, target_lon, base_alt)
|
target_gps = (target_lat, target_lon, base_alt)
|
||||||
|
|
||||||
params['rect_corners'] = rect_corners
|
params['rect_corners'] = rect_corners
|
||||||
waypoints_per_drone, center_origin, rendezvous_indices = self.mission_planner.plan_formation_mission(
|
context = {
|
||||||
drone_gps_positions, target_gps, MissionType.GRID_SWEEP,
|
'success_label': 'Grid Sweep 規劃完成',
|
||||||
params=params
|
'failure_label': 'Grid Sweep 規劃',
|
||||||
)
|
'verification_type': 'grid_sweep',
|
||||||
|
'draw_target': (target_lat, target_lon),
|
||||||
group.planned_waypoints = {
|
'rect_corners': rect_corners,
|
||||||
'drone_ids': selected_drones,
|
'expected_mission_type': group.mission_type,
|
||||||
'waypoints': waypoints_per_drone,
|
}
|
||||||
'rendezvous_indices': rendezvous_indices,
|
self._submit_mission_plan(
|
||||||
}
|
group, selected_drones, drone_gps_positions,
|
||||||
group.center_origin = center_origin
|
target_gps, MissionType.GRID_SWEEP, params, context
|
||||||
self.show_planned_waypoints(group)
|
)
|
||||||
|
|
||||||
center_lat, center_lon, _ = center_origin
|
|
||||||
self.drone_map.draw_mission_plan_for_group(
|
|
||||||
group.group_id, group.color,
|
|
||||||
center_lat, center_lon, target_lat, target_lon
|
|
||||||
)
|
|
||||||
self._launch_verification(
|
|
||||||
'grid_sweep', drone_gps_positions, selected_drones,
|
|
||||||
waypoints_per_drone, center_origin, rect_corners=rect_corners
|
|
||||||
)
|
|
||||||
|
|
||||||
panel = self.group_panels.get(group.group_id)
|
|
||||||
if panel:
|
|
||||||
panel.update_status()
|
|
||||||
panel.update_mission_info(center_lat, center_lon, target_lat, target_lon)
|
|
||||||
|
|
||||||
total_wps = sum(len(wps) for wps in waypoints_per_drone)
|
|
||||||
self.statusBar().showMessage(
|
|
||||||
f"Group {group.group_id}: Grid Sweep 規劃完成,{len(selected_drones)} 台,共 {total_wps} 個航點", 5000
|
|
||||||
)
|
|
||||||
except Exception as e:
|
|
||||||
self.statusBar().showMessage(f"Group {group.group_id}: Grid Sweep 規劃失敗: {str(e)}", 5000)
|
|
||||||
traceback.print_exc()
|
|
||||||
|
|
||||||
# ================================================================================
|
# ================================================================================
|
||||||
# 任務規劃 — 路徑確認 (Leader-Follower)
|
# 任務規劃 — 路徑確認 (Leader-Follower)
|
||||||
@@ -1917,53 +2027,28 @@ class ControlStationUI(QMainWindow):
|
|||||||
self.statusBar().showMessage(
|
self.statusBar().showMessage(
|
||||||
f"Group {group.group_id}: 正在規劃跟隨模式 ({len(selected_drones)} 台)", 2000)
|
f"Group {group.group_id}: 正在規劃跟隨模式 ({len(selected_drones)} 台)", 2000)
|
||||||
|
|
||||||
try:
|
drone_gps_positions = self._collect_drone_gps(selected_drones, base_alt)
|
||||||
drone_gps_positions = self._collect_drone_gps(selected_drones, base_alt)
|
if drone_gps_positions is None:
|
||||||
if drone_gps_positions is None:
|
return
|
||||||
return
|
|
||||||
|
|
||||||
target_lat = sum(p[0] for p in route_waypoints) / len(route_waypoints)
|
target_lat = sum(p[0] for p in route_waypoints) / len(route_waypoints)
|
||||||
target_lon = sum(p[1] for p in route_waypoints) / len(route_waypoints)
|
target_lon = sum(p[1] for p in route_waypoints) / len(route_waypoints)
|
||||||
target_gps = (target_lat, target_lon, base_alt)
|
target_gps = (target_lat, target_lon, base_alt)
|
||||||
|
|
||||||
params['route_waypoints'] = route_waypoints
|
params['route_waypoints'] = route_waypoints
|
||||||
waypoints_per_drone, center_origin, rendezvous_indices = self.mission_planner.plan_formation_mission(
|
context = {
|
||||||
drone_gps_positions, target_gps, MissionType.LEADER_FOLLOWER,
|
'success_label': '跟隨模式規劃完成',
|
||||||
params=params
|
'failure_label': '跟隨模式規劃',
|
||||||
)
|
'verification_type': 'LEADER_FOLLOWER',
|
||||||
|
'draw_target': (target_lat, target_lon),
|
||||||
group.planned_waypoints = {
|
'use_target_gps': True,
|
||||||
'drone_ids': selected_drones,
|
'route_waypoints': route_waypoints,
|
||||||
'waypoints': waypoints_per_drone,
|
'expected_mission_type': group.mission_type,
|
||||||
'rendezvous_indices': rendezvous_indices,
|
}
|
||||||
}
|
self._submit_mission_plan(
|
||||||
group.center_origin = center_origin
|
group, selected_drones, drone_gps_positions,
|
||||||
self.show_planned_waypoints(group)
|
target_gps, MissionType.LEADER_FOLLOWER, params, context
|
||||||
|
)
|
||||||
center_lat, center_lon, _ = center_origin
|
|
||||||
self.drone_map.draw_mission_plan_for_group(
|
|
||||||
group.group_id, group.color,
|
|
||||||
center_lat, center_lon, target_lat, target_lon
|
|
||||||
)
|
|
||||||
|
|
||||||
self._launch_verification(
|
|
||||||
'LEADER_FOLLOWER', drone_gps_positions, selected_drones,
|
|
||||||
waypoints_per_drone, center_origin,
|
|
||||||
target_gps=target_gps, route_waypoints=route_waypoints
|
|
||||||
)
|
|
||||||
|
|
||||||
panel = self.group_panels.get(group.group_id)
|
|
||||||
if panel:
|
|
||||||
panel.update_status()
|
|
||||||
panel.update_mission_info(center_lat, center_lon, target_lat, target_lon)
|
|
||||||
|
|
||||||
total_wps = sum(len(wps) for wps in waypoints_per_drone)
|
|
||||||
self.statusBar().showMessage(
|
|
||||||
f"Group {group.group_id}: 跟隨模式規劃完成,{len(selected_drones)} 台,共 {total_wps} 個航點", 5000
|
|
||||||
)
|
|
||||||
except Exception as e:
|
|
||||||
self.statusBar().showMessage(f"Group {group.group_id}: 跟隨模式規劃失敗: {str(e)}", 5000)
|
|
||||||
traceback.print_exc()
|
|
||||||
|
|
||||||
# ================================================================================
|
# ================================================================================
|
||||||
# 任務執行回呼
|
# 任務執行回呼
|
||||||
@@ -2063,6 +2148,30 @@ class ControlStationUI(QMainWindow):
|
|||||||
if not hasattr(self, 'overview_table') or self.overview_table is None: return
|
if not hasattr(self, 'overview_table') or self.overview_table is None: return
|
||||||
self.overview_table.set_drones(self.drones)
|
self.overview_table.set_drones(self.drones)
|
||||||
self.overview_table.update_table(drone_id, field, value)
|
self.overview_table.update_table(drone_id, field, value)
|
||||||
|
|
||||||
|
def queue_overview_update(self, drone_id, field, value):
|
||||||
|
if not hasattr(self, '_overview_cache'):
|
||||||
|
self._overview_cache = {}
|
||||||
|
self._overview_cache.setdefault(drone_id, {})[field] = value
|
||||||
|
|
||||||
|
def _flush_overview_table(self):
|
||||||
|
if not hasattr(self, 'overview_table') or self.overview_table is None:
|
||||||
|
return
|
||||||
|
if not getattr(self, '_overview_cache', None):
|
||||||
|
return
|
||||||
|
|
||||||
|
pending_updates = self._overview_cache
|
||||||
|
self._overview_cache = {}
|
||||||
|
|
||||||
|
self.overview_table.set_drones(self.drones)
|
||||||
|
self.overview_table.setUpdatesEnabled(False)
|
||||||
|
try:
|
||||||
|
for drone_id, fields in pending_updates.items():
|
||||||
|
for field, value in fields.items():
|
||||||
|
self.overview_table.update_table(drone_id, field, value)
|
||||||
|
finally:
|
||||||
|
self.overview_table.setUpdatesEnabled(True)
|
||||||
|
self.overview_table.viewport().update()
|
||||||
|
|
||||||
def get_socket_id(self, drone_id):
|
def get_socket_id(self, drone_id):
|
||||||
import re
|
import re
|
||||||
@@ -2100,7 +2209,7 @@ class ControlStationUI(QMainWindow):
|
|||||||
self.drone_panel_layout.addWidget(self.socket_groups[socket_id])
|
self.drone_panel_layout.addWidget(self.socket_groups[socket_id])
|
||||||
|
|
||||||
def _update_panel_and_map(self):
|
def _update_panel_and_map(self):
|
||||||
"""30Hz 定時更新 panel 和 map,批量更新 UI 以避免過度重繪"""
|
"""定時更新 panel / table,批量更新 UI 以避免過度重繪。"""
|
||||||
if not hasattr(self, '_message_cache') or not self._message_cache:
|
if not hasattr(self, '_message_cache') or not self._message_cache:
|
||||||
return
|
return
|
||||||
|
|
||||||
@@ -2115,10 +2224,6 @@ class ControlStationUI(QMainWindow):
|
|||||||
self._map_update_time = now
|
self._map_update_time = now
|
||||||
self._map_update_count = 0
|
self._map_update_count = 0
|
||||||
|
|
||||||
# ✅ 步驟 1: 暫停表格的即時重繪
|
|
||||||
if hasattr(self, 'overview_table') and self.overview_table:
|
|
||||||
self.overview_table.setUpdatesEnabled(False)
|
|
||||||
|
|
||||||
try:
|
try:
|
||||||
start_time = time.time()
|
start_time = time.time()
|
||||||
|
|
||||||
@@ -2144,8 +2249,8 @@ class ControlStationUI(QMainWindow):
|
|||||||
arm_text, arm_color = "--", '#AAAAAA'
|
arm_text, arm_color = "--", '#AAAAAA'
|
||||||
self.update_field(panel, drone_id, 'mode', mode, mode_color)
|
self.update_field(panel, drone_id, 'mode', mode, mode_color)
|
||||||
self.update_field(panel, drone_id, 'armed', arm_text, arm_color)
|
self.update_field(panel, drone_id, 'armed', arm_text, arm_color)
|
||||||
self.update_overview_table(drone_id, 'mode', mode)
|
self.queue_overview_update(drone_id, 'mode', mode)
|
||||||
self.update_overview_table(drone_id, 'armed', arm_text)
|
self.queue_overview_update(drone_id, 'armed', arm_text)
|
||||||
|
|
||||||
elif msg_type == 'battery':
|
elif msg_type == 'battery':
|
||||||
voltage = data.get('voltage', 16)
|
voltage = data.get('voltage', 16)
|
||||||
@@ -2158,39 +2263,39 @@ class ControlStationUI(QMainWindow):
|
|||||||
self.update_field(panel, drone_id, 'battery_pct', f"{percentage:.0f}%", voltage_color)
|
self.update_field(panel, drone_id, 'battery_pct', f"{percentage:.0f}%", voltage_color)
|
||||||
self.update_field(panel, drone_id, 'battery_vol', f"{voltage:.2f}V")
|
self.update_field(panel, drone_id, 'battery_vol', f"{voltage:.2f}V")
|
||||||
self.update_field(panel, drone_id, 'battery_cells', f"{cells}S")
|
self.update_field(panel, drone_id, 'battery_cells', f"{cells}S")
|
||||||
self.update_overview_table(drone_id, 'battery', f"{voltage:.2f}V")
|
self.queue_overview_update(drone_id, 'battery', f"{voltage:.2f}V")
|
||||||
|
|
||||||
elif msg_type == 'altitude':
|
elif msg_type == 'altitude':
|
||||||
altitude = data.get('altitude', 0)
|
altitude = data.get('altitude', 0)
|
||||||
text = f"{altitude:.1f} m"
|
text = f"{altitude:.1f} m"
|
||||||
self.update_field(panel, drone_id, 'altitude', text)
|
self.update_field(panel, drone_id, 'altitude', text)
|
||||||
self.update_overview_table(drone_id, 'altitude', text)
|
self.queue_overview_update(drone_id, 'altitude', text)
|
||||||
|
|
||||||
elif msg_type == 'local_pose':
|
elif msg_type == 'local_pose':
|
||||||
x, y = data.get('x', 0), data.get('y', 0)
|
x, y = data.get('x', 0), data.get('y', 0)
|
||||||
if not hasattr(self.monitor, 'drone_local'):
|
if not hasattr(self.monitor, 'drone_local'):
|
||||||
self.monitor.drone_local = {}
|
self.monitor.drone_local = {}
|
||||||
self.monitor.drone_local[drone_id] = {'x': x, 'y': y}
|
self.monitor.drone_local[drone_id] = {'x': x, 'y': y}
|
||||||
self.update_overview_table(drone_id, 'local', f"{x:.1f}, {y:.1f}")
|
self.queue_overview_update(drone_id, 'local', f"{x:.1f}, {y:.1f}")
|
||||||
|
|
||||||
elif msg_type == 'loss_rate':
|
elif msg_type == 'loss_rate':
|
||||||
text = f"{data.get('loss_rate', 0):.1f}%"
|
text = f"{data.get('loss_rate', 0):.1f}%"
|
||||||
self.update_field(panel, drone_id, 'loss_rate', text)
|
self.update_field(panel, drone_id, 'loss_rate', text)
|
||||||
self.update_overview_table(drone_id, 'loss_rate', text)
|
self.queue_overview_update(drone_id, 'loss_rate', text)
|
||||||
|
|
||||||
elif msg_type == 'ping':
|
elif msg_type == 'ping':
|
||||||
text = f"{data.get('ping', 0):.1f} ms"
|
text = f"{data.get('ping', 0):.1f} ms"
|
||||||
self.update_field(panel, drone_id, 'ping', text)
|
self.update_field(panel, drone_id, 'ping', text)
|
||||||
self.update_overview_table(drone_id, 'ping', text)
|
self.queue_overview_update(drone_id, 'ping', text)
|
||||||
|
|
||||||
elif msg_type == 'velocity':
|
elif msg_type == 'velocity':
|
||||||
self.update_overview_table(drone_id, 'velocity', f"{data['vx']:.1f}, {data['vy']:.1f}")
|
self.queue_overview_update(drone_id, 'velocity', f"{data['vx']:.1f}, {data['vy']:.1f}")
|
||||||
|
|
||||||
elif msg_type == 'attitude':
|
elif msg_type == 'attitude':
|
||||||
roll, pitch, yaw = data.get('roll', 0), data.get('pitch', 0), data.get('yaw', 0)
|
roll, pitch, yaw = data.get('roll', 0), data.get('pitch', 0), data.get('yaw', 0)
|
||||||
self.update_overview_table(drone_id, 'roll', f"{roll:.1f}°")
|
self.queue_overview_update(drone_id, 'roll', f"{roll:.1f}°")
|
||||||
self.update_overview_table(drone_id, 'pitch', f"{pitch:.1f}°")
|
self.queue_overview_update(drone_id, 'pitch', f"{pitch:.1f}°")
|
||||||
self.update_overview_table(drone_id, 'yaw', f"{yaw:.1f}°")
|
self.queue_overview_update(drone_id, 'yaw', f"{yaw:.1f}°")
|
||||||
panel._last_roll = roll
|
panel._last_roll = roll
|
||||||
panel._last_pitch = pitch
|
panel._last_pitch = pitch
|
||||||
if hasattr(panel, 'update_attitude'):
|
if hasattr(panel, 'update_attitude'):
|
||||||
@@ -2205,8 +2310,8 @@ class ControlStationUI(QMainWindow):
|
|||||||
if not hasattr(self.monitor, 'drone_gps'):
|
if not hasattr(self.monitor, 'drone_gps'):
|
||||||
self.monitor.drone_gps = {}
|
self.monitor.drone_gps = {}
|
||||||
self.monitor.drone_gps[drone_id] = {'lat': lat, 'lon': lon, 'alt': alt}
|
self.monitor.drone_gps[drone_id] = {'lat': lat, 'lon': lon, 'alt': alt}
|
||||||
self.update_overview_table(drone_id, 'latitude', f"{lat:.6f}°")
|
self.queue_overview_update(drone_id, 'latitude', f"{lat:.6f}°")
|
||||||
self.update_overview_table(drone_id, 'longitude', f"{lon:.6f}°")
|
self.queue_overview_update(drone_id, 'longitude', f"{lon:.6f}°")
|
||||||
|
|
||||||
elif msg_type == 'hud':
|
elif msg_type == 'hud':
|
||||||
hud_data = data
|
hud_data = data
|
||||||
@@ -2218,30 +2323,31 @@ class ControlStationUI(QMainWindow):
|
|||||||
hud_alt = hud_data.get('alt', 0)
|
hud_alt = hud_data.get('alt', 0)
|
||||||
climb = hud_data.get('climb', 0)
|
climb = hud_data.get('climb', 0)
|
||||||
|
|
||||||
self.update_overview_table(drone_id, 'heading', f"{heading:.1f}°")
|
self.queue_overview_update(drone_id, 'heading', f"{heading:.1f}°")
|
||||||
self.update_overview_table(drone_id, 'groundspeed', f"{groundspeed:.1f} m/s" if isinstance(groundspeed, (int, float)) else "--")
|
self.queue_overview_update(drone_id, 'groundspeed', f"{groundspeed:.1f} m/s" if isinstance(groundspeed, (int, float)) else "--")
|
||||||
self.update_overview_table(drone_id, 'airspeed', f"{airspeed:.1f} m/s" if isinstance(airspeed, (int, float)) else "--")
|
self.queue_overview_update(drone_id, 'airspeed', f"{airspeed:.1f} m/s" if isinstance(airspeed, (int, float)) else "--")
|
||||||
self.update_overview_table(drone_id, 'throttle', f"{throttle:.0f}%" if isinstance(throttle, (int, float)) else "--")
|
self.queue_overview_update(drone_id, 'throttle', f"{throttle:.0f}%" if isinstance(throttle, (int, float)) else "--")
|
||||||
self.update_overview_table(drone_id, 'hud_alt', f"{hud_alt:.1f} m" if isinstance(hud_alt, (int, float)) else "--")
|
self.queue_overview_update(drone_id, 'hud_alt', f"{hud_alt:.1f} m" if isinstance(hud_alt, (int, float)) else "--")
|
||||||
self.update_overview_table(drone_id, 'climb', f"{climb:.1f} m/s" if isinstance(climb, (int, float)) else "--")
|
self.queue_overview_update(drone_id, 'climb', f"{climb:.1f} m/s" if isinstance(climb, (int, float)) else "--")
|
||||||
|
|
||||||
self.update_field(panel, drone_id, 'heading', f"{heading:.1f}°")
|
self.update_field(panel, drone_id, 'heading', f"{heading:.1f}°")
|
||||||
self.update_field(panel, drone_id, 'groundspeed', f"{groundspeed:.1f} m/s" if isinstance(groundspeed, (int, float)) else "--")
|
self.update_field(panel, drone_id, 'groundspeed', f"{groundspeed:.1f} m/s" if isinstance(groundspeed, (int, float)) else "--")
|
||||||
self.update_field(panel, drone_id, 'speed', f"{groundspeed:.1f} m/s" if isinstance(groundspeed, (int, float)) else "--")
|
self.update_field(panel, drone_id, 'speed', f"{groundspeed:.1f} m/s" if isinstance(groundspeed, (int, float)) else "--")
|
||||||
|
|
||||||
if drone_id in self.drone_positions:
|
|
||||||
lat, lon = self.drone_positions[drone_id]
|
|
||||||
self.drone_map.update_drone_position(drone_id, lat, lon, heading)
|
|
||||||
|
|
||||||
elapsed = (time.time() - start_time) * 1000
|
elapsed = (time.time() - start_time) * 1000
|
||||||
if elapsed > 33:
|
if elapsed > 33:
|
||||||
_log("WARN", f"UI 更新耗時 {elapsed:.1f}ms (目標 <33ms)")
|
_log("WARN", f"UI 更新耗時 {elapsed:.1f}ms (目標 <33ms)")
|
||||||
|
except Exception as e:
|
||||||
finally:
|
_log("ERROR", f"Panel 更新錯誤: {e}")
|
||||||
# ✅ 步驟 3: 恢復表格重繪(所有資料已填好,一次性重繪)
|
traceback.print_exc()
|
||||||
if hasattr(self, 'overview_table') and self.overview_table:
|
|
||||||
self.overview_table.setUpdatesEnabled(True)
|
def _update_map_only(self):
|
||||||
self.overview_table.viewport().update()
|
"""獨立降頻更新地圖,避免地圖 JS 呼叫拖住 panel / table。"""
|
||||||
|
for drone_id, pos in list(self.drone_positions.items()):
|
||||||
|
heading = self.drone_headings.get(drone_id, 0)
|
||||||
|
lat, lon = pos
|
||||||
|
self.drone_map.update_drone_position(drone_id, lat, lon, heading)
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
@@ -2275,28 +2381,41 @@ class ControlStationUI(QMainWindow):
|
|||||||
# 但仍然打印詳細的堆棧跟踪以便調試
|
# 但仍然打印詳細的堆棧跟踪以便調試
|
||||||
traceback.print_exc()
|
traceback.print_exc()
|
||||||
|
|
||||||
def spin_ros(self):
|
def _ros_spin_thread(self):
|
||||||
try:
|
while self.ros_thread_running and rclpy.ok():
|
||||||
# 仅在 ROS2 正常工作时才尝试 spin
|
try:
|
||||||
if rclpy.ok():
|
|
||||||
self.executor.spin_once(timeout_sec=0.01)
|
self.executor.spin_once(timeout_sec=0.01)
|
||||||
for (drone_id, msg_type), data in self.monitor.latest_data.items():
|
|
||||||
self.monitor.signals.update_signal.emit(msg_type, drone_id, data)
|
latest_items = list(self.monitor.latest_data.items())
|
||||||
self.monitor.latest_data.clear()
|
self.monitor.latest_data.clear()
|
||||||
except RuntimeError as e:
|
|
||||||
# ROS2 context 被破坏或不可用
|
for (drone_id, msg_type), data in latest_items:
|
||||||
if "Context" in str(e) or "context" in str(e).lower():
|
self.monitor.signals.update_signal.emit(msg_type, drone_id, data)
|
||||||
_log("WARN", f"ROS2 context 錯誤(已忽略): {e}")
|
except RuntimeError as e:
|
||||||
else:
|
if "Context" in str(e) or "context" in str(e).lower():
|
||||||
_log("ERROR", f"ROS spin error: {e}")
|
_log("WARN", f"ROS2 context 錯誤(已忽略): {e}")
|
||||||
|
break
|
||||||
|
_log("ERROR", f"ROS spin thread error: {e}")
|
||||||
traceback.print_exc()
|
traceback.print_exc()
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
_log("ERROR", f"ROS spin error: {e}")
|
_log("ERROR", f"ROS spin thread error: {e}")
|
||||||
traceback.print_exc()
|
traceback.print_exc()
|
||||||
|
|
||||||
|
def _stop_ros_thread(self):
|
||||||
|
self.ros_thread_running = False
|
||||||
|
ros_thread = getattr(self, 'ros_thread', None)
|
||||||
|
if ros_thread and ros_thread.is_alive():
|
||||||
|
ros_thread.join(timeout=1.0)
|
||||||
|
|
||||||
def closeEvent(self, event):
|
def closeEvent(self, event):
|
||||||
|
self._closing = True
|
||||||
self._restore_stream_redirector()
|
self._restore_stream_redirector()
|
||||||
|
self._stop_ros_thread()
|
||||||
try:
|
try:
|
||||||
|
try:
|
||||||
|
self.planner_pool.shutdown(wait=False, cancel_futures=True)
|
||||||
|
except TypeError:
|
||||||
|
self.planner_pool.shutdown(wait=False)
|
||||||
for group in self.mission_groups.values():
|
for group in self.mission_groups.values():
|
||||||
if group.executor:
|
if group.executor:
|
||||||
group.executor.stop()
|
group.executor.stop()
|
||||||
|
|||||||
Reference in New Issue
Block a user