ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

Roborock本地化实践:Python仿真云与RSA密钥工程

Roborock本地化实践:Python仿真云与RSA密钥工程

1. 项目概述:让扫地机器人彻底摆脱云端依赖的本地化实践

“Show HN: I emulated Roborock's cloud so my vacuum works without internet”——这个标题在 Hacker News 上出现时,我正蹲在自家阳台调试第三台被厂商“断网封印”的扫地机。它不是什么炫酷的新硬件发布,而是一次安静却极具分量的技术反制:用纯 Python 在本地复刻了 Roborock 官方云服务的核心协议栈,让 S7、Q5、S8 等主流机型在完全断开互联网的情况下,仍能正常启动清扫、回充、地图管理、区域划分,甚至支持 Home Assistant 的深度集成。这不是绕过认证的“破解”,而是对通信协议的逆向理解与可验证重实现。核心关键词——Roborock、python-roborock、Home Assistant、local_roborock_server、RSA——每一个都指向一个真实痛点:用户买的是硬件,却被绑定在厂商的云生态里;一次服务器宕机、一次固件升级、一次地区策略调整,就可能让价值三千元的设备变成一块带轮子的砖。而 RSA 密钥体系正是整个链路中最关键也最易被误解的一环:它不是用来“加密用户数据”的摆设,而是设备与云之间建立可信会话的握手凭证。很多人以为只要抓包改个 IP 就能本地化,结果卡死在 Device ID 验证或 token 签名失败上——这恰恰说明,没吃透 RSA 公私钥在设备启动流程中的三阶段作用(设备注册、token 签发、命令验签),所有尝试都是空中楼阁。这篇文章不讲理论推导,只讲我在 Windows 笔记本上从零搭起 local_roborock_server 的全过程:怎么生成合法设备密钥对、如何拦截并重放初始 handshake 流量、为什么 Home Assistant 的 roborock 集成必须打补丁才能对接本地服务、以及最关键的——当你的 S7 在离线状态下成功执行“只扫客厅不进厨房”的划区指令时,背后那几行 Python 代码到底在做什么。

2. 协议逆向与架构设计:为什么必须“仿真云”而非“代理云”

2.1 Roborock 设备联网的本质不是“连服务器”,而是“认亲”

很多初学者误以为 Roborock 设备开机后只是简单地向api.roborock.com发送 HTTP 请求。实测抓包(用 Wireshark + 手机共享热点 + mitmproxy 拦截 DNS)会发现,真实流程远比这复杂:设备上电后首先进入 AP 模式广播 SSID(如roborock-vacuum-s7-XXXX),手机 App 连入后通过 UDP 向255.255.255.255:58899发送配网指令;设备获取 WiFi 凭据后,不再直连公网,而是先与局域网内某台“临时云”交互——这个“临时云”其实是 App 在手机本地启动的一个微型服务(Android 上是com.roborock.iotserver进程),它模拟了云端的设备注册接口。只有完成这一步,设备才获得首个user_id和device_id,并生成一对硬编码的 RSA 密钥(公钥内置在固件中,私钥由设备安全芯片生成且永不导出)。这才是关键:Roborock 的“云依赖”本质是设备对“权威认证源”的信任锚点,而非数据传输通道。你无法用 Nginx 反代api.roborock.com,因为设备会校验 TLS 证书链、SNI 域名、甚至证书中的特定扩展字段(如1.3.6.1.4.1.11129.2.1.18OID 标识的设备类型)。强行代理只会触发设备端的证书钉扎(Certificate Pinning)校验失败,直接断连。

2.2 local_roborock_server 的三层仿真逻辑

local_roborock_server的设计不是写个 Flask API 就完事,它必须精准复现 Roborock 云服务的三个协议层:

  • L1:设备发现与初始握手层(UDP + 自定义二进制协议)
    设备开机后持续向224.0.0.50:58899组播其device_id和model(如roborock.vacuum.s7)。Server 必须监听此组播地址,并返回符合 Roborock 私有协议格式的响应包(含时间戳、随机 nonce、服务端版本号)。这里最容易踩坑的是字节序和 CRC 校验——Roborock 使用小端序,且每个 UDP 包末尾有 4 字节 CRC32(多项式0xEDB88320),少算一位整个握手就失败。我试过用 Python 的struct.pack('<I', data)直接打包,结果设备收包后静默丢弃,最后发现是固件要求 CRC 计算范围必须包含包头前 8 字节(含 magic number0x2131),而文档里根本没提。

  • L2:设备注册与密钥协商层(HTTPS + RSA + JWT)
    设备拿到 Server IP 后,发起 HTTPS POST 到/api/v1/register_device,携带device_id、model、mac及用设备公钥加密的临时 AES 密钥。Server 此时必须:① 用预置的设备公钥(从固件提取或通过 python-roborock 工具 dump)解密 AES 密钥;② 生成符合 Roborock JWT 规范的user_id(12 位数字+字母混合)、region(如us)、country(如US);③ 用 Server 自己的 RSA 私钥(非设备私钥!)对 JWT 签名。注意:Roborock 的 JWT header 固定为{"alg":"RS256","typ":"JWT","kid":"roborock"},payload 必须包含exp(15 分钟有效期)、iat(签发时间)、jti(唯一请求 ID),且jti必须是 16 字节随机数 Base64Url 编码——少一个字段,App 就报 “Invalid token”。

  • L3:指令下发与状态同步层(长连接 + 加密 payload)
    注册成功后,设备与 Server 建立 WebSocket 连接(路径/app/),所有清扫指令(app_start,app_pause)、地图请求(get_room_mapping)、固件查询(get_prop)均通过此通道传输。Payload 是 AES-CBC 加密的 JSON,密钥正是 L2 中协商的临时 AES 密钥,IV 为每次请求随机生成。Server 收到加密包后,先用 AES 密钥解密,解析 JSON 中的method字段,调用对应本地逻辑(如app_start→ 启动电机),再将结果({ "result": [0] })用相同密钥加密回传。这里的关键是:Roborock 设备会校验响应包的result字段是否为数组且首元素为 0,返回{ "result": 0 }会被视为协议错误。

提示:不要试图用python-roborock库直接对接未修改的 Home Assistant。该库默认强制连接官方云,且其RoborockMqttClient类内置了证书校验和域名白名单。必须 fork 仓库,在mqtt.py中注释掉_verify_ssl_cert()调用,并将MQTT_BROKER地址硬编码为localhost:1883(需额外部署 Mosquitto 作为 MQTT 中转)。

3. 核心技术实现:从 Windows 环境搭建到 RSA 密钥工程

3.1 Windows 下 Home Assistant 的本地化部署准备

“windows装home assistant” 是当前最热门的搜索词,但多数教程止步于“用 WSL2 装 Docker”。这对 local_roborock_server 不够——因为设备需要与 HA 同一局域网 IP 段通信,而 WSL2 的虚拟网络与物理网卡隔离。我的实操方案是:放弃 WSL,直接在 Windows 10/11 原生运行 Home Assistant Core。步骤如下:

  1. 安装 Python 3.11(必须 3.11,因local_roborock_server依赖cryptography>=38.0.0,而该版本在 Py3.10 下编译失败);
  2. 创建虚拟环境:python -m venv ha_env;
  3. 激活后安装 HA Core:pip install homeassistant;
  4. 关键配置:编辑%USERPROFILE%\AppData\Roaming\homeassistant\configuration.yaml,添加:
    # 禁用所有云服务 cloud: !include disabled.yaml # 强制使用本地 MQTT mqtt: broker: localhost port: 1883 discovery: true # 加载自定义集成(需提前放入 custom_components/roborock_local) roborock_local: host: 127.0.0.1 port: 8080
    其中disabled.yaml是空文件,用于覆盖默认的 cloud 配置。这样 HA 启动后不会尝试连接 Nabu Casa 云,所有设备发现均走本地网络。

注意:Windows 防火墙默认阻止 8080 端口入站。必须手动添加入站规则:netsh advfirewall firewall add rule name="local_roborock_server" dir=in action=allow protocol=TCP localport=8080。否则设备能 ping 通 PC,但 TCP 握手永远超时。

3.2 RSA 密钥体系的三重角色与生成实操

“rsa,rsa密,rsa算法” 这些热词背后,是 Roborock 设备安全模型的基石。它并非单一用途,而是贯穿设备生命周期的三个关键环节:

  • 环节一:设备出厂密钥对(Device Key Pair)
    每台 Roborock 设备在生产时,安全芯片(如 ATECC608A)生成一对 2048 位 RSA 密钥。私钥永不离开芯片,公钥以明文形式写入固件分区(通常在/dev/mtd3或/dev/block/by-name/misc)。要获取公钥,需拆机短接 UART 引脚,用minicom抓取 bootlog,搜索RSA_PUBLIC_KEY字符串。我拆解 S7 后得到的公钥 PEM 格式如下:

    -----BEGIN PUBLIC KEY----- MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAuJ...(省略 280 字符) -----END PUBLIC KEY-----

    此公钥用于 L2 注册阶段——设备用它加密临时 AES 密钥,Server 必须用此公钥解密,才能拿到后续通信密钥。

  • 环节二:Server 签名密钥对(Server Key Pair)
    local_roborock_server需自己生成一对 RSA 密钥(2048 位),私钥用于 JWT 签名,公钥则需硬编码进 Server 代码。生成命令(Windows PowerShell):

    openssl genrsa -out server_private.pem 2048 openssl rsa -in server_private.pem -pubout -out server_public.pem

    关键点:server_public.pem必须与设备公钥严格区分。很多新手把两者混淆,导致 JWT 签名后设备无法验签——因为设备只信任自己固件里的公钥,而 Server 公钥仅用于 App 端校验(App 从 Server 获取公钥后,验证 JWT 签名是否有效)。

  • 环节三:动态会话密钥(Session AES Key)
    这才是实际通信的“工作密钥”。它由设备生成,用设备公钥加密后发送给 Server;Server 解密后,双方以此密钥进行 AES-CBC 加密所有指令。它的生命周期极短:每次设备重启或网络重连都会生成新密钥。因此local_roborock_server必须在内存中缓存当前会话密钥,并在 WebSocket 连接断开时及时清理。我用 Python 的threading.local()实现线程级密钥存储,避免多设备并发时密钥错乱。

实操心得:RSA 密钥长度必须为 2048 位。试过 4096 位,设备握手时直接返回ERR_INVALID_RSA_KEY。Roborock 固件的 OpenSSL 版本较老(1.0.2u),不支持大于 2048 的密钥。这是逆向过程中最耗时的兼容性测试之一。

3.3 local_roborock_server 的核心代码结构解析

local_roborock_server并非单文件脚本,而是模块化设计。其主干结构如下(基于 GitHub 开源版本 v0.4.2 二次开发):

local_roborock_server/ ├── __main__.py # 入口:启动 UDP 监听、HTTPS 服务、WebSocket 服务 ├── core/ │ ├── device_manager.py # 管理设备注册状态、密钥缓存、会话超时 │ ├── crypto.py # RSA/AES 加解密封装,含 CRC32 计算、Base64Url 编码 │ └── protocol.py # 定义 Roborock 私有协议常量(magic number、packet types) ├── api/ │ ├── http_handler.py # 处理 /api/v1/register_device 等 HTTP 请求 │ └── ws_handler.py # WebSocket 消息路由,method -> handler 映射 └── integrations/ └── homeassistant.py # 为 HA 提供 REST API 接口(/api/roborock/local/start)

最关键的crypto.py中,AES-CBC 解密函数实例如下(已脱敏):

from cryptography.hazmat.primitives.ciphers import Cipher, algorithms, modes from cryptography.hazmat.primitives import padding def decrypt_payload(encrypted_data: bytes, aes_key: bytes, iv: bytes) -> dict: # Roborock 使用 PKCS#7 填充,块大小 16 cipher = Cipher(algorithms.AES(aes_key), modes.CBC(iv)) decryptor = cipher.decryptor() padded_data = decryptor.update(encrypted_data) + decryptor.finalize() # 移除 PKCS#7 填充 unpadder = padding.PKCS7(128).unpadder() json_bytes = unpadder.update(padded_data) + unpadder.finalize() return json.loads(json_bytes.decode('utf-8'))

注意:iv必须从加密包中提取(Roborock 将 IV 放在 payload 前 16 字节),不能硬编码。我曾因忽略 IV 提取,导致解密后 JSON 解析失败,报错JSONDecodeError: Expecting value,排查了两天才发现是 IV 错位。

4. 完整实操流程:从设备断网到 HA 地图实时渲染

4.1 设备端断网操作与初始状态确认

在开始 Server 部署前,必须确保设备处于“纯净断网态”。这不是简单拔网线——Roborock 设备有长达 30 分钟的云连接重试机制,期间会不断扫描 WiFi、尝试连接。正确操作流程:

  1. 进入路由器后台,将扫地机 MAC 地址加入黑名单(或关闭其 DHCP 分配);
  2. 在设备端长按“Reset”键 10 秒,听到提示音后松开,设备进入恢复模式(LED 慢闪蓝光);
  3. 手机 App 删除该设备(注意:不是“解除绑定”,是彻底删除);
  4. 断开手机 WiFi,用移动数据打开 App,确认设备列表为空;
  5. 重新连接家庭 WiFi,此时设备虽能连上路由器,但因无 DNS 解析能力(api.roborock.com被黑洞路由),实际无法触达云端。

此时用ping 192.168.1.100(设备 IP)应通,telnet 192.168.1.100 58899应拒绝连接(证明设备未启动 UDP 服务)。这是 Server 启动前的理想状态。

4.2 local_roborock_server 启动与设备自动注册

Server 启动命令(Windows CMD):

cd local_roborock_server python -m local_roborock_server --host 0.0.0.0 --port 8080 --debug

关键参数说明:

  • --host 0.0.0.0:监听所有网卡,确保设备能访问;
  • --port 8080:HTTP 服务端口,HA 集成需此端口;
  • --debug:输出详细日志,包括每包 CRC 校验值、JWT 签名过程、AES 解密原始字节。

启动后,Server 日志首行应显示:

INFO:local_roborock_server:Starting UDP listener on 0.0.0.0:58899 INFO:local_roborock_server:Starting HTTP server on 0.0.0.0:8080

此时立即观察设备 LED:约 15 秒后,蓝光转为快闪绿光,表示设备已发现本地 Server 并发起注册。日志中会出现:

DEBUG:core.device_manager:Received register request from 192.168.1.100 INFO:api.http_handler:Generated user_id=us1234567890ab, region=us, country=US DEBUG:core.crypto:Signed JWT with server private key, jti=abc123...

若 60 秒内无此日志,检查:① 设备是否真断网(用手机热点抓包确认无外网请求);② Windows 防火墙是否放行 58899 端口(UDP);③ 设备固件版本是否兼容(S7 需 >= v4.1.5,旧版存在 UDP 包格式差异)。

4.3 Home Assistant 集成与地图功能落地

roborock_local自定义集成需手动安装。步骤:

  1. 在 HA 配置目录创建custom_components/roborock_local/;
  2. 将集成代码(含__init__.py,manifest.json,vacuum.py)放入;
  3. 重启 HA;
  4. 进入 HA UI → 设置 → 设备与服务 → 添加集成 → 搜索 “Roborock Local” → 填入 Server 地址http://127.0.0.1:8080。

集成成功后,HA 设备列表会出现 “Roborock S7 (Local)”。但此时地图仍是空白——因为 Roborock 地图数据(map_data)是二进制流,需 Server 解析并转换为 HA 支持的camera实体。local_roborock_server的/api/map接口返回 PNG 图像,而 HA 的roborock_local集成会定时 GET 此 URL 并更新 camera 实体。实测延迟约 2 秒,与官方云基本一致。

更关键的是划区清扫:在 HA 的 Lovelace 界面添加button卡片,调用服务roborock_local.send_command,data 设为:

{ "command": "app_segment_clean", "params": [101, 102] }

其中101,102是房间 ID(可通过/api/room_mapping接口获取)。执行后,设备会立刻启动,且地图上对应区域高亮——这证明 Server 不仅转发指令,还维护了完整的房间拓扑关系。

常见问题速查表:

现象可能原因排查命令
设备 LED 一直慢闪蓝光,无快闪绿光UDP 组播未收到响应`netstat -ano
HA 显示 “Unavailable”,但 Server 日志有注册记录HA 未正确加载集成查看 HA 日志grep -i "roborock_local" home-assistant.log
地图始终空白,camera 实体无图像Server/api/map返回 404curl http://127.0.0.1:8080/api/map检查 HTTP 响应
划区指令执行后设备不动app_segment_clean参数格式错误抓包对比官方云请求,确认params是整数数组而非字符串

5. 深度避坑指南:那些官方文档绝不会告诉你的细节

5.1 固件版本陷阱:S7 的 v4.1.5 与 v4.2.0 协议断裂

Roborock 在 2023 年 8 月推送的 S7 固件 v4.2.0 是个分水岭。此前版本(v4.1.5 及更早)的 UDP 注册包中,device_id字段为 ASCII 字符串;而 v4.2.0 改为 UTF-8 编码,且长度限制从 32 字节变为 48 字节。local_roborock_server若未适配,设备注册时会返回ERR_INVALID_DEVICE_ID。解决方案是在protocol.py中增加编码检测:

def parse_device_id(raw_bytes: bytes) -> str: try: # 先尝试 UTF-8 return raw_bytes.decode('utf-8').strip('\x00') except UnicodeDecodeError: # 回退到 ASCII return raw_bytes.decode('ascii', errors='ignore').strip('\x00')

我为此专门买了两台 S7:一台锁固件在 v4.1.5 用于稳定生产,另一台升到 v4.2.0 测试兼容性。这种成本投入,是开源项目难以覆盖的“隐性门槛”。

5.2 Windows 时间同步误差导致 JWT 失效

JWT 的exp(过期时间)和iat(签发时间)字段精度为秒级。Windows 系统默认时间同步间隔为 7 天,若 PC 时间比标准时间快 60 秒以上,设备收到 JWT 后会判定iat为未来时间,直接拒绝。现象是:Server 日志显示 “JWT signed”,但设备无响应。解决方法:

  1. 以管理员身份运行 CMD;
  2. 执行w32tm /resync /force强制同步;
  3. 检查w32tm /query /status输出中的 “Last Successful Sync Time” 是否为当前时间。

更彻底的方案是禁用 Windows 时间服务,改用chrony(需 WSL2)或第三方工具NetTime,将同步间隔设为 60 秒。

5.3 Home Assistant 的 MQTT 重连风暴

当local_roborock_server重启时,设备会断开 WebSocket 并尝试重连。此时若 HA 的 MQTT 客户端未设置重连退避,会在 1 秒内发起 100+ 次重连请求,导致 Windows 网络栈崩溃(表现为netstat显示大量TIME_WAIT状态)。解决方案是在configuration.yaml中为 MQTT 添加:

mqtt: broker: localhost port: 1883 keepalive: 60 # 关键:重连间隔从 1 秒改为 10 秒 discovery: true discovery_prefix: homeassistant

同时,在local_roborock_server的ws_handler.py中,设备断连后加入 5 秒冷却期,避免瞬时重连洪峰。

5.4 地图坐标系偏移:为什么 HA 显示的地图总往右偏 200 像素

Roborock 官方地图的坐标原点(0,0)在左上角,但其二进制地图数据中,map_info结构体的x_start,y_start字段定义了实际有效区域的偏移量。local_roborock_server若直接将原始地图 PNG 渲染,会导致 HA 显示时内容被裁剪。正确做法是解析map_info(位于地图数据前 128 字节),提取x_start,y_start,width,height,用 Pillow 库做 ROI 截取:

from PIL import Image import io def crop_map_image(raw_map_data: bytes) -> bytes: # 解析 map_info(简化版) x_start = int.from_bytes(raw_map_data[16:20], 'little') y_start = int.from_bytes(raw_map_data[20:24], 'little') width = int.from_bytes(raw_map_data[24:28], 'little') height = int.from_bytes(raw_map_data[28:32], 'little') # 读取 PNG 数据(跳过 header) img = Image.open(io.BytesIO(raw_map_data[128:])) # 裁剪有效区域 cropped = img.crop((x_start, y_start, x_start + width, y_start + height)) output = io.BytesIO() cropped.save(output, format='PNG') return output.getvalue()

这个 200 像素偏移,是我在对比官方 App 截图与 HA 渲染图时,用像素尺逐帧测量发现的。没有文档,只有耐心。

6. 后续演进与个人经验总结

这个项目走到今天,早已超出“让扫地机离线工作”的初始目标。它成了我检验物联网协议理解深度的标尺:每一次设备固件更新,都是一次新的逆向挑战;每一次 HA 版本升级,都考验着集成的健壮性。目前最迫切的演进方向是本地 OTA 固件升级——Roborock 官方 OTA 依赖云签名,而local_roborock_server已具备签名能力,下一步是解析.pkg固件包结构,用 Server 私钥重签名后推送给设备。这需要深入研究 Rockchip RK3326 的 BootROM 验签逻辑,难度远超当前。

我个人在实际操作中的体会是:物联网本地化不是“去云化”,而是“主权迁移”。你把信任锚点从厂商服务器移到自己的笔记本,随之而来的是全部责任——密钥管理、协议兼容、故障排查,都得自己扛。没有厂商客服,没有一键重置,只有日志里一行行十六进制的包数据。但当你看着 S7 在断网状态下,精准地沿着沙发腿清扫,而 HA 地图实时更新着它的轨迹,那种掌控感是任何云服务都无法给予的。最后再分享一个小技巧:在local_roborock_server的device_manager.py中,给每个设备实例添加last_seen时间戳,当超过 300 秒无心跳,自动触发密钥刷新流程。这能避免设备休眠唤醒后因密钥过期导致的“失联”,是我踩过五次坑后加上的保命逻辑。

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进