Tello EDU状态监控实战:用Python实时获取无人机飞行数据

当你第一次让Tello EDU无人机起飞,看着它平稳悬停在空中,那种感觉确实很酷。但作为一名开发者,真正让你兴奋的,可能不只是让它飞起来,而是能“看见”它飞行的每一个细节——它的速度、高度、电池还剩多少、电机是否在正常运转。这就像从一名普通的飞行员,变成了拥有全息仪表盘的机长。状态监控,正是打开这扇感知大门的钥匙。这篇文章,就是为那些不满足于基础控制,希望深入无人机数据腹地,进行飞行分析、性能评估甚至异常预警的开发者准备的。我们将绕过简单的指令发送,直击Tello EDU通过8890端口流淌出的实时数据流,用Python构建一个强大的状态监控与可视化系统。

1. 理解Tello EDU的数据通信架构

在开始写代码之前,我们必须先搞清楚Tello EDU是如何与我们对话的。很多教程只告诉你连接Wi-Fi、发送指令,却很少解释背后的通信模型,这导致一旦数据流出现问题,调试起来就无从下手。

Tello EDU采用了一种清晰的双通道UDP通信机制。你可以把它想象成两个并行的无线电频道:

  • 命令/响应通道 (端口 8889):这是一个典型的“一问一答”模式。你的程序(运行在PC或树莓派上)向Tello (192.168.10.1:8889) 发送一个字符串命令,比如 "takeoff"。Tello接收到后执行动作,然后向你的程序绑定的本地端口(例如 9000)返回一个简短的响应,通常是 "ok" 或错误信息。这个通道是你控制无人机起飞、降落、移动的基础。
  • 状态流通道 (端口 8890):这才是我们监控功能的核心。这是一个单向的、持续的数据流。一旦你连接到Tello的Wi-Fi,Tello就会自动地、以大约10Hz的频率,向你的设备广播状态数据包。你的程序不需要主动请求,只需要在本地开启一个UDP Socket,绑定到8890端口,然后“监听”这个广播即可。这个数据流包含了无人机几乎所有的实时传感器信息。

注意:状态流是广播式的。这意味着只要你设备上的某个程序绑定了8890端口,它就能收到数据。这也意味着你需要确保端口没有被其他程序占用。

为什么是UDP而不是TCP?对于实时性要求高、允许少量数据丢失的传感器数据流,UDP的无连接、低开销特性比TCP更合适。丢失一两个状态包对整体监控趋势影响不大,但TCP的握手、重传机制会带来不必要的延迟。

理解了这个架构,我们就能明白,状态监控程序的核心任务就是:建立一个稳定监听8890端口的服务,持续解析收到的数据包,并将其转化为可读、可用的信息。

2. 构建稳健的状态数据监听服务

直接修改一个简单的控制脚本,把端口从8889改成8890,确实能收到数据。但在实际项目中,我们需要一个更健壮、更模块化的监听服务。下面我们来一步步构建它。

首先,我们需要创建一个独立的Python模块来处理状态监听。我们将使用socketthreading库,让监听在后台线程中运行,不影响主程序的其他逻辑。

# tello_status_listener.py
import socket
import threading
import json
from datetime import datetime
from typing import Dict, Optional, Callable

class TelloStatusListener:
    """
    Tello EDU 状态监听器类。
    负责在后台线程中持续接收并解析来自8890端口的状态数据。
    """
    
    def __init__(self, local_ip='0.0.0.0', local_port=8890):
        """
        初始化监听器。
        :param local_ip: 本地绑定IP,通常为'0.0.0.0'以接收所有接口的数据
        :param local_port: 本地绑定端口,必须为8890以接收Tello广播
        """
        self.local_ip = local_ip
        self.local_port = local_port
        self.sock = None
        self.listening = False
        self.listener_thread = None
        self.current_status = {}  # 存储最新解析的状态字典
        self.status_callbacks = []  # 注册状态更新回调函数列表
        self._lock = threading.Lock()  # 用于线程安全地更新状态

    def start_listening(self):
        """启动状态监听线程。"""
        if self.listening:
            print("[Status Listener] 监听器已在运行。")
            return

        self.sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
        self.sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
        try:
            self.sock.bind((self.local_ip, self.local_port))
        except OSError as e:
            print(f"[Status Listener] 绑定端口 {self.local_port} 失败: {e}")
            print("请检查端口是否被其他程序(如另一个Python实例、其他无人机软件)占用。")
            return

        self.listening = True
        self.listener_thread = threading.Thread(target=self._listen_loop, daemon=True)
        self.listener_thread.start()
        print(f"[Status Listener] 已启动,正在监听 {self.local_ip}:{self.local_port}")

    def _listen_loop(self):
        """监听循环,运行在独立线程中。"""
        buffer_size = 1518  # 典型MTU大小
        while self.listening:
            try:
                data, _ = self.sock.recvfrom(buffer_size)
                status_text = data.decode('utf-8').strip()
                self._parse_and_update_status(status_text)
            except socket.timeout:
                continue
            except Exception as e:
                if self.listening:  # 仅在仍处于监听状态时打印错误
                    print(f"[Status Listener] 接收数据时出错: {e}")
                break

    def _parse_and_update_status(self, raw_string: str):
        """
        解析原始状态字符串并更新当前状态。
        原始字符串格式如:'pitch:12;roll:-3;yaw:45;vgx:0;vgy:0;vgz:0;...'
        """
        status_dict = {}
        try:
            for item in raw_string.split(';'):
                if ':' in item:
                    key, value = item.split(':', 1)
                    # 尝试将值转换为整数或浮点数
                    try:
                        # 处理可能的小数
                        if '.' in value:
                            status_dict[key] = float(value)
                        else:
                            status_dict[key] = int(value)
                    except ValueError:
                        # 转换失败则保留字符串
                        status_dict[key] = value
        except Exception as e:
            print(f"[Status Listener] 解析状态字符串失败: {e} | 原始数据: {raw_string}")
            return

        # 添加时间戳
        status_dict['timestamp'] = datetime.now().isoformat()

        # 线程安全地更新状态并触发回调
        with self._lock:
            self.current_status = status_dict
            for callback in self.status_callbacks:
                try:
                    callback(status_dict.copy())  # 传递副本以避免回调函数修改原始数据
                except Exception as cb_e:
                    print(f"[Status Listener] 回调函数执行出错: {cb_e}")

    def get_current_status(self) -> Dict:
        """获取当前最新的一份状态数据(线程安全)。"""
        with self._lock:
            return self.current_status.copy()

    def register_callback(self, callback: Callable[[Dict], None]):
        """
        注册一个回调函数,当状态更新时会被调用。
        :param callback: 接收一个状态字典作为参数的函数
        """
        self.status_callbacks.append(callback)

    def stop_listening(self):
        """停止监听线程并清理资源。"""
        self.listening = False
        if self.sock:
            self.sock.close()
        if self.listener_thread:
            self.listener_thread.join(timeout=2.0)
        print("[Status Listener] 已停止。")

这个TelloStatusListener类提供了一个清晰的接口。在你的主程序中,你可以这样使用它:

# main.py
from tello_status_listener import TelloStatusListener
import time

def my_status_handler(status):
    """自定义状态处理函数示例"""
    if 'bat' in status:
        print(f"[回调] 电池电量: {status['bat']}%", end=' | ')
    if 'h' in status:
        print(f"相对高度: {status['h']} cm")

# 创建并启动监听器
listener = TelloStatusListener()
listener.start_listening()

# 注册回调函数,每当新数据到达就会自动调用
listener.register_callback(my_status_handler)

# 主程序可以做其他事情,比如发送控制指令
try:
    while True:
        # 也可以主动获取当前状态
        current = listener.get_current_status()
        if current:
            # 这里可以添加其他逻辑,比如判断电量低时报警
            pass
        time.sleep(0.1)  # 避免CPU空转
except KeyboardInterrupt:
    print("\n用户中断。")
finally:
    listener.stop_listening()  # 确保程序退出前清理资源

这种设计将状态监听与业务逻辑解耦,使得代码更易于维护和扩展。回调机制允许你在数据到达时立即触发特定动作,比如记录日志或更新UI。

3. 深度解析状态数据:从原始字符串到可操作信息

Tello EDU发送的状态字符串看起来像一长串分号分隔的键值对。直接看原始数据可能有些晦涩,我们需要一个“解码器”。官方SDK文档列出了主要参数,但结合实践,我们可以更深入地理解它们。

下面这个表格整理了最核心的状态参数及其在实际应用中的意义:

参数键 数据类型 单位/范围 含义解析与实战意义
pitch 整数 度 (-90~90) 俯仰角。机头向下为正。实战:值持续为正且较大,可能表示无人机正在逆风飞行或电机推力不平衡。
roll 整数 度 (-90~90) 横滚角。向右倾斜为正。实战:飞行中非零的恒定值,可能表示存在侧风或需要校准IMU。
yaw 整数 度 (-180~180) 偏航角。顺时针旋转为正。实战:结合角速度agz,可以判断无人机是否在稳定悬停(yaw变化慢,agz接近0)。
vgx, vgy, vgz 整数 cm/s 地速。在X(前后)、Y(左右)、Z(上下)方向的速度。注意:这是基于视觉流传感器计算的速度,在光照不足或纹理单一的地面可能不准。实战vgz持续为负但高度h不变,可能表示无人机正在下降气流中努力维持高度。
agx, agy, agz 整数 度/秒 角速度。绕X、Y、Z轴旋转的速率。比姿态角pitch/roll/yaw更能反映瞬时的机动剧烈程度。实战agxagy突然大幅波动,可能提示遇到了突风或发生碰撞。
h 整数 厘米 相对起飞点的高度。由气压计和TOF(飞行时间)传感器融合得出。实战:在室内,TOF数据更可靠;室外无风环境下,气压计数据更平滑。两者差异过大可能提示传感器故障。
bat 整数 百分比 电池剩余电量。这是最重要的监控参数之一。实战:电量下降曲线并非线性。通常从100%到30%比较平缓,30%以下会加速下降。建议设置预警(如低于25%)和紧急返航(如低于15%)逻辑。
temph 整数 摄氏度 最高电机温度实战:长时间大推力飞行(如快速爬升、载重)会导致温度升高。持续高于70°C需要警惕,可能触发过热保护导致动力下降。
tof 整数 厘米 TOF传感器测量的高度。测量范围有限(约30-1000cm),但非常精确。实战:与h对比,可以评估气压计的漂移情况。在低空避障应用中,tof是主要数据源。
time 整数 电机累计运行时间。从本次开机开始计算。实战:用于估算单次飞行续航,结合电池电量,可以建立电池健康度模型。

解析完这些参数,我们可以在监听器中增强解析函数,直接计算出一些衍生指标,比如:

  • 飞行平稳度:计算短时间内pitch, roll, agx, agy的方差。
  • 电量消耗速率:根据battime的变化计算每分钟耗电百分比。
  • 高度保持精度:计算h值围绕目标高度的标准差。

将这些解析逻辑集成到_parse_and_update_status方法中,你就能获得更丰富、更直接可用的监控指标。

4. 实现实时数据可视化与异常监测

有了稳定流淌的数据和清晰的含义,下一步就是让数据“说话”。可视化不仅能直观展示飞行状态,更是发现异常模式的有力工具。我们将使用Python中流行的matplotlib库,创建一个动态更新的仪表盘。

这里我们设计一个包含四个子图的实时监控仪表板:

  1. 姿态仪表:用类似飞机姿态仪的方式显示pitchroll
  2. 高度与速度趋势图:显示h(高度)和vgz(垂直速度)随时间的变化。
  3. 电池与温度仪表:用进度条和数字显示battemph
  4. 数据表格:实时刷新所有关键参数的数值。
# visualizer.py
import matplotlib.pyplot as plt
from matplotlib.animation import FuncAnimation
import matplotlib.patches as patches
import numpy as np
from collections import deque
from tello_status_listener import TelloStatusListener  # 导入我们之前写的监听器

class TelloStatusVisualizer:
    def __init__(self, status_listener, history_len=200):
        self.listener = status_listener
        self.history_len = history_len  # 历史数据记录长度
        
        # 初始化数据历史队列
        self.time_history = deque(maxlen=history_len)
        self.h_history = deque(maxlen=history_len)  # 高度历史
        self.vgz_history = deque(maxlen=history_len) # 垂直速度历史
        self.bat_history = deque(maxlen=history_len) # 电量历史
        
        # 创建图形和子图
        self.fig, ((self.ax_att, self.ax_alt), 
                   (self.ax_bat, self.ax_table)) = plt.subplots(2, 2, figsize=(14, 10))
        self.fig.suptitle('Tello EDU 实时状态监控仪表板', fontsize=16)
        
        self._setup_attitude_display()   # 设置姿态显示
        self._setup_altitude_chart()     # 设置高度图表
        self._setup_battery_gauge()      # 设置电池仪表
        self._setup_status_table()       # 设置数据表格
        
        # 注册回调,当有新数据时更新图表
        self.listener.register_callback(self.on_new_status)
        
        # 动画对象
        self.ani = None
        
    def _setup_attitude_display(self):
        """设置飞机姿态指示器(人工地平线)。"""
        self.ax_att.set_title('飞行姿态 (Pitch & Roll)')
        self.ax_att.set_xlim(-50, 50)
        self.ax_att.set_ylim(-50, 50)
        self.ax_att.set_aspect('equal')
        self.ax_att.grid(True, linestyle='--', alpha=0.7)
        self.ax_att.axhline(y=0, color='black', linewidth=1)
        self.ax_hist.axvline(x=0, color='black', linewidth=1)
        
        # 绘制一个简单的飞机符号
        self.aircraft_body, = self.ax_att.plot([-10, 10, 0, -10], [0, 0, 8, 0], 'k-', linewidth=3)
        self.attitude_text = self.ax_att.text(-45, 45, '', fontsize=10, 
                                               bbox=dict(boxstyle='round', facecolor='wheat', alpha=0.8))
        
    def _setup_altitude_chart(self):
        """设置高度和垂直速度时间序列图。"""
        self.ax_alt.set_title('高度与垂直速度趋势')
        self.ax_alt.set_xlabel('时间 (相对)')
        self.ax_alt.set_ylabel('高度 (cm)', color='tab:blue')
        self.line_h, = self.ax_alt.plot([], [], 'b-', label='高度 (h)', linewidth=2)
        self.ax_alt.tick_params(axis='y', labelcolor='tab:blue')
        self.ax_alt.grid(True, alpha=0.3)
        
        # 创建第二个Y轴用于速度
        self.ax_vgz = self.ax_alt.twinx()
        self.ax_vgz.set_ylabel('垂直速度 (cm/s)', color='tab:red')
        self.line_vgz, = self.ax_vgz.plot([], [], 'r-', label='垂直速度 (vgz)', linewidth=1.5, alpha=0.8)
        self.ax_vgz.tick_params(axis='y', labelcolor='tab:red')
        
        # 合并图例
        lines = [self.line_h, self.line_vgz]
        labels = [l.get_label() for l in lines]
        self.ax_alt.legend(lines, labels, loc='upper left')
        
    def _setup_battery_gauge(self):
        """设置电池电量和温度仪表。"""
        self.ax_bat.set_title('电池与温度状态')
        self.ax_bat.set_xlim(0, 100)
        self.ax_bat.set_ylim(0, 3)
        self.ax_bat.axis('off')
        
        # 电池外框
        battery_rect = patches.Rectangle((10, 1), 80, 1.2, linewidth=2, 
                                         edgecolor='black', facecolor='none')
        battery_cap = patches.Rectangle((90, 1.4), 5, 0.4, linewidth=2,
                                        edgecolor='black', facecolor='black')
        self.ax_bat.add_patch(battery_rect)
        self.ax_bat.add_patch(battery_cap)
        
        # 电量填充(动态更新)
        self.battery_fill = patches.Rectangle((11, 1.1), 0, 1, 
                                              edgecolor='none', facecolor='lime')
        self.ax_bat.add_patch(self.battery_fill)
        
        # 电池文本
        self.bat_text = self.ax_bat.text(50, 2.5, '电池: --%', ha='center', fontsize=12, fontweight='bold')
        self.temp_text = self.ax_bat.text(50, 0.5, '温度: --°C', ha='center', fontsize=12)
        
    def _setup_status_table(self):
        """设置关键参数数据表格。"""
        self.ax_table.axis('tight')
        self.ax_table.axis('off')
        self.table_data = [
            ['姿态角', 'Pitch:', '--°', 'Roll:', '--°', 'Yaw:', '--°'],
            ['速度', 'VGX:', '--', 'VGY:', '--', 'VGZ:', '--'],
            ['角速度', 'AGX:', '--', 'AGY:', '--', 'AGZ:', '--'],
            ['传感器', 'TOF:', '-- cm', 'Time:', '-- s', 'Temp:', '--°C']
        ]
        self.table = self.ax_table.table(cellText=self.table_data, 
                                         cellLoc='left', 
                                         loc='center',
                                         colWidths=[0.15, 0.1, 0.1, 0.1, 0.1, 0.1, 0.1])
        self.table.auto_set_font_size(False)
        self.table.set_fontsize(10)
        self.table.scale(1.2, 2)
        
    def on_new_status(self, status):
        """监听器回调函数:接收新状态并更新历史数据。"""
        from datetime import datetime
        current_time = datetime.now()
        
        # 记录时间戳
        self.time_history.append(current_time)
        
        # 更新各项历史数据
        for key, history, default in [
            ('h', self.h_history, 0),
            ('vgz', self.vgz_history, 0),
            ('bat', self.bat_history, 0)
        ]:
            history.append(status.get(key, default))
            
        # 存储最新状态用于表格更新
        self.latest_status = status
        
    def update_plots(self, frame):
        """动画更新函数,由FuncAnimation调用。"""
        if not hasattr(self, 'latest_status'):
            return
            
        status = self.latest_status
        
        # 1. 更新姿态显示
        pitch = status.get('pitch', 0)
        roll = status.get('roll', 0)
        # 简化:根据roll和pitch旋转飞机符号(这里用偏移模拟)
        self.aircraft_body.set_data([-10+roll/5, 10+roll/5, 0+roll/5, -10+roll/5], 
                                     [0+pitch/5, 0+pitch/5, 8+pitch/5, 0+pitch/5])
        self.attitude_text.set_text(f'Pitch: {pitch}°\nRoll: {roll}°')
        
        # 2. 更新高度/速度图
        if len(self.time_history) > 1:
            # 将时间转换为相对秒数
            time_ref = list(self.time_history)
            time_secs = [(t - time_ref[0]).total_seconds() for t in time_ref]
            
            self.line_h.set_data(time_secs, list(self.h_history))
            self.line_vgz.set_data(time_secs, list(self.vgz_history))
            self.ax_alt.relim()
            self.ax_alt.autoscale_view()
            self.ax_vgz.relim()
            self.ax_vgz.autoscale_view()
            
        # 3. 更新电池仪表
        bat = status.get('bat', 0)
        bat_width = max(0, min(78, 78 * bat / 100))  # 计算填充宽度
        self.battery_fill.set_width(bat_width)
        # 根据电量改变颜色
        if bat > 50:
            self.battery_fill.set_facecolor('lime')
        elif bat > 20:
            self.battery_fill.set_facecolor('orange')
        else:
            self.battery_fill.set_facecolor('red')
            
        self.bat_text.set_text(f'电池: {bat}%')
        
        temp = status.get('temph', 0)
        self.temp_text.set_text(f'温度: {temp}°C')
        
        # 4. 更新数据表格
        # 只更新数值单元格
        update_map = {
            'Pitch:': f'{status.get("pitch", 0)}°',
            'Roll:': f'{status.get("roll", 0)}°',
            'Yaw:': f'{status.get("yaw", 0)}°',
            'VGX:': f'{status.get("vgx", 0)}',
            'VGY:': f'{status.get("vgy", 0)}',
            'VGZ:': f'{status.get("vgz", 0)}',
            'AGX:': f'{status.get("agx", 0)}',
            'AGY:': f'{status.get("agy", 0)}',
            'AGZ:': f'{status.get("agz", 0)}',
            'TOF:': f'{status.get("tof", 0)} cm',
            'Time:': f'{status.get("time", 0)} s',
            'Temp:': f'{status.get("temph", 0)}°C'
        }
        
        for row in self.table_data:
            for i, cell in enumerate(row):
                if cell in update_map:
                    row[i+1] = update_map[cell]  # 更新下一个单元格(数值)
        
        # 刷新表格
        self.table = self.ax_table.table(cellText=self.table_data, 
                                         cellLoc='left', 
                                         loc='center',
                                         colWidths=[0.15, 0.1, 0.1, 0.1, 0.1, 0.1, 0.1])
        self.table.auto_set_font_size(False)
        self.table.set_fontsize(10)
        self.table.scale(1.2, 2)
        
        self.fig.canvas.draw_idle()
        return self.aircraft_body, self.line_h, self.line_vgz, self.battery_fill, self.bat_text, self.temp_text
        
    def start_visualization(self):
        """启动可视化动画。"""
        self.ani = FuncAnimation(self.fig, self.update_plots, interval=200, cache_frame_data=False) # 200ms更新一次
        plt.tight_layout()
        plt.show()
        
    def stop_visualization(self):
        """停止可视化。"""
        if self.ani:
            self.ani.event_source.stop()
        plt.close(self.fig)

# 主程序示例
if __name__ == "__main__":
    listener = TelloStatusListener()
    listener.start_listening()
    
    visualizer = TelloStatusVisualizer(listener)
    
    try:
        print("启动状态监控可视化仪表板...")
        print("请确保Tello EDU已开机并连接到其Wi-Fi。")
        visualizer.start_visualization()
    except KeyboardInterrupt:
        print("\n正在关闭...")
    finally:
        visualizer.stop_visualization()
        listener.stop_listening()

运行这个可视化脚本,你会看到一个动态更新的仪表盘。它能让你在无人机飞行时,直观地看到姿态变化、高度曲线、电量消耗,所有关键参数一目了然。更重要的是,你可以基于这个框架,轻松添加异常监测逻辑。例如,在on_new_status回调函数中加入:

def check_anomalies(self, status):
    """简单的异常检测示例"""
    alerts = []
    
    # 低电量警报
    if status.get('bat', 100) < 20:
        alerts.append(f"⚠️ 低电量警报: {status['bat']}%")
        
    # 高温警报
    if status.get('temph', 0) > 70:
        alerts.append(f"🔥 电机高温警报: {status['temph']}°C")
        
    # 高度异常波动(简单示例:短时间内变化过大)
    if len(self.h_history) > 10:
        recent_heights = list(self.h_history)[-10:]
        if max(recent_heights) - min(recent_heights) > 50: # 最近10个点波动超过50cm
            alerts.append("📈 高度异常波动")
            
    # 姿态角过大(可能表示强风或失控)
    if abs(status.get('pitch', 0)) > 30 or abs(status.get('roll', 0)) > 30:
        alerts.append("✈️ 姿态角过大,请检查飞行环境")
        
    if alerts:
        # 这里可以将警报打印到控制台、记录到文件或发送到UI
        print(f"[警报] {datetime.now().strftime('%H:%M:%S')} - {' | '.join(alerts)}")

check_anomalies注册为另一个回调函数,你的监控系统就具备了基本的异常检测能力。在实际部署中,你可以将这些警报与自动控制逻辑结合,比如触发自动返航或降落。

5. 数据持久化与离线分析

实时监控很重要,但事后分析同样有价值。将飞行数据保存下来,可以用于性能评估、故障复盘或机器学习模型训练。我们可以扩展监听器,增加数据记录功能。

一个简单有效的方法是使用CSV格式记录,因为它易于生成,也方便用Excel、Python pandas或任何数据分析工具打开。

# 在TelloStatusListener类中添加数据记录功能
import csv
from pathlib import Path

class TelloStatusListenerWithLogging(TelloStatusListener):
    """带数据记录功能的状态监听器"""
    
    def __init__(self, log_dir="flight_logs", *args, **kwargs):
        super().__init__(*args, **kwargs)
        self.log_dir = Path(log_dir)
        self.log_dir.mkdir(exist_ok=True)
        self.log_file = None
        self.csv_writer = None
        
    def start_listening(self):
        # 在开始监听前创建日志文件
        from datetime import datetime
        timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
        self.log_file = self.log_dir / f"tello_flight_{timestamp}.csv"
        
        # 先写入CSV表头
        with open(self.log_file, 'w', newline='', encoding='utf-8') as f:
            writer = csv.writer(f)
            # 定义要记录的字段(可以根据需要调整)
            header = ['timestamp', 'pitch', 'roll', 'yaw', 
                     'vgx', 'vgy', 'vgz', 'agx', 'agy', 'agz',
                     'h', 'bat', 'temph', 'tof', 'time']
            writer.writerow(header)
            
        # 打开文件用于追加
        self.log_file_handle = open(self.log_file, 'a', newline='', encoding='utf-8')
        self.csv_writer = csv.writer(self.log_file_handle)
        
        # 注册一个回调,将每条状态记录到CSV
        self.register_callback(self._log_status)
        
        # 调用父类的启动方法
        super().start_listening()
        print(f"[Status Listener] 数据将记录到: {self.log_file}")
        
    def _log_status(self, status):
        """将状态数据写入CSV文件"""
        if self.csv_writer:
            try:
                row = [
                    status.get('timestamp', ''),
                    status.get('pitch', 0),
                    status.get('roll', 0),
                    status.get('yaw', 0),
                    status.get('vgx', 0),
                    status.get('vgy', 0),
                    status.get('vgz', 0),
                    status.get('agx', 0),
                    status.get('agy', 0),
                    status.get('agz', 0),
                    status.get('h', 0),
                    status.get('bat', 0),
                    status.get('temph', 0),
                    status.get('tof', 0),
                    status.get('time', 0)
                ]
                self.csv_writer.writerow(row)
                self.log_file_handle.flush()  # 确保数据及时写入磁盘
            except Exception as e:
                print(f"[Status Listener] 记录数据失败: {e}")
                
    def stop_listening(self):
        """停止监听并关闭日志文件"""
        if hasattr(self, 'log_file_handle') and self.log_file_handle:
            self.log_file_handle.close()
        super().stop_listening()

有了CSV日志文件,飞行结束后,你可以用pandas进行深入分析:

# flight_analysis.py
import pandas as pd
import matplotlib.pyplot as plt

# 加载飞行日志
df = pd.read_csv('flight_logs/tello_flight_20231027_143022.csv', parse_dates=['timestamp'])

# 计算飞行时间
flight_duration = (df['timestamp'].iloc[-1] - df['timestamp'].iloc[0]).total_seconds()
print(f"飞行时长: {flight_duration:.1f} 秒")

# 计算平均功耗(假设起始电量为100%)
if not df.empty:
    start_bat = df['bat'].iloc[0]
    end_bat = df['bat'].iloc[-1]
    bat_used = start_bat - end_bat
    power_rate = bat_used / flight_duration if flight_duration > 0 else 0
    print(f"电量消耗: {bat_used:.1f}% ({power_rate:.3f}%/秒)")

# 分析高度稳定性
mean_altitude = df['h'].mean()
altitude_std = df['h'].std()
print(f"平均高度: {mean_altitude:.1f} cm, 标准差: {altitude_std:.1f} cm (越小越稳定)")

# 绘制飞行剖面图
fig, axes = plt.subplots(3, 1, figsize=(12, 10), sharex=True)

# 高度剖面
axes[0].plot(df['timestamp'], df['h'], 'b-', linewidth=1)
axes[0].set_ylabel('高度 (cm)')
axes[0].grid(True, alpha=0.3)
axes[0].set_title('飞行高度剖面')

# 电池曲线
axes[1].plot(df['timestamp'], df['bat'], 'g-', linewidth=2)
axes[1].set_ylabel('电池电量 (%)')
axes[1].grid(True, alpha=0.3)
axes[1].axhline(y=20, color='r', linestyle='--', alpha=0.5, label='低电量线 (20%)')
axes[1].legend()

# 姿态角变化
axes[2].plot(df['timestamp'], df['pitch'], 'r-', label='Pitch', alpha=0.7)
axes[2].plot(df['timestamp'], df['roll'], 'b-', label='Roll', alpha=0.7)
axes[2].plot(df['timestamp'], df['yaw'], 'g-', label='Yaw', alpha=0.7)
axes[2].set_ylabel('角度 (度)')
axes[2].set_xlabel('时间')
axes[2].grid(True, alpha=0.3)
axes[2].legend()

plt.tight_layout()
plt.show()

# 生成飞行报告摘要
print("\n=== 飞行报告摘要 ===")
print(f"最大高度: {df['h'].max():.0f} cm")
print(f"最小高度: {df['h'].min():.0f} cm")
print(f"最大俯仰角: {df['pitch'].abs().max():.1f}°")
print(f"最大横滚角: {df['roll'].abs().max():.1f}°")
print(f"最高电机温度: {df['temph'].max():.0f}°C")

这种从实时监控到数据记录,再到离线分析的完整流程,构成了一个专业无人机开发项目的数据闭环。它让你不仅能实时感知无人机状态,还能积累数据、量化性能、优化飞行策略。在我自己的几个Tello EDU集群项目中,正是依靠这套监控系统,才准确发现了其中一台无人机电机老化导致的功耗异常,避免了它在飞行任务中突然掉电。

Logo

Agent 垂直技术社区,欢迎活跃、内容共建。

更多推荐