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。步骤如下:
- 安装 Python 3.11(必须 3.11,因
local_roborock_server依赖cryptography>=38.0.0,而该版本在 Py3.10 下编译失败); - 创建虚拟环境:
python -m venv ha_env; - 激活后安装 HA Core:
pip install homeassistant; - 关键配置:编辑
%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: 8080disabled.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、尝试连接。正确操作流程:
- 进入路由器后台,将扫地机 MAC 地址加入黑名单(或关闭其 DHCP 分配);
- 在设备端长按“Reset”键 10 秒,听到提示音后松开,设备进入恢复模式(LED 慢闪蓝光);
- 手机 App 删除该设备(注意:不是“解除绑定”,是彻底删除);
- 断开手机 WiFi,用移动数据打开 App,确认设备列表为空;
- 重新连接家庭 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自定义集成需手动安装。步骤:
- 在 HA 配置目录创建
custom_components/roborock_local/; - 将集成代码(含
__init__.py,manifest.json,vacuum.py)放入; - 重启 HA;
- 进入 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”,但设备无响应。解决方法:
- 以管理员身份运行 CMD;
- 执行
w32tm /resync /force强制同步; - 检查
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 秒无心跳,自动触发密钥刷新流程。这能避免设备休眠唤醒后因密钥过期导致的“失联”,是我踩过五次坑后加上的保命逻辑。