绿色再生·STM32+安卓4G设计的智能远程操作巡视机器人小车
https://mp.weixin.qq.com/s/Q1wSua7adrXZghVAF2Hwlg
【1】项目功能介绍
https://mp.weixin.qq.com/s/Q1wSua7adrXZghVAF2Hwlg
本项目设计并实现一款基于STM32微控制器的远程遥控安卓小车系统。该系统充分利用了淘汰下来的安卓旧手机作为车载信息处理单元,不仅实现了资源的有效再利用,还结合4G网络技术以及先进的流媒体服务和物联网技术,搭建起一套集远程操控、实时视频音频传输于一体的高效解决方案。
车载的旧安卓手机通过USB线连接到STM32主控板上,接收并执行来自远端手机APP的指令。这款由Qt开发的Android APP能够利用4G网络实现实时在线,并通过摄像头采集音视频数据,通过RTMP协议将这些数据推送到华为云ECS服务器上的NGINX流媒体服务器,从而实现高清流畅的远程视频监控。
为了实现双向交互和低延迟控制,整个系统还借助MQTT协议连接至华为云IOT服务器。另一台安装了同样由Qt开发的Android手机APP的终端设备,可以通过该APP拉取小车端的实时音视频流进行播放,并通过方向键菜单实现对小车的精准远程操控。这种设计不仅极大地拓展了传统遥控小车的功能性与实用性,还为其他类似应用场景提供了可借鉴的技术框架。

【2】设计实现的功能
(1)STM32主控板功能:
控制4个电机:STM32通过L298N驱动芯片驱动4个电机,实现小车的前进、后退、左转、右转等动作。
数据通信:STM32通过USB接口与安卓手机通信,接收手机APP发送的控制指令,并将小车的状态信息(如电量、速度、位置等)发送回手机。
电源管理:管理2节18650锂电池的供电,确保电压稳定并监控电池电量。
(2)安卓手机APP功能
控制指令下发:手机APP通过USB接口向STM32发送控制指令,控制小车的动作。
视频和音频流获取:APP从手机摄像头和麦克风获取视频和音频流,并进行编码处理。
流媒体推流:通过RTMP协议将编码后的视频和音频流推送到华为云ECS服务器+NGINX搭建的RTMP流媒体服务器。
MQTT连接:APP通过MQTT协议与华为云IOT服务器建立连接,实现双向通信。
(3)华为云服务器功能:
RTMP流媒体服务:接收并转发安卓手机APP推送的视频和音频流。
MQTT服务:作为MQTT消息代理,实现远程手机与STM32主控板之间的通信。
(4)远程Android手机APP功能:
实时视频和音频播放:从华为云ECS服务器拉取视频和音频流,并实时显示在APP界面上。
MQTT连接:与华为云IOT服务器建立连接,接收STM32主控板发送的小车状态信息。
远程控制:提供方向键控制菜单,允许用户远程控制小车前进、后退、转弯等动作。
【3】项目硬件模块组成
(1)电源模块:
- 电池组:采用两节18650锂电池作为供电源,它们具有高能量密度、体积小的特点,能够为整个系统提供稳定的直流电能。
(2)主控模块:
- STM32微控制器:这是整个小车的核心控制单元,负责处理所有的逻辑运算和数据通信任务。通过编程实现对电机驱动、USB通信、网络连接等功能的控制。
(3)电机驱动模块:
- L298N驱动芯片:用于驱动底座上的四个电机,L298N是一个高性能的H桥电机驱动器,可以接收来自STM32的信号,转换为足够驱动电机工作的电流和电压,并且支持电机正反转及速度调节
(4)移动平台模块:
- 四个直流电机:直接安装在小车底座上,通过L298N驱动进行精确的速度和方向控制,以实现小车前进、后退、左右转弯等运动功能。
(5)通信模块:
USB接口:STM32主控板通过USB线与安卓手机物理连接,实现数据传输,接收来自手机APP的控制指令。
4G模组:集成在安卓手机内部,插入SIM卡后可实现高速无线网络连接,使小车能够在远程环境下通过互联网与其他设备通信。
(6)多媒体采集模块:
- 安卓手机摄像头:用于捕捉实时视频和音频信息,是小车端环境感知的关键组件。
(7)云服务交互模块:
华为云ECS服务器+NGINX RTMP流媒体服务器:小车端将采集到的音视频流推送到华为云服务器上,通过RTMP协议实现实时音视频的低延迟传输和分发。
华为云IOT服务器:小车和远端控制手机均通过MQTT协议与之建立连接,实现远程数据交换和控制命令的下发。
【4】功能总结
(1)电机驱动与控制:通过STM32微控制器和L298N驱动芯片,实现对小车上四个电机的精确控制,包括前进、后退、左转、右转等动作,从而控制小车的移动方向和速度。
(2)无线通信与数据传输:STM32与安卓手机之间通过USB接口建立通信,实现控制指令的下发和小车状态信息的上传。同时,安卓手机通过4G网络连接到华为云服务器,实现了远程控制命令的远程传输和视频音频流的推送。
(3)流媒体推流与播放:安卓手机APP能够捕获手机摄像头和麦克风的视频和音频流,通过RTMP协议推送到华为云服务器。另一台安卓手机APP则从服务器拉取这些流,实现实时播放,从而允许用户远程观看小车的实时画面和音频。
(4)华为云服务器支持:华为云ECS服务器和NGINX搭建的RTMP流媒体服务器负责接收、转发视频和音频流,确保流媒体的稳定性和实时性。同时,华为云IOT服务器通过MQTT协议提供消息代理服务,实现远程手机与STM32之间的双向通信。
(5)用户界面与交互设计:安卓手机APP提供了直观的用户界面,包括控制按钮、状态显示、视频播放器等,使用户能够方便地对小车进行控制、观看视频、监听音频,以及监控小车的状态信息。
(6)远程控制:通过结合STM32的电机控制、华为云服务器的数据处理和传输,以及安卓手机的用户界面和交互设计,实现了从远程手机到小车的远程控制功能。用户可以在远离小车的地点,通过手机APP发出控制指令,实时观察小车的动作和周围环境。
在启动MainActivty 的Manifest声明USB授权 和在 Service初始化时 代码调用requestPermission 声明USB授权有什么区别
Manifest方式:适合需要系统级集成的普通应用 如果你不授权 你连APP都无法启动
在启动MainActivty 的Manifest声明USB授权,启动程序会 弹出系统 让用户手动授权 否则无法进入 启动MainActivty 无法启动App
Kotlin<activity android:name=".MainActivity" android:exported="true"> <intent-filter> <action android:name="android.intent.action.MAIN" /> <category android:name="android.intent.category.LAUNCHER" /> </intent-filter> <intent-filter> <action android:name="android.hardware.usb.action.USB_DEVICE_ATTACHED" /> </intent-filter> <meta-data android:name="android.hardware.usb.action.USB_DEVICE_ATTACHED" android:resource="@xml/device_filter" /> </activity>
Kotlin<?xml version="1.0" encoding="utf-8"?> <resources> <!-- 指定要过滤的USB设备 --> <usb-device vendor-id="1155" <!-- 0x0483,STMicroelectronics的VID --> product-id="22336" <!-- 0x5740,STM32 CDC设备的PID --> class="2" <!-- 通信设备类 --> subclass="2" /> <!-- ACM子类(抽象控制模型) --> </resources>
哈哈
**requestPermission()代码方式:**适合需要精确控制请求时机的场景,可以让你先启动app 进入主界面,但是代码方式usbManager.requestPermission()也是会弹框的
这个我们是可以先启动APP 进入主Activity, 只是用户操作到需要使用USB设备时,触发启动了 Service初始化时 使用代码动态请求 代码调用requestPermission 声明USB授权 才会弹出USB授权系统弹窗。
Kotlinclass UsbCommunicationService : Service() { fun requestUsbPermissionWhenNeeded() { val device = findUsbDevice() if (device != null && !usbManager.hasPermission(device)) { // 在合适的时机请求权限 val permissionIntent = PendingIntent.getBroadcast( this, 0, Intent(ACTION_USB_PERMISSION), PendingIntent.FLAG_IMMUTABLE ) usbManager.requestPermission(device, permissionIntent) } } }
目前采用requestPermission()代码方式, Manifest方式的声明测试掉 , 代码执行第一次弹出USB授权弹框 点击确认授权后 后面多次执行 不会再次弹框了 系统已经记录你的授权操作了
针对以上方式无论是 **Manifest方式还是requestPermission()代码方式 都会弹出系统USB授权弹框,都需要用户点击确认授权才可以正常使用USB功能。**所以最好的方案是 如果你的android开发板已经Root了 通过Root方案可以彻底解决避免USB授权弹框的 问题
Kotlin
/**
* Root方案:直接修改权限文件(需要Root权限)
*/
private fun grantUsbPermissionWithRoot(device: UsbDevice) {
try {
// 构建权限文件路径
val deviceName = device.deviceName.substringAfterLast("/")
val permissionFile = File("/data/system/users/0/usb_permissions.xml")
// 使用su命令修改权限文件
val process = Runtime.getRuntime().exec("su")
val outputStream = process.outputStream
val command = """
echo ' <permission deviceId="${device.deviceId}"
serialNumber="${device.serialNumber}"
granted="true" />' >> $permissionFile
""".trimIndent()
outputStream.write(command.toByteArray())
outputStream.flush()
outputStream.close()
process.waitFor()
} catch (e: Exception) {
Log.e(tag, "Root授权失败: ${e.message}")
}
}
流程:找设备 → 找数据接口 → 找端点 → 用端点通信。
如何找到我需要的usb设备STM32
usbManager.deviceList
会返回所有连接到Android设备的USB设备列表,包括鼠标、键盘、U盘、你的STM32等等。如何从中精准地找到你的STM32设备,
方法一:通过 Vendor ID 和 Product ID (最可靠、最专业)
这是首选方法,也是最准确的方式。每个USB设备都有两个唯一的标识符:
Vendor ID (VID):由USB-IF协会分配给设备厂商的唯一编号。
Product ID (PID):由厂商给自己产品分配的唯一编号。
STM32的CDC设备通常使用STMicroelectronics的默认VID和PID ,这是最准确、最可靠的方法。在代码中硬编码你的STM32设备的VID和PID。
VID (Vendor ID) :
0x0483
(十进制1155
)PID (Product ID) :
0x5740
(十进制22336
)
Kotlin
// 获取所有连接的USB设备
val deviceList = usbManager.deviceList
// 遍历设备列表,根据VID和PID进行过滤
usbDevice = deviceList.values.find { device ->
device.vendorId == 1155 && device.productId == 22336 // 使用十进制
// 或者 device.vendorId == 0x0483 && device.productId == 0x5740 (使用十六进制)
}
if (usbDevice != null) {
binding.textViewStatus.text = "找到STM32设备: ${usbDevice!!.deviceName}"
} else {
binding.textViewStatus.text = "未找到STM32设备"
}
如果你有多个相同的STM32开发板会怎样?
如果你有多个相同型号 且烧录了相同固件 的STM32开发板,它们插入电脑后,VID和PID会完全一样。
这时,
usbManager.deviceList
中会出现多个具有相同VID/PID的设备。deviceList.values.find {...}
只会返回第一个匹配的设备,这显然不是你想要的。最佳实践建议
对于个人项目/单一设备:直接使用VID/PID过滤第一个设备,简单有效。
对于可能连接多个相同设备的情况:
实现让用户选择的逻辑(方法一),这是最健壮的方式。
如果可能,修改STM32固件 ,为每个设备烧写一个唯一的PID或序列号,这是最专业的解决方案。
usbDevice.getInterfaceCount()
usbDevice.getInterfaceCount() 这个方法返回该USB设备提供的接口(Interface)的数量。
一个USB设备就像一个公司,而接口(Interface) 就像是这个公司里的不同部门。每个部门有自己特定的功能和工作方式。
USB设备有几个接口?
一个USB设备至少有一个接口。
复杂的USB设备(如多功能打印机、集线器)可能有多个接口。
这完全取决于设备的类型和功能。对于你的STM32 CDC设备,答案很典型:通常是 2 个接口。
|---------------------|----------------------------------|---------------------------|-----------------------------------|--------------------------------------------------------------|
| 接口索引 | 接口类型 | 类代码 | 作用 | 比喻 |
| 接口 0 (管理接口) | 通信接口 (Control Interface) | USB_CLASS_COMM
(2) | 管理连接。 用于设置波特率、数据位、流控等通信参数。 | 公司的行政部门。不处理具体业务,只负责制定规则和管理(如:我们公司今天9点上班,用中文沟通)。 |
| 接口 1 (数据接口) | 数据接口 (Data Interface) | USB_CLASS_CDC_DATA
(10) | 传输数据。 所有实际要发送和接收的字节数据都通过这个接口。 | 公司的业务部门。真正干活的部门,按照行政部门定好的规则进行实际工作(如:销售部根据"用中文沟通"的规则去谈客户) |
如何用代码找到正确的接口?
在你的Android代码中,你需要遍历所有接口,找到那个真正用于数据传输的数据接口。
Kotlin
// 遍历设备的所有接口
for (i in 0 until usbDevice.interfaceCount) {
val usbIf = usbDevice.getInterface(i)
// 打印接口信息用于调试(非常重要!)
Log.d("USB", "接口索引: $i")
Log.d("USB", "接口类: ${usbIf.interfaceClass}")
Log.d("USB", "接口子类: ${usbIf.interfaceSubclass}")
Log.d("USB", "接口协议: ${usbIf.interfaceProtocol}")
// 寻找CDC数据接口:类代码为 10 (0x0A)
if (usbIf.interfaceClass == UsbConstants.USB_CLASS_CDC_DATA) { // USB_CLASS_CDC_DATA = 10
usbInterface = usbIf // 这就是我们要找的"业务部门"!
Log.d("USB", "找到数据接口!")
break
}
}
找到了接口,然后呢?端点是做什么的?
找到了正确的接口(部门)后,还需要找到这个部门里的具体工作人员 ,这就是端点(Endpoint)。
每个接口可以有多个端点。
端点是数据通信的最终目的地。
端点有方向:
UsbConstants.USB_DIR_OUT
(0): 输出,主机→设备 (Android→STM32)UsbConstants.USB_DIR_IN
(128): 输入,设备→主机 (STM32→Android)
- 端点有类型:
- 批量传输(Bulk Transfer) : 用于大量、可靠的数据传输(文件、串行数据)。你的项目就用这种。
- 中断传输(Interrupt Transfer): 用于频繁、小量、保证延迟的数据(鼠标、键盘)。
- 等时传输(Isochronous Transfer): 用于实时数据流(音频、视频),可能丢失数据但保证速度。
在你的STM32 CDC设备的数据接口中,通常有2个端点:
一个
Bulk Out
端点(用于Android发送数据给STM32)一个
Bulk In
端点(用于STM32发送数据给Android)
查找端点的代码:
Kotlin
usbInterface?.let { interface ->
// 遍历该接口的所有端点
for (i in 0 until interface.endpointCount) {
val ep = interface.getEndpoint(i)
when (ep.direction) {
UsbConstants.USB_DIR_OUT -> {
// 找到输出端点,用于发送数据给STM32
endpointOut = ep
Log.d("USB", "找到Bulk Out端点: 地址=${ep.address}")
}
UsbConstants.USB_DIR_IN -> {
// 找到输入端点,用于接收来自STM32的数据
endpointIn = ep
Log.d("USB", "找到Bulk In端点: 地址=${ep.address}")
}
}
}
}