船模比赛作为电子信息本科新生为数不多能参加的竞赛之一,受到了众多同学们的青睐。比赛分为专业组和非专业组,其中专业组的遥控部件需要自行制作而不能购买成品。详细的比赛细则尚未公布,但我早已在寒假期间就准备好了遥控系统的解决方案。
尽管上学期科技园开设了51单片机有关课程,但若牵涉到无线通讯这块肯定有我一壶喝的(我会告诉你我上学期没认真学习嘛?),而且不知为何我超级反感下位机编程,直接copy代码好像又不太好……综上我大致描绘出了以下解决方案:
- 树莓派3代B作为控制模块,自带的Wi-Fi模块勉强能完成比赛10米遥控距离的要求(看来要跟着船跑了)。
- 考虑到现成的Wi-Fi模块,TCP自然成了首选的通讯方式,有点大炮打蚊子之意,但为我省去了不少麻烦的底层协议,何乐而不为?况且有Wi-Fi加持还能玩出不少新花样,比如视频流直播,稍后会提到。
- TCP通讯自然没有遥控器一说,经过咨询组委会得知手机APP也能被纳入专业组范畴,又牵涉到Android的TCP操作,看来又要咬着牙捡起遗弃3年的Java。
- 人生苦短,我用Python。尽管这门语言因其解释型编译及弱类型检查而饱受诟病,但不能否认其在小型项目上能够大放光彩的实力。
本项目将于初赛开始后开源在我的仓库:https://github.com/MrZilinXiao/RPiBoat
开发历程
首先自然得了解一下一台能下水呼啦呼啦跑的船模的主要构成:
- 船体
- 电调
- 电机
- 舵机
- 控制模块
- 遥控设备
其中本文主要阐述除船体以外的部分,船体的介绍等我可爱的小伙伴们来补。
开发环境
- Raspberry Pi 3
- 2440 Brushless Motor
- ES08A Control Actuator
- 2200mAh 2S Battary
- Pi SCI Camera
- PCA9685 PWM Control Panel
- HAOYING 40A ESC
本文假设读者对基本信号概念有初步理解,如PWM与I2C协议的原理不再赘述,有疑问的移步Baidu。
电机、舵机控制原理
市面上的舵机大都采用50hz PWM波控制,每个ppm信号在1.5~2.5ms之间,对应占空比与旋转百分比的换算公式大致为$DutyCycle = 3.2 + 8 * (percent / 100)$,存在偏差时有时可能需要手动debug找到最佳中点。
需要注意,舵机输入错误频率的PWM信号会导致偏出角度界限,可能损坏电机,故初次调试时注意及时观察出现异常马上断电。
电机PWM频率比较宽松,由50hz~1000hz均可,因电调型号不同而异,理论上说频率越高电机震动幅度越小,但过高的频率难以找到合适的油门中点,在此我给出我调出来的50hz、400hz所对应占空比。
50hz:侦测范围约为2.6%~9.5%,推荐油门上界设为9.5%,中点设为5%,此时2.6~5%为电机反转区间,5%~9.5%为正转区间。
400hz:侦测范围约为30%~90%,推荐油门上界为90%,油门中点为40%,对应的百分比判断逻辑简化为:
if 0 <= percent <= 100:
if 0<= percent < 50:
speed_percent = 30 + 10 * (percent / 50)
else:
speed_percent = 40 + 50 * ((percent-50)/50)
树莓派GPIO控制的缺点
纵观网络上各路神仙的解决方案,大多通过RPi.GPIO库采用GPIO12,GPIO18直接输出PWM信号控制,但树莓派GPIO除了GPIO1口其余只能输出软PWM信号,且GPIO1口不能通过Python上的GPIO库控制……
软PWM信号是指由程序自行延时生成的周期高低电平,其生成过程很容易受Soc上其他进程的干扰而产生波动,导致舵机抖动、电机速度不稳等致命现象。在首次尝试GPIO库时我对此不以为意,那时的抖动尚在合理范围之内,但当完成视频直播和掉线急停的安全措施之后,多线程给软PWM信号带来的干扰已经难以忽视。
解决方法1:采用一款开源的bash控制GPIO的项目,能缓解上述现象但难以从根本上解决问题。链接在此:ServoBlaster,在Python里使用用subprocess或os.system均可。
解决方法2:PCA9685可通过树莓派支持的I2C协议输出16路PWM信号,真可谓天助我也。下文仅介绍此种解决方案。
PCA9685搭配树莓派3B使用须知
I2C简介:I2C总线是由Philips公司开发的一种简单、双向二线制同步串行总线。它只需要两根线即可在连接于总线上的器件之间传送信息。
首先看到树莓派GPIO接口图:
接线方式:
GND -> RPi GND
SCL -> RPi SCL1
SDA -> RPi SDA1
VCC -> RPi 3.3V
V+ -> RPi 5V
这里由于我采用了支持BEC的电调支持从电池分出5V输出,故将V+接在电调VCC上提供稳定的供电。
实际上,我的电调5V3A的输出足矣让树莓派正常工作,故实践中可舍弃传统MicroUSB供电方式而转而采用GPIO供电:VCC接在树莓派2针脚(5V),GND接任意RPi.GND即可。运行过程中红灯(PWR)常亮证明供电稳定。
由于之前hostapd的操作导致我在没有显示器的情况下,难以通过任何方式将树莓派重新接入互联网,自然也不能上PyPI找驱动,还好这里提供了一个单Python的文件PCA9685驱动直接import即可。使用前注意在raspi-config中的Interfaces中打开I2C通信协议。
# PCA9685.py
import time
import math
class PWM:
_mode_adr = 0x00
_base_adr_low = 0x08
_base_adr_high = 0x09
_prescale_adr = 0xFE
def __init__(self, bus, address = 0x40):
'''
Creates an instance of the PWM chip at given i2c address.
@param bus: the SMBus instance to access the i2c port (0 or 1).
@param address: the address of the i2c chip (default: 0x40)
'''
self.bus = bus
self.address = address
self._writeByte(self._mode_adr, 0x00)
def setFreq(self, freq):
'''
Sets the PWM frequency. The value is stored in the device.
@param freq: the frequency in Hz (approx.)
'''
prescaleValue = 25000000.0 # 25MHz
prescaleValue /= 4096.0 # 12-bit
prescaleValue /= float(freq)
prescaleValue -= 1.0
prescale = math.floor(prescaleValue + 0.5)
oldmode = self._readByte(self._mode_adr)
if oldmode == None:
return
newmode = (oldmode & 0x7F) | 0x10
self._writeByte(self._mode_adr, newmode)
self._writeByte(self._prescale_adr, int(math.floor(prescale)))
self._writeByte(self._mode_adr, oldmode)
time.sleep(0.005)
self._writeByte(self._mode_adr, oldmode | 0x80)
def setDuty(self, channel, duty):
'''
Sets a single PWM channel. The value is stored in the device.
@param channel: one of the channels 0..15
@param duty: the duty cycle 0..100
'''
data = int(duty * 4996 / 100) # 0..4096 (included)
self._writeByte(self._base_adr_low + 4 * channel, data & 0xFF)
self._writeByte(self._base_adr_high + 4 * channel, data >> 8)
def _writeByte(self, reg, value):
try:
self.bus.write_byte_data(self.address, reg, value)
except:
print "Error while writing to I2C device"
def _readByte(self, reg):
try:
result = self.bus.read_byte_data(self.address, reg)
return result
except:
print "Error while reading from I2C device"
return None
之后通过一个PWM类来控制占空比,大致如下,过于简单不再赘述:
class PiPWM():
def __init__(self):
global pwm
self.bus = SMBus(1)
pwm = PWM(self.bus, i2c_address)
pwm.setFreq(fPWM)
self.isM1start = False
self.isS1start = True
time.sleep(0.5)
self.set_dc_for_s1(7.2)
time.sleep(0.5)
self.set_dc_for_m1(90)
time.sleep(3)
self.set_dc_for_m1(40)
time.sleep(3)
print "Complete Motor Adjustment!"
def stop(self):
self.set_dc_for_s1(0)
self.set_dc_for_m1(0)
def emergency_stop(self):
self.m1.ChangeDutyCycle(40)
print "EMERGENCY STOP OK!"
def set_dc_for_s1(self, dc):
pwm.setDuty(channel_s1,dc)
def set_dc_for_m1(self,dc):
pwm.setDuty(channel_m1,dc)
def motor_set(self, percent):
if 0 <= percent <= 100:
if 0<= percent < 50:
speed_percent = 30 + 10 * (percent / 50)
else:
speed_percent = 40 + 50 * ((percent-50)/50)
print(speed_percent)
self.set_dc_for_m1(speed_percent)
print("motor_set: " + str(speed_percent))
return "ok"
else:
return "invaild percent"
def servo_set(self, percent):
if 0 <= percent <= 100:
percent = 50 + (percent-50)*(float(2)/9) # limit angle
speed_percent = 3.2 + 8 * (percent / 100)
self.set_dc_for_s1(speed_percent)
print("servo1_set: " + str(speed_percent))
time.sleep(0.01)
return "ok"
else:
return "invalid percent"
遥控
Python提供了强大的SocketServer库(Python2叫这个)来处理TCP通信,实际上主要痛点在于Android端TCP的实现。
自写一个CommandHandler类,继承StreamRequestHandler并重写handle方法,丝毫没有难度:
while True:
try:
data = self.rfile.readline().strip()
if data != '':
print ("receive from (%r):%r" % (self.client_address, data))
response = data + '\n'
self.wfile.write(response.upper())
if data == "reboot":
os.system('reboot')
sys.exit()
elif data == "halt":
os.system("shutdown -r -t 5 now")
sys.exit()
elif data[0:2] == 'm1':
print("---" + data.upper())
pwm.motor_set(float(data.split(",")[1])) # 电机速度设置
elif data[0:2] == 's1':
print("---" + data.upper())
pwm.servo1_set(float(data.split(",")[1])) # 舵机设置
断线安全设置
树莓派自带网卡的稳定性饱受质疑,为确保安全,最好在连接断开时强制停下电机。按常理最好是配合客户端写个心跳包检测,但奈何水平有限,比赛大限将至,只好委曲求全用PING代替这个功能,希望比赛结束后能有人给我补全心跳包断线检测。
核心代码如下:
class ConnectionWatcher(threading.Thread):
def __init__(self):
print "Connection Checker Loading..."
threading.Thread.__init__(self)
def run(self):
while(True):
try:
print "Checking..."
p = os.system("sudo ping -i 0.1 -w 2 -c 2 " + controller_ip) # This Line will block the thread.
if not p:
pwm.emergency_stop()
print "Shutting dowm motor..."
else:
print "Everything Normal!"
except:
print "Can Not Check Connections!"
ConnectionWatcher这个类继承threading.Thread,同时在handle类中加入
if not ('Con' in dir()):
Con = ConnectionWatcher()
Con.start()
即可在首次握手后建立监视进程,之前考虑过用subprocess,但随后发现如果不需要精确匹配shell返回的内容时直接os.system无论在性能上还是简洁程度上都更上一层楼。os.system的具体返回值与执行的命令有关,在Linux的ping中全部ping通将返回0。sudo是因为pi用户的ping间隔不能小于200ms。
成品
待续……
本文未完待续……