使用 Raspberry Pi pico 做一个智能车库门

  • fennng 

上周的时候,我弄了一个树霉派控制继电器的小程序,然后把它我的车库门遥控器连在一起,瞬间把我的车库门变成智能的,可以用手机控制。

思路相当的简单,在树霉派中运行一个简单的WEB程序, 然后写一个 POST 接口, 当这个接口被呼叫的时候,把一个GPIO的针设为高,延时一秒后,把这个针再设为低。 这个GPIO针连接着一个 继电器, 继电器连在车库门钥匙上。 所以每次这个接口被呼叫的时候,就等于按一下车库门按钮。 这样就可以通过一个网址在手机上轻松的控制车库门了。

树霉派上的代码大概如下

pin = 17
led = LED(pin)
led.on()
from flask import Flask, request, jsonify
import time

app = Flask(__name__)

# Define the GPIO chip and line

@app.route('/control_relay/some-thing-long-hard-to-guess', methods=['POST'])
def control_relay():
    if request.method == 'POST':
        try:
            # Set the line high (turn on the relay)
            led.off()
            print("relay pass through")
            time.sleep(1)  # Keep the relay on for 1 second

            # Set the line low (turn off the relay)
            led.on()
            print("relay block")

            return jsonify({'message': 'Success'})

        except Exception as e:
            return jsonify({'error': str(e)})

        finally:
            # Release the line
            pass

if __name__ == '__main__':
    app.run(host='0.0.0.0', port=5001)

然后树霉派的 pin 17 (物理第11根针, 17是 BCM的编号)连到继电器的In针。 随便一个3.3V的针连继电器的VCC, 再随便一个GND针连继电器的GND。 再把继电器的公共端和常开端连到车库门遥控的微动开关两连就行了,这里可能要焊一下。其它地方都是面包板插线就行。

很显然,让树霉派做这事有点大材小用了,所以我就买了个PICO来代替它。 PICO的代码要复杂一些,因为它要在代码里去连接WIFI。建议使用 thonny 来安装各种依赖, 如 Microdot等。 Raspberry pi pico 中 mip 不好用, 很多库找不到。 而且要先跑一下 wifi 代码连上WIFI才能用。 代码如下:

import os
from time import sleep
import network
from microdot import Microdot
from machine import Pin
from picozero import pico_temp_sensor, pico_led, LED, Button

# Define the button (assuming the button is connected to GP14)
button = Button(14)

# Function to handle button press
def on_button_pressed():
    print("Button pressed, exiting program...")
    machine.reset()  # Reset the Pico to exit the program

# Assign the button press handler
button.when_pressed = on_button_pressed

led = LED(13) # Use GP13

# Function to get storage information
def get_storage_info():
    stats = os.statvfs('/')
    total_space = stats[0] * stats[2]
    free_space = stats[0] * stats[3]
    return total_space, free_space

# Function to format size in human-readable format
def format_size(size):
    for unit in ['B', 'KB', 'MB', 'GB', 'TB']:
        if size < 1024:
            return f"{size:.2f} {unit}"
        size /= 1024

# Connect to Wi-Fi network
def connect_to_wifi(ssid, password):
    wlan = network.WLAN(network.STA_IF)
    wlan.active(True)
    wlan.connect(ssid, password)
    elapsedTime = 0
    while not wlan.isconnected():
        print('Waiting for connection...')
        if (elapsedTime % 2):
            pico_led.off()
        else:
            pico_led.on()
        sleep(1)
        elapsedTime = elapsedTime + 1
        

    ip = wlan.ifconfig()[0]
    print(f'Connected to {ssid} at {ip}')
    for _ in range(3):
        pico_led.on()
        sleep(0.1)
        pico_led.off()
        sleep(0.1)
    return ip

# Generate HTML page with current temperature and LED state
def generate_webpage(temperature, state):
    html = f"""
        <!DOCTYPE html>
        <html>
        <body>
        <p>LED is {state}</p>
        <p>Temperature is {temperature}°C</p>
        </body>
        </html>
    """
    return html

    

# Create an instance of picoweb WebApp
app = Microdot()

@app.route("/")
async def index(req):
    temperature = pico_temp_sensor.temp
    global state
    state = 'on' if led.value else 'off'
    html_content = generate_webpage(temperature, state)
    headers = {
        'Content-Type': 'text/html; charset=utf-8',
        'Content-Encoding': 'utf-8'
    }
    return html_content, 200, headers

# Handler for /lighton endpoint
@app.route('/lighton/longstring', methods=['GET'])
def lighton(req):
    global state
    led.on()
    state = 'ON'
    return {'message': 'Success'}

# Handler for /lightoff endpoint
@app.route('/lightoff/longstring', methods=['GET'])
def lightoff(req):
    global state
    led.off()
    state = 'OFF'
    return {'message': 'Success'}

@app.route('/control_relay/longstring', methods=['POST'])
def control_relay(req):
    
 # Set the line high (turn on the relay)
    led.on()
    print("relay pass through")
    sleep(1)  # Keep the relay on for 1 second

    # Set the line low (turn off the relay)
    led.off()
    
    print("relay block")

    return {'message': 'Success'}

# Function to handle client requests and serve web pages
def serve_clients():
    global state
    state = 'OFF'
    pico_led.off()
    temperature = 0
    while True:
        try:
            yield from app
        except Exception as e:
            print("Exception:", e)

import ulogging as logging
logging.basicConfig(level=logging.INFO)

try:
    total, free = get_storage_info()
    print(f"Total storage: {format_size(total)}")
    print(f"Free storage: {format_size(free)}")

    # Flash LED to indicate starting
    for _ in range(2):
        pico_led.on()
        sleep(0.5)
        pico_led.off()
        sleep(0.5)

    # Connect to Wi-Fi and get IP address
    ssid = 'sweet-home'
    password = 'password'
    ip = connect_to_wifi(ssid, password)

    # Start serving clients
    
    # Run the web server on 0.0.0.0:80
    host = '0.0.0.0'
    port = 80
    pico_led.on()
    led.off()

    print(f"Starting web server on {host}:{port}")
    app.run(host=host, port=port, debug=True)


except KeyboardInterrupt:
    print("\nServer stopped by user.")
    machine.reset()


按上面操作就可以工作。但是现实往往没有那么美好。我们还要考虑得更多一些, 否则小偷可能会进入你大开的车库门!!!

安全第一,安全第一,安全第一!重要的事情说三遍。

因为安全的原因,要不少额外的设定,在树霉派上要比在PICO上要简单一些。 PICO要多花费不少工夫。

有哪些安全要素呢? 首先第一点是断电问题。 如果家里断电又来电了,车库门会不会自己打开。 如果树霉派重新启动了,车库门会不会打开。 继电器的电压够不够让继电器可靠的工作? 然后第二点就是网站的安全问题,如果你使用 HTTP访问你的网站来控制继电器, 你的网址可能被黑客或者爬虫看到。 爬虫就抓网页的时候就可能把你的车库门给打开。

这几个问题并不容易解决。我们一个一个来看。

如果家里突然断电了会怎么样? 断电后所有东西都没电,所以这时候车库门也没有电,并不会有什么问题。 但是当电力恢复的时候问题就来了。 我发现我使用的GOIP17在树霉派刚启动的时候是2.8V左右。 如果这时候连着继电器,继电器将会启动。这样就等于车库门遥控被按了下去。 这时候车库门就可能被打开。

所以我们需要找到一个在启动的时候是 PULL DOWN状态的PIN,这样才能避免这种断电的情况。 对我来说,因为我买到的继电器是低压触发的,所以在启动的时候是 PULL UP的PIN对我来说反而是刚刚好。 因为继电器平常是在非闭合状态,如果断电了,断电器保持非闭合状态, 突然来电了,因为GPIO 17一通电就是高位,继电器会保持在非闭合状态。然后等到我的程序自动开始运行, 运行后会把GPIO 17 设为3.3V (启动的时候是2.8V左右),这样还是保持在高位,继电器还是非闭合状态。 所以在整个重启的过程中, 继电器都不会闭合,没有打开车库门的风险。如果你有认真看上面的代码, 就会发现,我是用 led.off() 来代表按下按钮的。

在PICO上我就遇到了麻烦。 因为PICO上所有的PIN在刚启动的时候都是0V。 因为我的继电器是低压触发的,这样会导致PICO重启后我的继电器会触发按下车库门遥控。 所以我在面包板上加了几个电阻和晶体管把它转成了高压触发的。我在Youtube上的一个视频中找到了转换的方法。这个方法修复了两个东西,首先我的这个继电器不能在3.3V工作,而且是低压触 发的,通过这个方法转换后,它变成高压触发,而且也可以用3.3V来触发了。原理是VCC直接通过一个5K的电脑联到In脚,这样一通电, IN脚就有5V电压,使得它默认是高压,因为它是低压触发的,这样一来,一通电,它就高压了,不会触发。 当GOIO发送3.3V电压时,三极管被连通,这时候200R的电阻和IN的电阻并联,并联后电阻被小,原来5K的电阻是抢不到电压的,因为IN的电阻很大。但是现在并联后IN和200R并联的电阻就很小了,使5K的电阻可以分去一半的电压。In的电压由5V降到2.8V左右。这时候继电器就联通了。 https://www.youtube.com/watch?v=-wiygjMviFo&ab_channel=Aviprink 

我发现我这个继电器如果VCC和IN都连3.3V的时候,是可以工作的。 但是继电器上标的工作电压是5V, 所以把线圈也连3.3V可能不能可靠的工作,所以最好不要这样做。 用上面的方法转成高压触发应该是更好的办法。

我如果把车库遥控接在常闭开关(而不是常开),能不能直接解决这个问题呢。在没电的时间, 常闭开关是闭合的,这时候等于按下了车库门钥匙。这肯定不是个好主意。因为车库门钥匙是用电池的,它还在工作, 长时间按着按钮估计会有问题。而且,如果这时候是树霉派重启,车库门有电的情况,会把车库门打开。

现在来聊聊HTTP的安全问题。HTTP是不安全的,所以我们一定要使用HTTPS。否则决不能暴露在外网。 这里有多种方案,显然PICO的选择要少一些。

  • 使用VPN,如果你已经架设好了VPN,你可以在手机上连到你家的VPN再访问HTTP
  • 使用WIFI,如果只是要在车库门前打开车库门,你完全可以手机连上家里的WIFI再访问HTTP
  • 使用NGROK,localtonet 等端口转发服务。 我在树霉派中就是用了这个方法,我安装了 localtonet 并运行,这样我就可以用 https 访问我的HTTP了。 这个方法 PICO没办法使用。
  • 使用 nginx-proxy, 可以在树霉派中运行 nginx-proxy 来设置HTTPS的反向代理。 要在路由器中NAT nginx-proxy 所需的端口。这个方法 PICO没办法使用。
  • 使用 FRPS + FRPC设置反向代理。

换成PICO后,我用的是 FRPS的方法,因为我有不少VPS可以用,我的华硕 RT-AX86U 可以使用 cool center 安装 frpc 客户端。这个方法可以让我的PICO很好的工作。

首先登录到VPS上, 安装 FRPS。好吧,我承认我是个懒人,我没有安装,我直接用的docker 版。 https://hub.docker.com/r/cloverzrg/frps-docker 

准备配置文件

# vi /home/fennng/frps/frps.ini
[common]
bind_port = 7700
token = hidden
vhost_http_port = 8080
vhost_https_port = 8443

dashboard_port = 7500
dashboard_user = admin
dashboard_pwd = hidden


tcp_mux = true
max_pool_count = 10

然后运行一下就可以了

sudo docker run -d --name frp-server -p 7700:7700 -p 8080:8080 -p 7500:7500 -v /home/fennng/frps/conf:/conf --restart=always cloverzrg/frps-docker

这个配置是没有设置 HTTPS的,所以最好不要直接访问。 我只在防火墙开了7700的端口,因为客户端需要连接使用。 而 8080, 8443和 7500我都没有开。 需要用后台的话,我直接 ssh 上去做个端口映射就能访问 7500了。

设置可以参考这里
https://github.com/fatedier/frp 

8443是没有用的, 因为我没设置证书。 我的VPS上本来就跑了 nginx-proxy, 所以我只要给我的 frps 设置个新域名就行了。

sudo docker rm -f frp-server
sudo docker run  \
        -e "VIRTUAL_HOST"="smarthome.fengshare.com" \
        -e "VIRTUAL_PORT"="8080" \
        -e "LETSENCRYPT_HOST=smarthome.fengshare.com" \
        -e "LETSENCRYPT_EMAIL=soody@qq.com" \
        --network wp-net \
        --network-alias  smarthome-host \
        -d --name frp-server -p 7700:7700 -p 8080:8080 -p 7500:7500 -v /home/fennng/frps/conf:/conf --restart=always cloverzrg/frps-docker

这样,我只要访问 smarthome.fengshare.com  这个域名, 我的 nginx-proxy 会直接把请求转发到 frps 的 8080, 然后 frps 会把请求到发给 frpc, frpc 就会把请求发到我 PICO上。 这里其实是用到了两次反向代理和一次管道加密的。

FRPS装好后就在是 cool center 安装 frpc, 然后设置一下

设置好后可以进一下 FRPS的后台看看有没有连上

关于PICO的一些经验:

官方有些教程省略了安装固件的一步,导至新手怎么都连不上 thonny (只能认出几个串口,但没有认出是 raspberry pi pico), 不是 pico 坏了,而是要先安装固件。 Thonny 不会帮你安装固件的。

放置main.py 文件要小心,如果是个常驻留的程序,可能导至 thonny 连不上,可以用写一段代码使用 button 来中断。否则需要使用 flash_nuke.uf2 固件清空文件再重新安装官方固件。

ampy 执行程序如果错误,或者用 ctrl+c中断 (用 screen 联上后中断),就要断电重启才能联上。加上 try catch 能解决这个问题。

from time import sleep
while True:
  try:
    print("Running...")
    sleep(1)
  except KeyboardInterrupt:
    machine.reset()
  break

安装 picoweb, 现在不能使用 upip安装东西,网上教程过时。 要用 mip, 但好多包找不着, 比如 picoweb. (https://github.com/orgs/micropython/discussions/9565 )

mip.install(“pkg_resources”)
mip.install(“github:pfalcon/picoweb/picoweb/init.py”, target=”/lib/picoweb”)
mip.install(“github:pfalcon/picoweb/picoweb/utils.py”, target=”/lib/picoweb”)

安装软件要先联网。 还是推荐用 thonny 安装。我是因为把 pico 连在了 PI上, 然后SSH到PI上使用才需要用到MIP。 其实用 thonny 下载好后, 再用 scp 复制到 PI上, 再ampy put 到 pico 的 /lib 里面也是可以的。

使用 screen 连到 pico 的 REPL

sudo screen /dev/ttyACM0 115200

连上后可以粘贴这个代码先连上WIFI

import os
import network
import socket
from time import sleep
import machine
import sys

ssid = 'sweet-home'
password = 'password'

def connect():
    #Connect to WLAN
    wlan = network.WLAN(network.STA_IF)
    wlan.active(True)
    wlan.connect(ssid, password)
    while wlan.isconnected() == False:
        print('Waiting for connection...')
        sleep(1)
    ip = wlan.ifconfig()[0]
    print(wlan.ifconfig())
    print(f'Connected on {ip}')
    return ip


try:
    ip = connect()

except KeyboardInterrupt:
    machine.reset()

使用 ampy 直接运行代码

ampy --port /dev/ttyACM0 run pico-web.py

发表评论

您的电子邮箱地址不会被公开。 必填项已用*标注