0x00 项目动机

去年夏天在深圳租房时,笔者注意到前任租客留下的一个「智能」开关灯工具:

这看起来是一个舵机,应该是无线遥控的。笔者有点好奇它的工作原理:

  • 采用哪种无线通讯方式。这决定了耗电量和 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);
    }
}
💡
虽然网上的文档中声称高电平范围是 1ms 到 2ms,但笔者手上的这颗舵机,高电平时长范围大约是 0.3ms 到 2.5ms,可实现 180 度旋转。

现在使用 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,让舵机去干活。

0x03 集成到 iphone 和 ipad

我们没有接入 homekit,但是仍然可以利用 ios 的「快捷指令」达成「按一下按钮则开关灯」的效果。采用「获取 URL 内容」来发起 GET 请求,如下:

快捷指令会自动同步到 ipad。现在,我们按一下桌面上的图标,就能控制灯具了。