Home Assistant 与 Yeelight彩光灯泡的通信过程分析

Awaken your home.

前言

Home Assistant是一个隐私优先的智慧家庭系统,支持本地化部署,甚至允许在没有断网的情况下使用。

我是Home Assistant的铁杆粉,用它构建了一些好玩的东西,正打算基于它创建一些更有想象力的项目。

近期基本读完了Home Assistant所有的文档,喜欢它的架构设计。在架构上,codelab-adapter和它有许多相似之处。

Home Assistant的核心源码此前也大体上读了一遍,代码写得非常漂亮,堪称Python Asyncio的典范案例。

Home Assistant近期发布了0.94版本, 为1.0版本做好了准备,预计今年发布1.0版本。

考虑到项目已经基本上稳定下来,API不会再有大的变动。于是我近期准备重新审视一遍Home Assistant,在源码和架构层面对其做一些解读和梳理。

本文焦点

将硬件接入Home Assistant有许多种方式。正如大多数物联网项目一样,接入一款硬件是很容易的。MQTT这类的基础协议提供了充分的自由度。

但我们要知道此类系统往往会随着业务而发展,不断生长,如果没有精心设计的架构,很容易被后期的复杂度压垮。在操作系统的发展史上,太多这样的先烈了。

Home Assistant已经展示出其架构的高度灵活性(至今已经接入了1400+设备),也许有望成为物联网领域的linux。

往系统中硬编码接入一款硬件总是容易的,但随着系统的生长,这种生态十分容易溃败。所以本文关注Home Assistant社区是如何考虑设备接入这件事的,如何应对组件增长带来的复杂度升高。

我们将分析Home Assistant接入Yeelight彩光灯泡的细节,以此为例,对系统做深入了解。

本文将通过实验来跟踪通信的细节。

Yeelight彩光灯泡

Yeelight彩光灯泡搭载了wifi芯片。为了将其接入WiFi,你需要使用官方APP来为它配网。

由于我们准备用Home Assistant控制它,所以需要在APP中做些设置,使其接受来自局域网的控制指令。

实验

实验环境

  • MacOS 10.13.5
  • Python 3.7.2
  • homeassistant==0.94.2

运行 homeassistant

我在虚拟环境里安装homeassistant,为了不与系统中的homeassistant冲突,我选择在当前的虚拟python环境下运行homeassistant: python -m homeassistant

配置目录

第一次运行,会创建配置目录

Config directory: /Users/wuwenjie/.homeassistant

目录内容为:

➜  ~ ls /Users/wuwenjie/.homeassistant
automations.yaml     customize.yaml       groups.yaml          home-assistant_v2.db secrets.yaml
configuration.yaml   deps                 home-assistant.log   scripts.yaml         tts

从命名可以看出configuration.yaml是配置文件。

值得一提的是,如果你采用hass.io安装homeassistant,那么你永远不需要在命令行里进入配置目录,可以直接在网页上编辑,这对非技术用户很友好。

由于防火墙的存在,在国内安装hass.io非常痛苦,由于防火墙的存在,技术/科研人员的日常大多时候都是痛苦的。

fuck the GFW.

其他依赖

运行python -m homeassistant的时候,还将安装其他依赖,一些值得留意的依赖包括netdisco==2.6.0.

将灯泡信息添加到configuration.yaml

重启时,发现安装了yeelight==0.5.0

在Yeelight APP找到Yeelight彩光灯泡的IP地址,将其配置到configuration.yaml

yeelight:
  devices:
    192.168.31.30:
      name: test light

重启home-assistant服务,即可看到灯泡已经出现在用户面板里。控制开关和调节色温一切正常。

开始分析

在接下来的分析里,我们关注

  • 彩灯设备的发现和加载
  • 控制指令如何生效(通信细节)

意外发现

Yeelight是home-assistant支持的components之一,home-assistant目前支持1400+设备。

技术层面,components的代码见components。在探索如何接入新的components时,意外发现Integration Services已经完美解决了我们前头提到的home-assistant如何接入新设备的问题。

但我们仍然准备继续分析,深入到代码层面的细节。

分析工具

我使用sourcegraphchrome插件来阅读和跟踪源码,这个工具让我们在代码森林中穿梭变得自如。

程序入口

setup.py中可以看到程序的入口点: hass = homeassistant.__main__:main

其中ensure_config_file值得关注,这个函数的指责是确保配置文件的正常。如果不存在配置文件则帮助我们创建所需的配置文件(默认),后文提及的configuration.yaml就是由它创建的,如果你对初始化的配置文件感兴趣,从它入手可能是个好主意。更多细节:

此外,try_to_restart的机制怪有趣的,home-assistant是生产级的代码,很多细节考虑得非常周到,很有借鉴价值。

彩灯设备的发现和加载

我们进入到前头提到的分析目标之一:彩灯设备的发现和加载

components/yeelight/init.py中发现了Integration Services文档描述的内容:def setup(hass, config), 由此可知官方默认支持的components,与用户临时接入的组件,都遵循同一套接入机制。系统的同构性有利于健壮性和灵活性。

还记得我们在configuration.yaml做的设置吗?

yeelight:
  devices:
    192.168.31.30:
      name: test light

这个设置是如何被源码使用的呢?在def setup(hass, config)中看得很清楚:

def setup(hass, config):
    """Set up the Yeelight bulbs."""
    conf = config.get(DOMAIN, {})
    yeelight_data = hass.data[DATA_YEELIGHT] = {}

    def device_discovered(service, info):
        _LOGGER.debug("Adding autodetected %s", info['hostname'])

        device_type = info['device_type']

        name = "yeelight_%s_%s" % (device_type,
                                   info['properties']['mac'])
        ipaddr = info[CONF_HOST]
        device_config = DEVICE_SCHEMA({
            CONF_NAME: name,
            CONF_MODEL: device_type
        })

        _setup_device(hass, config, ipaddr, device_config)

    discovery.listen(hass, SERVICE_YEELIGHT, device_discovered)

    def update(event):
        for device in list(yeelight_data.values()):
            device.update()

    track_time_interval(
        hass, update, conf.get(CONF_SCAN_INTERVAL, SCAN_INTERVAL)
    )

    if DOMAIN in config:
        for ipaddr, device_config in conf[CONF_DEVICES].items():
            _LOGGER.debug("Adding configured %s", device_config[CONF_NAME])
            _setup_device(hass, config, ipaddr, device_config)

    return True

彩灯设备的发现和加载机制,这段代码已经很清晰展示了。

def setup(hass, config)中的细节(如track_time_interval)值得深入学习,留待之后有空进一步追踪。

控制指令如何生效(通信细节)

为了理解控制指令如何生效,我们可以跟踪用户操作过程中发生的交互。

从控制台可以看出开关等过程中并不发生http(ajax)交互,于是可以断定是使用websocket交互。

通过观察过程中发生的交互,我们可以使用其他客户端来模拟。下边使用Pharo来替代网页,与Yeelight进行交互。

webSocket := ZnWebSocket to: <URI>.

[ webSocket
   sendMessage: '{"type":"auth","access_token":"<access_token>"}'.] value.

[ (webSocket
   readMessage) logCr ]  schedule.

[ webSocket
   sendMessage: '{"type":"call_service","domain":"light","service":"turn_on","service_data":{"entity_id":"light.test_light"},"id":27}'.] value.

让我们进一步跟踪websocket消息是如何生效的。

追踪代码可以发现websocket_api竟是作为components在homeassistant中运行!homeassistant的同构性太惊人了,我非常喜欢这种高度一致的设计。

我们前头发送了开灯命令:

{
  "type": "call_service",
  "domain": "light",
  "service": "turn_on",
  "service_data": { "entity_id": "light.test_light" },
  "id": 27
}

handle这份命令的代码为:

@decorators.async_response
@decorators.websocket_command({
    vol.Required('type'): 'call_service',
    vol.Required('domain'): str,
    vol.Required('service'): str,
    vol.Optional('service_data'): dict
})
async def handle_call_service(hass, connection, msg):
    """Handle call service command.
    Async friendly.
    """
    blocking = True
    if (msg['domain'] == HASS_DOMAIN and
            msg['service'] in ['restart', 'stop']):
        blocking = False

    try:
        await hass.services.async_call(
            msg['domain'], msg['service'], msg.get('service_data'), blocking,
            connection.context(msg))
        connection.send_message(messages.result_message(msg['id']))
    except ServiceNotFound as err:
        if err.domain == msg['domain'] and err.service == msg['service']:
            connection.send_message(messages.error_message(
                msg['id'], const.ERR_NOT_FOUND, 'Service not found.'))
        else:
            connection.send_message(messages.error_message(
                msg['id'], const.ERR_HOME_ASSISTANT_ERROR, str(err)))
    except HomeAssistantError as err:
        connection.logger.exception(err)
        connection.send_message(messages.error_message(
            msg['id'], const.ERR_HOME_ASSISTANT_ERROR, str(err)))
    except Exception as err:  # pylint: disable=broad-except
        connection.logger.exception(err)
        connection.send_message(messages.error_message(
            msg['id'], const.ERR_UNKNOWN_ERROR, str(err)))

至此我们弄清楚了本文感兴趣的所有问题。

todo

  • 对多个组件的并行机制做了解

参考




Fork me on GitHub