米家看不到凌动开关的解决方案(皎月 480 升级版)

原文链接: Yeelight 吸顶灯【凌动开关】怎么开:从抓包到接入 Home Assistant | Zain’s Blog

Yeelight 吸顶灯【凌动开关】怎么开:从抓包到接入 Home Assistant

1. 问题

两台 Yeelight 吸顶灯(皎月 480 升级版 yeelink.light.ceiling15)装了凌动开关面板,但:

  • 米家 App 里找不到「凌动开关」选项
  • 切到「默认样式 → 传统样式」也没有
  • 只有 Yeelight 自己的 App 能开

灯本身是支持凌动的,问题只出在米家这边不显示入口。

于是有两个担心:

  1. 米家为什么不显示?是不是不支持?
  2. 万一 Yeelight App 哪天用不了,凌动还能不能开?

2. 先给结论

结论 说明
灯是支持的 皎月 480升级版(yeelink.light.ceiling15)支持凌动,更老的初代皎月(ceiling3 / ceiling4)不支持
米家不显示 ≠ 不支持 凌动是米家老 miIO 属性 smart_switch,不在 MiOT spec 里;米家插件没渲染这个入口,但属性本身存在且可写
写入方式是 set_ps set_ps ["cfg_smart_switch", "1"],1 开、0 关
走小米开放网关 POST <``https://openapp.io.mi.com/openapp/device/rpc/``><did>,不是 Yeelight 自家云
可以完全脱离 App 用 Home Assistant 里 xiaomi_home 的 token 就能调这个网关

3. 本次用的通道

通道 需要什么 重置后还能用 副作用
小米 openapp 云接口 任意已登录的小米系客户端 token 能 无

4. 怎么抓到的

4.1 环境

  • macOS + MuMu 模拟器(Android 12)
  • 安装 Yeelight App,用米家账号登录
  • Proxyman 做代理,模拟器 Wi-Fi 代理指向本机 9090

4.2 证书这步最容易卡住

Android 12 的「设置 → 安全」里如果只剩「安全状态 / SIM 卡锁定」,说明 ROM 把「加密与凭据」入口裁掉了,常规的「安装 CA 证书」根本走不到。

可行的做法是给模拟器 root,把证书塞进系统证书目录。但 /system 是只读的,mount -o rw,remount 会直接报 '/dev/block/sda1' is read-only,所以改用 bind mount:

# 1) 准备一份证书目录副本(放原始系统证书 + 自己的)
adb shell 'mkdir -p /data/local/tmp/cacerts && cp -a /system/etc/security/cacerts/. /data/local/tmp/cacerts/'

# 2) 证书按 subject_hash_old 命名,扩展名 .0,内容必须是 PEM
HASH=$(openssl x509 -in proxyman-ca.pem -subject_hash_old -noout)
adb push proxyman-ca.pem /data/local/tmp/cacerts/$HASH.0
adb shell "chmod 644 /data/local/tmp/cacerts/$HASH.0; chcon u:object_r:system_file:s0 /data/local/tmp/cacerts/$HASH.0"

# 3) 用 bind mount 覆盖系统证书目录
adb shell "mount --bind /data/local/tmp/cacerts /system/etc/security/cacerts"

# 4) 顺便把代理也设上(重启模拟器后 Wi-Fi 里的代理会丢)
adb shell settings put global http_proxy <Mac 局域网 IP>:9090

mount --bind 挂在重启后失效,需要重做;这也算一种安全的默认行为。

MuMu 开 root 可以用它的 CLI,不用点界面:

mumu-cli config 0 -s '{"vmRootEnable":true}'
mumu-cli restart 0

重启后 adb 端口可能变(16384 → 16385),要重新 adb connect。

4.3 抓到的请求

在 App 里把开关拨一次,Proxyman 里能看到两条几乎一样的请求:

POST <https://openapp.io.mi.com/openapp/device/rpc/123456789>
Content-Type: application/x-www-form-urlencoded

clientId=2882303761517308695
accessToken=V3_xxx
macKey=xxx&macAlgorithm=HmacSHA1&locale=zh-CN
data={"did":"123456789","id":1,"method":"set_ps","params":["cfg_smart_switch","1"]}

响应是 base64 的 JSON:

{"code":0,"message":"ok","result":["ok"]}

读状态用批量接口:

POST <https://openapp.io.mi.com/openapp/home/batchrpc>
data={"method":"get_prop","rpcs":[{"did":"123456789","params":["smart_switch"]}]}

属性顺序要和请求里的 params 对应,返回值是数组。

5. 用 Home Assistant 当客户端

5.1 关键发现

openapp.io.mi.com 不校验 macKey:随便填一个 27 位字符串也能通过,只认 clientId + accessToken。

而 xiaomi_home 集成本身就是小米官方客户端,它的 token 存在:

/config/.storage/xiaomi_home/miot_config/<uid>_cn.dict

文件是「JSON + 末尾 32 字节二进制」的混合格式,解析时要截断。HA 会自动续期(expires_in 约 43 分钟),所以每次运行现读即可,不硬编码。

5.2 脚本

保存为 /config/scripts/flex_switch.py:

#!/usr/bin/env python3
"""读写米家吸顶灯的凌动(smart_switch)开关。用法: flex_switch.py <did> get|on|off"""
import base64
import json
import sys
import urllib.parse
import urllib.request
from pathlib import Path

CONFIG_DIR = Path('/config/.storage/xiaomi_home/miot_config')
CLIENT_ID = '<xiaomi_home 的 clientId>'
ENDPOINT = '<https://openapp.io.mi.com/openapp>'
PROP = 'smart_switch'

def access_token():
    for path in sorted(CONFIG_DIR.glob('*.dict')):
        try:
            raw = path.read_bytes().decode('utf-8', 'ignore')
            data, _ = json.JSONDecoder().raw_decode(raw)
        except Exception:
            continue
        token = (data.get('auth_info') or {}).get('access_token')
        if token:
            return token
    raise RuntimeError('未找到 access_token')

def call(path, payload):
    body = urllib.parse.urlencode({
        'clientId': CLIENT_ID,
        'accessToken': access_token(),
        'macKey': '012345678901234567890123456',   # 网关不校验,占位即可
        'macAlgorithm': 'HmacSHA1',
        'locale': 'zh-CN',
        'data': json.dumps(payload, separators=(',', ':')),
    }).encode()
    req = urllib.request.Request(ENDPOINT + path, data=body, headers={
        'User-Agent': 'mijia-sdk-ua',
        'Content-Type': 'application/x-www-form-urlencoded',
    })
    text = urllib.request.urlopen(req, timeout=10).read().decode('utf-8', 'ignore')
    try:
        return json.loads(text)
    except json.JSONDecodeError:
        return json.loads(base64.b64decode(text + '=' * (-len(text) % 4)).decode())

def get_state(did):
    res = call('/home/batchrpc', {'method': 'get_prop', 'rpcs': [{'did': did, 'params': [PROP]}]})
    for item in res.get('result', []):
        if item.get('did') == did and item.get('code') == 0:
            return str(item['result'][0])
    raise RuntimeError('读取失败: %s' % res)

def set_state(did, value):
    res = call('/device/rpc/%s' % did, {
        'did': did, 'id': 1, 'method': 'set_ps',
        'params': ['cfg_' + PROP, value]})
    if res.get('code') != 0:
        raise RuntimeError('写入失败: %s' % res)

if __name__ == '__main__':
    did, action = sys.argv[1], sys.argv[2]
    if action == 'get':
        print(get_state(did))
    else:
        set_state(did, '1' if action == 'on' else '0')

clientId 从 xiaomi_home/miot/const.py 的 OAUTH2_CLIENT_ID 取。

5.3 变成 HA 开关

configuration.yaml:

command_line:
  - switch:
      name: 主卧吸顶灯 凌动
      unique_id: flex_switch_123456789
      command_on: "python3 /config/scripts/flex_switch.py 123456789 on"
      command_off: "python3 /config/scripts/flex_switch.py 123456789 off"
      command_state: "python3 /config/scripts/flex_switch.py 123456789 get"
      value_template: "{{ value | trim == '1' }}"
      scan_interval: 1800

automations.yaml 加一条,解决 HA 启动后状态不准:

- id: 'flex_switch_refresh_on_start'
  alias: 启动后刷新凌动开关
  trigger:
  - platform: homeassistant
    event: start
  action:
  - service: homeassistant.update_entity
    entity_id:
      - switch.zhu_wo_xi_ding_deng_ling_dong
      - switch.ci_wo_xi_ding_deng_ling_dong
  mode: single

6. 踩过的坑

坑 现象 处理
Android 12 无「加密与凭据」 设置里只有安全状态、SIM 卡锁定 root + bind mount 系统证书目录
/system 无法 remount '/dev/block/sda1' is read-only 不要硬改,用 mount --bind
代理设置重启丢失 settings get global http_proxy为 null 用 settings put global 重设
模拟器 adb 端口变化 device not found 重启后看 lsof 或重新 adb connect
value_template 永远不成立 实体一直显示 off 脚本输出带换行,用 `{{ value
重启后状态是 off 要等一个轮询周期才准 加 homeassistant start 触发 update_entity 的自动化

7. 边界与风险

  • token 敏感:accessToken 等价于账号在小米云上的操作凭证;截图、抓包文件(HAR)用完即删,不要贴进文章或仓库。
  • 云接口是叠加不是替换:openapp 是米家 App 自己在用的通道,短期不会下线;但 Yeelight App/模拟器仍建议保留一份做兜底。
  • 设备重置会做什么:smart_switch 回默认。只要设备重新绑回同一个米家账号,云接口方案照常可用,不需要重装 App。
  • 不适用于:没有实现这个属性的灯具(比如只走蓝牙 Mesh 的筒灯),本方案无效。

8. 一句话总结

「米家看不到凌动开关」不是灯不支持,而是新版插件不渲染 miIO 老属性;真正的开关是 smart_switch,用 set_ps 写。拿到任意小米系客户端的 token 就能远程设它——包括 Home Assistant 自己的 xiaomi_home,于是这件事就从「必须依赖某个旧 App」变成了「HA 里的开关」。