【行空板M10 + Xbox手柄】旋转发射平台——激光切割亚克力版
一、项目简介
这是一个用行空板M10 + Xbox蓝牙手柄控制的旋转发射平台。手柄左摇杆控制平台水平旋转,A键击发。发射器采用激光切割亚克力制作,通过半齿轮 + 齿条 + 皮筋的机械结构实现快速击发,电磁继电器控制电机启停。扩展板板载 WS2812 提供彩灯效果。

说明:原设计为两轴平台(水平 + 俯仰),后因俯仰舵机受重力负载持续抖动,且发射器重量对舵机扭矩要求过高,最终去掉了俯仰轴,只保留水平旋转。这一取舍过程也写进了"踩坑记录",供参考。
核心功能:
- 左摇杆左右 → 平台水平旋转(舵机 P2)
- A键 → 击发(继电器控制电机)
- X键 → 开启扩展板板载 WS2812 彩虹灯效
- B键 → 关闭灯效
- Y键 → 紧急停止(舵机回中 + 继电器断开)
二、效果演示
工作流程:
- 手柄左摇杆 → 水平舵机旋转到目标方位
- 按 A 键 → 继电器吸合,电机带动半齿轮旋转
- 半齿轮转到"无齿段"时,齿条在皮筋拉力下瞬间回弹
- 撞杆击打弹仓落下的"子弹",完成击发
三、硬件清单
| 部件 | 数量 | 说明 |
|---|---|---|
| 行空板 M10 | 1 | 主控 |
| 行空板扩展板 | 1 | 引出 P2 / P11 / P13 等引脚 |
| Xbox Series X 手柄 | 1 | 蓝牙连接 |
| 水平舵机 | 1 | 接 P2,驱动平台旋转 |
| 电磁继电器模块 | 1 | 接 P11,控制击发电机 |
| WS2812 灯板 | 1 | 扩展板板载,3 颗,接 P13 |
| 直流电机 | 1 | 驱动半齿轮 |
| 激光切割亚克力板 | 若干 | 3mm/5mm,发射器结构件 |
| 皮筋 | 2~3 根 | 提供回弹力 |
| 半齿轮 + 齿条 | 1 套 | 击发核心机构 |
| 独立 5V/3A 电源 | 1 | 舵机供电 |
| 杜邦线、螺丝、铜柱 | 若干 | 装配 |

四、机械结构设计
4.1 发射器结构
发射器全部用激光切割亚克力拼接,主要包含:
┌─────────────────────────────────┐
│ 发射器侧视图 │
│ │
│ [弹仓] │
│ │ │
│ ▼ 子弹落入 │
│ ┌─────┐ │
│ │撞杆 │◄── 皮筋拉伸 │
│ └──┬──┘ │
│ │ │
│ ┌──▼───┐ ┌────────┐ │
│ │ 齿条 │◄──►│ 半齿轮 │◄─ 电机 │
│ └──────┘ └────────┘ │
│ │
└─────────────────────────────────┘
工作原理:
- 上膛阶段:电机转动,半齿轮的"有齿段"咬合齿条,把齿条向后拉,同时拉伸皮筋储能
- 击发阶段:半齿轮继续转到"无齿段",齿条失去咬合约束,在皮筋拉力下瞬间向前回弹
- 撞击阶段:齿条带动撞杆高速前冲,击打从弹仓孔落入的"子弹"
这种"半齿轮 + 齿条 + 皮筋"的方案是玩具枪里非常成熟的机构,优点是结构简单、击发力度大、响应快。

4.2 旋转平台结构
- 舵机固定在底座上,舵盘连接上层平台
- 发射器固定在平台上,随舵机水平旋转
- 舵机轴线平行于重力方向,负载只有平台的旋转惯性,没有持续阻力矩
4.3 亚克力设计要点
- 齿轮与齿条的中心距要精确,否则会卡齿
- 弹仓孔径略大于子弹直径 0.5~1mm,保证顺利落下但不会漏出
- 撞杆行程要留足,皮筋预紧力适中:太松打不远,太紧拉不动

五、电路连接
行空板 M10
│
├── P2 ──→ 水平舵机(信号线)
├── P11 ──→ 继电器 IN
├── P13 ──→ WS2812 灯板 DIN
│
├── 5V ──→ 继电器 VCC(若为5V继电器)
├── GND ──→ 继电器 GND、灯板 GND、舵机 GND
│
└── USB ──→ Xbox 手柄蓝牙(板载蓝牙)
外部 5V/3A 电源
│
├── 舵机 VCC
└── GND ──→ 与行空板 GND 共地 ← 关键!
关键提示:
- 舵机一定要独立供电!行空板 5V 输出带舵机容易电压跌落,导致抖动或复位
- 电源和行空板必须共地,否则 PWM 信号会漂移
- 继电器如果是 5V 的,可以从行空板 5V 取电(电流小,约 70mA)


六、软件设计
6.1 开发环境
- 行空板系统:V0.4.5 及以上
- Python 3.7
- 依赖库:
pygame(手柄)、pinpong(硬件控制) - 两个库行空板都预装了,无需额外安装
6.2 核心代码
# -*- coding: utf-8 -*-
"""
行空板M10 + Xbox蓝牙手柄 + 单舵机 + 电磁继电器 + WS2812彩灯
引脚:舵机=P2 继电器=P11 彩灯=P13
"""
import sys
import time
import colorsys
import pygame
from pinpong.board import Board, Pin, Servo, NeoPixel
# ==================== 配置 ====================
SERVO_H_PIN = Pin.P2
RELAY_PIN = Pin.P11
LED_PIN = Pin.P13
LED_COUNT = 3
H_MIN, H_MAX = 0, 180
H_CENTER = 90
AXIS_H = 0 # 左摇杆水平
DEADZONE = 0.05
LOOP_INTERVAL = 0.05
FIRE_DURATION = 0.5
LED_UPDATE_INTERVAL = 0.05
LED_HUE_STEP = 5
# ==================== 按钮索引(蓝牙Xbox Series X实测) ====================
BTN_A = 0 # 击发
BTN_B = 1 # 关闭彩灯
BTN_X = 3 # 开启彩灯
BTN_Y = 4 # 紧急停止
def apply_deadzone(value, deadzone=DEADZONE):
return 0.0 if abs(value) < deadzone else value
def map_joystick_to_angle(value, out_min, out_max):
normalized = (value + 1.0) / 2.0
return out_min + normalized * (out_max - out_min)
def hsv_to_rgb255(h, s, v):
r, g, b = colorsys.hsv_to_rgb(h / 360.0, s, v)
return int(r * 255), int(g * 255), int(b * 255)
def init_hardware():
print("[硬件] 初始化行空板...")
Board("UNIHIKER").begin()
servo_h = Servo(Pin(SERVO_H_PIN))
relay = Pin(RELAY_PIN, Pin.OUT)
relay.write_digital(0)
np = NeoPixel(Pin(LED_PIN), LED_COUNT)
np.range_color(0, LED_COUNT - 1, 0x000000)
servo_h.angle(H_CENTER)
print("[硬件] 完成:舵机=P2 继电器=P11 彩灯=P13")
return servo_h, relay, np
def init_joystick():
print("[手柄] 初始化 pygame...")
pygame.init()
pygame.joystick.init()
for i in range(15):
pygame.event.pump()
if pygame.joystick.get_count() > 0:
js = pygame.joystick.Joystick(0)
js.init()
print(f"[手柄] 已连接: {js.get_name()}")
return js
print(f"[手柄] 等待连接... ({i+1}/15)")
time.sleep(1)
raise RuntimeError("未检测到手柄")
def main():
servo_h, relay, np = init_hardware()
try:
joystick = init_joystick()
except RuntimeError as e:
print(f"[错误] {e}")
sys.exit(1)
firing = False
fire_start_time = 0
prev_a = prev_b = prev_x = prev_y = False
last_angle_h = H_CENTER
last_led_update = 0
led_on = False
led_hue = 0
print(" 控制就绪:左摇杆→水平 A→击发 X→开灯 B→关灯 Y→急停")
try:
while True:
pygame.event.pump()
now = time.time()
# 摇杆 → 舵机(axis_h 前加负号可反向)
axis_h = apply_deadzone(joystick.get_axis(AXIS_H))
angle_h = int(round(map_joystick_to_angle(-axis_h, H_MIN, H_MAX)))
angle_h = max(H_MIN, min(H_MAX, angle_h))
if angle_h != last_angle_h:
servo_h.angle(angle_h)
last_angle_h = angle_h
# 按键
a = joystick.get_button(BTN_A)
b = joystick.get_button(BTN_B)
x = joystick.get_button(BTN_X)
y = joystick.get_button(BTN_Y)
if a and not prev_a:
relay.write_digital(1)
firing = True
fire_start_time = now
print(">>> 击发!")
prev_a = a
if x and not prev_x:
led_on = True
print(">>> 彩灯开")
prev_x = x
if b and not prev_b:
led_on = False
np.range_color(0, LED_COUNT - 1, 0x000000)
print(">>> 彩灯关")
prev_b = b
if y and not prev_y:
servo_h.angle(H_CENTER)
last_angle_h = H_CENTER
relay.write_digital(0)
firing = False
print(">>> 紧急停止")
prev_y = y
if firing and (now - fire_start_time) >= FIRE_DURATION:
relay.write_digital(0)
firing = False
# 彩灯彩虹效果
if led_on and (now - last_led_update) >= LED_UPDATE_INTERVAL:
last_led_update = now
for i in range(LED_COUNT):
hue = (led_hue + i * 120) % 360
r, g, b_ = hsv_to_rgb255(hue, 1.0, 1.0)
np.range_color(i, i, (r << 16) | (g << 8) | b_)
led_hue = (led_hue + LED_HUE_STEP) % 360
time.sleep(LOOP_INTERVAL)
except KeyboardInterrupt:
print("\n[退出]")
finally:
try:
relay.write_digital(0)
servo_h.angle(H_CENTER)
np.range_color(0, LED_COUNT - 1, 0x000000)
except Exception:
pass
pygame.quit()
if __name__ == "__main__":
main()
6.3 蓝牙手柄配对配置
Xbox 手柄与行空板通过蓝牙连接。行空板首次使用前需完成一次配对,之后手柄开机后会自动回连,无需重复操作。
6.3.1 手柄进入配对模式
按住 Xbox 手柄顶部的配对按钮(手柄正面 logo 旁的小圆形按钮),直到 logo 指示灯快速闪烁,表示已进入配对模式。
6.3.2 行空板终端执行配对
通过 SSH 或行空板桌面上的终端应用,依次执行以下命令:
# 进入蓝牙控制工具
bluetoothctl
# 打开蓝牙适配器
power on
# 开始扫描设备
scan on
等待终端出现手柄的 MAC 地址,例如:
[NEW] Device 40:8E:2C:22:84:31 Xbox Wireless Controller
记下这个 MAC 地址,然后:
# 停止扫描
scan off
# 配对(替换为实际 MAC)
pair 40:8E:2C:22:84:31
# 信任设备 —— 关键,否则重启后要重新配对
trust 40:8E:2C:22:84:31
# 连接
connect 40:8E:2C:22:84:31
# 退出
exit
6.3.3 验证连接
# 应输出 Connected: yes 和 Trusted: yes
bluetoothctl info 40:8E:2C:22:84:31 | grep -E "Connected|Trusted|Paired"
# 应出现 /dev/input/js0 设备节点
ls /dev/input/js*
还可以用 jstest 工具实时查看手柄输入:
jstest /dev/input/js0
拨动摇杆、按 A/B/X/Y,如果终端数值正常响应,说明配对完全成功。
七、踩坑记录
这部分是项目最耗时的部分,记录下所有踩过的坑,供大家参考。
坑1:Xbox 手柄蓝牙连接无限"连接-断开"循环
现象:
[CHG] Device 40:8E:2C:22:84:31 Connected: yes
[CHG] Device 40:8E:2C:22:84:31 Connected: no
(重复无限循环)
原因:行空板原固件的蓝牙协议栈(BlueZ)与 Xbox 手柄存在 L2CAP 配置兼容问题,ERTM 模式下握手失败。
尝试过的方案:
- 禁用 ERTM:
echo 'options bluetooth disable_ertm=1' | sudo tee /etc/modprobe.d/xbox_bt.conf - 修改
main.conf的 LE 参数 - 清理配对缓存重新配对
最终解决:重新烧录行空板固件到 V0.4.5,问题彻底消失。这是最省事的方案。
坑2:Xbox 手柄按钮索引与标准映射不一致
现象:程序里 get_button(2) 应该是 X 键,但按 X 却触发了 Y 键的功能。
原因:Xbox Series X 手柄通过蓝牙连接时,按钮索引与 USB 连接不同。
解决方案:写一个检测脚本,把 A/B/X/Y 依次按一遍,看打印的索引:
import pygame, time
pygame.init(); pygame.joystick.init()
js = pygame.joystick.Joystick(0); js.init()
prev = [False] * js.get_numbuttons()
while True:
pygame.event.pump()
for i in range(js.get_numbuttons()):
p = js.get_button(i)
if p and not prev[i]:
print(f"按钮 [{i}] 被按下")
prev[i] = p
time.sleep(0.02)
我的实测结果:
A=0, B=1, X=3, Y=4
注意索引 2 被跳过(可能是 View 键的位置)。
坑3:P7 不能用作舵机引脚
现象:Servo(Pin(Pin.P7)) 报错
ValueError: 7 is not in list
原因:行空板 M10 支持的舵机引脚是 P0、P2、P3、P8、P9、P10、P16、P21、P22、P23,P7 不在其中。
解决:改用 P2。做项目前一定先查引脚表,避免走弯路。
坑4:pinpong 的 NeoPixel.write() 不能无参调用
现象:
np[i] = (r, g, b)
np.write() # TypeError: write() missing 2 required positional arguments
原因:pinpong 库中 NeoPixel 的 write() 方法与常见的 Adafruit 库不同,需要指定索引和值。
解决:改用 range_color() 方法:
# 清除所有灯
np.range_color(0, LED_COUNT - 1, 0x000000)
# 设置单颗灯
np.range_color(i, i, (r << 16) | (g << 8) | b)
坑5:舵机到达目标后左右抖动
现象:摇杆停稳后,舵机到达目标角度,但会轻微左右抖动,然后停止。
排查过程:
第一轮尝试(加了算法反而更糟):
- 摇杆死区 0.20
- 滑动平均滤波窗口 5
- 角度变化阈值 1.5°
- 平滑逼近步长 4°
结果:舵机跟随变钝,抖动没有根本改善。
第二轮(回归最简):
- 去掉所有滤波和平滑
- 只保留
if angle != last_angle才下发指令 - 死区从 0.20 降到 0.05
结果:响应快、几乎无抖动。
根因分析:
- 原算法里的"平滑逼近"会让舵机持续接收小幅变化指令,每次都可能触发舵机内部死区修正,产生抖动
- 最简版只在角度整数变化时才发指令,舵机到位后完全静默,内部电机不再被反复触发
结论:过度滤波反而可能引入抖动。先用最简版本验证硬件,再按需加算法。
坑6:为什么最终去掉了俯仰轴
初始设计:两轴平台(水平旋转 + 上下俯仰),两个舵机。
问题:水平舵机工作正常,俯仰舵机持续抖动。
根因:
- 水平舵机旋转轴平行重力,平台旋转时重力不产生阻力矩
- 俯仰舵机旋转轴垂直重力,发射器重力形成持续的阻力矩
- 舵机为了保持位置,内部电机必须持续通电抵抗重力 → 反复微调 → 抖动
尝试过的缓解方案:
- 软件层加 2° 阈值,减少无效指令:抖动减轻但不根治
- 加大死区、滤波:同上
- 拆下发射器空载测试:不抖 → 确认为重力负载问题
最终取舍:
- 配重方案需要重新设计发射器重心,工作量大
- 换大扭矩舵机(MG996R、DS3218)成本较高,且需重新适配安装孔位
- 本项目对俯仰精度的需求不高,去掉俯仰轴后,整机结构更简洁、可靠性更高
经验总结:舵机控制旋转轴平行重力时很稳定,垂直重力时需谨慎评估负载。如果非要保留俯仰轴,建议:
- 发射器配重,让重心靠近旋转轴
- 换大扭矩金属齿轮舵机
- 独立供电 + 滤波电容
八、装配与调试要点
8.1 上电顺序
- 先接好所有线,确认 GND 共地
- 外部 5V 电源先开
- 再开行空板
- 最后开 Xbox 手柄(会自动回连)
8.2 舵机中立位校准
首次运行前,手动把平台转到物理中位,再运行程序让 H_CENTER = 90 与之匹配。如果不一致,调整 H_CENTER 的值。
8.3 击发时长的调整
FIRE_DURATION = 0.5 # 单位:秒
- 太短:半齿轮没转到位,无法完成一次完整上膛击发
- 太长:电机多转,齿轮可能重复咬合,造成卡顿
建议实测:把发射器空转,观察半齿轮转一圈需要多久,然后把 FIRE_DURATION 设成略大于一圈的时间。
8.4 皮筋张力的调整
- 皮筋太松:子弹打不远
- 皮筋太紧:电机负载大,回弹噪音大
建议左右各用 1 根普通橡皮筋(直径约 1.5mm),张力适中。
8.5 舵机旋转方向
如果摇杆左推舵机往右转,把映射改成:
angle_h = int(round(map_joystick_to_angle(-axis_h, H_MIN, H_MAX)))
加一个负号即可反向。
九、改进方向
这个项目还有不少可以优化的地方,欢迎大家一起讨论:
- 恢复俯仰轴:加配重或换大扭矩舵机,实现真正的两轴控制
- 加入扳机模拟:使用 RT 扳机(轴 5)替代 A 键,更接近真实射击手感
- 弹仓扩容:改为弹链供弹或重力式弹匣
- 命中反馈:加一个红外或超声波传感器,射击后自动检测命中
十、写在最后
这个项目从"想做一个能用手柄遥控的发射平台"的简单想法开始,踩了蓝牙、按钮、引脚、舵机抖动一圈坑,最后回归到"最简单的控制逻辑往往最稳定"这个朴素的结论。去掉俯仰轴也是这个思路的延续——与其和重力负载死磕,不如做减法,让项目真正能跑起来。
特别感谢 DF 创客社区和行空板官方 Wiki 提供的资料。结构图纸都在附件里,欢迎复刻和改进。




