这些小活动你都参加了吗?快来围观一下吧!>>
电子产品世界 » 论坛首页 » DIY与开源设计 » 电子DIY » 【let'sdo第1期】静音步进电机控制实践-成果贴基于nRF54L15与微信小

共1条 1/1 1 跳转至

【let'sdo第1期】静音步进电机控制实践-成果贴基于nRF54L15与微信小程序的静音步进滑台无线控制系统

菜鸟
2026-07-03 11:04:59     打赏

【let's do第1期】静音步进电机控制实践-成果贴 基于 nRF54L15 与微信小程序的静音步进滑台无线控制系统

由于之前打样的nrf54l15模块今天才到,晚上才有时间焊接,就先借用nrf54l15dk先实现一下功能吧。

本次成果贴实现了一套低功耗蓝牙(BLE)控制的静音步进滑台系统,通过微信小程序发送指令,完成速度调节、绝对/相对位置移动、急停、使能控制等功能。硬件采用 nRF54L15-DK 开发板,驱动 42 步进电机(带 TMC 静音驱动),软件分为 BLE 通信协议层、电机控制层和小程序 UI 层。

一,系统架构

image.png

image.png

二,通信协议定义

完整协议包定义(总长 8 字节)

所有指令遵循统一帧格式:

字节偏移名称类型说明





0帧头uint8_t固定 0xAA,用于数据包同步
1命令码uint8_t见下方命令码表
2数据长度uint8_t固定为 0x04(表示后续有4字节数据)
3 ~ 6参数数据int32_t小端模式(低位在前),含义取决于命令码
7校验和uint8_t异或校验(XOR),从字节0异或到字节6

命令码与参数详解

命令码名称参数说明





0x01绝对定位目标步数移动到绝对位置
0x02相对移动偏移步数相对当前位置移动
0x03急停忽略立即停止脉冲输出(保持使能)
0x04设置零点忽略复位当前位置为0
0x05设置速度速度值(步/秒)动态调整运动速度
0x06设置使能0=失能,1=使能控制电机使能/失能

三,微信小程序实现

界面如下

image.png


app.json

{
  "pages": [
    "pages/index/index"
  ],
  "window": {
    "navigationBarTitleText": "步进滑台控制器",
    "navigationBarBackgroundColor": "#2C3E8F",
    "navigationBarTextStyle": "white",
    "backgroundColor": "#F0F4FA"
  },
  "permission": {
    "scope.bluetooth": {
      "desc": "用于连接您的步进滑台控制器"
    }
  },
  "sitemapLocation": "sitemap.json"
}

index.wxml

<view>
  <!-- 状态栏(同上) -->
  <view>
    <view class="status-icon {{isConnected ? 'connected' : 'disconnected'}}"></view>
    <text>{{statusText}}</text>
    <button bindtap="startScan" disabled="{{isConnected || scanning}}" size="mini" type="primary">
      {{scanning ? '扫描中…' : '扫描设备'}}
    </button>
  </view>

  <!-- 设备列表(同上) -->
  <view wx:if="{{devices.length > 0 && !isConnected}}">
    <view>请选择设备:</view>
    <scroll-view scroll-y>
      <view wx:for="{{devices}}" wx:key="deviceId" bindtap="connectDevice" data-deviceid="{{item.deviceId}}">
        <text>{{item.name || '未知设备'}}</text>
        <text>信号 {{item.RSSI}} dBm</text>
      </view>
    </scroll-view>
  </view>

  <!-- 主控制面板(连接后显示) -->
  <view wx:if="{{isConnected}}">
    <!-- 当前位置卡片(同上) -->
    <view>
      <view>当前位置</view>
      <view>
        <text>{{currentPositionMM}}</text>
        <text>mm</text>
      </view>
      <view>
        <progress percent="{{progressPercent}}" stroke-width="8" activeColor="#2C8EF0" backgroundColor="#E5E9F0" border-radius="4" />
        <text>{{progressPercent}}%</text>
      </view>
      <view>
        <text>步数:{{currentSteps}}</text>
        <text>总行程:{{totalTravelMM}} mm</text>
      </view>
    </view>

    <!-- 目标设定卡片(同上) -->
    <view>
      <view>目标位置</view>
      <view>
        <input type="digit" placeholder="0.0" bindinput="onTargetInput" value="{{targetInput}}" />
        <text>mm</text>
        <button type="primary" bindtap="moveToTarget" disabled="{{isMoving}}">
          {{isMoving ? '运行中…' : '移动'}}
        </button>
      </view>
      <view>
        <button size="mini" bindtap="quickMove" data-delta="-10">-10</button>
        <button size="mini" bindtap="quickMove" data-delta="-1">-1</button>
        <button size="mini" bindtap="quickMove" data-delta="1">+1</button>
        <button size="mini" bindtap="quickMove" data-delta="10">+10</button>
        <button size="mini" type="warn" bindtap="sendHome">归零</button>
      </view>
    </view>

    <!-- 速度控制卡片(新增滑块) -->
    <view>
      <view>⚡ 速度控制</view>
      <view>
        <text>速度:</text>
        <slider min="1" max="125" step="0.5" value="{{speedMM}}" bindchange="onSpeedSlider" />
        <text>{{speedMM}} mm/s</text>
        <button type="primary" size="mini" bindtap="applySpeed">应用</button>
      </view>
    </view>

    <!-- 使能控制(新增开关) -->
    <view style="padding: 12px 20px;">
      <view>
        <text>电机使能</text>
        <switch checked="{{isEnabled}}" bindchange="onEnableSwitch" color="#2C8EF0" />
        <text>{{isEnabled ? '已使能' : '已失能'}}</text>
      </view>
    </view>

    <!-- 急停按钮 -->
    <button bindtap="emergencyStop">
      <text>⏹ 急停</text>
    </button>

    <!-- 日志(同上) -->
    <view>
      <text>运行日志</text>
      <scroll-view scroll-y scroll-top="{{logScrollTop}}">
        <view wx:for="{{logs}}" wx:key="index">{{item}}</view>
      </scroll-view>
    </view>
  </view>
</view>


index.js

// ========== 协议定义 ==========
const SERVICE_UUID = '6e400001-b5a3-f393-e0a9-e50e24dcca9e';
const RX_CHAR_UUID = '6e400002-b5a3-f393-e0a9-e50e24dcca9e';
const TX_CHAR_UUID = '6e400003-b5a3-f393-e0a9-e50e24dcca9e';

// 命令码
const CMD_ABS_MOVE   = 0x01;
const CMD_REL_MOVE   = 0x02;
const CMD_STOP       = 0x03;
const CMD_SET_HOME   = 0x04;
const CMD_SET_SPEED  = 0x05;
const CMD_SET_ENABLE = 0x06;

// 物理参数(请根据您的实际硬件调整)
const TOTAL_TRAVEL_MM = 350;          // 总行程 35cm
const STEPS_PER_MM = 40;              // 步数/毫米(1/8细分,20齿GT2)
const TOTAL_STEPS = TOTAL_TRAVEL_MM * STEPS_PER_MM;
const SPEED_STEPS_PER_MS = 2;         // 模拟进度动画用,可忽略

Page({
  data: {
    // 蓝牙状态
    statusText: '未连接',
    isConnected: false,
    scanning: false,
    devices: [],
    deviceId: null,
    serviceId: null,

    // 位置控制
    currentSteps: 0,
    targetSteps: 0,
    currentPositionMM: 0,
    progressPercent: 0,
    targetInput: '',
    isMoving: false,

    // 速度控制
    speedMM: 20,                 // 当前速度(mm/s)
    pendingSpeedMM: 20,

    // 使能控制
    isEnabled: true,

    // 日志
    logs: [],
    logScrollTop: 0,

    // 运动模拟定时器
    moveTimer: null,
  },

  // ========== 生命周期 ==========
  onLoad() {
    this.initBluetooth();
    this.addLog('小程序已启动');
  },

  onUnload() {
    this.stopMoveTimer();
    if (this.data.isConnected) {
      wx.closeBLEConnection({ deviceId: this.data.deviceId });
    }
    wx.stopBluetoothDevicesDiscovery({});
    wx.closeBluetoothAdapter({});
    wx.offBluetoothDeviceFound();
    wx.offBLECharacteristicValueChange();
    wx.offBluetoothAdapterStateChange();
  },

  // ========== 蓝牙初始化 ==========
  initBluetooth() {
    wx.openBluetoothAdapter({
      success: () => {
        console.log('蓝牙初始化成功');
        this.setData({ statusText: '蓝牙已就绪' });
        wx.onBluetoothAdapterStateChange((res) => {
          if (!res.available) {
            this.setData({
              statusText: '蓝牙已关闭',
              isConnected: false,
              devices: []
            });
            wx.showToast({ title: '请开启蓝牙', icon: 'none' });
          }
        });
      },
      fail: (err) => {
        console.error('蓝牙初始化失败', err);
        this.setData({ statusText: '蓝牙初始化失败,请检查权限' });
        wx.showModal({
          title: '提示',
          content: '请确认手机蓝牙已开启,且已授予微信蓝牙和位置权限',
          showCancel: false
        });
      }
    });
  },

  // ========== 扫描设备 ==========
  startScan() {
    if (this.data.scanning) return;
    if (this.data.isConnected) {
      wx.showToast({ title: '请先断开连接', icon: 'none' });
      return;
    }

    // 清理残留连接
    if (this.data.deviceId) {
      wx.closeBLEConnection({
        deviceId: this.data.deviceId,
        complete: () => {
          this.resetBluetoothState();
          this.doStartScan();
        }
      });
    } else {
      this.doStartScan();
    }
  },

  doStartScan() {
    this.setData({
      devices: [],
      scanning: true,
      statusText: '扫描中...'
    });

    wx.onBluetoothDeviceFound((res) => {
      const device = res.devices[0];
      if (!device || !device.deviceId) return;
      const exist = this.data.devices.find(d => d.deviceId === device.deviceId);
      if (!exist && device.name) {
        const newDevices = [...this.data.devices, device];
        this.setData({ devices: newDevices });
        console.log('发现设备:', device.name);
      }
    });

    wx.startBluetoothDevicesDiscovery({
      allowDuplicatesKey: false,
      success: () => {
        console.log('扫描已启动');
        setTimeout(() => {
          if (this.data.scanning) {
            this.stopScan('扫描结束,请选择设备');
          }
        }, 12000);
      },
      fail: (err) => {
        console.error('扫描失败', err);
        this.stopScan('扫描启动失败');
      }
    });
  },

  stopScan(msg) {
    if (!this.data.scanning) return;
    wx.stopBluetoothDevicesDiscovery({
      success: () => {
        this.setData({
          scanning: false,
          statusText: msg || '已停止扫描'
        });
        if (this.data.devices.length === 0) {
          wx.showToast({ title: '未发现设备', icon: 'none' });
        }
      },
      fail: () => {
        this.setData({ scanning: false });
      }
    });
  },

  // ========== 连接设备 ==========
  connectDevice(e) {
    const deviceId = e.currentTarget.dataset.deviceid;
    if (!deviceId) return;
    this.setData({ statusText: '正在连接...', deviceId });

    wx.createBLEConnection({
      deviceId: deviceId,
      success: () => {
        console.log('连接成功');
        this.setData({
          isConnected: true,
          statusText: '已连接',
          scanning: false,
          devices: []
        });
        wx.stopBluetoothDevicesDiscovery({});
        this.addLog('已连接设备');
        this.getServices(deviceId);
      },
      fail: (err) => {
        console.error('连接失败', err);
        this.setData({ statusText: '连接失败', deviceId: null });
        wx.showToast({ title: '连接失败', icon: 'none' });
      }
    });
  },

  getServices(deviceId) {
    wx.getBLEDeviceServices({
      deviceId: deviceId,
      success: (res) => {
        const service = res.services.find(s => s.uuid.toUpperCase() === SERVICE_UUID.toUpperCase());
        if (service) {
          this.setData({ serviceId: service.uuid });
          this.getCharacteristics(deviceId, service.uuid);
        } else {
          this.addLog('未找到目标服务,请检查设备');
          this.disconnectDevice();
        }
      },
      fail: (err) => {
        console.error('获取服务失败', err);
        this.addLog('获取服务失败');
        this.disconnectDevice();
      }
    });
  },

  getCharacteristics(deviceId, serviceId) {
    wx.getBLEDeviceCharacteristics({
      deviceId: deviceId,
      serviceId: serviceId,
      success: (res) => {
        const rx = res.characteristics.find(c => c.uuid.toUpperCase() === RX_CHAR_UUID.toUpperCase());
        const tx = res.characteristics.find(c => c.uuid.toUpperCase() === TX_CHAR_UUID.toUpperCase());
        if (rx && tx) {
          this.addLog('已发现数据通道');
          this.enableNotify(deviceId, serviceId, tx.uuid);
        } else {
          this.addLog('未找到完整特征值');
          this.disconnectDevice();
        }
      },
      fail: (err) => {
        console.error('获取特征失败', err);
        this.addLog('获取特征失败');
        this.disconnectDevice();
      }
    });
  },

  enableNotify(deviceId, serviceId, charId) {
    wx.notifyBLECharacteristicValueChange({
      deviceId: deviceId,
      serviceId: serviceId,
      characteristicId: charId,
      state: true,
      success: () => {
        console.log('通知已启用');
        this.addLog('已开启数据接收');
        wx.onBLECharacteristicValueChange((res) => {
          if (res.characteristicId.toUpperCase() === TX_CHAR_UUID.toUpperCase()) {
            this.handleReceivedData(res.value);
          }
        });
      },
      fail: (err) => {
        console.error('启用通知失败', err);
        this.addLog('启用通知失败');
      }
    });
  },

  // ========== 数据收发(通用发送函数) ==========
  sendBinaryCommand(cmd, steps) {
    if (!this.data.isConnected || !this.data.deviceId) {
      wx.showToast({ title: '未连接', icon: 'none' });
      return false;
    }
    const buffer = new ArrayBuffer(8);
    const view = new DataView(buffer);
    view.setUint8(0, 0xAA);
    view.setUint8(1, cmd);
    view.setUint8(2, 0x04);
    view.setInt32(3, steps, true); // 小端模式
    let checksum = 0;
    for (let i = 0; i < 7; i++) checksum ^= view.getUint8(i);
    view.setUint8(7, checksum);

    wx.writeBLECharacteristicValue({
      deviceId: this.data.deviceId,
      serviceId: this.data.serviceId || SERVICE_UUID,
      characteristicId: RX_CHAR_UUID,
      value: buffer,
      success: () => {
        console.log('指令发送成功, cmd=', cmd, 'steps=', steps);
      },
      fail: (err) => {
        console.error('发送失败', err);
        wx.showToast({ title: '发送失败', icon: 'none' });
      }
    });
    return true;
  },

  handleReceivedData(buffer) {
    // 此处可解析固件回传数据,例如查询当前位置
    console.log('收到数据:', buffer);
    this.addLog('收到数据: ' + this.ab2hex(buffer));
  },

  // ========== 速度控制 ==========
  onSpeedSlider(e) {
    const val = parseFloat(e.detail.value);
    this.setData({ speedMM: val });
  },

  applySpeed() {
    const speedMM = this.data.speedMM;
    const speedSteps = Math.round(speedMM * STEPS_PER_MM);
    if (speedSteps < 40 || speedSteps > 5000) {
      wx.showToast({ title: '速度范围 1~125 mm/s', icon: 'none' });
      return;
    }
    this.sendBinaryCommand(CMD_SET_SPEED, speedSteps);
    this.addLog(`速度设为 ${speedMM} mm/s (${speedSteps} 步/秒)`);
    wx.showToast({ title: `速度已设为 ${speedMM} mm/s`, icon: 'success' });
  },

  // ========== 使能控制 ==========
  onEnableSwitch(e) {
    const enabled = e.detail.value;
    this.setData({ isEnabled: enabled });
    this.sendBinaryCommand(CMD_SET_ENABLE, enabled ? 1 : 0);
    this.addLog(enabled ? '电机已使能' : '电机已失能');
    wx.showToast({ title: enabled ? '已使能' : '已失能', icon: 'success' });
  },

  // ========== 运动控制 ==========
  moveToTarget() {
    if (this.data.isMoving) return;
    const input = parseFloat(this.data.targetInput);
    if (isNaN(input) || input < 0 || input > TOTAL_TRAVEL_MM) {
      wx.showToast({ title: `请输入0~${TOTAL_TRAVEL_MM}的数值`, icon: 'none' });
      return;
    }
    const targetSteps = Math.round(input * STEPS_PER_MM);
    this.startMove(targetSteps);
  },

  quickMove(e) {
    if (this.data.isMoving) return;
    const delta = parseInt(e.currentTarget.dataset.delta);
    let newPos = this.data.currentPositionMM + delta;
    newPos = Math.max(0, Math.min(TOTAL_TRAVEL_MM, newPos));
    this.setData({ targetInput: newPos.toFixed(1) });
    this.moveToTarget();
  },

  sendHome() {
    if (this.data.isMoving) return;
    this.setData({ targetInput: '0' });
    this.moveToTarget();
  },

  startMove(targetSteps) {
    if (targetSteps < 0) targetSteps = 0;
    if (targetSteps > TOTAL_STEPS) targetSteps = TOTAL_STEPS;

    this.sendBinaryCommand(CMD_ABS_MOVE, targetSteps);

    this.setData({
      isMoving: true,
      targetSteps: targetSteps,
      statusText: '运行中...'
    });
    this.addLog(`开始移动到 ${(targetSteps / STEPS_PER_MM).toFixed(1)} mm`);

    // 启动进度模拟动画
    this.startProgressAnimation(targetSteps);
  },

  startProgressAnimation(targetSteps) {
    this.stopMoveTimer();
    const startSteps = this.data.currentSteps;
    const totalDelta = targetSteps - startSteps;
    if (totalDelta === 0) {
      this.finishMove();
      return;
    }
    const direction = totalDelta > 0 ? 1 : -1;
    let current = startSteps;

    const timer = setInterval(() => {
      const stepDelta = direction * Math.min(Math.abs(totalDelta) * 0.05, SPEED_STEPS_PER_MS * 20);
      current += stepDelta;
      if ((direction > 0 && current >= targetSteps) || (direction < 0 && current <= targetSteps)) {
        current = targetSteps;
        this.updatePosition(current);
        this.finishMove();
        clearInterval(timer);
        return;
      }
      this.updatePosition(current);
    }, 20);

    this.setData({ moveTimer: timer });
  },

  updatePosition(steps) {
    steps = Math.round(steps);
    const mm = steps / STEPS_PER_MM;
    const percent = (steps / TOTAL_STEPS) * 100;
    this.setData({
      currentSteps: steps,
      currentPositionMM: mm,
      progressPercent: Math.min(100, Math.max(0, percent))
    });
  },

  finishMove() {
    this.stopMoveTimer();
    this.setData({
      isMoving: false,
      statusText: '已就绪'
    });
    this.addLog(`到达位置 ${(this.data.currentSteps / STEPS_PER_MM).toFixed(1)} mm`);
  },

  stopMoveTimer() {
    if (this.data.moveTimer) {
      clearInterval(this.data.moveTimer);
      this.setData({ moveTimer: null });
    }
  },

  // ========== 急停 ==========
  emergencyStop() {
    if (!this.data.isConnected) return;
    this.sendBinaryCommand(CMD_STOP, 0);
    this.stopMoveTimer();
    this.setData({
      isMoving: false,
      statusText: '已急停'
    });
    this.addLog('⚠️ 急停指令已发送(保持使能)');
    wx.showToast({ title: '急停已触发', icon: 'none' });
  },

  // ========== 断开连接 ==========
  disconnectDevice() {
    if (this.data.deviceId) {
      wx.closeBLEConnection({
        deviceId: this.data.deviceId,
        complete: () => {
          this.resetBluetoothState();
        }
      });
    } else {
      this.resetBluetoothState();
    }
  },

  resetBluetoothState() {
    this.stopMoveTimer();
    wx.offBluetoothDeviceFound();
    wx.offBLECharacteristicValueChange();
    wx.offBluetoothAdapterStateChange();

    this.setData({
      isConnected: false,
      deviceId: null,
      serviceId: null,
      statusText: '已断开',
      isMoving: false,
      devices: [],
      scanning: false
    });
    this.addLog('已断开连接');
  },

  // ========== 输入事件 ==========
  onTargetInput(e) {
    this.setData({ targetInput: e.detail.value });
  },

  // ========== 日志 ==========
  addLog(msg) {
    const logs = this.data.logs;
    const time = new Date().toLocaleTimeString();
    logs.push(`[${time}] ${msg}`);
    if (logs.length > 100) logs.shift();
    this.setData({
      logs: logs,
      logScrollTop: logs.length * 30
    });
  },

  // ========== 工具函数 ==========
  ab2hex(buffer) {
    const hexArr = Array.prototype.map.call(new Uint8Array(buffer), byte => byte.toString(16).padStart(2, '0'));
    return hexArr.join(' ');
  },
});

index.wxss

page {
  background: linear-gradient(135deg, #E8EEF9 0%, #D4DEEE 100%);
  height: 100%;
}

.container {
  display: flex;
  flex-direction: column;
  padding: 16px 16px 20px;
  height: 100%;
  box-sizing: border-box;
}

/* 状态栏 */
.status-bar {
  display: flex;
  align-items: center;
  background: rgba(255,255,255,0.85);
  backdrop-filter: blur(8px);
  padding: 10px 16px;
  border-radius: 16px;
  box-shadow: 0 2px 12px rgba(0,0,0,0.08);
  margin-bottom: 12px;
}
.status-icon {
  width: 12px;
  height: 12px;
  border-radius: 50%;
  margin-right: 10px;
}
.status-icon.connected {
  background: #2ECC71;
  box-shadow: 0 0 8px #2ECC71;
}
.status-icon.disconnected {
  background: #E74C3C;
  box-shadow: 0 0 8px #E74C3C;
}
.status-text {
  flex: 1;
  font-size: 15px;
  font-weight: 500;
  color: #2C3E50;
}
.btn-scan {
  background: #2C8EF0;
  border-radius: 20px;
  font-size: 12px;
  padding: 4px 16px;
}

/* 设备列表 */
.device-list {
  background: rgba(255,255,255,0.92);
  border-radius: 16px;
  padding: 12px 0;
  max-height: 240px;
  margin-bottom: 12px;
  box-shadow: 0 2px 12px rgba(0,0,0,0.06);
}
.list-title {
  font-size: 14px;
  font-weight: 600;
  color: #2C3E50;
  padding: 0 16px 8px 16px;
  border-bottom: 1px solid #ECF0F1;
}
.device-scroll {
  max-height: 200px;
}
.device-item {
  padding: 12px 16px;
  border-bottom: 1px solid #F0F4FA;
  display: flex;
  justify-content: space-between;
  align-items: center;
}
.device-item:active {
  background: #E8EEF9;
}
.device-name {
  font-size: 15px;
  color: #1A2A3A;
  font-weight: 500;
}
.device-rssi {
  font-size: 12px;
  color: #7F8C8D;
}

/* 卡片 */
.card {
  background: rgba(255,255,255,0.92);
  backdrop-filter: blur(8px);
  border-radius: 20px;
  padding: 18px 20px 20px;
  margin-bottom: 14px;
  box-shadow: 0 4px 16px rgba(0,0,0,0.06);
}
.card-title {
  font-size: 14px;
  font-weight: 600;
  color: #34495E;
  margin-bottom: 12px;
  letter-spacing: 0.5px;
}
.position-display {
  display: flex;
  align-items: baseline;
  justify-content: center;
  margin-bottom: 8px;
}
.position-value {
  font-size: 36px;
  font-weight: 700;
  color: #1A2A3A;
}
.position-unit {
  font-size: 16px;
  color: #7F8C8D;
  margin-left: 4px;
}
.progress-wrapper {
  position: relative;
  margin: 12px 0 6px;
}
.progress-label {
  position: absolute;
  right: 0;
  top: -20px;
  font-size: 12px;
  color: #2C8EF0;
  font-weight: 500;
}
.position-detail {
  display: flex;
  justify-content: space-between;
  font-size: 12px;
  color: #95A5A6;
  margin-top: 4px;
}

/* 目标输入 */
.target-input-row {
  display: flex;
  align-items: center;
  margin-bottom: 12px;
}
.target-input {
  flex: 1;
  background: #F5F8FC;
  border: 1.5px solid #DCE3EC;
  border-radius: 12px;
  padding: 10px 14px;
  font-size: 16px;
  color: #1A2A3A;
  margin-right: 8px;
}
.target-input:focus {
  border-color: #2C8EF0;
}
.unit-label {
  font-size: 16px;
  color: #7F8C8D;
  margin-right: 10px;
  font-weight: 500;
}
.btn-move {
  background: #2C8EF0;
  border-radius: 30px;
  padding: 0 24px;
  height: 44px;
  line-height: 44px;
  font-size: 16px;
  font-weight: 600;
  border: none;
}
.btn-move[disabled] {
  background: #B0C4DE;
}

/* 快捷按钮 */
.quick-buttons {
  display: flex;
  flex-wrap: wrap;
  gap: 8px;
  margin-top: 4px;
}
.quick-buttons button {
  flex: 1;
  min-width: 50px;
  background: #F0F4FA;
  border-radius: 20px;
  font-size: 13px;
  color: #2C3E50;
  border: none;
  padding: 6px 0;
}
.quick-buttons button[type="warn"] {
  background: #E67E22;
  color: #fff;
}

/* 急停按钮 */
.btn-emergency {
  background: #E74C3C;
  border-radius: 60px;
  height: 60px;
  line-height: 60px;
  font-size: 20px;
  font-weight: 700;
  color: #fff;
  border: none;
  box-shadow: 0 4px 16px rgba(231, 76, 60, 0.4);
  margin: 6px 0 16px;
  letter-spacing: 2px;
  transition: all 0.2s;
}
.btn-emergency:active {
  transform: scale(0.96);
  box-shadow: 0 2px 8px rgba(231, 76, 60, 0.3);
}

/* 日志 */
.log-area {
  flex: 1;
  background: rgba(255,255,255,0.85);
  backdrop-filter: blur(4px);
  border-radius: 16px;
  padding: 12px 14px;
  min-height: 80px;
  box-shadow: 0 2px 8px rgba(0,0,0,0.04);
}
.log-title {
  font-size: 12px;
  font-weight: 600;
  color: #7F8C8D;
  display: block;
  margin-bottom: 6px;
}
.log-scroll {
  height: 100%;
  max-height: 120px;
  overflow-y: auto;
}
.log-item {
  font-size: 12px;
  color: #34495E;
  padding: 3px 0;
  border-bottom: 1px solid #ECF0F1;
  font-family: monospace;
}
/* 速度控制行 */
.speed-row {
  display: flex;
  align-items: center;
  gap: 10px;
}
.speed-label {
  font-size: 14px;
  color: #2C3E50;
  font-weight: 500;
}
.speed-slider {
  flex: 1;
  height: 28px;
}
.speed-value {
  min-width: 60px;
  font-size: 14px;
  color: #2C3E50;
  font-weight: 600;
}
.btn-apply-speed {
  background: #9B59B6;
  border-radius: 20px;
  padding: 0 16px;
  height: 30px;
  line-height: 30px;
  font-size: 13px;
  border: none;
}
/* 使能行 */
.enable-row {
  display: flex;
  align-items: center;
  gap: 12px;
}
.enable-label {
  font-size: 15px;
  font-weight: 500;
  color: #2C3E50;
}
.enable-status {
  font-size: 14px;
  color: #7F8C8D;
}


四,nRF54L15 固件实现

main.c

/*
 * nRF54L15-DK BLE Stepper Motor Control
 * Based on peripheral_uart example
 *
 * Hardware:
 *   STEP: P1.09 (PWM20 OUT0)
 *   DIR:  P1.05
 *   EN:   P1.07
 *
 * Protocol (8-byte frame):
 *   [0] 0xAA | [1] CMD | [2] 0x04 | [3..6] int32 LE | [7] XOR
 */

#include <zephyr/kernel.h>
#include <zephyr/drivers/gpio.h>
#include <zephyr/drivers/pwm.h>
#include <zephyr/bluetooth/bluetooth.h>
#include <zephyr/bluetooth/uuid.h>
#include <zephyr/bluetooth/gatt.h>
#include <zephyr/bluetooth/hci.h>
#include <zephyr/settings/settings.h>
#include <bluetooth/services/nus.h>
#include <dk_buttons_and_leds.h>

/* ======================================================================
 *  Pin Definitions (DTS-based)
 * ====================================================================== */
static const struct gpio_dt_spec dir_pin =
 GPIO_DT_SPEC_GET(DT_NODELABEL(dir_pin), gpios);
static const struct gpio_dt_spec en_pin =
 GPIO_DT_SPEC_GET(DT_NODELABEL(en_pin), gpios);

static const struct pwm_dt_spec step_pwm = {
 .dev = DEVICE_DT_GET(DT_NODELABEL(pwm20)),
 .channel = 0,
 .period = 0,
 .flags = PWM_POLARITY_NORMAL
};

/* ======================================================================
 *  Protocol
 * ====================================================================== */
#define FRAME_HEADER    0xAA
#define CMD_ABS_MOVE    0x01
#define CMD_REL_MOVE    0x02
#define CMD_STOP        0x03
#define CMD_SET_HOME    0x04
#define CMD_SET_SPEED   0x05
#define CMD_SET_ENABLE  0x06

#define STEPS_PER_MM        40
#define TOTAL_TRAVEL_MM     350
#define TOTAL_STEPS         (TOTAL_TRAVEL_MM * STEPS_PER_MM)
#define DEFAULT_SPEED_SPS   200
#define SPEED_MIN_SPS       100
#define SPEED_MAX_SPS       5000

/* ======================================================================
 *  State
 * ====================================================================== */
static int32_t current_position;
static int32_t target_position;
static uint16_t speed_sps = DEFAULT_SPEED_SPS;
static bool motor_enabled;
static volatile bool is_moving;
static struct k_timer stop_timer;
static struct k_mutex motor_mutex;

/* ======================================================================
 *  BLE Advertising
 * ====================================================================== */
static const struct bt_data ad[] = {
 BT_DATA_BYTES(BT_DATA_FLAGS, (BT_LE_AD_GENERAL | BT_LE_AD_NO_BREDR)),
 BT_DATA(BT_DATA_NAME_COMPLETE, CONFIG_BT_DEVICE_NAME,
  sizeof(CONFIG_BT_DEVICE_NAME) - 1),
};

static const struct bt_data sd[] = {
 BT_DATA_BYTES(BT_DATA_UUID128_ALL, BT_UUID_NUS_VAL),
};

/* ======================================================================
 *  BLE Callbacks
 * ====================================================================== */
static void connected(struct bt_conn *conn, uint8_t err)
{
 if (err) {
  printk("Connection failed (err %d)n", err);
  return;
 }
 printk("Connectedn");
 dk_set_led_on(DK_LED1);
}

static void disconnected(struct bt_conn *conn, uint8_t reason)
{
 printk("Disconnected (reason %d)n", reason);
 dk_set_led_off(DK_LED1);
}

BT_CONN_CB_DEFINE(conn_callbacks) = {
 .connected    = connected,
 .disconnected = disconnected,
};

/* ======================================================================
 *  Send response via NUS
 * ====================================================================== */
static void send_response(uint8_t cmd, int32_t param)
{
 uint8_t buf[8];
 uint8_t cs = 0;

 buf[0] = FRAME_HEADER;
 buf[1] = cmd;
 buf[2] = 0x04;
 buf[3] = (uint8_t)(param & 0xFF);
 buf[4] = (uint8_t)((param >> 8) & 0xFF);
 buf[5] = (uint8_t)((param >> 16) & 0xFF);
 buf[6] = (uint8_t)((param >> 24) & 0xFF);

 for (int i = 0; i < 7; i++) cs ^= buf[i];
 buf[7] = cs;

 bt_nus_send(NULL, buf, sizeof(buf));
}

/* ======================================================================
 *  PWM Control
 * ====================================================================== */
static void stop_pwm(void)
{
 /* Set 0% duty cycle to stop stepping */
 pwm_set_cycles(step_pwm.dev, step_pwm.channel,
         0, 0, step_pwm.flags);
 is_moving = false;
 printk("PWM: stoppedn");
}

static void stop_timer_handler(struct k_timer *timer_id)
{
 (void)timer_id;
 k_mutex_lock(&motor_mutex, K_FOREVER);
 current_position = target_position;  /* Update position only on completion */
 printk("POSITION: %dn", current_position);
 stop_pwm();
 k_mutex_unlock(&motor_mutex);
}

static int start_pwm(uint16_t sps)
{
 uint64_t rate_hz;
 uint32_t period_cycles, pulse_cycles;
 int ret;

 if (sps < SPEED_MIN_SPS || sps > SPEED_MAX_SPS) {
  printk("PWM: sps %d out of rangen", sps);
  return -EINVAL;
 }

 /* Get PWM clock rate for ns-to-cycles conversion */
 ret = pwm_get_cycles_per_sec(step_pwm.dev, step_pwm.channel, &rate_hz);
 if (ret < 0 || rate_hz == 0) {
  printk("PWM: get rate failed (ret=%d)n", ret);
  return -EIO;
 }

 period_cycles = (uint32_t)(rate_hz / (uint64_t)sps);
 pulse_cycles = period_cycles / 2;

 printk("PWM: sps=%d rate=%llu period=%d cycles pulse=%d cyclesn",
        sps, rate_hz, period_cycles, pulse_cycles);

 ret = pwm_set_cycles(step_pwm.dev, step_pwm.channel,
        period_cycles, pulse_cycles, step_pwm.flags);
 printk("PWM: ret=%dn", ret);
 return ret;
}

/* ======================================================================
 *  Motor Commands
 * ====================================================================== */
static void cmd_emergency_stop(void)
{
 k_mutex_lock(&motor_mutex, K_FOREVER);
 k_timer_stop(&stop_timer);
 stop_pwm();
 k_mutex_unlock(&motor_mutex);
}

static void cmd_set_home(void)
{
 k_mutex_lock(&motor_mutex, K_FOREVER);
 current_position = 0;
 k_mutex_unlock(&motor_mutex);
}

static int cmd_set_speed(int32_t param)
{
 if (param < SPEED_MIN_SPS || param > SPEED_MAX_SPS) {
  return -EINVAL;
 }
 k_mutex_lock(&motor_mutex, K_FOREVER);
 speed_sps = (uint16_t)param;
 k_mutex_unlock(&motor_mutex);
 return 0;
}

static void cmd_set_enable(int32_t param)
{
 bool en = (param != 0);
 k_mutex_lock(&motor_mutex, K_FOREVER);
 motor_enabled = en;
 gpio_pin_set_dt(&en_pin, en ? 1 : 0);  /* ACTIVE_LOW: 1=asserted=low=enabled */
 k_mutex_unlock(&motor_mutex);
}

static int cmd_abs_move(int32_t target)
{
 uint32_t runtime_ms;
 int32_t delta;
 int ret;

 printk("MOVE: target=%d pos=%d speed=%d en=%dn",
        target, current_position, speed_sps, motor_enabled);

 k_mutex_lock(&motor_mutex, K_FOREVER);

 if (is_moving) {
  printk("MOVE: busyn");
  k_mutex_unlock(&motor_mutex);
  return -EBUSY;
 }
 if (target < 0 || target > TOTAL_STEPS || !motor_enabled) {
  printk("MOVE: reject: target=%d range=[0,%d] en=%dn",
         target, TOTAL_STEPS, motor_enabled);
  k_mutex_unlock(&motor_mutex);
  return (target < 0 || target > TOTAL_STEPS) ? -EINVAL : -EACCES;
 }

 delta = target - current_position;
 if (delta == 0) {
  printk("MOVE: already at targetn");
  k_mutex_unlock(&motor_mutex);
  return 0;
 }

 printk("MOVE: delta=%d dir=%sn", delta, (delta > 0) ? "CW" : "CCW");
 gpio_pin_set_dt(&dir_pin, (delta > 0) ? 1 : 0);
 runtime_ms = (uint32_t)(((delta > 0 ? delta : -delta) * 1000UL) / speed_sps);
 if (runtime_ms < 1) { runtime_ms = 1; }
 printk("MOVE: runtime=%umsn", runtime_ms);
 ret = start_pwm(speed_sps);
 if (ret < 0) {
  printk("MOVE: PWM start failedn");
  k_mutex_unlock(&motor_mutex);
  return ret;
 }

 is_moving = true;
 target_position = target;   /* Store target, don't update current_position yet */
 k_timer_start(&stop_timer, K_MSEC(runtime_ms), K_NO_WAIT);
 k_mutex_unlock(&motor_mutex);
 printk("MOVE: started OKn");
 return 0;
}

static int cmd_rel_move(int32_t delta)
{
 int32_t target;
 k_mutex_lock(&motor_mutex, K_FOREVER);
 target = current_position + delta;
 if (target < 0 || target > TOTAL_STEPS) {
  k_mutex_unlock(&motor_mutex);
  return -EINVAL;
 }
 k_mutex_unlock(&motor_mutex);
 return cmd_abs_move(target);
}

/* ======================================================================
 *  NUS Receive Callback
 * ====================================================================== */
static void nus_received_cb(struct bt_conn *conn, const uint8_t *const data,
       uint16_t len)
{
 uint8_t hdr, cmd, dlen, cs_rx, cs_calc = 0;
 int32_t param;

 /* Print raw received data */
 printk("BLE RX (%d): ", len);
 for (int i = 0; i < len && i < 16; i++) {
  printk("%02X ", data[i]);
 }
 printk("n");

 if (len < 8) {
  printk("  -> too shortn");
  goto respond_err;
 }

 hdr   = data[0];
 cmd   = data[1];
 dlen  = data[2];
 param = (int32_t)(data[3] | (data[4] << 8) |
      (data[5] << 16) | (data[6] << 24));
 cs_rx = data[7];

 printk("  -> hdr=0x%02X cmd=0x%02X dlen=%d param=%d cs=0x%02Xn",
        hdr, cmd, dlen, param, cs_rx);

 if (hdr != FRAME_HEADER) { printk("  -> bad headern"); goto respond_err; }
 if (dlen != 0x04) { printk("  -> bad dlenn"); goto respond_err; }
 for (int i = 0; i < 7; i++) { cs_calc ^= data[i]; }
 if (cs_calc != cs_rx) { printk("  -> cs mismatchn"); goto respond_err; }

 printk("  -> cmd %d acceptedn", cmd);

 /* Print internal state */
 printk("  state: pos=%d speed=%d en=%d moving=%dn",
        current_position, speed_sps, motor_enabled, is_moving);

 switch (cmd) {
 case CMD_ABS_MOVE: {
  int ret = cmd_abs_move(param);
  send_response(cmd, (ret < 0) ? ret : 0);
  break;
 }
 case CMD_REL_MOVE: {
  int ret = cmd_rel_move(param);
  send_response(cmd, (ret < 0) ? ret : 0);
  break;
 }
 case CMD_STOP:
  cmd_emergency_stop();
  send_response(cmd, 0);
  break;
 case CMD_SET_HOME:
  cmd_set_home();
  send_response(cmd, 0);
  break;
 case CMD_SET_SPEED: {
  int ret = cmd_set_speed(param);
  send_response(cmd, (ret < 0) ? ret : speed_sps);
  break;
 }
 case CMD_SET_ENABLE:
  cmd_set_enable(param);
  send_response(cmd, 0);
  break;
 default:
  goto respond_err;
 }
 return;

respond_err:
 send_response(0xFF, -1);
}

static struct bt_nus_cb nus_cb = {
 .received = nus_received_cb,
};

/* ======================================================================
 *  BLE Ready (same structure as peripheral_uart)
 * ====================================================================== */
static void bt_ready(void)
{
 int err;

 err = bt_nus_init(&nus_cb);
 if (err) {
  printk("NUS init failed (err %d)n", err);
  return;
 }

 err = bt_le_adv_start(
  BT_LE_ADV_PARAM(BT_LE_ADV_OPT_CONN,
    BT_GAP_ADV_FAST_INT_MIN_2,
    BT_GAP_ADV_FAST_INT_MAX_2, NULL),
  ad, ARRAY_SIZE(ad), sd, ARRAY_SIZE(sd));
 if (err) {
  printk("Advertising failed (err %d)n", err);
  return;
 }
 printk("Advertising startedn");
}

/* ======================================================================
 *  Main
 * ====================================================================== */
int main(void)
{
 int err;

 printk("nRF54L15-DK BLE Stepper Motor Controln");

 k_mutex_init(&motor_mutex);
 k_timer_init(&stop_timer, stop_timer_handler, NULL);

 if (!pwm_is_ready_dt(&step_pwm)) {
  printk("PWM not readyn");
  return 0;
 }
 if (!device_is_ready(dir_pin.port) || !device_is_ready(en_pin.port)) {
  printk("GPIO not readyn");
  return 0;
 }
 gpio_pin_configure_dt(&dir_pin, GPIO_OUTPUT_INACTIVE);
 gpio_pin_configure_dt(&en_pin, GPIO_OUTPUT_ACTIVE);  /* 1=asserted=low=enabled at boot */
 motor_enabled = true;

 dk_leds_init();
 dk_set_leds_state(0, DK_ALL_LEDS_MSK);

 err = bt_enable(NULL);
 if (err) {
  printk("BT init failed (%d)n", err);
  return 0;
 }
 if (IS_ENABLED(CONFIG_SETTINGS)) {
  settings_load();
 }
 bt_ready();

 while (1) {
  dk_set_led(DK_LED2, is_moving ? 1 : 0);
  k_msleep(200);
 }
 return 0;
}

nrf54l15dk_nrf54l15_cpuapp.overlay

/*
 * Device Tree Overlay for nRF54L15-DK Stepper Motor Control
 * STEP: P1.09 (PWM20 OUT0)
 * DIR:  P1.11 (GPIO)
 * EN:   P1.13 (GPIO, ACTIVE_LOW)
 */

/ {
    stepper_pins: stepper_pins {
        compatible = "gpio-leds";
        dir_pin: dir_pin {
            gpios = <&gpio1 11 GPIO_ACTIVE_HIGH>;
            label = "Stepper DIR";
        };
        en_pin: en_pin {
            gpios = <&gpio1 13 GPIO_ACTIVE_LOW>;
            label = "Stepper EN";
        };
    };
};

&pwm20 {
    status = "okay";
    pinctrl-0 = <&pwm20_stepper>;
    pinctrl-1 = <&pwm20_stepper_sleep>;
    pinctrl-names = "default", "sleep";
};

&pinctrl {
    pwm20_stepper: pwm20_stepper {
        group1 {
            psels = <NRF_PSEL(PWM_OUT0, 1, 9)>;  /* P1.09 for STEP */
        };
    };

    pwm20_stepper_sleep: pwm20_stepper_sleep {
        group1 {
            psels = <NRF_PSEL(PWM_OUT0, 1, 9)>;
            low-power-enable;
        };
    };
};


五,关键踩坑与解决

42步进电机通过短接两个线转动轴承,如果很阻塞,则为同一组绕线,分别接B+,B-,但是这个模块的命名时M1B M1A M2A M2B,我开始将同组绕线接了M1B和M2B,但是电机怎么都不转,一直来回晃动,最好通过将同组绕行分别接M1B和M1A才正常驱动。

六,成果演示




关键词: 步进电机     nRF54L15    

共1条 1/1 1 跳转至

回复

匿名不能发帖!请先 [ 登陆 注册 ]