加载中...
<cover></cover>

# 「一块钢板的重生」——7年前的小米4还能干什么

2021-07-31 技术向

本文最后更新于 天前，文中部分描述可能已经过时。

随着家里接入米家生态链的智能家庭设备越来越多，我已经习惯于使唤房间里的小爱音箱帮我完成各种开关操作。就在前几天，我甚至都忘记了房间里的电风扇不是智能的，对着小爱同学张口就来，最后还得自己去开。那么对于这样的非智能家电，是否能够将其接入到智能家居的生态链中呢？答案是肯定的，比如我房间里的这台风扇支持红外遥控，只需要加钱买个米家生态链的红外万能遥控器就解决了。

然而目前市面上一台红外万能遥控器价格在几十块到一百多块不等，贫穷使我我不得不思索另一种替代方案。看到抽屉里的小米4以及机身顶部的红外发射器，我突然有了灵感。这台小米4是我初中的时候用的，上了高中换了手机以后就放在抽屉里没动过了，算下来已经是7年前的老古董了。虽然这样一台手机在今天干啥都有那么点卡顿，而且无论是官方的还是第三方的 ROM 都已经停止支持了，但是当一个万能遥控绰绰有余。因此，这篇文章就来谈谈我是如何让7年前的小米4重获新生的。

## 准备工作

首先既然要干这么有意思的事情，当然受不住 MIUI 条条框框的束缚，所以第一件事就是刷入第三方 Recovery（我选择的是 TWRP）以及第三方 ROM（我选择的是 Lineage OS）。由于年代久远，Lineage OS 也停止了对于小米4的支持，因此我 Google 了一下，在 XDA 论坛上找到了一个比较新的 Unofficial 版本，刷入后各项功能都正常。接着又刷入了 OpenGApps 以便于使用 Google Play 商店安装应用。

因为我并不会安卓开发，所以我选择了 Termux。在使用 Google Play 安装了 Termux 本体以及 Termux-API，配置好 openssh-server 后，这台手机就变身为一块 ARM 开发版。通过 Termux-API 可以调用手机的各项功能，当然其中也就包括红外发射。

由于红外发射需要红外遥控码，不同品牌、同一品牌不同型号的设备的红外遥控码都大概率是不一样的，而厂家一般也不会公开这些信息，这时就需要一个维护好的红外码库，从中查询自己的电器遥控器上每个按钮对应的红外遥控码。虽然现在网上一搜就能看到很多这种码库，但基本上全是要收费的。如果不想要花钱就只能自己买红外接收器，逐个按键录入遥控码，这无疑又是一笔额外开销。

好在几年前有人维护过一个开源的红外码库叫做 IRext，但是由于一些原因（估计是动了谁的蛋糕）这个码库的官网关闭了，文档下线了，GitHub 仓库也删除了，虽然有相关的 Fork 但是 Readme 里面的链接和文档都打不开了。好在 IRext 的 API 服务器其实还是偷偷开着的，只是因为没有文档不知道如何使用。我也是很懵地研究了很久，最后终于摸索清楚了。考虑到这个码库关闭的原因，我在本文中就不指明了，留下一点线索供需要的人参考。

- 官网和 API 文档虽然已经无法访问，但是互联网档案馆的 Wayback Machine 有过 Snapshot，借助它可以穿梭时空。
- API 需要 Credentials 鉴权后才能使用，在 GitHub 上搜一搜使用了 IRext 的项目有惊喜。
- 注意仔细阅读 API 文档中有关「按键映射」的部分，这里详细展示了按键编号与功能的对应关系，不要理所当然地认为X号键一定有功能，否则你就会像我一样试了半天发现不管用，因为X号键不对应遥控器上的任何一个按键。
- 最后获得的红外遥控码应该是一串形如`[1250,340,340,1250,340,1250,1250,1250,340,1250,...]`的编码。

## 变身红外遥控

在这一步我将展示如何利用这台小米4将我房间里的风扇接入 HomeAssistant。

确保手机连接了 WiFi，安装好了 Termux 及 Termux-API，通过 SSH 连接到手机上的 Termux。调用`termux-infrared-transmit`指令以了解红外发射功能的使用方法。

调用如下的命令以测试红外发射，`-f`指定了发射的频率，一般的红外遥控器频率为38kHz，因此指定频率为`38000`Hz。后面的第二个参数即红外遥控码。如果命令执行完毕被控设备正常响应则没有问题。

    termux-infrared-transmit -f 38000 1250,340,340,1250,340,1250,1250,1250,340,1250,...

接着为了让局域网里运行在软路由上的 HomeAssistant 能够控制该设备，我用 Python 和 Flask 写了一个 API 接口。先在 Termux 中安装好 Python3，然后编写`api.py`。

    from flask import Flask,request
    import json
    import os

    app=Flask(__name__)

    @app.route("/airmate_fan_power",methods=["GET"])
    def airmate_fan_power():
        return_dict= {'return_code': '200', 'return_info': 'ok'}
        os.system("termux-infrared-transmit -f 38000 1250,430,1250,430,430,1250,1250,430,1250,430,430,1250,430,1250,430,1250,430,1250,430,1250,430,1250,1250,7430,1250,430,1250,430,430,1250,1250,430,1250,430,430,1250,430,1250,430,1250,430,1250,430,1250,430,1250,1250,7430,1250,430,1250,430,430,1250,1250,430,1250,430,430,1250,430,1250,430,1250,430,1250,430,1250,430,1250,1250,7430")
        return json.dumps(return_dict, ensure_ascii=False)

    @app.route("/airmate_fan_rotate",methods=["GET"])
    def airmate_fan_rotate():
        return_dict= {'return_code': '200', 'return_info': 'ok'}
        os.system("termux-infrared-transmit -f 38000 1250,430,1250,430,430,1250,1250,430,1250,430,430,1250,430,1250,1250,430,430,1250,430,1250,430,1250,430,8250,1250,430,1250,430,430,1250,1250,430,1250,430,430,1250,430,1250,1250,430,430,1250,430,1250,430,1250,430,8250,1250,430,1250,430,430,1250,1250,430,1250,430,430,1250,430,1250,1250,430,430,1250,430,1250,430,1250,430,8250")
        return json.dumps(return_dict, ensure_ascii=False)

    @app.route("/airmate_fan_wind",methods=["GET"])
    def airmate_fan_wind():
        return_dict= {'return_code': '200', 'return_info': 'ok'}
        os.system("termux-infrared-transmit -f 38000 1250,430,1250,430,430,1250,1250,430,1250,430,430,1250,430,1250,430,1250,430,1250,430,1250,1250,430,430,8250,1250,430,1250,430,430,1250,1250,430,1250,430,430,1250,430,1250,430,1250,430,1250,430,1250,1250,430,430,8250,1250,430,1250,430,430,1250,1250,430,1250,430,430,1250,430,1250,430,1250,430,1250,430,1250,1250,430,430,8250")
        return json.dumps(return_dict, ensure_ascii=False)

    if __name__ == "__main__":
        app.run(debug=True, host="0.0.0.0")

（需要注意的是本段代码实现的 API 功能没有进行任何的鉴权，因为是开放在家里内网上的，只要家里内网不被打穿相对而言比较安全，切勿开放在公网上）

通过下面的指令使其在后台运行。

    nohup python api.py >> flask.log 2>&1 &

打开局域网内任何设备的浏览器，访问 `http://MI4-IP:5000/airmate_fan_power`，其中`MI4-IP`为手机的局域网 IP 地址，如果风扇开了/关了，说明成功。

最后通过给 HomeAssistant 的 `configuration.yaml` 添加配置以注册设备。

    shell_command:
      airmate_fan_power: curl http://192.168.1.225:5000/airmate_fan_power
      airmate_fan_rotate: curl http://192.168.1.225:5000/airmate_fan_rotate
      airmate_fan_wind: curl http://192.168.1.225:5000/airmate_fan_wind

    script:
      airmate_fan_power:
        alias: "卧室的艾美特风扇-电源"
        icon: "mdi:fan"
        sequence:
          - service: shell_command.airmate_fan_power
      airmate_fan_rotate:
        alias: "卧室的艾美特风扇-摆风"
        icon: "mdi:fan"
        sequence:
          - service: shell_command.airmate_fan_rotate
      airmate_fan_wind:
        alias: "卧室的艾美特风扇-风速"
        icon: "mdi:fan"
        sequence:
          - service: shell_command.airmate_fan_wind

打开 HomeAssistant 可见设备，可以点击「运行」进行测试。如果配置了 HomeKit，在 iOS 的「家庭」应用中也可以看到设备。

## 接入米家生态链

至此貌似还没有解决最初的问题，即能够通过小爱同学来控制风扇。由于注册小爱开放平台貌似需要把 HomeAssistant 暴露在公网，再加上各种实名认证、审核太过麻烦，我决定曲线救国。

众所周知「米家」APP 中有一个「其他平台设备」的功能，可以添加第三方平台的设备，其中「点灯科技」吸引了我的兴趣。在阅读了点灯科技 Blinker 的文档后，我发现这个办法可行。

由于 Blinker 推荐的 SDK 是 Typescript 的，在弱小的小米 4 上跑这玩意简直要命（试过，几个小时后 node 进程因为内存不足被杀了），好在有软路由，我选择丢到软路由上去跑。

参照 Blinker 的文档注册设备、配置环境、安装 SDK 的过程这里不赘述了，可以自行搜索，选择 Broker 的时候注意选择阿里云，选择点灯科技将不支持小爱控制。安装完后改写 SDK 目录下 `example/miot/example_miot_light.ts`，加入控制语句。

    import { BlinkerDevice } from '../../lib/blinker';
    import { Miot, VA_TYPE, MI_LIGHT_MODE } from '../../lib/voice-assistant';

    const { exec } = require('child_process');

    let device = new BlinkerDevice('/* Your Secret */');

    let miot = device.addVoiceAssistant(new Miot(VA_TYPE.LIGHT));

    device.ready().then(() => {
        miot.powerChange.subscribe(message => {
            switch (message.data.set.pState) {
                case "true":
                    message.power("on").update();
                    exec('curl http://192.168.1.225:5000/airmate_fan_power', (err, stdout, stderr) => {});
                    break;
                case "false":
                    message.power("off").update();
                    exec('curl http://192.168.1.225:5000/airmate_fan_power', (err, stdout, stderr) => {});
                    break;
                default:
                    break;
            }
        })
        miot.modeChange.subscribe(message => {
            switch (message.data.set.mode) {
                case MI_LIGHT_MODE.DAY:
                    exec('curl http://192.168.1.225:5000/airmate_fan_rotate', (err, stdout, stderr) => {});
                    break;
                case MI_LIGHT_MODE.NIGHT:
                    exec('curl http://192.168.1.225:5000/airmate_fan_wind', (err, stdout, stderr) => {});
                    break;
                case MI_LIGHT_MODE.COLOR:

                    break;
                case MI_LIGHT_MODE.WARMTH:

                    break;
                case MI_LIGHT_MODE.TV:

                    break;
                case MI_LIGHT_MODE.READING:

                    break;
                case MI_LIGHT_MODE.COMPUTER:

                    break;
                default:
                    break;
            }
            message.mode(message.data.set.mode).update();
        })

        miot.colorChange.subscribe(message => {
            console.log('RGB:', int2rgb(Number(message.data.set.col)));
            message.color(message.data.set.col).update();
        })

        miot.colorTempChange.subscribe(message => {
            console.log(message);
            message.colorTemp(255).update();
        })

        let brightness = 50;
        miot.brightnessChange.subscribe(message => {
            if (typeof message.data.set.bright != 'undefined') {
                brightness = Number(message.data.set.bright)
            } else if (typeof message.data.set.upBright != 'undefined') {
                brightness = brightness + Number(message.data.set.upBright)
            } else if (typeof message.data.set.downBright != 'undefined') {
                brightness = brightness - Number(message.data.set.downBright)
            }
            message.brightness(brightness).update();
        })

        miot.stateQuery.subscribe(message => {
            message.power('on').update()
        })

        device.dataRead.subscribe(message => {
            console.log('otherData:', message);
        })

        device.builtinSwitch.change.subscribe(message => {
            console.log('builtinSwitch:', message);
            device.builtinSwitch.setState(turnSwitch()).update();
        })

    })

    function rgb2int(r: number, g: number, b: number) {
        return ((0xFF << 24) | (r << 16) | (g << 8) | b)
    }

    function int2rgb(value: number) {
        let r = (value & 0xff0000) >> 16;
        let g = (value & 0xff00) >> 8;
        let b = (value & 0xff);
        return [r, g, b]
    }

    let switchState = false
    function turnSwitch() {
        switchState = !switchState
        device.log("切换设备状态为" + (switchState ? 'on' : 'off'))
        return switchState ? 'on' : 'off'
    }

通过 `ts-node example/miot/example_miot_light.ts` 让其跑起来就可以了。

细心的你可能发现了，要接入的不是风扇吗，怎么用的是灯的 SDK？要怪就怪 Blinker 提供的 SDK 根本就是个半成品，风扇 SDK 还没写呢，只能先拿灯凑合用。只要把设备名称修改成「风扇」，小爱同学就听得懂了。至于风速、摆风功能我把它们绑定在了日光模式和夜灯模式上，利用小爱训练计划可以重定向指令，比如当我说“让风扇摇头”的时候执行“设置风扇为日光模式”就可以了。

最后在米家中绑定点灯科技并同步设备，来一句“小爱同学，打开风扇”，正常的话风扇就开咯。

## 更多玩法

使用`termux-sensor -l`可以获得手机上都有哪些传感器，我惊讶地发现小米4竟然有货真价实的气压传感器，并不是通过 GPS 海拔数据倒推出来的，于是搭建了一个气象监测站，每5分钟提交一次数据至 Firebase 实时数据库，并使用 Apache Echarts 和 Jquery 制作了数据展示页面。

https://lab.hans362.cn/weather

当然`termux-sensor`还告诉我有温度传感器，但这明显是 CPU 的温度传感器，测的并不是环境温度，就没有什么利用价值了。

另外有条件可以购入一张物联卡插入手机中，这样家里 WiFi 断开的时候也可以持续完成数据采集和上报。

## 总结

就这样，小米4结束了它作为手机的使命，却在2021年成为了智能家庭与传统家电沟通的桥梁。

本文所提及的思路和方法适用于多数带有遥控功能的安卓手机，遥控对象可以是任何家电（只要有遥控码），欢迎尝试呀。

「一块钢板的重生」——7年前的小米4还能干什么
https://blog.hans362.cn/post/rebirth-of-xiaomi-4/
本文作者
Hans362
最后更新
2021-07-31
许可协议
CC BY-NC-SA 4.0
转载或引用本文时请遵守许可协议，注明出处、不得用于商业用途！
喜欢这篇文章吗？考虑支持一下作者吧～
爱发电 支付宝