跳到主要内容

下位机教程

开发者补充:下位机串口配置

app.py 通过 GPIO UART 与 ESP32 下位机通信。串口配置如下:

上位机环境串口波特率
Jetson Orin Nano/dev/ttyTHS0115200
Jetson Orin NX/dev/ttyTHS1115200

其中 /dev/ttyTHS0 是 Jetson Orin Nano 默认串口,/dev/ttyTHS1 用于 Jetson Orin NX。若镜像改动过串口映射,按系统中列出的串口节点检查。

提示

单条低风险 JSON 指令,例如 OLED 和 Head Light / Base Light,建议优先通过 Web 命令输入框测试,由 Web 主程序统一转发,不需要手动释放串口。

涉及底盘连续动作、定时停止、多步动作序列时,建议使用 Jupyter Notebook 或 Python 脚本编排。串口节点和占用进程检查在 JupyterLab Terminal / SSH 终端中执行;ROS 2 相关操作请参考 ROS 2 教程。

若 Notebook 直接使用 BaseController 打开底盘串口,请先完成 串口占用检查与释放

Web端JSON 指令控制下位机

提示

如果只是通过 Web 命令输入框发送 base -c ... 指令,由 Web 主程序统一转发,不需要手动释放串口。

只有 Notebook / Python 脚本直接使用 BaseController(...) 打开底盘串口时,才需要做串口占用检查。

基础运动控制

CMD_SPEED_CTRL - 速度闭环控制

base -c {"T":1,"L":0,"R":0}

用于控制左右侧车轮目标线速度,也可用于停止底盘。

  • 参数:
    • T=1:速度闭环控制。
    • L:左侧车轮目标线速度,单位为 m/s
    • R:右侧车轮目标线速度,单位为 m/s
  • 推荐起步值:
    • 停止:L=0,R=0
    • 架空低速测试:L=0.03,R=0.03
    • 地面低速测试:L=0.03~0.05,R=0.03~0.05
  • 取值范围 / 参考说明:
    • LR 的正负号表示运动方向;
    • 具体可用速度范围与电机、减速器、轮径和供电状态有关,首次测试请从较小速度逐步增加。
  • 现象:
    • LR 同为正值时,底盘向一个方向移动;
    • LR 同为负值时,底盘向反方向移动;
    • LR 一正一负时,底盘原地转向;
    • L=0,R=0 时,底盘停止。

CMD_PWM_INPUT - PWM 开环控制

base -c {"T":11,"L":64,"R":64}
  • 参数:
    • T=11:PWM 开环控制。
    • L:左侧电机 PWM。
    • R:右侧电机 PWM。
  • 取值范围 / 参考范围:
    • L/R=-255~255
    • 正负号表示方向;
    • 绝对值越大,输出越强。
  • 现象:
    • 对应侧电机按 PWM 方向转动;
    • 无闭环速度调节,低速和负载变化时表现可能不稳定。
注意

PWM 开环控制不按目标线速度闭环调节,不建议作为首次运动测试。若必须排查电机驱动,请架空底盘,并从短时、低绝对值开始。

CMD_SET_MOTOR_PID - 设置底盘 PID

PID 是闭环控制参数,用于让实际电机速度接近目标速度。P 影响响应力度,I 用于修正长期误差,D 用于抑制变化趋势。PID 参数不合适时,可能出现抖动、响应迟钝、跑偏或电机持续受力。

base -c {"T":2,"P":20,"I":2000,"D":0,"L":255}
注意

普通通信验证不要修改 PID。车轮不动、轮子偏斜、机械卡滞、供电不足或电机发热时,不要先改 PID。

云台控制

Web 页面中的云台方向按钮和键盘控制,主要表现为改变云台目标角度。按一次或按住时,云台会转向新的目标角度;达到目标角度后会停下。因此不要把 Web 页面方向按钮理解成“持续运动直到发送 T=135 停止”。

CMD_GIMBAL_CTRL_SIMPLE - 云台角度控制

base -c {"T":133,"X":0,"Y":0,"SPD":200,"ACC":10}

控制云台转到指定角度,可用于回中或小角度转动。

  • 参数:X 为水平角度,Y 为俯仰角度,SPD 为速度参数,ACC 为加速度参数。
  • 推荐起步值:小角度、非零速度和加速度,例如 SPD=200,ACC=10
  • 取值范围 / 参考范围:X=-180~180Y=-30~90SPD=0ACC=0 表示最快速度 / 加速度,初次测试不要使用最快。
注意

每次动作前检查线缆和机械限位。

  • 现象:云台按目标角度短时移动,达到目标角度后停下。
开发者补充:云台连续控制和 UI 控制接口

T=134T=135 属于云台连续控制相关指令。它们与 Web 页面方向按钮的实际体验不完全相同。需要验证连续控制时,应单独测试,并确认停止方式。

base -c {"T":134,"X":45,"Y":45,"SX":300,"SY":300}
base -c {"T":135}

T=141 是底层 UI 控制接口,X/Y 可为 -101SPD 为速度。普通操作优先使用 Web 页面云台按钮或 T=133 小角度验证。

base -c {"T":141,"X":0,"Y":0,"SPD":300}

灯光控制

Head Light / Base Light 控制

Web 页面中的 Head Light CtrlBase Light Ctrl 对应下位机两路灯光输出。底层指令仍使用 T=132,通过 IO4IO5 设置两路输出亮度。

base -c {"T":132,"IO4":64,"IO5":64}
base -c {"T":132,"IO4":0,"IO5":0}
  • 参数:IO4IO5 为两路 PWM 输出。
  • 推荐起步值:64 这类低亮度值。
  • 取值范围 / 参考范围:0~2550 表示关闭,255 表示最大输出。
提示

关闭灯光重新发送 JSON,把对应亮度设置为 0。不要长时间高亮;如果外接设备,需要额外确认供电和电流能力。

OLED 显示控制

CMD_OLED_CTRL - OLED 指定行显示

base -c {"T":3,"lineNum":0,"Text":"UGV TEST"}
base -c {"T":-3}
  • lineNum=0..3,其中 0 是最上方;Text 为显示文本。建议使用短英文或数字,文本过长可能换行并影响其它行。
  • UGV TEST 可能只短暂显示,随后被 Web 主程序刷新为默认界面;默认界面中的 STA / AP 是当前网络模式显示,不是 OLED 文本指令切换网络模式。需要恢复默认显示时发送 T=-3
提示

如果 Web 主程序仍在运行,OLED 通常会在几秒后自动刷新为网络状态界面。需要手动恢复默认显示时,在 Web 命令输入框发送:

base -c {"T":-3}

如果已经停止 Web 主程序并切换到 Notebook 直连串口,使用 base.base_default_oled() 恢复默认显示;需要继续显示 STA / AP、IP 和运行时间等动态状态时,重新启动 Web 主程序或重启设备。

开发者补充:正常操作不需要修改的 JSON 指令
指令用途为什么不放主线修改风险
T=13ROS 侧封装调用的线速度 / 角速度指令ROS 2 操作放在 ROS 2 教程与 Web 或 Notebook 串口控制混用会增加排查难度。
T=136设置运动心跳时间普通操作通常保持默认设置过长会降低安全性,设置过短可能导致运动断续。
T=138T=139设置 / 读取左右速度比率只用于轻微跑偏校正不用于解决机械卡死、车轮不动或电机发热。
T=126T=130T=131IMU 与底盘反馈读取更适合 Notebook / Python 串口读取Web 命令输入框附近不一定显示返回内容。
T=142设置连续反馈额外间隔调整反馈频率时使用设置不当会影响串口输出频率。
T=143串口回显开关通信排查时使用长期开启会增加串口输出。
T=127T=128T=129IMU 校准 / 偏移量当前普通功能验证不需要执行修改偏移可能影响 IMU 数据。
T=4T=900产品和模块类型开机流程会按配置发送修改错误会影响后续模块控制。
T=401~408WiFi 设置网络配置类操作修改前要确认恢复方式。
T=501T=210T=502舵机 ID、力矩、中位设置舵机配置类操作可能影响云台识别、保持力和回中。
T=300~306ESP-NOW 组网 / 广播控制不属于本页主线测试可能影响设备配对或广播控制。

什么是心跳时间

心跳时间是下位机等待新的运动指令的超时时间。若在设定时间内没有收到新的运动指令,下位机会停止运动,用于避免上位机断开或程序卡住后底盘持续运动。

base -c {"T":136,"cmd":3000}

其中 cmd=3000 表示约 3000 ms。普通操作通常不需要修改该值。

什么是串口回显

串口回显是让下位机把收到的指令再输出回来,用于确认指令是否到达下位机。它适合排查通信问题,不适合长期打开。

base -c {"T":143,"cmd":1}
base -c {"T":143,"cmd":0}

cmd=1 表示开启回显,cmd=0 表示关闭回显。Web 命令输入框不一定显示回显内容,建议在 Notebook / Python 串口读取流程中观察。

机械臂相关泛用指令存在于通用指令集中,但 UGV Rover PT 页面不展开机械臂控制流程。

使用 Notebook 操作下位机

读取反馈、直接发送运动指令或运行 Python 脚本时,会直接打开底盘串口。进入这些步骤前,先完成目标串口确认和占用释放。

串口占用检查与释放

确认目标串口

在 Jetson 终端或 JupyterLab Terminal 中列出当前存在的硬件串口:

ls /dev/ttyTHS*

如果输出中有多个串口,例如:

/dev/ttyTHS1 /dev/ttyTHS2

只选择准备给底盘通信使用的那个串口,不需要逐个释放所有串口。如果当前设备没有 /dev/ttyTHS0,不要继续使用 /dev/ttyTHS0

下面以 /dev/ttyTHS1 为例。后续命令中的 PORT 和 Notebook 代码中的 SERIAL_PORT 应保持一致;如果你的设备使用其它串口,请替换为实际串口。

PORT=/dev/ttyTHS1

串口能打开只说明程序可以访问该串口,不代表它一定能控制底盘。后续低速短时动作有效后,再把该串口固定用于真实动作。

释放目标底盘串口

查看目标串口是否被占用:

sudo fuser -v $PORT

如果有输出,说明该串口正在被某个进程占用。准备切换到 Notebook 直连时,先尝试温和释放:

sudo fuser -TERM -k $PORT
sleep 2
sudo fuser -v $PORT

如果最后一条命令没有输出,说明串口已经释放,可以进入 Notebook 操作。

如果仍然有输出,再执行强制释放:

sudo fuser -KILL -k $PORT
sleep 1
sudo fuser -v $PORT

最后一次 sudo fuser -v $PORT 没有输出,才表示目标串口已经空闲。

sudo fuser -TERM -k $PORT 只会结束正在占用 $PORT 指向串口的进程。它比 sudo killall -9 python 更精确,不会主动结束所有 Python 程序。

如果需要确认占用者是谁,可以先通过 sudo fuser -v $PORT 找到 PID,再用 ps -fp <PID> -ww 查看该进程的完整命令。

提示

如果继续使用 Web 页面、Web 命令输入框或 Web 视频流,不需要释放串口。只有准备切换到 Notebook / Python 直接打开底盘串口时,才释放目标串口。

危险

不要使用 sudo killall -9 python 释放串口。它可能同时结束 Web 主程序、JupyterLab、Notebook Kernel、jtop 和其它 Python 脚本。

需要释放串口时,只释放目标串口对应的占用进程。

Notebook 连接下位机

完成串口释放后,回到 Jupyter Notebook,新建代码单元格运行下面代码。不要把这段 Python 代码粘贴到 Terminal。

这段代码会进入 ~/ugv_jetson 项目目录,导入 BaseController,打开刚才释放的目标串口,并先发送停止指令。

from pathlib import Path
import os
import sys
import time

PROJECT_DIR = Path("~/ugv_jetson").expanduser()
os.chdir(PROJECT_DIR)

if str(PROJECT_DIR) not in sys.path:
sys.path.insert(0, str(PROJECT_DIR))

from base_ctrl import BaseController

SERIAL_PORT = "/dev/ttyTHS1" # 与前面释放的 PORT 保持一致
BAUDRATE = 115200

base = BaseController(SERIAL_PORT, BAUDRATE)
base.send_command({"T": 1, "L": 0, "R": 0})
time.sleep(0.1)

print("下位机串口已连接,并已发送停止指令。")

运行后应显示 下位机串口已连接,并已发送停止指令。。本示例只发送停止指令,车轮不会转动;如果出现串口报错,请确认 PORTSERIAL_PORT 使用同一个目标串口。

Notebook 通用控制函数

前一段代码中已经运行过:

base = BaseController(SERIAL_PORT, BAUDRATE)

这里的 base 是 Notebook 中保存的下位机控制对象,已经打开 SERIAL_PORT 指向的底盘串口。后续发送运动、OLED、灯光、云台和读取反馈时,都通过这个 base 对象完成。

注意

同一个 Notebook 中只创建一次 BaseController(...)。后续代码复用 base 发送指令,不要反复打开同一个串口;如果前一个连接没有关闭,可能导致串口占用或打开失败。

def send_cmd(cmd):
base.send_command(cmd)
time.sleep(0.05)

def stop():
base.send_command({"T": 1, "L": 0, "R": 0})
time.sleep(0.1)

stop()
print("通用控制函数已准备完成。")

运行后应显示 通用控制函数已准备完成。。如果提示 NameError: name 'base' is not defined,说明前面的 Notebook 连接下位机 代码单元格没有成功运行。请先回到前一节,确认 base = BaseController(SERIAL_PORT, BAUDRATE) 已经执行完成。

低速运动测试

本节从 Notebook 直接向下位机发送 JSON,不经过 Web 页面。Web 页面按钮和键盘控制请参考前面的 Web 端 JSON 控制部分。方向不确定时先架空底盘。

运行本小节任意代码前,必须先完成 Notebook 连接下位机Notebook 通用控制函数。如果提示 send_cmdstop 未定义,请回到这两个小节按顺序运行代码。

停止运动

stop()
print("已发送停止指令。")

低速前进 0.3 秒

TEST_SPEED = 0.03
ACTION_TIME = 0.3

try:
print("低速前进")
send_cmd({"T": 1, "L": TEST_SPEED, "R": TEST_SPEED})
time.sleep(ACTION_TIME)
finally:
stop()
print("已停止")

低速原地左转 0.3 秒

try:
print("低速原地左转")
send_cmd({"T": 1, "L": -TEST_SPEED, "R": TEST_SPEED})
time.sleep(ACTION_TIME)
finally:
stop()
print("已停止")

低速原地右转 0.3 秒

try:
print("低速原地右转")
send_cmd({"T": 1, "L": TEST_SPEED, "R": -TEST_SPEED})
time.sleep(ACTION_TIME)
finally:
stop()
print("已停止")

Notebook 中读取下位机反馈

读取类 JSON 更适合在 Notebook / Python 直连串口时观察返回数据。Web 命令输入框可以发送读取指令,但页面不一定会在输入框附近显示返回内容。

运行本小节代码前,必须先完成 Notebook 连接下位机Notebook 通用控制函数。如果提示 send_cmdstopbase 未定义,请回到这两个小节按顺序运行代码。

编码器用于估计轮子转动和行驶距离,IMU 用于感知角速度、加速度和姿态变化。ROS 2 中的里程计、IMU topic 检查请参考 ROS 2 教程。

单次读取底盘反馈

send_cmd({"T": 130})
data = base.feedback_data()
print(data)

返回内容通常类似:

{'T': 1001, 'L': 0, 'R': 0, 'ax': 92, 'ay': -440, 'az': 8306, 'gx': 2, 'gy': -7, 'gz': 12, 'mx': -622, 'my': 457, 'mz': 2498, 'odl': 0, 'odr': 2, 'v': 1197}
参数含义
T返回数据类型,1001 表示底盘反馈数据。
L / R左右侧轮速或运动反馈值,静止时通常接近 0
ax / ay / az加速度计三轴原始值。
gx / gy / gz陀螺仪三轴原始值。
mx / my / mz磁力计三轴原始值,容易受周围磁场影响。
odl / odr左右侧编码器 / 里程计相关值,轮子转动时会变化。
v底盘电压反馈值,单位为 0.01V,例如 1197 约为 11.97V

如果输出为 None 或没有明显数据,请确认串口连接、底盘供电和反馈状态。

连续反馈读取

连续反馈适合短时间观察数据变化。读取完成后要关闭连续反馈。

try:
send_cmd({"T": 131, "cmd": 1})

for _ in range(20):
data = base.feedback_data()
print(data)
time.sleep(0.1)

finally:
send_cmd({"T": 131, "cmd": 0})
print("已关闭连续反馈。")

读取 IMU 数据

send_cmd({"T": 126})
imu_data = base.feedback_data()
print(imu_data)
提示

地面打滑、轮径参数、轮距参数和 IMU 安装方向都会影响里程计。发现数据异常时,不要只改算法参数,也要先检查机械和传感器安装。

OLED、灯光与云台封装函数

本节只使用前面创建的 base 对象和 Python 封装函数,不再重复 Web 命令输入框操作。

运行本小节代码前,必须先完成 Notebook 连接下位机Notebook 通用控制函数。如果提示 base 未定义,请先回到前面的连接步骤。

OLED 显示

try:
base.base_oled(0, "UGV TEST")
time.sleep(1)
finally:
base.base_default_oled()

运行后,OLED 第 0 行会短暂显示 UGV TEST。随后 base.base_default_oled() 会发送恢复默认显示指令,OLED 会回到下位机默认显示界面。

提示

默认显示界面可能包含网络模式、状态信息或其它系统默认内容,例如 STAAP 等。但不影响后续的使用,如果需要恢复之前的OLED显示界面,可以重启设备再重新设置。

灯光控制

try:
base.lights_ctrl(64, 64)
time.sleep(1)
finally:
base.lights_ctrl(0, 0)

云台小角度控制

本示例使用 gimbal_ctrl(x, y, speed, acceleration),它会通过 T=133 云台角度控制 指令让云台转到指定角度。x 为水平角度,y 为俯仰角度,speed 为速度参数,acceleration 为加速度参数。先使用小角度、非最快速度测试,避免线缆或结构件干涉。

try:
base.gimbal_ctrl(0, 0, 200, 10)
time.sleep(1)
base.gimbal_ctrl(10, 0, 200, 10)
time.sleep(1)
base.gimbal_ctrl(0, 0, 200, 10)
finally:
base.gimbal_ctrl(0, 0, 200, 10)
危险

不要用手强行掰动云台。若云台抖动、卡住或线缆被拉扯,请立即停止控制。

结束 Notebook 下位机控制

完成 Notebook 控制后,先恢复到安全状态:

运行下面代码前,需要已经完成 Notebook 连接下位机Notebook 通用控制函数

try:
stop()
base.lights_ctrl(0, 0)
base.base_default_oled()
finally:
base.gimbal_dev_close()
print("已停止底盘、关闭灯光,并关闭串口。")

gimbal_dev_close() 用于关闭当前串口连接。函数名称保留封装中的命名,不需要修改源码。

如果后续要重新使用 Web 页面控制底盘,需要重新启动 Web 主程序,或重启设备。不要让 Web 主程序和 Notebook 同时打开同一个底盘串口。

开发者补充:自定义串口客户端

本页主线使用 BaseController 封装操作下位机。只有需要开发自定义串口通信逻辑时,才参考下面的自定义串口客户端模板。

该模板默认 dry_run,只查看或生成 JSON 指令,不直接打开串口;进入真实发送前再完成 串口占用检查与释放

  1. 先保持 dry_run=True 运行脚本。 确认不会控制硬件。
  2. 确认是否需要真实发送。 只查看 JSON 或 dry-run 时,不需要释放串口。
  3. 真实发送前检查串口占用。 按 串口占用检查与释放 处理。
  4. 确认串口可用后。 才改为 dry_run=False
  5. 任何异常退出都必须发送停止指令。
import json
import time
from dataclasses import dataclass

import serial


@dataclass
class SerialConfig:
port: str = "/dev/ttyTHS1" # 按串口检查结果修改
baudrate: int = 115200
timeout_s: float = 1.0
dry_run: bool = True


class LowerComputerClient:
def __init__(self, config: SerialConfig):
self.config = config
self.ser = None
if not config.dry_run:
self.ser = serial.Serial(config.port, config.baudrate, timeout=config.timeout_s)

def send(self, command: dict):
line = json.dumps(command, ensure_ascii=False) + "\n"
if self.config.dry_run:
print("[DRY-RUN]", line.strip())
return
self.ser.write(line.encode("utf-8"))

def read_line(self):
if self.config.dry_run:
return {"status": "dry-run"}
raw = self.ser.readline().decode("utf-8", errors="replace").strip()
if not raw:
return None
return json.loads(raw)

def stop(self):
self.send({"T": 1, "L": 0, "R": 0})

def close(self):
if self.ser:
self.ser.close()


def main():
client = LowerComputerClient(SerialConfig(dry_run=True))
try:
client.send({"T": 3, "lineNum": 0, "Text": "UGV TEST"})
client.send({"T": 132, "IO4": 64, "IO5": 64})
time.sleep(0.5)
client.send({"T": 132, "IO4": 0, "IO5": 0})
print(client.read_line())
finally:
client.stop()
client.close()


if __name__ == "__main__":
main()