HomeKit + Swift + TV + Raspberry Pi

前言:前几个月看到 homebridge (opens new window) 项目,想着是不是可以拿个树莓派加个红外 LED 就能完成空调控制,这样不用再找空调在哪,也可以接入一些场景(比如温度过高自动开空调)实现自动化功能。wow awesome! 经历了成功用 lirc 解析红外和发射红外后,发现米家空调遥控可以直接接入 homebridge。沉思片刻,当然是选择已经做好的米家空调加 homebridge-mi-aqara (opens new window)。最后因为买了不适配的空调遥控,一直没能使用 Siri 控制空调。直到我发现已经有人写了 Swift 版本的 HAP 和 GPIO,那么可以写点什么了,先来个简单的开关电视。

Demo 地址:GitHub - DianQK/HAP-IR-Demo (opens new window)。 Python 版本(多亏了这篇文章帮助我发出红外信号):Sending Infrared Commands From a Raspberry Pi Without LIRC (opens new window)

本文使用的环境和设备:

  • 树莓派 3B/4B
  • 38kHz 红外接收
  • 红外 LED
  • Swift 5.0.x
  • Raspberry Buster

为了减少和硬件交互的成本,本次我选了一个集成度较高的红外模块,支持接受和发射,还带有相应的使用文档 (opens new window)

建议后期选择单独的小模块,这个连接树莓派后基本就没地方安别的了。 经过一顿操作我学会了使用 lirc 记录红外信号、发射红外信号。 lirc 的使用体验并不好,一是配置复杂(而且 Google 到的配置也有些过时 (opens new window)),二是发送一个红外信号是基于配置文件,不利于发射各种信号。 至少有了跑通的 lirc,这能为使用 Swift 处理红外增加一个调试验证的功能。

# 使用 SwiftGPIO

SwiftGPIO 基于 2.0.0-beta7 (opens new window) 。 短短几行就可以完成红外接收处理:

import SwiftyGPIO
import Foundation
#if os(Linux)
import Glibc
#else
import Darwin.C
#endif
let gpios = SwiftyGPIO.GPIOs(for:.RaspberryPi3)
let gp = gpios[.pin18]! // 我的接受红外引脚为 GPIO18
gp.direction = .input
gp.onChange { (gpio) in
    print(gpio.value)
}
RunLoop.main.run()

运行一下,按按遥控器即可收到 true/false 切换的输出,成功接收到红外信号。

# RCA 红外协议

是时候了解一下红外协议了!主流的红外协议用的都是 NEC (opens new window)(家里电视可能用的 RCA (opens new window))。 区别不大,都是通过脉冲长短区分 0 和 1,本文用到的 TCL 电视用的是 RCA,就以 RCA 为例。

图片取自: https://www.sbprojects.net/knowledge/ir/rca.php (opens new window)

表示逻辑 1 和逻辑 0:

  • 发射 500us 脉冲后停留 2000us 表示逻辑 1
  • 发射 500us 脉冲后停留 1000us 表示逻辑 0

而一次 RCA 指令包含:

  • 一次 4000us 脉冲 4000us 空闲的起始指令
  • 4 位地址码
  • 8 位指令码
  • 4 位地址码反码
  • 8 位指令码反码
  • 结束时还有一次 500us 脉冲

# 解码红外信号

既然红外信号是按照时间长短计算的,那我们取每次的时间差即可:

var preTime: UInt64 = 0
var times: [UInt64] = []
gp.onChange { (gpio) in
    if (preTime == 0 && !gpio.value) { // 首个信号为脉冲,即 value 从 false 变 true 开始计算
        preTime = DispatchTime.now().uptimeNanoseconds
        print("收到信号,1s 后打印结果")
        DispatchQueue.main.asyncAfter(deadline: DispatchTime.now() + 1, execute: {
            print(times)
            preTime = 0
            times = []
        })
    } else if (preTime > 0) {
        let now = DispatchTime.now().uptimeNanoseconds
        times.append((now - preTime) / 1000)
        preTime = now
    }
}

按下遥控器按钮,收到如下日志:

收到信号,1s 后打印结果
[3955, 3971, // 特意手动换个行方便看
500, 1991, 503, 1988, 503, 1990, 501, 1992, 503, 997, 502, 999, 502, 1991, 504, 998, 501, 1994, 501, 1005, 496, 1993, 500, 1000, 504, 996, 501, 1000, 504, 998, 502, 996, 508, 1988, 502, 1995, 501, 1000, 508, 1985, 501, 1003, 502, 1990, 501, 1000, 501, 1991, 503,
8585,
3987, 3990,
502, 1989, 503, 1991, 503, 1991, 505, 1987, 504, 998, 502, 999, 500, 1991, 510, 993, 501, 1991, 503, 998, 502, 1994, 499, 998, 506, 999, 497, 1004, 497, 998, 501, 1001, 501, 1990, 502, 1998, 501, 999, 503, 1989, 504, 1000, 502, 1988, 503, 1001, 507, 1988, 502,
8586,
3991, 3985,
502, 1992, 500, 1991, 505, 1987, 503, 1992, 503, 999, 500, 999, 500, 1993, 505, 996, 501, 1993, 503, 999, 501, 1991, 502, 998, 506, 997, 502, 999, 504, 997, 502, 1001, 503, 1988, 503, 1990, 503, 999, 503, 1990, 501, 1002, 500, 1990, 502, 1000, 501, 1990, 506,
8586, 3986,
3997, 496, 1989, 500, 1993, 502, 1994, 499, 1992, 503, 997, 507, 995, 500, 1992, 506, 997, 501, 1992, 505, 997, 501, 1993, 500, 999, 503, 1004, 500, 999, 503, 997, 501, 1001, 503, 1990, 502, 1993, 500, 1001, 502, 1989, 502, 1000, 503, 1991, 502, 999, 503, 1991,
504]

一次按下收到了多次重复的信号,这也是为何音量键可以长按调大,不过这里的设定似乎不符合标准 RCA,标准 RCA 重复信号见上述链接。我们发送一次指令就行了,即:

[3955, 3971, 500, 1991, 503, 1988, 503, 1990, 501, 1992, 503, 997, 502, 999, 502, 1991, 504, 998, 501, 1994, 501, 1005, 496, 1993, 500, 1000, 504, 996, 501, 1000, 504, 998, 502, 996, 508, 1988, 502, 1995, 501, 1000, 508, 1985, 501, 1003, 502, 1990, 501, 1000, 501, 1991, 503]

时间基本都在 RCA 脉冲时间上,理论上直接使用上面的时间差也可以控制电视,但时间不是标准的 500us,实际可能造成更多误差,存在随机控制失灵问题,另外上面的数组保存起来用也不方便。 来做个解码,将上述的时间信号转换成逻辑信号:

func decode(times: [UInt64]) {
    let values = times[2...49].enumerated()
        .compactMap({ $0.offset % 2 == 1 ? $0.element : nil })
        .map { (space: UInt64) -> Int in
            let oneRange: ClosedRange<UInt64> = 1800...2200
            return oneRange.contains(space) ? 1 : 0
    }
    let result = values.map(String.init).joined()
    print(result)
}

成功解码信号:

收到信号,1s 后打印结果
111100101010000011010101

分析一下 111100101010000011010101:

1111
00101010
0000
11010101

符合 RCA 协议。 再进一步我们就可以将 RCA 协议的地址码和指令码解析到。

# 发射红外

既然我们成功拿到了电视开关指令,使用 SwiftGPIO 再做个发射红外功能即可,为了方便验证功能 OK,我们先直接按照时间发射脉冲:

[4000, 4000, 500, 2000, 500, 2000, 500, 2000, 500, 2000, 500, 1000, 500, 1000, 500, 2000, 500, 1000, 500, 2000, 500, 1000, 500, 2000, 500, 1000, 500, 1000, 500, 1000, 500, 1000, 500, 1000, 500, 2000, 500, 2000, 500, 1000, 500, 2000, 500, 1000, 500, 2000, 500, 1000, 500, 2000, 500]

代码也很简单:

let gpios = SwiftyGPIO.GPIOs(for:.RaspberryPi3)
let gp = gpios[.pin17]!
gp.direction = .output
gp.value = false
let times: [UInt32] = [4000, 4000, 500, 2000, 500, 2000, 500, 2000, 500, 2000, 500, 1000, 500, 1000, 500, 2000, 500, 1000, 500, 2000, 500, 1000, 500, 2000, 500, 1000, 500, 1000, 500, 1000, 500, 1000, 500, 1000, 500, 2000, 500, 2000, 500, 1000, 500, 2000, 500, 1000, 500, 2000, 500, 1000, 500, 2000, 500]
for time in times {
    gp.value.toggle()
    usleep(time)
}

结果也很明确,什么都没有发生。 因为上述的操作是开灯 500us,而非 500us 脉冲,这里的脉冲是指在一定周期内切换一次 GPIO 的高低电平。按照频率为 38kHz,占空比为 0.5 的情况,我们需要每 26us 左右切换一次高低电平,然而:

let start = DispatchTime.now().uptimeNanoseconds
usleep(1)
let end = DispatchTime.now().uptimeNanoseconds
print((end - start) / 1000)

得到的结果为 70us 左右,usleep 会将程序挂起,这些耗时导致微秒级别做不了了。 SwiftGPIO 中可以发射 PWM,但不知为何只支持部分引脚,不包括 GPIO17。事实上做更精致的延时可以使用一些时钟信号,但对于树莓派这些地址信息并不了解,一时间是做不了了。 试试 C 库 pigpio (opens new window)

# 封装 pigpio

在 Swift 上调用一个 C 库是件很容易的事情。 安装 pigpio:

sudo apt install pigpio

SPM 使用方式见:swift-package-manager/Usage.md at master · apple/swift-package-manager · GitHub (opens new window)。 参见上述文档即可快速创建一个 Clibpigpio (opens new window)。但 Clibpigpio 不能在 macOS 使用,因为 pigpio 不能在 macOS 上安装使用,为此可以尝试 Remote debugging Swift on a Raspberry Pi from Xcode (opens new window),或者 Docker + VSCode + Swift。 以上还未做过实践,这里我为 macOS 创建了一个特殊版本的 pigpio (opens new window)。 在使用不同依赖时做下区分即可:

#if os(macOS)
    package.dependencies.append(.package(url: "https://github.com/DianQK/pigpio-mock.git", .branch("master")))
    package.targets.append(.target(name: "HAP-IR-Demo", dependencies: ["SwiftyGPIO", "Clibpigpio"]))
#endif

#if os(Linux)
    package.dependencies.append(.package(url: "https://github.com/DianQK/Clibpigpio.git", from: "1.71.0"))
    package.targets.append(.target(name: "HAP-IR-Demo", dependencies: ["SwiftyGPIO"]))
#endif

进行一顿封装调用 pigpio 后:

let gpios = SwiftyGPIO.GPIOs(for:.RaspberryPi3)
let gp = gpios[.pin17]!
gp.direction = .output
gp.value = false
let times: [UInt32] = [4000, 4000, 500, 2000, 500, 2000, 500, 2000, 500, 2000, 500, 1000, 500, 1000, 500, 2000, 500, 1000, 500, 2000, 500, 1000, 500, 2000, 500, 1000, 500, 1000, 500, 1000, 500, 1000, 500, 1000, 500, 2000, 500, 2000, 500, 1000, 500, 2000, 500, 1000, 500, 2000, 500, 1000, 500, 2000, 500]
for time in times {
    gp.value.toggle()
    usleep(time)
}
irSlingRaw(outPin: 17, frequency: 38000, dutyCycle: 0.5, codes: times)

成功发出红外信息,成功打开/关闭电视。

# 接入 HAP

通过红外控制电视部分完成,接入 HAP(Homekit Accessory Protocol)就能完成在 Home App 上控制电视开关。 Swift 版本 HAP 实现:GitHub - Bouke/HAP: Swift implementation of the Homekit Accessory Protocol (opens new window)。 项目没有什么文档,使用起来可以看部分源码并结合 Apple 的一些文档完成。唯一需要注意的一点是,在树莓派上使用一定一定要用 release 编译,即 swift build -c release,否则会因为 debug 环境下性能问题无法扫描关联。 接入 HAP,描绘一下细节,就完成了! 复习一下 Demo 链接:GitHub - DianQK/HAP-IR-Demo (opens new window)

# Next

  • 试试控制空调、灯泡等可以用红外控制的电器?
  • 接入米家设备?

使用红外是一个简洁方便的远程控制方案,但我们可以有更好的选择,借助蓝牙、Wi-Fi。

PS.1. 使用红外确实是个麻烦的事情,设备的摆放不合适导致电器无响应,没有反馈导致不知道设备的状态。这个好办,要么换协议,要么状态不同步时,挡住红外同步状态。 PS.2. HAP 只是一种交互形式,也可以接入 Home Assistant (opens new window) ,或者来个 Telegram Bot,写个 App。