项目介绍
基于树莓派RP2040的嵌入式系统学习平台制作摇杆鼠标,能够通过摇杆和按键实现鼠标的功能,包括长按、短按、拖动和滚动,并在LCD上显示鼠标图像,并且添加了适当动画。
设计思路
首先读取摇杆和按键的数据,判断当前有无操作,有操作的话,针对不同模式,不同操作,通过HID控制鼠标进行相应的响应,之后对LCD的鼠标进行相应的刷新,同时绘制交互动画。
软件流程图
由于编辑器不方便添加图片,图片将放置在附件中,实现功能中的图片展示也将放在附件中。
硬件介绍
基于树莓派RP2040的嵌入式系统学习平台是硬禾学堂推出的一款开发平台,采用树莓派Pico核心芯片RP2040,具备丰富的外设,包括:240*240的全彩LCD,姿态传感器,蜂鸣器,红外发射器和接收器,四向摇杆和两种不同形态的按键,此外,还提供了一组扩展接口。
实现的功能
利用板上的四向摇杆和按键设计一款“鼠标”
在240*240的LCD屏幕内可以通过该鼠标进行菜单选择和参数控制,在屏幕上要有图形化的箭头形状
通过USB端口可以控制PC屏幕上的光标移动和点击操作,行使电脑鼠标的功能
主要代码片段及说明
0.环境搭建
采用Thonny进行开发,micropython语言进行编写。使用固件为笛子老师提供的包含hid接口的mpy-sge-pmrn-v20220118.uf2。
https://github.com/EETree-git/RP2040_Game_Kit/tree/main/lecture
1.初始化程序
这里包括创建模式变量,按压变量,保存鼠标当前位置和上一个位置的变量。同时对鼠标,spi接口,LCD显示进行了初始化,绘制了初始界面,同时初始化了按键、摇杆和LED灯,并对按键添加了外部中断。
# variable
press_flag = False
point = [120, 77]
point_next = [0, 0]
mode = 0
# init hid
m = hid.Mouse()
# init spi
spi_sck=machine.Pin(2)
spi_tx=machine.Pin(3)
spi0=machine.SPI(0,baudrate=4000000, phase=1, polarity=1, sck=spi_sck, mosi=spi_tx)
# init st7789
disp_width = 240
disp_height = 240
st7789_res = 0
st7789_dc = 1
display = st7789.ST7789(spi0, disp_width, disp_width,
reset=machine.Pin(st7789_res, machine.Pin.OUT),
dc=machine.Pin(st7789_dc, machine.Pin.OUT),
xstart=0, ystart=0, rotation=0)
# 初始界面
display.fill(st7789.BLACK)
display.text(font2, "MouseController", 0, 60, st7789.WHITE, st7789.BLACK)
display.line(0,100,239,100,st7789.WHITE)
for i,ele in enumerate(bsp.x):
display.pixel(bsp.y[i]+120,bsp.x[i]+120,st7789.WHITE)
display.text(font2, " Press Strat", 0, 160, st7789.WHITE, st7789.BLACK)
# init button and joystick
yAxis = ADC(Pin(28))
xAxis = ADC(Pin(29))
buttonB = Pin(5,Pin.IN, Pin.PULL_UP) #B
buttonA = Pin(6,Pin.IN, Pin.PULL_UP) #A
buttonStart = Pin(7,Pin.IN, Pin.PULL_UP)
buttonSelect = Pin(8,Pin.IN, Pin.PULL_UP)
buttonValueStart = 1
buttonA.irq(trigger=machine.Pin.IRQ_RISING|machine.Pin.IRQ_FALLING, handler=buttonAcallback)
buttonB.irq(trigger=machine.Pin.IRQ_RISING|machine.Pin.IRQ_FALLING, handler=buttonBcallback)
buttonSelect.irq(trigger=machine.Pin.IRQ_FALLING, handler=buttonSelectcallback)
# init led
led = Pin(4,Pin.OUT)
2.绘制圆圈函数
绘制圆圈的程序来自ICISTRUE的Pico的征途——水平仪, https://www.eetree.cn/project/detail/250
它的输入参数包含了圆心的坐标,半径的大小,以及圆圈的颜色,默认颜色为黑色。这里采用绘制圆圈函数主要是为了作为按键按下的反馈,当左键或者右键按下时,屏幕上鼠标的外围会绘制一个圆圈,左键为绿色,右键为红色,当按键松开的时候,圆圈又会消失。
def draw_circle(xpos0, ypos0, rad, col=st7789.color565(255, 255, 255)):
x = rad-1
y = 0
dx = 1
dy = 1
err = dx - (rad << 1)
while x >= y:
display.pixel(xpos0 + x, ypos0 + y, col)
display.pixel(xpos0 + y, ypos0 + x, col)
display.pixel(xpos0 - y, ypos0 + x, col)
display.pixel(xpos0 - x, ypos0 + y, col)
display.pixel(xpos0 - x, ypos0 - y, col)
display.pixel(xpos0 - y, ypos0 - x, col)
display.pixel(xpos0 + y, ypos0 - x, col)
display.pixel(xpos0 + x, ypos0 - y, col)
if err <= 0:
y += 1
err += dy
dy += 2
if err > 0:
x -= 1
dx += 2
err += dx - (rad << 1)
3.按键函数及代码
按键函数是为了执行左右键按下时的操作,按键没有采用主循环检测,而是选择采用外部中断的方式,能够更加及时有效的响应按键操作,同时也为实现拖动简化了逻辑。
当按键按下时,向计算机发送按下鼠标按键的指令,同时绘制鼠标响应动画,点亮LED灯,当按键松开时,向计算机发送释放鼠标按键的指令,绘制底色覆盖鼠标响应动画,熄灭LED灯。
select键的功能是切换鼠标的移动模式和滚动模式,采用软件消抖的方式,按键按下以后,绘制屏幕分界线,并在LCD屏幕中标识当前模式,同时绘制红色装饰线。
start键与其它键不同,采用的是死循环的方式。因为此时正处于初始界面,未进行任何操作,所以不需要采用中断的方式。当start键未被按下时,系统处于初始界面。按键按下以后,由初始界面过渡到控制界面。此时的默认模式为移动模式。
def buttonAcallback(self):
if buttonA.value() == 0:
m.press(m.BUTTON_RIGHT)
draw_circle(point[0]+6, point[1]+9, 11, st7789.color565(255, 0, 0))
draw_circle(point[0]+6, point[1]+9, 12, st7789.color565(255, 0, 0))
led.value(1)
else:
m.release(m.BUTTON_RIGHT)
draw_circle(point[0]+6, point[1]+9, 11, st7789.WHITE)
draw_circle(point[0]+6, point[1]+9, 12, st7789.WHITE)
led.value(0)
def buttonBcallback(self):
if buttonB.value() == 0:
m.press(m.BUTTON_LEFT)
draw_circle(point[0]+6, point[1]+9, 11, st7789.color565(0, 255, 0))
draw_circle(point[0]+6, point[1]+9, 12, st7789.color565(0, 255, 0))
led.value(1)
else:
m.release(m.BUTTON_LEFT)
draw_circle(point[0]+6, point[1]+9, 11, st7789.WHITE)
draw_circle(point[0]+6, point[1]+9, 12, st7789.WHITE)
led.value(0)
def buttonSelectcallback(self):
global mode
utime.sleep(0.01)
if buttonSelect.value() == 0:
mode = not mode
# 刷新界面以消除bug
display.fill(st7789.WHITE)
for i,ele in enumerate(bsp.x):
display.pixel(bsp.y[i]+point[0],bsp.x[i]+point[1],st7789.BLACK)
display.line(0,135,239,135,st7789.BLACK) # 中间的横线
if mode:
display.fill_rect(0, 140, 240, 8, st7789.RED)
display.text(font1, "Roll Mode", 10, 140, st7789.BLACK, st7789.WHITE)
else:
display.fill_rect(0, 140, 240, 8, st7789.RED)
display.text(font1, "Move Mode", 10, 140, st7789.BLACK, st7789.WHITE)
while(buttonValueStart):
buttonValueStart = buttonStart.value()
pass
else:
# 从初始界面过渡到控制界面
display.fill(st7789.WHITE)
for i,ele in enumerate(bsp.x):
display.pixel(bsp.y[i]+point[0],bsp.x[i]+point[1],st7789.BLACK)
display.line(0,135,239,135,st7789.BLACK) # 中间的横线
display.fill_rect(0, 140, 240, 8, st7789.RED)
display.text(font1, "Move Mode", 10, 140, st7789.BLACK, st7789.WHITE)
4.判断移动代码
程序会不断循环,读取四向摇杆adc的值和按键的状态。经过测试可以得到,adc的范围是0-65535。当摇杆处于默认位置的时候,adc的值为32768左右。由于误差,这个值并不是恒定的。为了可以简化判断,将adc的范围从0-65535映射到0-2。这时,摇杆的默认范围对应的是数值1。为了避免误差,对0.9-1.1数值的摇杆进行忽略,认为他们处于默认状态。为了方便后续程序的处理,将有偏移的摇杆从0-2映射到-1到1,这样就可以通过数值的正负来判断鼠标移动的方向。同时默认状态的摇杆被置为0,方便进行摇杆状态的判断。
while True:
# 读取adc
yValue = (yAxis.read_u16()-0)/32768
xValue = (xAxis.read_u16()-0)/32768
buttonValueA = buttonA.value()
buttonValueB = buttonB.value()
buttonValueStart = buttonStart.value()
buttonValueSelect = buttonSelect.value()
# 判断是否为有效数据
if(xValue>1.1 or xValue<0.9):
xValue = xValue - 1
else:
xValue = 0
if(yValue>1.1 or yValue<0.9):
yValue = yValue - 1
else:
yValue = 0
5.移动鼠标代码
移动鼠标之前首先进行判断,摇杆是否被拨动。若摇杆波动则通过摇杆偏移量和当前鼠标坐标计算下一个鼠标的位置。在上文中已经提到,摇杆数值被映射到-1到1,这里我们需要乘以一个系数,来控制鼠标的移动步进。经过测试,当步进为10的时候,既能快速响应,又避免了移动过冲,错失目标。
由于计算机显示器和LCD显示屏的显示范围是有限的,所以鼠标的移动和在LCD上的显示范围也是有限的。所以需要对鼠标的坐标变量进行限制。当鼠标的坐标变量到达限制以后,就不能沿原变化方向继续变化。
计算好鼠标在LCD显示屏上的坐标以后,通过hid函数执行移动命令,注意将LCD鼠标移动的限制映射为计算机显示器鼠标的移动限制。
之后在LCD显示屏上清除上一坐标的鼠标,并在新的位置上绘制鼠标图标。将当前坐标覆盖上一个坐标,等待下一次比较。
# 移动鼠标
if mode == 0:
if xValue+yValue == 0:
continue
# 计算下一个坐标
point_next[0] = int(point[0]+xValue*10)
point_next[1] = int(point[1]+yValue*10)
# 限幅 240_135
if(point_next[0]>239):
point_next[0] = 239
elif(point_next[0]<0):
point_next[0] = 0
if(point_next[1]>134):
point_next[1] = 134
elif(point_next[1]<0):
point_next[1] = 0
# 判断是否需要刷新鼠标
if(point != point_next):
# 移动电脑鼠标
m.moveto(int(32768/240*point_next[0]), int(32768/135*point_next[1]))
# 清除上次鼠标痕迹
for i,ele in enumerate(bsp.x):
display.pixel(bsp.y[i]+point[0], bsp.x[i]+point[1], st7789.WHITE)
for i,ele in enumerate(bsp.x):
# 不绘制横线下的内容
if(bsp.x[i]+point_next[1]>134):
continue
display.pixel(bsp.y[i]+point_next[0], bsp.x[i]+point_next[1], st7789.BLACK)
# 保留坐标
point[0] = point_next[0]
point[1] = point_next[1]
6.滑动滚轮代码
滑动滚轮中,因为滑动过程中会有交互动画,所以当摇杆处于默认位置时,需要首先用底色覆盖动画范围。
滑动滚轮不需要计算当前坐标与上一座标,通过hid函数直接发送adc映射后的数值与敏感系数的乘积。经过测试,系数为2的时候有较好的操控体验。
交互动画的效果与摇杆的偏移量有关,偏移量越大,交互动画越长。不同的偏移方向采用不同的颜色进行标识。同时在底部提示是水平方向的滚轮在滚动还是垂直方向的滚轮在滚动。
# 转动滚轮
else:
if xValue+yValue == 0:
# 清除roll信息
display.fill_rect(0, 150, 240, 8, st7789.WHITE)
display.fill_rect(point[0]+20, point[1], 8, int(1*20), st7789.WHITE)
display.fill_rect(point[0], point[1]+20, int(1*20), 8, st7789.WHITE)
continue
m.move(0, 0, -int(yValue*2), +int(xValue*2))
if xValue:
if xValue>0:
display.fill_rect(point[0], point[1]+20, int(xValue*20), 8, st7789.RED)
else:
display.fill_rect(point[0], point[1]+20, -int(xValue*20), 8, st7789.GREEN)
display.text(font1, "Roll Horizontal", 0, 150, st7789.BLACK, st7789.WHITE)
if yValue:
if yValue>0:
display.fill_rect(point[0]+20, point[1], 8, int(yValue*20), st7789.RED)
else:
display.fill_rect(point[0]+20, point[1], 8, -int(yValue*20), st7789.GREEN)
display.text(font1, "Roll Vertical", 130, 150, st7789.BLACK, st7789.WHITE)
7.hid文件内的函数分析
hid文件由笛子老师提供,需要关注的主要是按压与释放函数,鼠标移动函数和鼠标左右键变量。具体内容如下。
### hid.py --- support hid device.
## modified-by: picospuch
# RP2 USB HID library for mouse device
# see lib/tinyusb/src/class/hid/hid_device.h for mouse (relative) descriptor
# see ports/rp2/tusb_port.c for mouse (moveto/absolute) descriptor
import usb_hid
class Mouse:
BUTTON_NONE = 0x00
BUTTON_LEFT = 0x01
BUTTON_RIGHT = 0x02
BUTTON_MIDDLE = 0x04
BUTTON_PREVIOUS = 0x08
BUTTON_NEXT = 0x10
BUTTON_ALL = BUTTON_LEFT | BUTTON_MIDDLE | BUTTON_RIGHT | BUTTON_PREVIOUS | BUTTON_NEXT
def __init__(self):
self._report = bytearray(5)
def _send_no_move(self) -> None:
for i in range(1, 4):
self._report[i] = 0
usb_hid.report(usb_hid.MOUSE, self._report)
def press(self, buttons : int) -> None:
self._report[0] |= buttons & 0xff
print(self._report[0])
self._send_no_move()
def release(self, buttons : int) -> None:
self._report[0] &= ~(buttons & 0xff)
self._send_no_move()
def click(self, buttons : int) -> None:
self.press(buttons)
self.release(buttons)
# relative mouse move
# v /|\ - \|/ +
# h <- + -> -
def move(self, x=0, y=0, v=0, h=0) -> None:
clamp = lambda change: min(127, max(-127, change))
while (x != 0 or y != 0 or v != 0 or h !=0):
dx = clamp(x)
dy = clamp(y)
dv = clamp(v)
dh = clamp(h)
self._report[1] = dx
self._report[2] = dy
self._report[3] = dv
self._report[4] = dh
usb_hid.report(usb_hid.MOUSE, self._report)
x -= dx
y -= dy
v -= dv
h -= dh
# absolute mouse move ([0-32767],[0-32767])
def moveto(self, x : int, y : int) -> None:
self._report[1] = x & 0xff
self._report[2] = (x >> 8) & 0xff
self._report[3] = y & 0xff
self._report[4] = (y >> 8) & 0xff
usb_hid.report(usb_hid.MOUSE_ABS, self._report)
遇到的主要难题及解决方法
遇到的主要问题是鼠标图标的绘制方法,没有找到比较合适的方法。所以采用matlab读取一张鼠标图片,对鼠标图标进行二值化,之后将数据复制到Excel中,通过Excel进行修饰。修饰完以后,将数据复制回matlab中,将鼠标轮廓的线性索引转换为下标。
最后采用绘制像素的方式,依据鼠标图标的下标进行绘制,就可以得到鼠标图标了。
a = imread("2.bmp");
b = rgb2gray(a);
c = imresize(b, [20, 20]);
c(c>128) = 255;
c(c<128) = 0;
figure; imshow(c); axis on;
imwrite(c,"3.bmp");
d = uint8(zeros(18,18));
%%
imwrite(d,"4.bmp");
%%
ind = find(d<255);
[mm,nn]=ind2sub(size(d),ind);
dd = [mm, nn]
dd = dd - 1;
mmm = mm-1
nnn = nn-1
未来的计划或建议
目前还有一些问题没有解决,后续有时间会解决相应问题。
第一个是刷新问题,鼠标静止的时候是正常的,但当鼠标移动时会产生闪烁现象。这是因为屏幕刷新率较低,当鼠标移动时,会先擦除上一个鼠标的痕迹,再绘制下一个鼠标。所以产生闪烁现象。若是先绘制新的鼠标,再擦除上一个鼠标会产生拖影现象,同时还要注意,擦除的时候不能将新绘制的鼠标一起擦除。
这个问题的解决目前没有较好的办法,计划在项目结束后,参考一下其他同学的优秀作品。