手机床电机超时后为什么再按一次也不动
给手机做一张会自动盖被子的床,最难的未必是让两台电机转起来。
更难的一幕发生在半路。机构还没有碰到终点,超时保护先让电机停了。再发一次关床命令,程序却像没听见一样。电机已经断了驱动,软件还把“正在往床尾走”当成“已经在床尾”。
这个现象可以从控制源码直接复现。下面沿着真实电路核对六根 GPIO,再把控制逻辑改成一个可以单独测试的状态机。手机床只是入口,这套方法同样适用于抽屉、滑台和小型开合机构。
图1 原项目的实物照片,仅用于说明双电机开合机构的应用场景。本文改进的是软件控制逻辑,未制作或实测改版实物。
超时确实停了,位置却记错了
原控制任务每轮检查限位,之后把计数器加一,每次延时50ms。计数达到100,四路PWM归零并挂起任务。它已经有超时保护。
问题藏在另一个变量里。关床命令先把 limit_direction 设为 -1;下一次关床命令看到 -1,便认为床尾已经到位,直接返回。超时分支清掉了PWM和计数器,却没有清掉这个方向标记,也没有单独记录超时故障。
用原文件编译一份主机复现程序,把GPIO输入固定为“两个限位均未触发”,将PWM调用换成可检查的模拟接口,结果如下。
REPRODUCED synthetic timeout at 4950 ms; direction=-1; same-direction retry ignored
CONFIRMED existing quilt_limit_reset permits retry; it does not encode a timeout fault
这里的4950ms是模拟接口中99次50ms延时的总和。原循环第一轮马上计数,实际板上的调度开销还会改变时间;这不是示波器测得的停机时刻。
原代码也提供了 quilt_limit_reset(),不能把它说成“完全无法恢复”。只是把方向标记置零,还不能表达“为什么停机”“位置是否可信”和“现在是否允许重试”。
要修好这个缺口,目标、位置和故障必须分别保存。命令说的是想去哪儿,限位才有资格证明到了哪儿。
先把六根线和两种停止方式认清
控制板的电机接口是两颗DRV8870,限位板使用两颗DRV5032FA。下面的图都从当前在线EDA工程重新导出;旧硬件下载包里还有不同版本和位号,不能直接混用。
图2 在线Controller版的CN1相关引脚分成两个放大局部,下方为U9接口。U9的1、2脚分别连到GPIO40、GPIO38,3脚是GND,4脚是3.3V。连接器编号不等于从线缆某个视角看到的左右顺序。
导出的网表确认了这组关系。
| ESP32-S3引脚 | 本版连接 | 最小例程用途 |
|---|---|---|
| GPIO2 | U6的IN1 | PWM通道0 |
| GPIO3 | U6的IN2 | PWM通道1 |
| GPIO4 | U4的IN1 | PWM通道2 |
| GPIO5 | U4的IN2 | PWM通道3 |
| GPIO40 | U9的1脚 | 床头限位输入 |
| GPIO38 | U9的2脚 | 床尾限位输入 |
例程沿用这六个端口,床头、床尾均按低电平有效读取。两台电机的安装方向可能相反,所以逻辑“开”不等于每台电机都使用同一种H桥输入组合。接线和实际方向仍要在自己的机构上核对。GPIO3还是ESP32-S3的绑带引脚,复位电平要检查,不能为了“稳定输入”随意外加强上下拉。
图3 U4与U6的电机驱动电路,下方放大U6引脚。VREF为4脚,接3.3V;ISEN为7脚,接地。不要把这两个脚认反。
DRV8870的输入 00 对应高阻输出,电机滑行;11 才是主动刹车。本例保留四路PWM归零的滑行停止方式,没有把“停止驱动”写成“机械立刻停住”。本版ISEN直接接地,不能借一个5秒软件计时器宣称已经实现可设定的堵转限流。
图4 DRV5032FADMRR供电3.3V。图中的GPIO1、GPIO2是限位板的局部网络标签,不是让你改接ESP32的GPIO1、GPIO2。
这颗传感器的FA型号是推挽输出,更新率20Hz。10ms读一次GPIO,只能缩短软件等候下一次读取的时间,不能把磁传感器变成100Hz器件。输入没有做内部上下拉,断线也不保证呈现某个确定电平;需要断线诊断时应另做硬件与输入设计。
图5 同一在线Controller工程的顶面与翻板后的底面导出图。本次没有改PCB,不据此宣称通过制造检查或温升测试。
图6 MotorLimit板的顶底面及两端局部。安装后先用磁铁分别触发两端,核对日志中的head、bottom,再检查U9到传感器的线序;单靠排线颜色不足以证明接对了。
一次动作只花一份时间预算
新的 MotionGuard 只有一个任务负责推进状态和写PWM。界面或串口只投递命令,不直接修改电机状态,避免多个调用入口各自记一份“当前位置”。
状态分为空闲、运动、反向等待、限位确认和故障。位置另有未知、床头、床尾三种值,故障另有超时、双限位冲突、限位不稳定与配置错误。
处理顺序决定了边界行为。
- 两端限位同时有效,停止驱动并锁存故障。
- 一次动作从启动起共用5000ms预算;重复同向命令和半途反向都不续期。
- 反向先停驱,等待50ms后再允许另一方向驱动。这个等待值只是软件示例参数,不能证明转子已经停稳。
- 首次读到目标限位,立即停止驱动,再确认连续有效30ms;中途消失,锁存限位不稳定故障,不自动重新开电机。
- 超时后位置为未知。新的运动命令不能自行解除故障,必须先明确复位,再重新给命令。
5000ms包含反向等待和限位确认。若到截止时刻才首次读到限位,例程按超时处理。这是刻意选定的保守边界,不会把迟到的信号追认为正常完成。
下面是核心中实际使用的超时判断。started_ 在新动作启动时赋值,同向重复命令不会更新它。
if (busy() && elapsed(now, started_) >= cfg_.timeout_ms) {
trip(Fault::Timeout);
return End::Unknown;
}
时间差用无符号减法计算,支持32位毫秒计数回绕;配置约束和任务调用频率仍须满足例程说明,不能任意长时间停止调用。
图7 运行真实状态机得到的轨迹,输入由程序合成。5000ms超时停驱,5010ms同方向重试仍停驱;5020ms显式复位,5030ms重新启动;5200ms限位有效即停驱,5230ms才登记床尾到位。图中曲线是软件输出请求,不是电机电压或机械位移。
“停止”和“到位”也不合并。s 命令可以让运动退出,但没有端点证据时,位置保持未知。开机不自动寻找床头;空闲状态看到稳定限位后,才能更新位置。配置错误不能靠复位绕过去。
图8 上图在100ms要求反向,150ms才恢复驱动;中图显示重复和反向命令都不延长原5000ms预算;下图的目标限位只出现10ms,输出马上停止,随后锁存故障。均为合成输入的软件验证。
先跑测试,再接入实际驱动
独立核心无需ESP32就能编译运行。五组测试覆盖到位确认、超时与显式恢复、重复命令、反向预算、停止、双限位冲突、计数回绕、开机已在端点及非法配置;另用固定种子执行20万步事件序列,检查故障、确认和冲突期间不会输出运动请求等不变量。
PASS 5 suites, 1200540 assertions, 200000 seeded event steps
这类测试特别适合把正常演示不容易碰到的组合变成固定用例。它验证状态机的决策,不替代传感器响应、驱动时序、机械摩擦和惯性测试。
ESP32-S3最小工程固定使用ESP-IDF v5.5.4,分别完成了默认停驱配置和启用PWM配置的整工程编译、链接及二进制生成。原仓库标注的开发环境是v5.5.1,本文没有把两个版本混写成同一个。
例程保留四路10位、1kHz PWM,驱动占空比值816,对应816/1024。负责输出的任务每10ms读取限位,用 esp_timer_get_time() 提供单调时间;串口输入任务通过队列发送 o、c、r,s 单独置停止请求,由输出任务优先处理并清掉待执行运动命令。
const auto now = static_cast<std::uint32_t>(esp_timer_get_time()/1000);
const Limits limits{gpio_get_level(HEAD) == 0, gpio_get_level(BOTTOM) == 0};
const End out = guard.step(now, limits, command);
if (out != previous_output) {
apply_output(out);
previous_output = out;
}
完整最小工程见文末源码。先在电脑上运行 cmake -S . -B build-host、cmake --build build-host --config Release、ctest --test-dir build-host -C Release --output-on-failure。进入 esp32s3 后运行 idf.py set-target esp32s3、idf.py build,连接实际开发板再用 idf.py -p 实际端口 flash monitor。
默认 PHONEBED_ENABLE_OUTPUT 关闭,四个驱动输入保持低电平,日志仍能显示逻辑运动请求、位置和故障。首次验证应先保持这个配置,用实际磁铁观察两端输入,再核对每个电机的安装与转向。确认后才在 idf.py menuconfig 的例程菜单中启用输出并重新编译。例程使用USB Serial/JTAG控制台,开发板对应USB口和驱动需要正常工作。
这是一份独立电机控制例程,未合入原来的LCD、闹钟、无线充电与风扇程序。不能直接覆盖原整机固件后期待所有界面功能保持可用。移植到自己的应用时,把原界面动作改为发命令,并明确显示 position() 与 fault();不要再把“目标端点”当成“已到端点”。
还差哪些实体检查
目前完成了原源码的模拟接口复现、状态机主机运行、合成输入轨迹,以及ESP32-S3两个输出配置的编译。没有做实体烧录、磁响应测量、机械停机距离、堵转电流或温升测试,也没有修改和重新验证PCB。
真正把机构装起来后,应测量磁铁触发到停止驱动的延迟,再留出足够的行程余量;50ms反向等待与30ms确认也要结合实际机构重新定值。限位有效不等于两台电机完全同步,小床盖被子尚且需要注意卡斜,负载更大的机构更不能仅凭一个公共端点信号判断两侧都已到位。
能迁移走的经验很具体。超时负责结束一次没有完成的动作,限位负责提供位置证据,显式复位负责重新允许尝试。三个职责分别写清楚,“保护已经停机,下一次操作却失效”的问题就有了可以检查、可以复现的入口。
源码、来源与验证范围
完整最小工程源码列在下方附录,可按文件名直接复制保存,包含同版控制核心、ESP32-S3适配、全部测试、轨迹程序、构建配置和MIT许可。主机运行结果及两种ESP32输出配置的编译结果已在正文说明。 同版便捷附件 phonebed-motion-guard-v1.0.zip 另含原文件、模拟接口复现、验证记录与CSV;不含构建缓存或预编译固件。
可直接复制的完整最小工程
按下面的相对路径保存文件。主机工程需要CMake与C++17,ESP32-S3工程使用ESP-IDF v5.5.4。这里包含独立核心、全部五组测试、CSV轨迹生成器和ESP32适配;原控制行为的模拟接口对照留在ZIP中。配置默认停驱,运行与验证范围见README。
README.md
# PhoneBed 独立电机状态机示例 v1.0
芯片 ESP32-S3。SDK 固定 ESP-IDF v5.5.4;无第三方组件依赖。
这是独立例程,不包含原闹钟、LCD、风扇和无线充电功能,不应替换原整机固件后期待这些功能存在。
## 引脚与输入
以在线 Controller/MotorLimit 2026-03-31 工程为准;导出网表验证关系如下。
GPIO2 -> U6.IN1,GPIO3 -> U6.IN2,GPIO4 -> U4.IN1,GPIO5 -> U4.IN2。
GPIO40 -> U9.pin1 床头,GPIO38 -> U9.pin2 床尾,U9.pin3 GND,pin4 3.3V。
DRV5032FA:3.3V、20Hz、推挽,磁触发按低电平有效转换。
PCB板自身标签 GPIO1/GPIO2 不是 MCU GPIO1/GPIO2。
实际线序、磁铁安装、两电机方向和 USB Serial/JTAG 连接需用户在设备上核验。
GPIO3 是 ESP32-S3 的绑带引脚,保留原板连接时也要检查复位电平,不任意外加强上下拉。
软件轮询10ms并不保证10ms磁响应。断线输入、机械惯性和卡斜不由这份示例保证。
## 主机运行
需要 CMake>=3.16 和支持 C++17 的编译器。
```sh
cmake -S . -B build-host
cmake --build build-host --config Release
ctest --test-dir build-host -C Release --output-on-failure
```
`test_motion_guard` 五组定向/随机检查;`trace_motion_guard` 输出合成输入CSV。
`upstream/original` 为固定提交的MIT控制文件,保留原LICENSE。完整ZIP中还含 HAL mock 与原行为复现,使用 C++20 编译此对照目标。
模拟接口刻意不执行实际 RTOS,模拟自挂起以异常退出;不能验证任务并发时序或实体驱动行为。
## ESP32-S3构建与运行
在已安装并激活 ESP-IDF v5.5.4 的终端中执行。
```sh
cd esp32s3
idf.py set-target esp32s3
idf.py build
idf.py -p YOUR_ACTUAL_PORT flash monitor
```
默认配置关闭 `PHONEBED_ENABLE_OUTPUT`,控制引脚初始化为低电平,不自动归零。
先保持停驱配置,触发真实限位核对日志,再检查输出、电机方向及机构余量。
确认后 `idf.py menuconfig` -> PhoneBed independent control example -> Enable physical motor PWM,重新构建。
串口命令 `o` 开,`c` 关,`s` 停止,`r` 显式复位;复位不产生运动。
界面移植时只向命令队列发送请求,仅 owner_task 更新核心和PWM。
默认 timeout=5000ms、reverse_wait=50ms、confirm=30ms;均为示例软件参数。
重复/反向命令不重置动作预算,预算包含停驱等待与限位确认。
目标限位第一次有效立即停止驱动,连续有效30ms才登记到位;期间消失锁存故障。
恰到截止点优先判超时。故障时 `position=Unknown`,清除异常输入后显式Reset,随后另发运动命令。
STOP不伪造到位。两端同时有效不可Reset。配置错误须重新创建合法配置的核心实例。
毫秒差值采用 uint32_t 模运算,timeout需<2^31ms;调用间隔必须有界,不能跨越一个完整计数周期再调用。
H桥 `00` 为滑行,不是主动刹车;50ms等待不证明机械完全静止。
状态枚举 Idle=0, Moving=1, Reversing=2, Confirming=3, Fault=4。
位置/输出 Unknown=0, Head=1, Bottom=2。
故障 None=0, Timeout=1, BothLimits=2, UnstableLimit=3, BadConfig=4。
## 已完成的验证
主机 MSVC 14.51.36231:PASS 5 suites, 1200540 assertions, 200000 seeded event steps。
原源码模拟接口:4950ms延时总和超时、同方向重试被忽略;原Reset接口可以允许重试。
ESP-IDF v5.5.4 / Xtensa GCC esp-14.2.0_20260121:默认停驱和启用PWM两配置完整编译、链接、生成BIN。
未烧录实体,未做电压、响应、机械停止距离、堵转/电流、温升、ERC/DRC验证。
`verification.json`、`motion-trace.csv` 与此版本配套。
项目来源 https://oshwhub.com/htx-studio/project_kpilekig
仓库 https://gitee.com/htxstudio/PhoneBed
原控制提交 7ba4b45b5c64ce00f853d2fdd75e302116d8b0d9,Copyright (c) 2026 htx-studio,MIT。
本文独立核心与适配同为MIT。TI资料 DRV8870 / DRV5032 官方数据手册。
motion_guard.h
// SPDX-License-Identifier: MIT
// Copyright (c) 2026. Independent motor-control example.
#pragma once
#include <cstdint>
namespace phonebed {
enum class End { Unknown, Head, Bottom };
enum class Command { None, Open, Close, Stop, Reset };
enum class State { Idle, Moving, Reversing, Confirming, Fault };
enum class Fault { None, Timeout, BothLimits, UnstableLimit, BadConfig };
struct Limits { bool head; bool bottom; }; // true = active, after GPIO polarity conversion
struct Config {
std::uint32_t timeout_ms = 5000;
std::uint32_t reverse_wait_ms = 50;
std::uint32_t confirm_ms = 30;
};
class MotionGuard {
public:
explicit MotionGuard(Config config = {}) : cfg_(config) {
if (!cfg_.timeout_ms || !cfg_.reverse_wait_ms || !cfg_.confirm_ms ||
cfg_.timeout_ms >= 0x80000000u ||
cfg_.reverse_wait_ms >= cfg_.timeout_ms ||
cfg_.confirm_ms >= cfg_.timeout_ms) {
trip(Fault::BadConfig);
}
}
// Call from one owner task. Return Head/Bottom to drive; Unknown means coast.
End step(std::uint32_t now, Limits limits, Command command = Command::None) {
// Invalid configuration is permanent for this instance, even on conflicts.
if (fault_ == Fault::BadConfig) return End::Unknown;
if (limits.head && limits.bottom) {
trip(Fault::BothLimits);
return End::Unknown;
}
if (command == Command::Reset && !busy()) {
state_ = State::Idle;
fault_ = Fault::None;
position_ = End::Unknown;
idle_candidate_ = End::Unknown;
}
if (state_ == State::Fault) return End::Unknown;
if (command == Command::Stop) {
state_ = State::Idle;
target_ = position_ = End::Unknown;
idle_candidate_ = End::Unknown;
observe_idle(now, limits);
return End::Unknown;
}
// The original operation deadline survives repeated and reverse commands.
// A limit first observed at the deadline is deliberately treated as late.
if (busy() && elapsed(now, started_) >= cfg_.timeout_ms) {
trip(Fault::Timeout);
return End::Unknown;
}
const End requested = command == Command::Open ? End::Head :
command == Command::Close ? End::Bottom : End::Unknown;
if (requested != End::Unknown) {
if (!busy()) {
started_ = now;
target_ = requested;
position_ = End::Unknown;
if (active(limits, target_)) confirm(now);
else state_ = State::Moving;
} else if (requested != target_) {
target_ = requested;
position_ = End::Unknown;
if (active(limits, target_)) confirm(now);
else if (state_ != State::Reversing) {
state_ = State::Reversing;
wait_started_ = now;
}
}
}
if (state_ == State::Idle) {
observe_idle(now, limits);
} else if (state_ == State::Moving && active(limits, target_)) {
confirm(now); // stop immediately; do not drive during confirmation
} else if (state_ == State::Reversing) {
if (active(limits, target_)) confirm(now);
else if (elapsed(now, wait_started_) >= cfg_.reverse_wait_ms)
state_ = State::Moving;
} else if (state_ == State::Confirming) {
if (!active(limits, target_)) trip(Fault::UnstableLimit);
else if (elapsed(now, verify_started_) >= cfg_.confirm_ms) {
position_ = target_;
idle_candidate_ = target_;
idle_since_ = verify_started_;
target_ = End::Unknown;
state_ = State::Idle;
}
}
return state_ == State::Moving ? target_ : End::Unknown;
}
State state() const { return state_; }
Fault fault() const { return fault_; }
End position() const { return position_; }
End target() const { return target_; }
private:
static std::uint32_t elapsed(std::uint32_t now, std::uint32_t then) {
return now - then; // unsigned modulo arithmetic across the 32-bit wrap
}
static bool active(Limits l, End e) {
return e == End::Head ? l.head : e == End::Bottom ? l.bottom : false;
}
bool busy() const {
return state_ == State::Moving || state_ == State::Reversing ||
state_ == State::Confirming;
}
void confirm(std::uint32_t now) {
state_ = State::Confirming;
verify_started_ = now;
}
void trip(Fault reason) {
state_ = State::Fault;
fault_ = reason;
target_ = position_ = End::Unknown;
}
void observe_idle(std::uint32_t now, Limits l) {
const End candidate = l.head ? End::Head : l.bottom ? End::Bottom : End::Unknown;
if (candidate != idle_candidate_) {
idle_candidate_ = candidate;
idle_since_ = now;
position_ = End::Unknown;
}
if (candidate == End::Unknown) position_ = End::Unknown;
else if (elapsed(now, idle_since_) >= cfg_.confirm_ms) position_ = candidate;
}
Config cfg_;
State state_ = State::Idle;
Fault fault_ = Fault::None;
End target_ = End::Unknown, position_ = End::Unknown;
End idle_candidate_ = End::Unknown;
std::uint32_t started_ = 0, wait_started_ = 0;
std::uint32_t verify_started_ = 0, idle_since_ = 0;
};
} // namespace phonebed
test_motion_guard.cpp
#include "motion_guard.h"
#include <cstdio>
#include <cstdlib>
#include <limits>
using namespace phonebed;
static unsigned checks = 0;
#define REQUIRE(x) do { ++checks; if (!(x)) { \
std::fprintf(stderr, "FAIL line %d: %s\n", __LINE__, #x); std::exit(1); } } while (0)
static void arrival_and_glitch() {
MotionGuard g;
REQUIRE(g.step(0, {false,false}, Command::Close) == End::Bottom);
REQUIRE(g.position() == End::Unknown);
REQUIRE(g.step(1000, {false,true}) == End::Unknown);
REQUIRE(g.state() == State::Confirming);
REQUIRE(g.step(1029, {false,true}) == End::Unknown);
REQUIRE(g.position() == End::Unknown);
g.step(1030, {false,true});
REQUIRE(g.position() == End::Bottom && g.state() == State::Idle);
g.step(1040, {false,false});
REQUIRE(g.position() == End::Unknown); // released sensor invalidates position
MotionGuard noisy;
noisy.step(0, {false,false}, Command::Open);
REQUIRE(noisy.step(100, {true,false}) == End::Unknown);
REQUIRE(noisy.step(110, {false,false}) == End::Unknown);
REQUIRE(noisy.fault() == Fault::UnstableLimit);
}
static void timeout_and_recovery() {
MotionGuard g;
g.step(0, {false,false}, Command::Close);
for (unsigned t = 10; t < 5000; t += 10)
REQUIRE(g.step(t, {false,false}, Command::Close) == End::Bottom);
REQUIRE(g.step(5000, {false,false}, Command::Open) == End::Unknown);
REQUIRE(g.fault() == Fault::Timeout && g.position() == End::Unknown);
REQUIRE(g.step(5010, {false,false}, Command::Close) == End::Unknown);
g.step(5020, {false,false}, Command::Reset);
REQUIRE(g.fault() == Fault::None && g.state() == State::Idle);
REQUIRE(g.step(5030, {false,false}, Command::Close) == End::Bottom);
MotionGuard late;
late.step(0, {false,false}, Command::Open);
late.step(5000, {true,false});
REQUIRE(late.fault() == Fault::Timeout); // exact boundary wins over arrival
}
static void reverse_budget_and_stop() {
MotionGuard g;
REQUIRE(g.step(0, {false,false}, Command::Close) == End::Bottom);
REQUIRE(g.step(100, {false,false}, Command::Open) == End::Unknown);
REQUIRE(g.step(149, {false,false}) == End::Unknown);
REQUIRE(g.step(150, {false,false}) == End::Head);
REQUIRE(g.step(4999, {false,false}, Command::Close) == End::Unknown);
REQUIRE(g.step(5000, {false,false}) == End::Unknown);
REQUIRE(g.fault() == Fault::Timeout); // reverse cannot renew the deadline
MotionGuard stopped;
stopped.step(0, {false,false}, Command::Close);
REQUIRE(stopped.step(100, {false,false}, Command::Stop) == End::Unknown);
REQUIRE(stopped.state() == State::Idle && stopped.position() == End::Unknown);
MotionGuard opposite;
opposite.step(0, {true,false}, Command::Close);
REQUIRE(opposite.step(5, {true,false}, Command::Open) == End::Unknown);
opposite.step(35, {true,false});
REQUIRE(opposite.position() == End::Head);
}
static void contradiction_wrap_and_at_end() {
MotionGuard g;
REQUIRE(g.step(0, {true,true}, Command::Close) == End::Unknown);
REQUIRE(g.fault() == Fault::BothLimits);
REQUIRE(g.step(10, {true,true}, Command::Reset) == End::Unknown);
REQUIRE(g.state() == State::Fault);
const std::uint32_t start = std::numeric_limits<std::uint32_t>::max() - 99;
MotionGuard wrap;
wrap.step(start, {false,false}, Command::Open);
REQUIRE(wrap.step(start+4999u, {false,false}) == End::Head);
REQUIRE(wrap.step(start+5000u, {false,false}) == End::Unknown);
REQUIRE(wrap.fault() == Fault::Timeout);
MotionGuard at_end;
REQUIRE(at_end.step(0, {true,false}, Command::Open) == End::Unknown);
at_end.step(30, {true,false});
REQUIRE(at_end.position() == End::Head);
MotionGuard bad({0,50,30});
REQUIRE(bad.step(100, {false,false}, Command::Reset) == End::Unknown);
REQUIRE(bad.fault() == Fault::BadConfig);
bad.step(110, {true,true}, Command::Reset);
bad.step(120, {false,false}, Command::Reset);
REQUIRE(bad.fault() == Fault::BadConfig);
REQUIRE(bad.step(130, {false,false}, Command::Close) == End::Unknown);
}
static void randomized_invariants() {
std::uint32_t seed = 20261010u;
for (unsigned run = 0; run < 200; ++run) {
MotionGuard g;
std::uint32_t now = std::numeric_limits<std::uint32_t>::max() - 3000u;
for (unsigned n = 0; n < 1000; ++n) {
seed = 1664525u*seed + 1013904223u;
now += 1u+(seed%80u);
const Limits l{bool((seed>>8)&1u), bool((seed>>9)&1u)};
const auto cmd = static_cast<Command>((seed>>12)%5u);
const End out = g.step(now, l, cmd);
REQUIRE(!(l.head && l.bottom) || out == End::Unknown);
REQUIRE(g.state() == State::Moving || out == End::Unknown);
REQUIRE(g.state() != State::Moving || g.position() == End::Unknown);
REQUIRE(g.fault() == Fault::None || out == End::Unknown);
REQUIRE(!(out == End::Head && l.head));
REQUIRE(!(out == End::Bottom && l.bottom));
}
}
}
int main() {
arrival_and_glitch();
timeout_and_recovery();
reverse_budget_and_stop();
contradiction_wrap_and_at_end();
randomized_invariants();
std::printf("PASS 5 suites, %u assertions, 200000 seeded event steps\n", checks);
}
trace_motion_guard.cpp
#include "motion_guard.h"
#include <cstdio>
using namespace phonebed;
static const char* state_name(State s) {
const char* n[]={"Idle","Moving","Reversing","Confirming","Fault"};
return n[static_cast<int>(s)];
}
static const char* end_name(End e) {
const char* n[]={"Unknown","Head","Bottom"}; return n[static_cast<int>(e)];
}
int main() {
std::puts("case,t_ms,head,bottom,command,state,fault,position,output");
for (int scenario=0; scenario<3; ++scenario) {
MotionGuard g;
for (unsigned t=0; t<=5300; t+=10) {
Limits l{false,false}; Command c=Command::None;
if (t==0) c=Command::Close;
if (scenario==0) {
if (t==5010 || t==5030) c=Command::Close;
if (t==5020) c=Command::Reset;
if (t>=5200) l.bottom=true;
}
if (scenario==1) {
if (t==100 || t==4900) c=Command::Open;
if (t==4990) c=Command::Close;
}
if (scenario==2 && t==1000) l.bottom=true;
const auto out=g.step(t,l,c);
std::printf("%d,%u,%d,%d,%d,%s,%d,%s,%s\n",scenario,t,l.head,l.bottom,
static_cast<int>(c),state_name(g.state()),static_cast<int>(g.fault()),
end_name(g.position()),end_name(out));
}
}
}
CMakeLists.txt
cmake_minimum_required(VERSION 3.16)
project(phonebed_motion_guard LANGUAGES CXX)
set(CMAKE_CXX_STANDARD 17)
set(CMAKE_CXX_STANDARD_REQUIRED ON)
add_executable(test_motion_guard test_motion_guard.cpp)
enable_testing()
add_test(NAME motion_guard COMMAND test_motion_guard)
add_executable(trace_motion_guard trace_motion_guard.cpp)
if(EXISTS "${CMAKE_CURRENT_SOURCE_DIR}/upstream/original/motor_bsp.cpp")
add_executable(reproduce_upstream upstream/reproduce.cpp upstream/original/motor_bsp.cpp)
target_include_directories(reproduce_upstream PRIVATE upstream/mocks)
set_property(TARGET reproduce_upstream PROPERTY CXX_STANDARD 20)
add_test(NAME upstream_reproduction COMMAND reproduce_upstream)
endif()
esp32s3/CMakeLists.txt
cmake_minimum_required(VERSION 3.16)
include($ENV{IDF_PATH}/tools/cmake/project.cmake)
project(phonebed_motion_guard)
esp32s3/sdkconfig.defaults
CONFIG_IDF_TARGET="esp32s3"
CONFIG_FREERTOS_HZ=1000
CONFIG_ESP_CONSOLE_USB_SERIAL_JTAG=y
CONFIG_PHONEBED_ENABLE_OUTPUT=n
esp32s3/main/CMakeLists.txt
idf_component_register(SRCS "main.cpp" INCLUDE_DIRS "." "../.."
REQUIRES esp_driver_gpio esp_driver_ledc esp_timer)
esp32s3/main/Kconfig.projbuild
menu "PhoneBed independent control example"
config PHONEBED_ENABLE_OUTPUT
bool "Enable physical motor PWM after wiring and direction checks"
default n
help
Keep disabled for first boot. The console still shows requested
direction and faults. Enabling this can move the mechanism.
endmenu
esp32s3/main/main.cpp
// SPDX-License-Identifier: MIT
// Independent example. ESP-IDF v5.5.4, ESP32-S3.
#include "motion_guard.h"
#include <atomic>
#include <cstdio>
#include "freertos/FreeRTOS.h"
#include "freertos/task.h"
#include "freertos/queue.h"
#include "driver/gpio.h"
#include "driver/ledc.h"
#include "esp_timer.h"
#include "esp_log.h"
#include "sdkconfig.h"
using namespace phonebed;
static const char* TAG = "motion_guard";
// Online Controller edition 2026-03-31. Do not copy the commented v1.1 swap.
static constexpr gpio_num_t PWM[4] = {GPIO_NUM_2, GPIO_NUM_3, GPIO_NUM_4, GPIO_NUM_5};
static constexpr gpio_num_t HEAD = GPIO_NUM_40, BOTTOM = GPIO_NUM_38;
static constexpr std::uint32_t DUTY = 816; // 816/1024; matches upstream nominal 80%
static QueueHandle_t commands;
static std::atomic<bool> stop_requested{false};
static void init_gpio_pwm() {
gpio_config_t in{};
in.pin_bit_mask = (1ULL << HEAD) | (1ULL << BOTTOM);
in.mode = GPIO_MODE_INPUT;
in.pull_up_en = GPIO_PULLUP_DISABLE;
in.pull_down_en = GPIO_PULLDOWN_DISABLE;
in.intr_type = GPIO_INTR_DISABLE;
ESP_ERROR_CHECK(gpio_config(&in));
// Even the output-disabled build first makes the four control pins low.
gpio_config_t out{};
for (auto pin : PWM) out.pin_bit_mask |= 1ULL << pin;
out.mode = GPIO_MODE_OUTPUT;
ESP_ERROR_CHECK(gpio_config(&out));
for (auto pin : PWM) ESP_ERROR_CHECK(gpio_set_level(pin, 0));
#if CONFIG_PHONEBED_ENABLE_OUTPUT
ledc_timer_config_t timer{};
timer.speed_mode = LEDC_LOW_SPEED_MODE;
timer.duty_resolution = LEDC_TIMER_10_BIT;
timer.timer_num = LEDC_TIMER_2;
timer.freq_hz = 1000;
timer.clk_cfg = LEDC_AUTO_CLK;
ESP_ERROR_CHECK(ledc_timer_config(&timer));
for (unsigned i = 0; i < 4; ++i) {
ledc_channel_config_t ch{};
ch.gpio_num = PWM[i];
ch.speed_mode = LEDC_LOW_SPEED_MODE;
ch.channel = static_cast<ledc_channel_t>(i);
ch.timer_sel = LEDC_TIMER_2;
ch.duty = 0;
ESP_ERROR_CHECK(ledc_channel_config(&ch));
}
#endif
}
static void apply_output(End end) {
#if CONFIG_PHONEBED_ENABLE_OUTPUT
// All inputs go low before a new pattern; no 11 active-brake command here.
for (unsigned i = 0; i < 4; ++i) {
ESP_ERROR_CHECK(ledc_set_duty(LEDC_LOW_SPEED_MODE, static_cast<ledc_channel_t>(i), 0));
ESP_ERROR_CHECK(ledc_update_duty(LEDC_LOW_SPEED_MODE, static_cast<ledc_channel_t>(i)));
}
std::uint32_t duty[4]{};
if (end == End::Head) { duty[1] = DUTY; duty[2] = DUTY; }
if (end == End::Bottom) { duty[0] = DUTY; duty[3] = DUTY; }
for (unsigned i = 0; i < 4; ++i) {
ESP_ERROR_CHECK(ledc_set_duty(LEDC_LOW_SPEED_MODE, static_cast<ledc_channel_t>(i), duty[i]));
ESP_ERROR_CHECK(ledc_update_duty(LEDC_LOW_SPEED_MODE, static_cast<ledc_channel_t>(i)));
}
#else
(void)end; // Dry-run only. Never attach LEDC to these outputs.
#endif
}
static void owner_task(void*) {
MotionGuard guard;
TickType_t tick = xTaskGetTickCount();
End previous_output = End::Unknown;
State previous_state = State::Idle;
Fault previous_fault = Fault::None;
End previous_position = End::Unknown;
for (;;) {
Command command = Command::None;
if (stop_requested.exchange(false)) {
xQueueReset(commands); // discard queued motion commands on explicit stop
command = Command::Stop;
} else {
xQueueReceive(commands, &command, 0);
}
const auto now = static_cast<std::uint32_t>(esp_timer_get_time()/1000);
const Limits limits{gpio_get_level(HEAD) == 0, gpio_get_level(BOTTOM) == 0};
const End out = guard.step(now, limits, command);
if (out != previous_output) { apply_output(out); previous_output = out; }
if (guard.state() != previous_state || guard.fault() != previous_fault ||
guard.position() != previous_position || command != Command::None) {
ESP_LOGI(TAG, "t=%lu head=%d bottom=%d cmd=%d state=%d fault=%d pos=%d request=%d output_enabled=%d",
static_cast<unsigned long>(now), limits.head, limits.bottom,
static_cast<int>(command), static_cast<int>(guard.state()),
static_cast<int>(guard.fault()), static_cast<int>(guard.position()),
static_cast<int>(out),
#if CONFIG_PHONEBED_ENABLE_OUTPUT
1
#else
0
#endif
);
previous_state = guard.state(); previous_fault = guard.fault();
previous_position = guard.position();
}
vTaskDelayUntil(&tick, pdMS_TO_TICKS(10));
}
}
static void console_task(void*) {
for (;;) {
const int ch = std::getchar();
if (ch == 's') { stop_requested.store(true); continue; }
const Command cmd = ch == 'o' ? Command::Open : ch == 'c' ? Command::Close :
ch == 'r' ? Command::Reset : Command::None;
if (cmd != Command::None && xQueueSend(commands, &cmd, 0) != pdTRUE)
ESP_LOGW(TAG, "Command queue full; request rejected");
if (ch == EOF) { clearerr(stdin); vTaskDelay(pdMS_TO_TICKS(10)); }
}
}
extern "C" void app_main() {
commands = xQueueCreate(8, sizeof(Command));
configASSERT(commands);
init_gpio_pwm();
ESP_LOGI(TAG, "o=open c=close s=stop r=reset; no automatic homing on boot");
configASSERT(xTaskCreate(owner_task, "motor_owner", 4096, nullptr, 5, nullptr) == pdPASS);
configASSERT(xTaskCreate(console_task, "motor_console", 3072, nullptr, 4, nullptr) == pdPASS);
}
LICENSE
MIT License
Copyright (c) 2026 Independent motor-control example contributors
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
取材项目为HTXStudio手机床,团队HTX工作室。项目网址 https://oshwhub.com/htx-studio/project_kpilekig 。源码镜像 https://gitee.com/htxstudio/PhoneBed ,本次审查提交 7ba4b45b5c64ce00f853d2fdd75e302116d8b0d9,许可MIT,Copyright (c) 2026 htx-studio。实物照片来自该提交的Docs/Images,原理图与PCB来自项目在线EDA工程2026-03-31版,在2026-10-10重新导出。图片只作裁剪、缩放、版面组合及轻度锐化,PCB丝印与电路信息保留;软件轨迹由本文独立例程运行生成。下载硬件包包含旧版,本文图、位号与引脚表以在线Controller/MotorLimit版为准。
参考资料为TI官方DRV8870与DRV5032数据手册,分别核对Bridge Control Table、Pin Functions与Device Comparison;以及Espressif ESP-IDF v5.5.4的GPIO、LEDC和ESP Timer接口。完整入口 https://www.ti.com/document-viewer/DRV8870/datasheet 、https://www.ti.com/document-viewer/DRV5032/datasheet 、https://docs.espressif.com/projects/esp-idf/en/v5.5.4/esp32s3/ 。
本文独立完成目标、位置、故障分离的控制核心、ESP32-S3适配与测试。复用的原控制文件保留MIT许可,作为主机复现对照;所有编译与合成输入结论均不作为整机实体效果证明。
技术交流,欢迎关注微信公众号:美男子玩编程。




