记一次折腾:把微信小程序控制的宿舍空调接入米家

宿舍里的空调用的是海享汇,平时只能用指定的微信小程序控制,没法接入米家。对于习惯了语音控制的人来说确实不太方便。为了能让小爱同学使唤它,我这几天折腾了一下,自己写了个服务把它接进去了。

这里简单记录一下整个探索过程和踩的坑,权当是个复盘。

第一步:抓包找接口

想自己控制设备,第一步肯定得先搞清楚小程序是怎么发指令的。

我用手机抓了一下包,在小程序里点了几下空调开关,很容易就找到了对应的 HTTP 请求。接口大概长这样:

  • 发送 POST 请求到 https://api.haixianghui.com/api/air/remote/open/设备ID 就是开机。
  • open 换成 close 就是关机。

请求头里最关键的一个参数是 Authorization: Bearer <Token>。这个 Token 就是身份凭证,只要拿到它和设备 ID,我们就可以自己发请求了。我首先顺手用 Python 写了几行代码试了一下,确实能成功开关机,这就说明接口可以脱离小程序独立调用。

第二步:用巴法云做中转

接口有了,怎么把它连到米家呢?这里我用到了智能家居玩家比较熟悉的“巴法云(Bemfa)”。

米家 App 是原生支持绑定巴法云账号的。我在巴法云控制台建了一个以 002 结尾的主题(在巴法云的规则里,002 代表开关类设备)。建好之后,米家 App 里就会同步出现一个虚拟开关。

接下来的整体思路就很清晰了:米家点开关 -> 指令发给巴法云 -> 我在本地跑个程序监听巴法云 -> 收到指令后去请求空调的接口。

第三步:用 Go 语言写本地服务

这个本地监听服务我打算放在宿舍里的飞牛 NAS(Linux 系统)上跑。为了省事,不想在 NAS 上折腾 Python 环境和依赖,我选了 Go 语言。

代码逻辑主要分两块:

  1. 建一个 TCP 客户端连上巴法云的 8344 端口,订阅刚才建的主题,并保持一个 30 秒一次的心跳防掉线。
  2. 只要 TCP 收到 msg=onmsg=off 的消息,就去调第一步抓包拿到的 HTTP 接口。

写完之后,利用 Go 的交叉编译,敲一行命令就能打出一个独立的 Linux 二进制文件:

Bash

1
CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -o air-bridge main.go

加上 CGO_ENABLED=0 强制静态编译后,这个文件不需要任何外部依赖就能跑,非常清爽。

第四步:Docker 部署与 scratch 镜像

既然要在 NAS 上跑,为了不弄脏宿主机环境,肯定要用 Docker 部署。

本来想用常规的 alpine 作为基础镜像,但遇上了老大难的网络问题,拉取镜像一直卡死在获取元数据那一步。后来转念一想,我的 Go 程序已经是纯静态编译了,根本不需要 Linux 系统的底层库,那我为什么还要下个操作系统?

于是我直接换成了 Docker 自带的 scratch 镜像。它是一个 0 字节的空白镜像,完全不需要联网下载。Dockerfile 简单到只有四行:

Dockerfile

1
2
3
4
FROM scratch
WORKDIR /app
COPY air-bridge .
CMD ["./air-bridge"]

这样一改,打包瞬间完成,而且最终生成的 Docker 镜像不到 10MB。配合 docker-compose.yml 把配置文件挂载进去,以后 Token 过期了直接在外面改文本就能热更新。

第五步:意料之外的两个坑

本以为这就顺利搞定了,结果在实际测试的时候,接连踩了两个坑:

坑一:HTTPS 证书报错

运行后日志里提示 x509: certificate signed by unknown authority

原因是 scratch 镜像实在太空了,连操作系统自带的“根证书信任列表”都没有,导致 Go 发起 HTTPS 请求时验证不了服务器的证书。解决办法也简单粗暴,既然是自己抓的接口,我直接在 Go 代码的 HTTP 客户端里加了个 InsecureSkipVerify: true,跳过证书校验。

坑二:改了代码却没生效

我在本地改完证书跳过的代码后,重新用 docker compose up --build 打包,发现还是报上面那个错。

排查了半天反应过来:我在本地改了源码,但Docker 打包时直接用了带旧代码的缓存镜像。最后把旧文件删了,重新编译生成,终于成功上线。

总结

搞定之后,对小爱同学喊一句开空调,几乎是秒响应,体验挺好。这次折腾不仅把没法接入生态的设备拉了进来,还顺便实践了一把 Go 的静态编译和极轻量的 scratch 镜像部署,算是一个挺实用的小项目。


附录

GO语言主程序:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
package main

import (
"bufio"
"bytes"
"crypto/tls"
"encoding/json"
"fmt"
"io"
"log"
"net"
"net/http"
"os"
"strings"
"time"
_ "time/tzdata" // 适配 scratch 镜像,内置时区数据
)

// Config 对应 config.json 结构
type Config struct {
DeviceID string `json:"device_id"`
Token string `json:"token"`
BemfaUID string `json:"bemfa_uid"`
BemfaTopic string `json:"bemfa_topic"`
}

var config Config

func loadConfig(path string) {
file, err := os.Open(path)
if err != nil {
log.Fatalf("无法打开配置文件: %v", err)
}
defer file.Close()
if err := json.NewDecoder(file).Decode(&config); err != nil {
log.Fatalf("解析配置文件失败: %v", err)
}
}

// 发送控制指令到海享汇 API
func sendAirCommand(action string) {
url := fmt.Sprintf("https://api.haixianghui.com/api/air/remote/%s/%s", action, config.DeviceID)
req, _ := http.NewRequest("POST", url, bytes.NewBuffer(nil))

// 模拟小程序请求头
req.Header.Set("Authorization", "Bearer "+config.Token)
req.Header.Set("requestfrom", "wx_ma")
req.Header.Set("content-type", "application/json")

// 关键:忽略 HTTPS 证书校验(解决 scratch 镜像无根证书问题)
tr := &http.Transport{
TLSClientConfig: &tls.Config{InsecureSkipVerify: true},
}
client := &http.Client{Timeout: 10 * time.Second, Transport: tr}

resp, err := client.Do(req)
if err != nil {
log.Printf("[API] 请求异常: %v\n", err)
return
}
defer resp.Body.Close()

if resp.StatusCode == 200 {
log.Printf("[API] 成功:空调已执行 %s 操作\n", action)
} else {
log.Printf("[API] 失败,状态码: %d\n", resp.StatusCode)
}
}

// 维持巴法云 TCP 连接与心跳
func startBemfaClient() {
address := "bemfa.com:8344"
for {
conn, err := net.DialTimeout("tcp", address, 10*time.Second)
if err != nil {
log.Printf("[TCP] 连接失败: %v,5秒后重试...\n", err)
time.Sleep(5 * time.Second)
continue
}

// 订阅主题
subMsg := fmt.Sprintf("cmd=1&uid=%s&topic=%s\r\n", config.BemfaUID, config.BemfaTopic)
conn.Write([]byte(subMsg))

// 开启心跳协程
go func() {
for {
time.Sleep(30 * time.Second)
if _, err := conn.Write([]byte("ping\r\n")); err != nil {
return
}
}
}()

// 循环读取指令
reader := bufio.NewReader(conn)
for {
line, err := reader.ReadString('\n')
if err != nil {
break
}
msg := strings.TrimSpace(line)
if strings.Contains(msg, "msg=on") {
sendAirCommand("open")
} else if strings.Contains(msg, "msg=off") {
sendAirCommand("close")
}
}
conn.Close()
time.Sleep(5 * time.Second)
}
}

func main() {
os.Setenv("TZ", "Asia/Shanghai")
loadConfig("./config.json")
log.Println("=== 运行版本:V2 (已跳过证书校验) ===")
log.Println("=== 桥接服务启动 ===")
startBemfaClient()
}