【let's do第1期】静音步进电机控制实践-成果贴 基于 nRF54L15 与微信小程序的静音步进滑台无线控制系统
由于之前打样的nrf54l15模块今天才到,晚上才有时间焊接,就先借用nrf54l15dk先实现一下功能吧。
本次成果贴实现了一套低功耗蓝牙(BLE)控制的静音步进滑台系统,通过微信小程序发送指令,完成速度调节、绝对/相对位置移动、急停、使能控制等功能。硬件采用 nRF54L15-DK 开发板,驱动 42 步进电机(带 TMC 静音驱动),软件分为 BLE 通信协议层、电机控制层和小程序 UI 层。
一,系统架构


二,通信协议定义
完整协议包定义(总长 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=使能 | 控制电机使能/失能 |
三,微信小程序实现
界面如下

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才正常驱动。
我要赚赏金
