cb2206/marvin-connected-home

GitHub: cb2206/marvin-connected-home

一个非官方的异步 Python 客户端,通过逆向工程对接 Marvin Connected Home 云端 API,实现对电动窗、天窗、门和隐私玻璃的自动化控制与实时状态推送。

Stars: 0 | Forks: 0

# marvin-connected-home [Marvin Connected Home](https://www.marvin.com/solutions/connected-home) 云端 API 的异步 Python 客户端 —— 用于自动化窗户、天窗、门和隐私玻璃。 **非官方。** Marvin 未公开任何公共 API。这里的所有内容都是通过对 Marvin Home Android 应用进行逆向工程得出的。Marvin 此前已经对该服务进行过一次平台迁移(从 Google Cloud IoT Core 迁移至 Azure),因此相关功能随时可能未经通知即发生变更。 本项目构建为平台无关的,以便为 Home Assistant 集成([ha-marvin-connected-home](https://github.com/cb2206/ha-marvin-connected-home))或其他任何项目提供支持。 ## 为什么使用云端而不是本地控制 这些窗户是 ESP32 设备,与 Azure IoT Hub 保持单一的出站 MQTT/TLS 连接。对真实硬件进行完整的 65,535 端口扫描后,发现**没有监听端口** —— 没有 HTTP,没有本地 MQTT,也没有 ESPHome 风格的 API。如果不修改固件,就无法实现本地控制。 其优势在于:云端 API 通过 SignalR 以**亚秒级**速度推送状态,甚至包括那些根本没有经过云端的更改。由干触点继电器触发的关闭动作产生了实时的渐进位置更新以及最终的锁定确认。这明显优于 Marvin 官方认可的 Control4 驱动程序所受限的约 10 分钟轮询延迟。 ## 安装 ``` pip install marvin-connected-home ``` ## 用法 ``` import aiohttp from marvin_connected_home import B2CTokenProvider, MarvinClient, MarvinRealtime async with aiohttp.ClientSession() as session: tokens = B2CTokenProvider( session, refresh_token=saved_refresh_token, # Called on every rotation. Persist what you receive here: the old # refresh token must be assumed single-use. on_refresh_token_update=save_refresh_token, ) client = MarvinClient(session, tokens) houses = await client.async_get_houses() house = await client.async_get_house(houses[0]["data"][0]["id"]) for asset in house.assets: device = asset.primary print(f"{asset.name}: {device.sash_position}% open, locked={device.locked}") # Positions are percentages, not discrete stops. await client.async_set_sash_position(house.assets[0].asset_id, 45) # Live updates realtime = MarvinRealtime(session, tokens) realtime.on_asset_update(lambda a: print(f"{a.name} -> {a.primary.sash_position}%")) await realtime.async_start() ``` Refresh token 在每次续订(大约每小时)时都会轮换。 `on_refresh_token_update` 回调是用于持久化的预期途径 —— 它在每次轮换时都会触发, 因此存储中永远不会持有过期的凭证。在每次调用后手动读取 `tokens.refresh_token` 仍然有效,但很容易出错;提供该回调是因为,如果消费者仅持久化 登录时的 token,那么只需重启一次就会被迫重新登录。 所有请求都带有明确的超时时间(REST 为 15 秒,token endpoint 为 30 秒),因此“云端不可达”会在几秒钟内以 `MarvinConnectionError` 的形式呈现,而不是等待 aiohttp 默认的五分钟 —— 这对于那些会故障转移至本地控制路径的消费者来说至关重要。可通过 `MarvinClient(..., request_timeout=...)` 进行覆盖设置。 ## 状态 | 领域 | 状态 | |---|---| | 窗扇位置(获取 / 设置) | 已针对硬件**验证** | | 锁定、雨水、紧急制动、电池、RSSI、固件 | **已验证** | | 配置写入(`setconfig`) | **已验证** —— 接触位置、雨水关闭、蜂鸣器、LED | | 房屋偏好设置(自动通风) | **已验证** —— 所有限制和切换键;单键和多键请求体均被接受 | | 设备重命名 | **已验证** | | 固件更新触发 | **已验证** | | SignalR 实时通信 | **已验证** —— 已对 `AssetUpdated` 和 `PreferencesUpdated` 建模;`GroupStateUpdated`、`GroupListChanged`、`HouseGroupStateUpdated` 可通过 `on_raw_message` 获取 | | 遮阳、LED、锁定、隐私玻璃命令 | 由应用常量**推断** —— 未测试 | | 群组命令 | **不存在。** 通过 House id 进行全屋广播是有效的;服务器端群组没有命令 endpoint —— 应用在客户端将其分发为一个批处理的 `/commands` 请求 | | 重启 / 重新校准 | **已验证。** `async_recalibrate_device` 会驱动窗扇进行完整的行程 —— 在执行此操作前请做好确认提示 | 上述验证仅针对 Modern Automated Casement/Awning 窗户。Awaken 天窗、Multi-Slide 门和 CLiC 隐私玻璃通过能力标志提供尽力而为的支持 —— 如果您拥有这些硬件,欢迎提交 bug 报告。 ## 那些容易踩坑的地方 ### 偏好设置键名在读取和写入时拼写不同 读取房屋信息会得到 `temperatureUpperLimit`。将其原样写回则毫无作用 —— 写入的键名是 `tempUpperLimit`。只有四个与温度相关的键会受到影响; `humidityUpperLimit` 及相关键在读写时是完全相同的。 `async_set_house_preferences` 接受读取时的键名并会自动进行转换,因此 只有当您自己构建请求时才会遇到这个问题。参见 `PREFERENCE_WRITE_KEYS`。 露点限制(`dewPointUpperLimit`、`dewPointLowerLimit`)是可读的,但其 **写入**名称未知 —— 在抓包期间,应用从未写入过这些字段。鉴于上述的不对称性,请勿随意猜测。 在同一次调用中传递范围的两端。该 endpoint 接受多键请求体,如果拆分 它们,会导致房屋在过渡期间处于 `lower > upper` 的状态。 ### 温度单位为华氏度 API 中没有任何地方提供单位字段。`/defaults` 这个看似明显包含该信息的 endpoint 返回的是 `{"data": []}`。数值均为华氏度;Marvin 仅在 美国和加拿大销售。此结论仅在单一账户上进行了验证,因此应将其视为有力的证据,而非绝对的保证。 ### `GET /houses` 和 `GET /houses/{id}` 是互补的 列表 endpoint 会填充 `state` 并将 `preferences` 置为 null。详情 endpoint 的操作则恰恰相反。两者互不为超集 —— `awayModeIsActive` 仅能从 列表中获取。 这些问题都已由本库处理。列出它们是因为任何阅读原始 API 的人都会遇到这些问题。 **哨兵值。** 缺失的数字会返回为*类型最小值*,而不是 null —— double 类型为 `-1.7976931348623157e308`,int 类型为 `-2147483648` —— 并且在同级字段之间表现不一致(`outdoorHumidity` 在 float 中使用了 int 的哨兵值,而 `indoorHumidity` 使用了 double 的哨兵值)。这些值会通过 `denull()` 被统一规范化为 `None`。如果将一个哨兵值作为温度记录下来,将会永久破坏长期统计数据。 **读取的数据是经过双重编码的 JSON。** 请求体是一个 JSON *字符串*,其内容才是真正的文档,因此它以引号开头,而不是花括号,并且必须解码两次。SignalR 的 `arguments[0]` 也是如此。 **写入返回的是纯文本**,且极不一致:`setconfig` 返回 `Ok`,重命名返回 `OK`,偏好设置返回 `Success`,而 performota 返回完整的句子。任何假定响应体为 JSON 的代码在每次写入时都会出错。 **键的大小写因 endpoint 而异。** `GET /assets/{id}` 返回 `WCBfirmwareVersion`;SignalR 返回 `wcBfirmwareVersion`。本库已进行不区分大小写的解析处理。 **`GET /houses/{id}` 仅返回设备的存根(stub)** (`{id, name}`)。完整的设备树位于 `GET /houses/{id}/assets`;`async_get_house()` 会同时获取这两者。 **在每次抓包中,`AssetUpdated` 推送都包含了完整的 asset 信息 —— 但这只是观察结果,而非保证**,且上述的存根行为证明 Marvin 在某些上下文中确实会发送部分的 asset。`merge_assets()` 在将推送数据合并到缓存状态时会保留原有字段,因此局部的推送绝不会将缓存的配置翻转为未知状态。跨推送持有状态的消费者应使用此方法,而不是直接全盘替换。 **实时连接会永远以指数退避算法进行重连。** 单次失败将以 debug 级别记录日志;连续十次失败会记录一条警告(很可能发生了结构性的变更)。身份验证失败将停止循环而不是继续重试 —— 重试无法生成有效的 token,且消费者自身的认证流程会暴露出问题。 ## 身份验证 使用 Azure AD B2C,并借用了移动应用的公共 client id(它内置在 APK 中;Marvin 不提供第三方注册)。 | | | |---|---| | 租户 | `marvinwindowsb2c.onmicrosoft.com` | | Client id | `0d117826-a605-4d81-999e-ae67e85de895` | | 策略 | `B2C_1A_AuroraSignInRegister` | | 重定向 | `aurora://login/verify` | | 作用域 | `openid offline_access` | | 流程 | 授权码模式 + PKCE | 请使用 `scripts/login.py` 进行登录,它会打印出一个授权 URL,然后将您重定向到的内容进行兑换。此过程已完成端到端验证,包括凭证续订。 **Bearer token 是 `id_token`,而不是 access token。** 因为 Marvin 不请求任何资源 scope,B2C 仅返回 `id_token` 和 `refresh_token`。他们的应用使用 id_token 作为 bearer —— 因此这是一个带有 `aud=` 和 `emailAddress` claim 的 token —— 所以本库也采取相同的做法。值得注意的是这很不寻常:id_token 本应用于向 client 标识用户身份,而不是用于授权 API 调用。如果 Marvin 以后要求提供具有正确受众的 access token,此功能将失效。 **重定向使用的是自定义 URI scheme**,因此没有 Web 服务器能够接收它,并且无法针对 Marvin 的租户注册新的重定向。桌面和无头消费者必须手动收集代码 —— Chrome 会将失败的 `aurora://` URL 保留在地址栏中;Safari 则会丢弃它,在这种情况下,请在启用*保留日志 (Preserve log)* 的 DevTools 中读取最终 302 的 `Location` header。 Token 处理是可插拔的: - `B2CTokenProvider` —— 常规途径;负责刷新并轮换存储的 token - `StaticTokenProvider` —— 自带 token,用于测试 - `TokenProvider` —— client 所依赖的协议,您可以据此实现自己的逻辑 如果 Marvin 公开其 Control4 驱动程序所暗示的设备授权配对功能,那将更适合无头消费者,并应在此处作为第三个 provider 实现。 ## 验证 已针对真实账户进行了端到端验证:refresh-token 续订、完整的 读取面、配置写入、房屋偏好设置、asset 重命名、OTA 触发 以及 SignalR 推送。`scripts/smoke_test.py` 会以只读方式针对 您自己的账户运行该测试套件,且不会触碰任何设备。 ## 开发 ``` python -m venv .venv && ./.venv/bin/pip install -e ".[dev]" ./.venv/bin/python -m pytest ``` 切勿提交 `.mitm` 抓包文件 —— 它们包含实时的 bearer token。`.gitignore` 已将其涵盖在内。 ## 许可证 MIT
标签:API客户端, Python, 异步编程, 无后门, 智能家居, 物联网, 自动化控制, 计算机取证, 逆向分析, 逆向工具