1. 项目概述让扫地机器人彻底摆脱云端依赖的本地化实践“Show HN: I emulated Roborocks 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_id12 位数字字母混合、region如us、country如US③ 用 Server 自己的 RSA 私钥非设备私钥对 JWT 签名。注意Roborock 的 JWT header 固定为{alg:RS256,typ:JWT,kid:roborock}payload 必须包含exp15 分钟有效期、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依赖cryptography38.0.0而该版本在 Py3.10 下编译失败创建虚拟环境python -m venv ha_env激活后安装 HA Corepip 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: 8080其中disabled.yaml是空文件用于覆盖默认的 cloud 配置。这样 HA 启动后不会尝试连接 Nabu Casa 云所有设备发现均走本地网络。注意Windows 防火墙默认阻止 8080 端口入站。必须手动添加入站规则netsh advfirewall firewall add rule namelocal_roborock_server dirin actionallow protocolTCP localport8080。否则设备能 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 Pairlocal_roborock_server需自己生成一对 RSA 密钥2048 位私钥用于 JWT 签名公钥则需硬编码进 Server 代码。生成命令Windows PowerShellopenssl 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这才是实际通信的“工作密钥”。它由设备生成用设备公钥加密后发送给 ServerServer 解密后双方以此密钥进行 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 CMDcd local_roborock_server python -m local_roborock_server --host 0.0.0.0 --port 8080 --debug关键参数说明--host 0.0.0.0监听所有网卡确保设备能访问--port 8080HTTP 服务端口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_idus1234567890ab, regionus, countryUS DEBUG:core.crypto:Signed JWT with server private key, jtiabc123...若 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_commanddata 设为{ command: app_segment_clean, params: [101, 102] }其中101,102是房间 ID可通过/api/room_mapping接口获取。执行后设备会立刻启动且地图上对应区域高亮——这证明 Server 不仅转发指令还维护了完整的房间拓扑关系。常见问题速查表现象可能原因排查命令设备 LED 一直慢闪蓝光无快闪绿光UDP 组播未收到响应netstat -anoHA 显示 “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, errorsignore).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, formatPNG) 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 秒无心跳自动触发密钥刷新流程。这能避免设备休眠唤醒后因密钥过期导致的“失联”是我踩过五次坑后加上的保命逻辑。