【行空板M10 + Xbox手柄】旋转发射平台——激光切割亚克力版

2026-10-155

一、项目简介

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

说明:原设计为两轴平台(水平 + 俯仰),后因俯仰舵机受重力负载持续抖动,且发射器重量对舵机扭矩要求过高,最终去掉了俯仰轴,只保留水平旋转。这一取舍过程也写进了"踩坑记录",供参考。

核心功能:

  • 左摇杆左右 → 平台水平旋转(舵机 P2)
  • A键 → 击发(继电器控制电机)
  • X键 → 开启扩展板板载 WS2812 彩虹灯效
  • B键 → 关闭灯效
  • Y键 → 紧急停止(舵机回中 + 继电器断开)

二、效果演示

演示视频

工作流程:

  1. 手柄左摇杆 → 水平舵机旋转到目标方位
  2. 按 A 键 → 继电器吸合,电机带动半齿轮旋转
  3. 半齿轮转到"无齿段"时,齿条在皮筋拉力下瞬间回弹
  4. 撞杆击打弹仓落下的"子弹",完成击发

三、硬件清单

部件 数量 说明
行空板 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 舵机供电
杜邦线、螺丝、铜柱 若干 装配

【行空板M10 + Xbox手柄】旋转发射平台——激光切割亚克力版_image_2.webp

四、机械结构设计

4.1 发射器结构

发射器全部用激光切割亚克力拼接,主要包含:

┌─────────────────────────────────┐
│         发射器侧视图             │
│                                 │
│   [弹仓]                        │
│     │                           │
│     ▼ 子弹落入                  │
│   ┌─────┐                       │
│   │撞杆 │◄── 皮筋拉伸            │
│   └──┬──┘                       │
│      │                          │
│   ┌──▼───┐    ┌────────┐        │
│   │ 齿条 │◄──►│ 半齿轮 │◄─ 电机  │
│   └──────┘    └────────┘        │
│                                 │
└─────────────────────────────────┘

工作原理:

  1. 上膛阶段:电机转动,半齿轮的"有齿段"咬合齿条,把齿条向后拉,同时拉伸皮筋储能
  2. 击发阶段:半齿轮继续转到"无齿段",齿条失去咬合约束,在皮筋拉力下瞬间向前回弹
  3. 撞击阶段:齿条带动撞杆高速前冲,击打从弹仓孔落入的"子弹"

这种"半齿轮 + 齿条 + 皮筋"的方案是玩具枪里非常成熟的机构,优点是结构简单、击发力度大、响应快。
【行空板M10 + Xbox手柄】旋转发射平台——激光切割亚克力版_image_3.webp

4.2 旋转平台结构

  • 舵机固定在底座上,舵盘连接上层平台
  • 发射器固定在平台上,随舵机水平旋转
  • 舵机轴线平行于重力方向,负载只有平台的旋转惯性,没有持续阻力矩

4.3 亚克力设计要点

  • 齿轮与齿条的中心距要精确,否则会卡齿
  • 弹仓孔径略大于子弹直径 0.5~1mm,保证顺利落下但不会漏出
  • 撞杆行程要留足,皮筋预紧力适中:太松打不远,太紧拉不动

【行空板M10 + Xbox手柄】旋转发射平台——激光切割亚克力版_image_4.webp

五、电路连接

行空板 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)

【行空板M10 + Xbox手柄】旋转发射平台——激光切割亚克力版_image_5.webp
【行空板M10 + Xbox手柄】旋转发射平台——激光切割亚克力版_image_6.webp

六、软件设计

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)成本较高,且需重新适配安装孔位
  • 本项目对俯仰精度的需求不高,去掉俯仰轴后,整机结构更简洁、可靠性更高

经验总结:舵机控制旋转轴平行重力时很稳定,垂直重力时需谨慎评估负载。如果非要保留俯仰轴,建议:

  1. 发射器配重,让重心靠近旋转轴
  2. 换大扭矩金属齿轮舵机
  3. 独立供电 + 滤波电容

八、装配与调试要点

8.1 上电顺序

  1. 先接好所有线,确认 GND 共地
  2. 外部 5V 电源先开
  3. 再开行空板
  4. 最后开 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)))

加一个负号即可反向。


九、改进方向

这个项目还有不少可以优化的地方,欢迎大家一起讨论:

  1. 恢复俯仰轴:加配重或换大扭矩舵机,实现真正的两轴控制
  2. 加入扳机模拟:使用 RT 扳机(轴 5)替代 A 键,更接近真实射击手感
  3. 弹仓扩容:改为弹链供弹或重力式弹匣
  4. 命中反馈:加一个红外或超声波传感器,射击后自动检测命中

十、写在最后

这个项目从"想做一个能用手柄遥控的发射平台"的简单想法开始,踩了蓝牙、按钮、引脚、舵机抖动一圈坑,最后回归到"最简单的控制逻辑往往最稳定"这个朴素的结论。去掉俯仰轴也是这个思路的延续——与其和重力负载死磕,不如做减法,让项目真正能跑起来。

特别感谢 DF 创客社区和行空板官方 Wiki 提供的资料。结构图纸都在附件里,欢迎复刻和改进。

创作许可协议

本项目采用 CC BY(署名) 进行许可。

评论(0)
- 没有更多了 -