本帖最后由 豆爸 于 2025-10-6 16:19 编辑
一、简介
本项目基于 ESP32 开发板,通过 ESP-NOW 无线通信协议构建了一套 “摇杆指令发送 - 接收 - 显示” 系统。发送端利用 ADC 模块采集摇杆的 X、Y 轴模拟信号,转化为 “上 / 下 / 左 / 右 / 停止”5 种控制指令;接收端通过 ESP-NOW 接收指令后,在 LCD 屏幕上实时显示指令内容,同时在串口输出 sender MAC 地址与指令信息,实现了低延迟、近距离的无线指令交互。 该系统展示了低功耗无线通信技术在物联网设备控制中的应用,适用于遥控小车、智能家居控制等多种场景。
二、硬件
1、硬件清单
2、硬件简介
(1)FireBeetle 2 ESP32-C5 开发板
FireBeetle 2 ESP32-C5 是一款搭载乐鑫 ESP32-C5 模组的低功耗 IoT 开发板,面向智能家居和广泛物联网场景,集高性能计算、多协议支持与智能电源管理于一体,为各种部署需求提供高可靠性、高灵活性与长续航的解决方案。
当前试用赠送的Firebeetle 2 ESP32-C5开发板板载ESP32-C5模组为ECO1 版本,ECO1版本的ESP32-C5模组是基于 ESP32-C5 revision v0.1 版本芯片。
(2)Gravity: JoyStick 摇杆
DFRobot的JoyStick摇杆采用原装优质金属PS2摇杆电位器制作,具有(X,Y)2轴模拟输出,(Z)1路按钮数字输出。
(3)Gravity: I2C OLED-2864 显示屏
Gravity OLED-2864 显示屏是一款无需背景光源,自发光式的显示模块。模块采用蓝色背景,显示尺寸控制在0.96英寸,采用OLED专用驱动芯片SSD1306控制。模块采用Gravity I2C通用接口。
(4)ESP32编程掌机
ESP32编程掌机搭载了EESP32-D0WD MCU,并配备1.8寸ST7735 LCD屏幕、6个按键、蜂鸣器、光感器、电机驱动、锂电池,同时支持外接传感器扩展。
- 按键:上=2,下=13,左=27,右=35,A =34,B= 12
- LCD屏幕:SPl=2,sck=18,mosi=23,cs=5,dc=4,res=19,bl=None
- SD卡:SPl=2,sck=18,mosi=23,miso=19,cs=22
- 蜂鸣器:14,光照:36,热敏电阻:39,
- I2C:SCL=15,SDA=21
- 端口1:33,端口2:32,端口3:26,端口4:25,端口5:UART,端口6:I2C
(5)Type-C&Micro二合一USB线
Type-C&Micro二合一USB线 ,采用扁平线不易打结,加上硅胶扎带方便收纳,同时两个接口都可以上传程序,一根线解决多种板子使用问题。
三、软件
1、开发环境:Thonny IDE
Thonny是一款专为Python初学者设计的集成开发环境(IDE),提供简洁直观的界面和易用特性,支持代码即时语法高亮显示、内置教程及逐步指导等功能,帮助用户专注于编程学习。
下载地址:https://github.com/thonny/thonny/releases/download/v4.1.7/thonny-4.1.7.exe
2、固件
(1)FireBeetle 2 ESP32-C5ESP32 MicroPython 固件
(2)游戏掌机MicroPython 固件
ESP32_GENERIC-SPIRAM-20250911-v1.26.1.bin
3、依赖库
(1)显示驱动库:st7735_buf.py(ST7735 LCD 屏幕底层驱动)
(2)显示工具库:easydisplay.py(简化 LCD 文本显示的封装库)
(3)系统内置库:network(网络配置)、espnow(ESP-NOW 通信)、machine(硬件引脚控制)、ubinascii(MAC 地址格式转换) 三、制作过程 1、硬件接线
(1)发送端接线(Gravity: JoyStick 摇杆、 FireBeetle 2 ESP32-C5)
(2)接收端(ESP32 、ST7735 LCD)
2、烧录固件
(1)Firebeetle 2 ESP32-C5
打开ESP系列芯片烧录工具,按下面步骤进行操作:
- 选择正确的芯片类型,以Firebeetle 2 ESP32-C5 ver ECO1,选择esp32c5。
- 选择“使用内置MicroPython固件”。
- 点击“开始烧录”按钮。
- “确认”对话框中,点击“是”按钮,进入烧录程序。
- 当出现“固件烧录完成”,即完成了固件烧录,如下图所示。
(2)ESP32编程掌机
打开ESP系列芯片烧录工具,按下面步骤进行操作:
- 选择正确的芯片类型,这里选择esp32。
- 选择“使用外置MicroPython固件”。
- 点击“浏览”按钮,选择固件文件,这里选择ESP32_GENERIC-SPIRAM-20250911-v1.26.1.bin。
- 输入正确的地址,这里使用0x1000。
- 点击“开始烧录”按钮。
- “确认”对话框中,点击“是”按钮,进入烧录程序。
- 当出现“固件烧录完成”,即完成了固件烧录。
3、依赖库上传
打开Thonny,选择“运行”->“配置解释器”,然后按下图所示进行设置。
选择菜单“视图”->“文件”,打开文件视图。在文件视图中找到要上传的库文件,右键弹出菜单选择“上传到/”,分别将ssd1306.pys上传到 FireBeetle 2 ESP32-C5,将t7735_buf.py和easydisplay.py上传到ESP32编程掌机。
4、编写程序
新建文件,复制 “发送端代码”,修改peer_mac为接收端的实际 MAC 地址,保存为main.py并上传到发送端 FireBeetle 2 ESP32-C5。新建文件,复制 “接收端代码”,保存为main.py并上传到ESP32编程掌机。
- import network
- import espnow
- import time
- import machine
- import ubinascii
- from machine import I2C, Pin
- from ssd1306 import SSD1306_I2C
-
- # ----------------------------
- # 常量定义 - 集中管理配置参数
- # ----------------------------
- # ADC引脚配置 (连接摇杆)
- ADC_X_PIN = 3
- ADC_Y_PIN = 2
-
- # 摇杆校准参数 (根据实际硬件调整)
- JOYSTICK_CALIB = {
- 'x_min': 1978,
- 'x_center': 3634,
- 'x_max': 4095,
- 'y_min': 1974,
- 'y_center': 3600,
- 'y_max': 4095,
- 'threshold': 50 # 死区阈值
- }
-
- # OLED屏幕配置
- OLED_WIDTH = 128
- OLED_HEIGHT = 64
- OLED_I2C_SCL = 10
- OLED_I2C_SDA = 9
-
- # ESP-NOW配置
- TARGET_MAC = b'\xac\x67\xb2\x44\xba\x8c' # 目标设备MAC地址
- LOOP_DELAY = 0.1 # 主循环延迟(秒)
-
- # 方向指令常量
- DIRECTIONS = {
- 'stop': 'Stop',
- 'forward': 'Forward',
- 'backward': 'Backward',
- 'left': 'Left',
- 'right': 'Right'
- }
-
- # ----------------------------
- # 硬件初始化函数
- # ----------------------------
- def init_adc():
- """初始化ADC引脚用于读取摇杆值"""
- adc_x = machine.ADC(machine.Pin(ADC_X_PIN))
- adc_y = machine.ADC(machine.Pin(ADC_Y_PIN))
- # 设置衰减以支持0-3.3V范围
- adc_x.atten(machine.ADC.ATTN_11DB)
- adc_y.atten(machine.ADC.ATTN_11DB)
- return adc_x, adc_y
-
- def init_oled():
- """初始化OLED屏幕"""
- i2c = I2C(0, scl=Pin(OLED_I2C_SCL), sda=Pin(OLED_I2C_SDA), freq=400000)
- oled = SSD1306_I2C(OLED_WIDTH, OLED_HEIGHT, i2c)
- oled.fill(0) # 清屏
- return oled
-
- def init_espnow():
- """初始化ESP-NOW通信"""
- # 初始化WiFi为STA模式
- sta = network.WLAN(network.STA_IF)
- sta.active(True)
-
- # 显示本机MAC地址
- mac_bytes = sta.config('mac')
- mac_str = ubinascii.hexlify(mac_bytes, ':').decode()
- print(f"本机MAC地址: {mac_str}")
- sta.disconnect() # 不需要连接到AP
-
- # 初始化ESP-NOW
- esp_now = espnow.ESPNow()
- esp_now.active(True)
-
- # 添加目标设备
- esp_now.add_peer(TARGET_MAC)
- target_mac_str = ubinascii.hexlify(TARGET_MAC, ':').decode()
- print(f"已添加目标设备MAC: {target_mac_str}")
-
- return esp_now
-
- # ----------------------------
- # 功能函数
- # ----------------------------
- def read_joystick_values(adc_x, adc_y):
- """读取摇杆原始ADC值"""
- return adc_x.read(), adc_y.read()
-
- def calculate_direction(x, y):
- """根据摇杆位置计算方向指令"""
- x_deviation = x - JOYSTICK_CALIB['x_center']
- y_deviation = y - JOYSTICK_CALIB['y_center']
-
- # 判断是否在死区内(停止状态)
- if abs(x_deviation) < JOYSTICK_CALIB['threshold'] and \
- abs(y_deviation) < JOYSTICK_CALIB['threshold']:
- return DIRECTIONS['stop']
-
- # 判断X/Y方向优先级
- if abs(x_deviation) > abs(y_deviation):
- return DIRECTIONS['right'] if x_deviation > 0 else DIRECTIONS['left']
- else:
- return DIRECTIONS['forward'] if y_deviation > 0 else DIRECTIONS['backward']
-
- def calculate_analog_values(x, y):
- """
- 计算摇杆的模拟量值(-127~+127)
- 返回: (x_analog, y_analog)
- """
- x_deviation = x - JOYSTICK_CALIB['x_center']
- y_deviation = y - JOYSTICK_CALIB['y_center']
-
- # 如果在死区内,返回0
- if abs(x_deviation) < JOYSTICK_CALIB['threshold'] and \
- abs(y_deviation) < JOYSTICK_CALIB['threshold']:
- return 0, 0
-
- # 计算X轴模拟量
- if x_deviation > 0:
- # 右方向:从中心到最大值映射到0~127
- x_range = JOYSTICK_CALIB['x_max'] - JOYSTICK_CALIB['x_center']
- x_analog = int((x_deviation / x_range) * 127)
- else:
- # 左方向:从最小值到中心映射到-127~0
- x_range = JOYSTICK_CALIB['x_center'] - JOYSTICK_CALIB['x_min']
- x_analog = int((x_deviation / x_range) * 127)
-
- # 计算Y轴模拟量
- if y_deviation > 0:
- # 前方向:从中心到最大值映射到0~127
- y_range = JOYSTICK_CALIB['y_max'] - JOYSTICK_CALIB['y_center']
- y_analog = int((y_deviation / y_range) * 127)
- else:
- # 后方向:从最小值到中心映射到-127~0
- y_range = JOYSTICK_CALIB['y_center'] - JOYSTICK_CALIB['y_min']
- y_analog = int((y_deviation / y_range) * 127)
-
- # 限制在-127~127范围内
- x_analog = max(-127, min(127, x_analog))
- y_analog = max(-127, min(127, y_analog))
-
- return x_analog, y_analog
-
- def send_analog_values(esp_now, x_analog, y_analog):
- """
- 发送模拟量值到目标设备
- 格式: "ANALOG:X:Y" 例如: "ANALOG:100:-50"
- """
- analog_msg = f"ANALOG:{x_analog}:{y_analog}"
- return esp_now.send(TARGET_MAC, analog_msg, True)
-
- def get_centered_position(text, font_width=8, font_height=8):
- """计算文字在OLED屏幕上的居中位置"""
- text_width = len(text) * font_width
- x = (OLED_WIDTH - text_width) // 2
- y = (OLED_HEIGHT - font_height) // 2 + 8 # 垂直居中偏上一点
- return x, y
-
- # ----------------------------
- # 主程序
- # ----------------------------
- def main():
- # 初始化硬件
- adc_x, adc_y = init_adc()
- #oled = init_oled()
- esp_now = init_espnow()
-
- # 显示目标设备信息
- target_mac_str = ubinascii.hexlify(TARGET_MAC, ':').decode()
- target_mac_str_no_colon = ubinascii.hexlify(TARGET_MAC).decode()
- oled.text(f"To:{target_mac_str_no_colon}", 0, 0)
- oled.show()
-
- last_direction = None
- last_x_analog = 0
- last_y_analog = 0
- analog_mode = False # 模拟量模式开关
-
- print("控制器启动,开始监控摇杆...")
-
- try:
- while True:
- # 读取并计算方向
- x, y = read_joystick_values(adc_x, adc_y)
- current_direction = calculate_direction(x, y)
- x_analog, y_analog = calculate_analog_values(x, y)
-
- # 模拟量模式
- if x_analog != last_x_analog or y_analog != last_y_analog:
- send_result = send_analog_values(esp_now, x_analog, y_analog)
- if send_result:
- print(f"发送模拟量: X={x_analog:4d}, Y={y_analog:4d}")
- else:
- print(f"发送失败: X={x_analog:4d}, Y={y_analog:4d}")
-
- # 更新OLED显示
- oled.fill_rect(0, 16, OLED_WIDTH, OLED_HEIGHT-16, 0)
- oled.text("Analog Mode", 0, 16)
- oled.text(f"X:{x_analog:4d}", 0, 32)
- oled.text(f"Y:{y_analog:4d}", 0, 48)
- oled.show()
-
- last_x_analog = x_analog
- last_y_analog = y_analog
-
-
- time.sleep(LOOP_DELAY)
-
- except KeyboardInterrupt:
- print("程序被用户终止")
- finally:
- # 清理资源
- oled.fill(0)
- oled.text("Stopped", 40, 32)
- oled.show()
- esp_now.send(TARGET_MAC, DIRECTIONS['stop'], True)
- print("已发送停止指令,程序退出")
-
- if __name__ == "__main__":
- main()
复制代码
(2)接收端代码(main.py)
- # 导入必要库:网络、ESP-NOW通信、硬件控制及LCD驱动
- import network
- import espnow
- import time
- from time import sleep_ms
- from machine import SPI, Pin
- from driver import st7735_buf # ST7735 LCD底层驱动
- from driver import drivers # 电机、光线传感器、温度传感器驱动
- from lib.easydisplay import EasyDisplay # 简化LCD显示操作
- import ubinascii
-
- # ----------------------------------
- # 1. 定义“计算居中坐标”的函数
- # ----------------------------------
- def get_center_pos(text, font_width=8, font_height=8):
- """
- 计算文字居中时的起始坐标 (x, y)
- text: 要显示的文字(如 "forward")
- font_width: 字体宽度(默认 8 像素)
- font_height: 字体高度(默认 8 像素)
- """
- # 计算文字总宽度(字符数 × 字体宽度)
- text_total_width = len(text) * font_width
- # X 轴:屏幕水平中心 - 文字总宽度的一半
- x = (160 - text_total_width) // 2
- # Y 轴:屏幕垂直中心 - 字体高度的一半
- y = (128 - font_height) // 2 + 8
- return x, y
-
- # 初始化
- spi = SPI(2, baudrate=20000000, polarity=0, phase=0, sck=Pin(18), mosi=Pin(23))
- dp = st7735_buf.ST7735(width=160, height=128, spi=spi, cs=5, dc=4, res=19, rotate=1, bl=None,invert=False, rgb=True)
- ed = EasyDisplay(display=dp, font="/font/text_lite_16px_2312.v3.bmf", show=True, color=0xFFFF, clear=True,color_type="RGB565")
- hd = drivers.HardwareDrivers() # 创建硬件驱动实例
-
- # 初始化ESP-NOW通信
- # 初始化sta
- sta = network.WLAN(network.STA_IF)
- sta.active(True)
- # 获取MAC地址(以字节形式)
- mac_bytes = sta.config('mac')
-
- # 将字节转换为人类可读的十六进制字符串
- mac_str = ubinascii.hexlify(mac_bytes, ':').decode()
-
- print(f"MAC地址: {mac_str}")
- sta.disconnect()
-
- ed.text(mac_str, 10, 10)
-
- e = espnow.ESPNow()
- e.active(True) # 启用ESP-NOW
-
- def receive_messages():
- """接收ESP-NOW消息,处理后在LCD和串口显示"""
- while True:
- try:
- host, msg = e.recv(0) # 非阻塞接收消息
-
- if msg:
- # 解码消息
- command = msg.decode('utf-8').strip() if isinstance(msg, bytes) else str(msg).strip()
- # 格式化发送端MAC
- sender_mac = ':'.join(['%02x' % b for b in host])
- print(f"来自 {sender_mac} 的指令: {command}")
-
- # LCD显示指令
- # ed.fill(0x0000) # 清屏(黑色)
- dir_x, dir_y = get_center_pos(command)
- ed.text(command, dir_x, dir_y)
-
- # 首先判断是方向指令还是模拟量指令
- if command.startswith("ANALOG:"):
- # 模拟量指令 - 解析X和Y值
- try:
- parts = command.split(":")
- x_analog = int(parts[1])
- y_analog = int(parts[2])
-
- # 死区处理 - 如果摇杆接近中心位置,则停止
- if abs(x_analog) < 10 and abs(y_analog) < 10:
- hd.motor_stop("ALL")
- print("车辆停止")
-
- else:
- # 基础速度计算(基于Y轴)
- base_speed = abs(y_analog) * 2 # 转换为0-255范围
-
- # 转向系数(基于X轴)
- turn_factor = x_analog / 127.0 # -1.0 到 +1.0
-
- if y_analog > 10: # 前进
- # 差速转向:一个轮子快,一个轮子慢
- left_speed = int(base_speed * (1 - turn_factor))
- right_speed = int(base_speed * (1 + turn_factor))
-
- # 限制速度在0-255范围内
- left_speed = max(0, min(255, left_speed))
- right_speed = max(0, min(255, right_speed))
-
- hd.motor_run(1, "CW", right_speed) # 右轮
- hd.motor_run(2, "CW", left_speed) # 左轮
- print(f"前进 - 左轮: {left_speed}, 右轮: {right_speed}")
-
- elif y_analog < -10: # 后退
- # 差速转向:一个轮子快,一个轮子慢
- left_speed = int(base_speed * (1 + turn_factor))
- right_speed = int(base_speed * (1 - turn_factor))
-
- # 限制速度在0-255范围内
- left_speed = max(0, min(255, left_speed))
- right_speed = max(0, min(255, right_speed))
-
- hd.motor_run(1, "CCW", right_speed) # 右轮
- hd.motor_run(2, "CCW", left_speed) # 左轮
- print(f"后退 - 左轮: {left_speed}, 右轮: {right_speed}")
-
- else: # 原地转向(只有X轴输入)
- turn_speed = abs(x_analog) * 2
- if x_analog > 10: # 原地右转
- hd.motor_run(1, "CCW", min(turn_speed, 255))
- hd.motor_run(2, "CW", min(turn_speed, 255))
- print(f"原地右转 - 速度: {turn_speed}")
- elif x_analog < -10: # 原地左转
- hd.motor_run(1, "CW", min(turn_speed, 255))
- hd.motor_run(2, "CCW", min(turn_speed, 255))
- print(f"原地左转 - 速度: {turn_speed}")
-
- except (ValueError, IndexError):
- print(f"模拟量指令解析错误: {command}")
-
- else:
- # 方向指令 - 原有的逻辑保持不变
- if command == "Forward":
- hd.motor_run(1, "CW", 255)
- hd.motor_run(2, "CW", 255)
- print("车辆前进")
- elif command == "Backward":
- hd.motor_run(1, "CCW", 255)
- hd.motor_run(2, "CCW", 255)
- print("车辆后退")
- elif command == "Left":
- hd.motor_run(1, "CW", 255)
- hd.motor_run(2, "CCW", 255)
- print("车辆左转")
- elif command == "Right":
- hd.motor_run(1, "CCW", 255)
- hd.motor_run(2, "CW", 255)
- print("车辆右转")
- elif command == "Stop":
- hd.motor_stop("ALL")
- print("车辆停止")
-
- sleep_ms(10)
- except Exception as ex:
- print(f"接收错误: {ex}")
- sleep_ms(100)
-
- def main():
- """主函数:初始化系统并启动消息接收"""
- try:
- # ed.fill(0x0000)
- receive_messages() # 启动接收循环
- except KeyboardInterrupt:
- print("程序被中断")
- except Exception as ex:
- print(f"错误: {ex}")
- finally:
- # 清理资源
- e.active(False)
- sta.active(False)
- ed.fill(0x0000)
- ed.text("已停止", 50, 60)
-
- if __name__ == "__main__":
- main()
-
复制代码
5、系统调试
(1)分别给发送端、接收端上电,打开 Thonny 的 “串口监视器”(波特率默认 115200)。
(2)发送端串口会打印自身 MAC 地址和 “准备发送指令” 提示;接收端会打印自身 MAC 地址和 “等待指令” 提示,LCD 屏幕显示 “ESPNow 接收端”“状态:运行中”。
(3)FireBeetle 2 ESP32-C5(发送端)拨动摇杆,发送ESPNOW指令,OLED屏幕显示“Forward/Backward/Left/Right”,串口打印 “发送指令:Forward/Backward/Left/Right/Stop”。
(4)ESP32掌机(接收端)串口打印 “来自 [发送端 MAC] 的指令:Forward/Backward/Left/Right/Stop”,且 LCD 屏幕实时显示“Forward/Backward/Left/Right/Stop”。
四、技术原理
1、ESP-NOW 通信
ESP-NOW 是一种由乐鑫公司定义的无连接 Wi-Fi 通信协议。在 ESP-NOW 中,应用程序数据被封装在各个供应商的动作帧中,然后在无连接的情况下,从一个 Wi-Fi 设备传输到另一个 Wi-Fi 设备。其特点是低延迟(毫秒级)、低功耗、无需 IP 地址,适合物联网设备间的短数据传输(如指令、传感器数据)。
(1)配对逻辑:发送端需先将接收端的 MAC 地址添加为 “peer(对等设备)”,才能向其发送数据;接收端无需主动添加,可被动接收已配对设备的消息。
(2)数据传输:本项目中发送端通过e.send(peer_mac, dir, True)发送指令(True表示等待接收端确认),接收端通过e.recv(0)非阻塞接收消息(0表示不等待,立即返回)。
2、ADC 摇杆信号采集
ESP32 的 ADC 模块(模拟 - 数字转换器)可将摇杆的模拟电压信号(0~3.3V)转换为 0~4095 的数字值(12 位精度)。
(1)信号校准:通过X_CENTER, Y_CENTER = 3634, 3600设置摇杆 “中立位置” 的基准值(需根据实际摇杆校准,避免漂移)。
(2)阈值过滤:通过THRESHOLD = 50设置 “死区阈值”,当摇杆偏移量小于 50 时,判定为 “stop”,避免轻微晃动导致误触发。
(3)方向判断:比较 X 轴(xd)和 Y 轴(yd)的偏移量绝对值,绝对值大的轴为 “有效方向”,再根据正负判断具体方向(如 xd>0 则为 “right”)。
3、LCD 屏幕显示
通过EasyDisplay封装库简化 ST7735 屏幕控制。
(1)初始化:先通过st7735_buf.ST7735配置 SPI 引脚、屏幕分辨率、旋转方向等底层参数,再通过EasyDisplay设置字体、默认颜色、清屏模式。
(2)文本显示:调用ed.text(内容, x坐标, y坐标)在指定位置显示文本,ed.fill(0x0000)实现清屏(0x0000 为 RGB565 格式的黑色)。
附件:程序、固件、工具
|