宿舍里的空调用的是海享汇,平时只能用指定的微信小程序控制,没法接入米家。对于习惯了语音控制的人来说确实不太方便。为了能让小爱同学使唤它,我这几天折腾了一下,自己写了个服务把它接进去了。
这里简单记录一下整个探索过程和踩的坑,权当是个复盘。
第一步:抓包找接口
想自己控制设备,第一步肯定得先搞清楚小程序是怎么发指令的。
我用手机抓了一下包,在小程序里点了几下空调开关,很容易就找到了对应的 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 语言。
代码逻辑主要分两块:
- 建一个 TCP 客户端连上巴法云的
8344 端口,订阅刚才建的主题,并保持一个 30 秒一次的心跳防掉线。
- 只要 TCP 收到
msg=on 或 msg=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" )
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) } }
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")
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) } }
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() }
|