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

Cover Image

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

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

然而目前市面上一台红外万能遥控器价格在几十块到一百多块不等,贫穷使我我不得不思索另一种替代方案。看到抽屉里的小米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,因此指定频率为38000Hz。后面的第二个参数即红外遥控码。如果命令执行完毕被控设备正常响应则没有问题。

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还能干什么
本文作者
Hans362
最后更新
2021-07-31
许可协议
转载或引用本文时请遵守许可协议,注明出处、不得用于商业用途!
喜欢这篇文章吗?考虑支持一下作者吧~
爱发电 支付宝

评论

您所在的地区可能无法访问 Disqus 评论系统,请切换网络环境再尝试。