0x00 项目动机
去年夏天在深圳租房时,笔者注意到前任租客留下的一个「智能」开关灯工具:
![](https://www.ruanx.net/content/images/2024/07/p1.jpg)
这看起来是一个舵机,应该是无线遥控的。笔者有点好奇它的工作原理:
- 采用哪种无线通讯方式。这决定了耗电量和 MCU 选型。可选的主流协议有 WiFi、BLE 和 Zigbee。
- 如何集成到智能家居中。笔者的主要使用苹果方案,大约可以通过 Home Assistant 接入这些 DIY 产品。
- 如何供电。如果是交流转直流,则需要拉线供电,但外观上没有看到供电线。由于上图中的 86 盒是明装的,不排除供电模块放在了 86 盒中,但是单火线如何取电也是个大难题。如果是电池供电,则功耗和电流峰值都会受到严重限制(参考这篇讨论),且需要定期更换电池。
现在,笔者也动手复刻一个遥控开关出来。笔者打算用 http 协议控制灯具,因此选择了 ESP32-C3。至于供电,由于笔者要控制的开关恰巧离桌面很近,所以可以用 usb 线供电。舵机则采用最常见的 9g 舵机。
软件方面,使用 MicroPython 以简化开发。
0x01 舵机控制
我们先在 RP2040 上学习舵机控制。根据文档,舵机本身有闭环控制系统,我们只需通过 PWM 向其输入「旋转角度」数据。PWM 周期为 20ms(50Hz),由占空比表示期望的角度,高电平时间最小 1ms,最大 2ms。由此写出程序:
#include "pico/stdlib.h"
#include <cstdio>
const static int pin = 26;
void init() {
gpio_init(pin);
gpio_set_dir(pin, GPIO_OUT);
}
// 发送一次 pwm 波形
void send_pulse(uint us) {
auto us_high = 1000 + us;
gpio_put(pin, true);
busy_wait_us(us_high);
gpio_put(pin, false);
busy_wait_us(20000 - us_high);
}
// 连续发送 1s,以等待舵机旋转到位
void move_to(uint pos) {
for(int j=0; j<50; j++) {
send_pulse(pos);
}
}
int main() {
stdio_init_all();
init();
while(true) {
move_to(0);
move_to(1000);
}
}
现在舵机开始重复进行 90° 旋转了。也可以用 pwm 外设实现更高的精准度:
#include "pico/stdlib.h"
#include "hardware/pwm.h"
#include <cstdio>
class Servo {
private:
uint pin, pwm_slice, pwm_chan;
public:
explicit Servo(int pin) {
this->pin = pin;
gpio_set_function(pin, GPIO_FUNC_PWM);
pwm_slice = pwm_gpio_to_slice_num(pin);
pwm_chan = pwm_gpio_to_channel(pin);
// 将 PWM 时钟分频到 1MHz,每 1us 自增 counter
pwm_set_clkdiv_int_frac(pwm_slice, 125, 0);
// counter 设为从 0 到 20000,即 20ms
pwm_set_wrap(pwm_slice, 20000);
pwm_set_chan_level(pwm_slice, pwm_chan, 0);
pwm_set_enabled(pwm_slice, true);
}
void set_high_us(uint us) const {
pwm_set_chan_level(pwm_slice, pwm_chan, us);
}
};
int main() {
stdio_init_all();
auto srv = Servo(26);
while(true) {
int val;
printf("(cmd) ");
scanf("%d", &val);
printf("high %d us\n", val);
srv.set_high_us(val);
}
}
现在使用 ESP32-C3 来控制舵机。本文使用了 micropython-servo 库,看看源码:
import machine
import math
class Servo:
def __init__(self,pin_id,min_us=544.0,max_us=2400.0,min_deg=0.0,max_deg=180.0,freq=50):
self.pwm = machine.PWM(machine.Pin(pin_id))
self.pwm.freq(freq)
self.current_us = 0.0
self._slope = (min_us-max_us)/(math.radians(min_deg)-math.radians(max_deg))
self._offset = min_us
def write(self,deg):
self.write_rad(math.radians(deg))
def read(self):
return math.degrees(self.read_rad())
def write_rad(self,rad):
self.write_us(rad*self._slope+self._offset)
def read_rad(self):
return (self.current_us-self._offset)/self._slope
def write_us(self,us):
self.current_us=us
self.pwm.duty_ns(int(self.current_us*1000.0))
def read_us(self):
return self.current_us
def off(self):
self.pwm.duty_ns(0)
使用起来也十分简单,只需用 write()
方法发送期望旋转角度。在实践上,我们采用比较长的一字型摇臂,平时将摇臂摆在与墙面大致平行的角度,进行动作时,摆到特定位置,触发电灯开关,然后立即返回。通过实验找到合适的角度,最终代码如下:
from servo import Servo
my_servo = Servo(pin_id=0)
base = 75
def move_to(x):
my_servo.write(x)
time.sleep(0.2)
my_servo.write(base)
time.sleep(0.2)
my_servo.off()
move_to(base)
status = 'A'
def flip():
global status
if status == 'A':
move_to(60)
status = 'B'
else:
move_to(100)
status = 'A'
0x02 HTTP 服务
我们采用 MicroPyServer 库建立服务端,这个库没有发布到 pypi,需要手动将代码拷贝到 mcu 中。
完整代码:
import machine
from machine import Pin
import time
from servo import Servo
my_servo = Servo(pin_id=0)
base = 75
def move_to(x):
my_servo.write(x)
time.sleep(0.2)
my_servo.write(base)
time.sleep(0.2)
my_servo.off()
move_to(base)
status = 'A'
def flip():
global status
if status == 'A':
move_to(60)
status = 'B'
else:
move_to(100)
status = 'A'
import network
wlan = network.WLAN(network.STA_IF)
wlan.active(True)
wlan.connect('******', '***********')
print(wlan.ifconfig())
from micropyserver import MicroPyServer
def show_index(request):
html = '''
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf8">
<meta name="viewport" content="width=device-width, initial-scale=1">
</head>
<body>
<div>
<button style="display: block; width: 100%; height: 20vi; font-size: xx-large;" onclick="do_flip()">flip</button>
</div>
</body>
<script>
function do_flip() {
var xhttp = new XMLHttpRequest();
xhttp.open("GET", "flip", true);
xhttp.send();
}
</script>
</html>
'''
server.send("HTTP/1.0 200 OK\r\n")
server.send("Content-Type: text/html\r\n\r\n")
server.send(html)
def handler_flip(request):
server.send("HTTP/1.0 200 OK\r\n")
server.send("Content-Type: text/html\r\n\r\n")
server.send('ok')
flip()
server = MicroPyServer()
server.add_route("/", show_index)
server.add_route("/flip", handler_flip)
server.start()
现在我们有简单的界面了,按一下按钮之后,浏览器会访问 /flip
,让舵机去干活。
![](https://www.ruanx.net/content/images/2024/07/QQ_1720760372391.png)
![](https://www.ruanx.net/content/images/2024/07/c737f1f707f8bb5b0afae24b54065b62.png)
0x03 集成到 iphone 和 ipad
我们没有接入 homekit,但是仍然可以利用 ios 的「快捷指令」达成「按一下按钮则开关灯」的效果。采用「获取 URL 内容」来发起 GET 请求,如下:
![](https://www.ruanx.net/content/images/2024/07/49d1e7e210445a1fefd00ab9681bd6e5.png)
![](https://www.ruanx.net/content/images/2024/07/e333f6653c3b73841a6b4b50887feb3a.png)
![](https://www.ruanx.net/content/images/2024/07/fe309d6611c440782b2c2c7d4132f1cc_720.png)
快捷指令会自动同步到 ipad。现在,我们按一下桌面上的图标,就能控制灯具了。