Merge branch 'ken' into chiyu
commit
4d82b92a11
@ -0,0 +1,444 @@
|
||||
#!/usr/bin/env python3
|
||||
import rclpy
|
||||
from rclpy.node import Node
|
||||
from PyQt6.QtWidgets import (QApplication, QMainWindow, QVBoxLayout, QHBoxLayout,
|
||||
QWidget, QLabel, QSplitter, QScrollArea,
|
||||
QSizePolicy, QApplication)
|
||||
from PyQt6.QtCore import Qt, QTimer, QObject, pyqtSignal, QUrl
|
||||
from PyQt6.QtGui import QColor
|
||||
from PyQt6.QtWebEngineWidgets import QWebEngineView
|
||||
import math
|
||||
import re
|
||||
import os
|
||||
import sys
|
||||
from threading import Lock
|
||||
from geometry_msgs.msg import Point, Vector3
|
||||
from sensor_msgs.msg import BatteryState, NavSatFix, Imu
|
||||
from std_msgs.msg import String, Float64
|
||||
from mavros_msgs.msg import VfrHud
|
||||
|
||||
class DroneSignals(QObject):
|
||||
update_signal = pyqtSignal(str, str, object) # (msg_type, drone_id, data)
|
||||
|
||||
class DroneMonitor(Node):
|
||||
def __init__(self):
|
||||
super().__init__('drone_monitor')
|
||||
self.signals = DroneSignals()
|
||||
self.drone_topics = {}
|
||||
self.lock = Lock()
|
||||
|
||||
# 主题检测定时器
|
||||
self.create_timer(1.0, self.scan_topics)
|
||||
|
||||
def scan_topics(self):
|
||||
topics = self.get_topic_names_and_types()
|
||||
drone_pattern = re.compile(r'/MavLinkBus/(s\d+)/(\w+)')
|
||||
|
||||
found_drones = set()
|
||||
for topic_name, _ in topics:
|
||||
if match := drone_pattern.match(topic_name):
|
||||
drone_id, topic_type = match.groups()
|
||||
found_drones.add(drone_id)
|
||||
with self.lock:
|
||||
self.drone_topics.setdefault(drone_id, set()).add(topic_type)
|
||||
|
||||
for drone_id in found_drones:
|
||||
if not hasattr(self, f'drone_{drone_id}_subs'):
|
||||
self.setup_drone_subs(drone_id)
|
||||
|
||||
def setup_drone_subs(self, drone_id):
|
||||
base_topic = f'/MavLinkBus/{drone_id}'
|
||||
|
||||
subs = {
|
||||
'attitude': self.create_subscription(
|
||||
Imu,
|
||||
f'{base_topic}/attitude',
|
||||
lambda msg, did=drone_id: self.attitude_callback(did, msg),
|
||||
10
|
||||
),
|
||||
'battery': self.create_subscription(
|
||||
BatteryState,
|
||||
f'{base_topic}/battery',
|
||||
lambda msg, did=drone_id: self.battery_callback(did, msg),
|
||||
10
|
||||
),
|
||||
'flightMode': self.create_subscription(
|
||||
String,
|
||||
f'{base_topic}/flightMode',
|
||||
lambda msg, did=drone_id: self.mode_callback(did, msg),
|
||||
10
|
||||
),
|
||||
'global': self.create_subscription(
|
||||
NavSatFix,
|
||||
f'{base_topic}/global_position/global',
|
||||
lambda msg, did=drone_id: self.gps_callback(did, msg),
|
||||
10
|
||||
),
|
||||
'rel_alt': self.create_subscription(
|
||||
Float64,
|
||||
f'{base_topic}/global_position/rel_alt',
|
||||
lambda msg, did=drone_id: self.altitude_callback(did, msg),
|
||||
10
|
||||
),
|
||||
'local_pose': self.create_subscription(
|
||||
Point,
|
||||
f'{base_topic}/local_position/pose',
|
||||
lambda msg, did=drone_id: self.local_pose_callback(did, msg),
|
||||
10
|
||||
),
|
||||
'local_vel': self.create_subscription(
|
||||
Vector3,
|
||||
f'{base_topic}/local_position/velocity',
|
||||
lambda msg, did=drone_id: self.local_vel_callback(did, msg),
|
||||
10
|
||||
),
|
||||
'vfr_hud': self.create_subscription(
|
||||
VfrHud,
|
||||
f'{base_topic}/vfr_hud',
|
||||
lambda msg, did=drone_id: self.hud_callback(did, msg),
|
||||
10
|
||||
)
|
||||
}
|
||||
|
||||
setattr(self, f'drone_{drone_id}_subs', subs)
|
||||
self.signals.update_signal.emit('new_drone', drone_id, None)
|
||||
|
||||
def quaternion_to_euler(self, q):
|
||||
sinr_cosp = 2 * (q.w * q.x + q.y * q.z)
|
||||
cosr_cosp = 1 - 2 * (q.x**2 + q.y**2)
|
||||
roll = math.atan2(sinr_cosp, cosr_cosp)
|
||||
|
||||
sinp = 2 * (q.w * q.y - q.z * q.x)
|
||||
pitch = math.asin(sinp) if abs(sinp) < 1 else math.copysign(math.pi/2, sinp)
|
||||
|
||||
siny_cosp = 2 * (q.w * q.z + q.x * q.y)
|
||||
cosy_cosp = 1 - 2 * (q.y**2 + q.z**2)
|
||||
yaw = math.atan2(siny_cosp, cosy_cosp)
|
||||
|
||||
return math.degrees(roll), math.degrees(pitch), math.degrees(yaw)
|
||||
|
||||
# 回调函数
|
||||
def attitude_callback(self, drone_id, msg):
|
||||
if hasattr(msg, 'orientation'):
|
||||
roll, pitch, yaw = self.quaternion_to_euler(msg.orientation)
|
||||
self.signals.update_signal.emit('attitude', drone_id, {
|
||||
'roll': roll,
|
||||
'pitch': pitch,
|
||||
'yaw': yaw,
|
||||
'rates': (msg.angular_velocity.x,
|
||||
msg.angular_velocity.y,
|
||||
msg.angular_velocity.z)
|
||||
})
|
||||
|
||||
def battery_callback(self, drone_id, msg):
|
||||
self.signals.update_signal.emit('battery', drone_id, {
|
||||
'voltage': msg.voltage
|
||||
})
|
||||
|
||||
def mode_callback(self, drone_id, msg):
|
||||
self.signals.update_signal.emit('mode', drone_id, msg.data)
|
||||
|
||||
def gps_callback(self, drone_id, msg):
|
||||
self.signals.update_signal.emit('gps', drone_id, {
|
||||
'lat': msg.latitude,
|
||||
'lon': msg.longitude
|
||||
,'alt': msg.altitude
|
||||
})
|
||||
|
||||
def local_vel_callback(self, drone_id, msg):
|
||||
self.signals.update_signal.emit('velocity', drone_id, {
|
||||
'vx': msg.x,
|
||||
'vy': msg.y,
|
||||
'vz': msg.z
|
||||
})
|
||||
|
||||
def altitude_callback(self, drone_id, msg):
|
||||
self.signals.update_signal.emit('altitude', drone_id, {
|
||||
'altitude': msg.data
|
||||
})
|
||||
|
||||
def local_pose_callback(self, drone_id, msg):
|
||||
self.signals.update_signal.emit('local_pose', drone_id, {
|
||||
'x': msg.x,
|
||||
'y': msg.y,
|
||||
'z': msg.z
|
||||
})
|
||||
|
||||
def hud_callback(self, drone_id, msg):
|
||||
self.signals.update_signal.emit('hud', drone_id, {
|
||||
'airspeed': msg.airspeed,
|
||||
'groundspeed': msg.groundspeed,
|
||||
'heading': msg.heading,
|
||||
'throttle': msg.throttle,
|
||||
'alt': msg.altitude,
|
||||
'climb': msg.climb
|
||||
})
|
||||
|
||||
class ControlStationUI(QMainWindow):
|
||||
def __init__(self):
|
||||
super().__init__()
|
||||
self.setWindowTitle('GCS')
|
||||
self.resize(1400, 900)
|
||||
self.drones = {}
|
||||
|
||||
# 初始化ROS2
|
||||
rclpy.init()
|
||||
self.monitor = DroneMonitor()
|
||||
self.monitor.signals.update_signal.connect(self.update_ui)
|
||||
|
||||
# ROS执行器配置
|
||||
self.executor = rclpy.executors.SingleThreadedExecutor()
|
||||
self.executor.add_node(self.monitor)
|
||||
|
||||
# 初始化UI
|
||||
self.drone_positions = {}
|
||||
self.map_loaded = False
|
||||
self.init_ui()
|
||||
|
||||
# 定时处理ROS事件
|
||||
self.timer = QTimer()
|
||||
self.timer.timeout.connect(self.spin_ros)
|
||||
self.timer.start(50)
|
||||
|
||||
def init_ui(self):
|
||||
main_splitter = QSplitter(Qt.Orientation.Horizontal)
|
||||
|
||||
# 左侧信息面板
|
||||
left_panel = QWidget()
|
||||
left_layout = QVBoxLayout(left_panel)
|
||||
left_layout.setContentsMargins(5, 5, 5, 5)
|
||||
|
||||
# 信息滚动区域
|
||||
scroll_area = QScrollArea()
|
||||
scroll_area.setWidgetResizable(True)
|
||||
self.info_container = QWidget()
|
||||
self.info_layout = QVBoxLayout(self.info_container)
|
||||
self.info_layout.setAlignment(Qt.AlignmentFlag.AlignTop)
|
||||
scroll_area.setWidget(self.info_container)
|
||||
|
||||
left_layout.addWidget(scroll_area)
|
||||
|
||||
# 右侧地图区域
|
||||
self.map_view = QWebEngineView()
|
||||
|
||||
inline_html = '''
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<link rel="stylesheet" href="https://unpkg.com/leaflet/dist/leaflet.css" />
|
||||
<script src="https://unpkg.com/leaflet/dist/leaflet.js"></script>
|
||||
<script src="https://unpkg.com/leaflet-rotatedmarker/leaflet.rotatedMarker.js"></script>
|
||||
<style>
|
||||
html, body, #map { height: 100%; margin: 0; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div id="map"></div>
|
||||
<script>
|
||||
var map = L.map('map').setView([0, 0], 2);
|
||||
L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', {
|
||||
maxZoom: 19
|
||||
}).addTo(map);
|
||||
|
||||
var arrowIcon = L.icon({
|
||||
iconUrl: 'https://cdn-icons-png.flaticon.com/512/399/399308.png', // 朝上的箭頭
|
||||
iconSize: [40, 40],
|
||||
iconAnchor: [20, 20]
|
||||
});
|
||||
|
||||
var markers = {};
|
||||
|
||||
function updateDrone(lat, lon, id, heading) {
|
||||
if (markers[id]) {
|
||||
markers[id].setLatLng([lat, lon]);
|
||||
markers[id].setRotationAngle(heading);
|
||||
} else {
|
||||
markers[id] = L.marker([lat, lon], {
|
||||
icon: arrowIcon,
|
||||
rotationAngle: heading,
|
||||
rotationOrigin: 'center'
|
||||
}).addTo(map).bindPopup(id);
|
||||
}
|
||||
map.setView([lat, lon], 16);
|
||||
}
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
'''
|
||||
self.map_view.setHtml(inline_html)
|
||||
self.map_view.loadFinished.connect(self.on_map_loaded)
|
||||
|
||||
main_splitter.addWidget(left_panel)
|
||||
main_splitter.addWidget(self.map_view)
|
||||
main_splitter.setSizes([400, 1000])
|
||||
|
||||
self.setCentralWidget(main_splitter)
|
||||
|
||||
def on_map_loaded(self, ok: bool):
|
||||
if ok:
|
||||
self.map_loaded = True
|
||||
else:
|
||||
print("⚠️ 地图页加载失败")
|
||||
|
||||
def create_drone_panel(self, drone_id):
|
||||
panel = QWidget()
|
||||
panel.setObjectName(f"panel_{drone_id}")
|
||||
panel.setStyleSheet("""
|
||||
QWidget#panel_%s {
|
||||
background-color: #2A2A2A;
|
||||
border-radius: 8px;
|
||||
margin: 6px;
|
||||
padding: 10px;
|
||||
border: 1px solid #444;
|
||||
}
|
||||
QLabel {
|
||||
color: #DDD;
|
||||
font-size: 12px;
|
||||
padding: 2px;
|
||||
}
|
||||
""" % drone_id)
|
||||
|
||||
layout = QVBoxLayout(panel)
|
||||
layout.setContentsMargins(8, 8, 8, 8)
|
||||
layout.setSpacing(4)
|
||||
|
||||
# 顶部标题栏
|
||||
header = QWidget()
|
||||
header_layout = QHBoxLayout(header)
|
||||
header_layout.setContentsMargins(0, 0, 0, 0)
|
||||
|
||||
# ID显示
|
||||
id_label = QLabel(drone_id)
|
||||
id_label.setStyleSheet("""
|
||||
font-weight: bold;
|
||||
font-size: 14px;
|
||||
color: #7FFFD4;
|
||||
min-width: 80px;
|
||||
""")
|
||||
|
||||
# 状态指示灯
|
||||
status_light = QLabel("●")
|
||||
status_light.setStyleSheet("color: #00FF00; font-size: 16px;")
|
||||
|
||||
header_layout.addWidget(id_label)
|
||||
header_layout.addWidget(status_light)
|
||||
header_layout.addStretch()
|
||||
|
||||
layout.addWidget(header)
|
||||
|
||||
# 数据字段(带标签)
|
||||
self.create_data_row(layout, "模式:", f"{drone_id}_mode", "--")
|
||||
self.create_data_row(layout, "電壓:", f"{drone_id}_battery", "--")
|
||||
self.create_data_row(layout, "GPS:", f"{drone_id}_gps", "--")
|
||||
self.create_data_row(layout, "高度:", f"{drone_id}_altitude", "--")
|
||||
self.create_data_row(layout, "Local:", f"{drone_id}_local", "--")
|
||||
self.create_data_row(layout, "HUD:", f"{drone_id}_hud", "--")
|
||||
|
||||
return panel
|
||||
|
||||
def create_data_row(self, layout, title, object_name, default):
|
||||
row = QWidget()
|
||||
hbox = QHBoxLayout(row)
|
||||
hbox.setContentsMargins(0, 0, 0, 0)
|
||||
|
||||
# 标题标签
|
||||
title_label = QLabel(title)
|
||||
title_label.setStyleSheet("color: #888; min-width: 80px;")
|
||||
title_label.setSizePolicy(QSizePolicy.Policy.Fixed, QSizePolicy.Policy.Preferred)
|
||||
|
||||
# 数据标签
|
||||
value_label = QLabel(default)
|
||||
value_label.setObjectName(object_name)
|
||||
value_label.setWordWrap(True)
|
||||
value_label.setStyleSheet("margin-left: 10px;")
|
||||
|
||||
hbox.addWidget(title_label)
|
||||
hbox.addWidget(value_label)
|
||||
layout.addWidget(row)
|
||||
|
||||
def update_ui(self, msg_type, drone_id, data):
|
||||
if msg_type == 'new_drone':
|
||||
self.add_drone(drone_id)
|
||||
return
|
||||
|
||||
if not (panel := self.drones.get(drone_id)):
|
||||
return
|
||||
|
||||
if msg_type == 'mode':
|
||||
self.update_field(panel, drone_id, 'mode', f"{data}",
|
||||
'#FF5555' if '返航' in data else '#55FF55')
|
||||
|
||||
elif msg_type == 'battery':
|
||||
voltage = data.get('voltage', 0)
|
||||
self.update_field(panel, drone_id, 'battery',
|
||||
f"{voltage:.1f} V",
|
||||
'#FF6464' if voltage < 12 else '#FFFFFF')
|
||||
|
||||
elif msg_type == 'gps':
|
||||
lat, lon = data['lat'], data['lon']
|
||||
self.drone_positions[drone_id] = (lat, lon)
|
||||
text = (f"緯度: {lat:.6f}°\n"
|
||||
f"經度: {lon:.6f}°")
|
||||
self.update_field(panel, drone_id, 'gps', text)
|
||||
|
||||
elif msg_type == 'altitude':
|
||||
text = (f"{data['altitude']:.1f} m")
|
||||
self.update_field(panel, drone_id, 'altitude', text)
|
||||
|
||||
elif msg_type == 'local_pose':
|
||||
text = (f"({data['x']:.1f}, {data['y']:.1f}, {data['z']:.1f})")
|
||||
self.update_field(panel, drone_id, 'local', text)
|
||||
|
||||
elif msg_type == 'hud':
|
||||
heading = data['heading']
|
||||
text = (f"地速: {data['groundspeed']:.1f} m/s\n"
|
||||
f"航向: {heading:.1f}°")
|
||||
self.update_field(panel, drone_id, 'hud', text)
|
||||
|
||||
if self.map_loaded and drone_id in self.drone_positions:
|
||||
lat, lon = self.drone_positions[drone_id]
|
||||
js = f"updateDrone({lat:.6f}, {lon:.6f}, '{drone_id}', {heading:.1f});"
|
||||
self.map_view.page().runJavaScript(js)
|
||||
'''
|
||||
elif msg_type == 'velocity':
|
||||
text = (f"VX: {data['vx']:.1f} m/s\n"
|
||||
f"VY: {data['vy']:.1f} m/s\n"
|
||||
f"VZ: {data['vz']:.1f} m/s")
|
||||
self.update_field(panel, drone_id, 'velocity', text)
|
||||
|
||||
elif msg_type == 'attitude':
|
||||
text = (f"Roll: {data['roll']:.1f}°\n"
|
||||
f"Pitch: {data['pitch']:.1f}°\n"
|
||||
f"Yaw: {data['yaw']:.1f}°")
|
||||
self.update_field(panel, drone_id, 'attitude', text)
|
||||
'''
|
||||
def update_field(self, panel, drone_id, field, text, color=None):
|
||||
if label := panel.findChild(QLabel, f"{drone_id}_{field}"):
|
||||
label.setText(text)
|
||||
if color:
|
||||
label.setStyleSheet(f"color: {color};")
|
||||
|
||||
def add_drone(self, drone_id):
|
||||
if drone_id not in self.drones:
|
||||
panel = self.create_drone_panel(drone_id)
|
||||
self.info_layout.addWidget(panel)
|
||||
self.drones[drone_id] = panel
|
||||
|
||||
def spin_ros(self):
|
||||
try:
|
||||
self.executor.spin_once(timeout_sec=0)
|
||||
except Exception as e:
|
||||
print(f"ROS spin error: {e}")
|
||||
|
||||
def closeEvent(self, event):
|
||||
self.monitor.destroy_node()
|
||||
self.executor.shutdown()
|
||||
rclpy.shutdown()
|
||||
event.accept()
|
||||
|
||||
if __name__ == '__main__':
|
||||
app = QApplication(sys.argv)
|
||||
station = ControlStationUI()
|
||||
station.show()
|
||||
app.exec(app.exec())
|
||||
Loading…
Reference in New Issue