Skip to content

Feat/mac launchagent lnp - #472

Open
HCl8 wants to merge 2 commits into
mainfrom
feat/mac-launchagent-lnp
Open

Feat/mac launchagent lnp#472
HCl8 wants to merge 2 commits into
mainfrom
feat/mac-launchagent-lnp

Conversation

@HCl8

@HCl8 HCl8 commented Jul 30, 2026

Copy link
Copy Markdown
Collaborator

本 PR 含两条独立主线,分别对应两个 commit。

1. 接入本地中枢网关(miot SDK 侧)

在 miot SDK 接入小米本地中枢网关,实现设备的局域网低延迟控制与实时推送、云端兜底:

  • mDNS 发现:纯 Python legacy-unicast 子网定向广播、逐网卡(取代 zeroconf/dns-sd,跨平台)
  • 家庭归属过滤:只连 Miloco 启用的家庭,静态网关也校验归属;切换家庭重置作用域
  • mTLS MQTT + MIPS TLV RPC 本地控制/推送;控制路由下沉到 MIoTClient,业务层薄委托
  • 本地失败按类型分流(对齐 xiaomi-ha:可用则本地、失败不双发、冷却期走云端)
  • 切号/登出清理本地身份;0x87 Not authorized 给可操作提示;首连失败周期性重连自愈
  • 含 P0 单元/集成 + 边界场景单测
  • 附修:cloud.py 设备列表解析 local_ip 字段键名对齐 API 实际返回的 localip

2. macOS LaunchAgent + 签名启动器

macOS Local Network Privacy 拦用户态后端连中枢网关的 TCP(Errno 65),仅 root 例外。
让 python 作为签名 app(com.xiaomi.miloco.backend)的子进程运行,LNP 按 app 身份授权、
一次即通、跨重启/发版持久。

  • launcher/:通用签名 C stub → miloco.app(通用二进制 arm64+x86_64,adhoc 签名,vendored 入库的预编译二进制,~101 KB
  • cli service.py:darwin 用 launchd 全功能替代 supervisord(start/stop/restart/status/kill 走 launchctl;EnvironmentVariables 复刻并补 HOME/PATH;处理 bootout→bootstrap 的 EIO 竞态)
  • openclaw/hermes 调 miloco-cli service 透明驱动同一 LaunchAgent,运行时无需改
  • build.sh/sync-to-remote.sh/install.py:打包 + 部署落地 miloco.app;darwin 跳过 supervisor
  • install-hermes.sh --diagnose:改走 miloco-cli service status(兼容 launchd)
  • 文档 knowledge/03-features/macos-launchagent-lnp.md(含 mermaid 流程图)

@github-actions

Copy link
Copy Markdown

👋 感谢提交 PR @HCl8!维护者会尽快 review。

提交前请确认:

  • CI 全绿(test / lint / build)
  • 改动聚焦单一主题,便于审阅
  • 若改动了依赖(lockfile / pyproject.toml / package.json),需维护者评论 /allow-dependencies-change <当前 head SHA> 放行(之后再 push 需重新放行)

Comment thread backend/miot/src/miot/mdns.py Fixed
Comment thread backend/miot/src/miot/central_hub.py Fixed
Comment thread backend/miot/src/miot/mdns.py Fixed
Comment thread backend/miot/src/miot/mdns.py Fixed
Comment thread backend/miot/src/miot/mips_local.py Fixed
Comment thread backend/miot/src/miot/mips_local.py Fixed
@HCl8
HCl8 force-pushed the feat/mac-launchagent-lnp branch from e927b10 to 58f3ec6 Compare July 30, 2026 08:44
@github-actions

github-actions Bot commented Jul 30, 2026

Copy link
Copy Markdown

[PR #472]: Feat/mac launchagent lnp — 接入本地中枢网关 + macOS LaunchAgent 迁移

作者: HCl8
范围: feat/mac-launchagent-lnp → main(+5935 / -239,43 文件)

修改方案

要解决的问题

Miloco 控制设备此前只有云端一条路径,延迟高、断网即失能。小米的「中枢网关」在同一局域网内支持 mTLS 直连,但 SDK 从未接入。macOS 上还叠加了一道 Local Network Privacy(LNP)障碍——按进程签名身份拦截用户态进程的局域网 TCP,导致即便本地控制逻辑写对了也连不上网关。

整体方案(7 条主线)

  • 主线 1 — 局域网发现网关。 mac 上 5353 端口被系统 mDNSResponder 独占,现成库 python-zeroconf 收不到任何东西,所以改成纯标准库实现 RFC 6762 §6.7 的 legacy unicast 查询:从随机高位端口发出 PTR 请求,网关点对点回到该端口(mdns.py)。请求发给每张网卡所在子网的广播地址而非组播地址,在组播被吃掉的环境里也能工作;每张网卡各开一个 socket 并用系统调用钉死在那张卡上,避免多网卡机器的查询全从默认路由走。

  • 主线 2 — 客户端身份与准入。 随机生成 64 位数字作为虚拟设备号,持久化到 KV 表保证跨重启稳定,注入给 SDK 用作 MQTT client_id / 证书 CN / 回复推送主题前缀(_ensure_virtual_did)。用该身份生成密钥对和 CSR,交给云端换短期证书,到期前自动续签(cert.py)。根证书随代码内置,落盘后按写死的 SHA-256 指纹校验,指纹不匹配时自动删掉重写一次再校验(自愈机制)。发现到的网关还要检查家庭归属,归属判断 fail-open(宁可多连)、身份判断 fail-closed。

  • 主线 3 — mTLS MQTT + MIPS TLV RPC 本地通道。 网关应用层是 TLV 二进制打包的 RPC,字符串字段长度含结尾 0 字节(mips_local.py)。paho 回调跑在自己的网络线程上,所有结果通过 call_soon_threadsafe 跳回事件循环,在同一个回调里原子地检查 done() + 填结果,避免超时和应答同时到达时重复置值(_resolve_future / _complete_future)。连上后拉一次全量设备清单写进 _dev_table;推送里的增量故意不用,而是重新拉全量再对账,因为只有全量才能正确删掉已消失的设备。

  • 主线 4 — 本地失败分类降级。 沉到 SDK 里做,业务层薄委托,保证所有调用方拿到一致的降级行为。核心是「失败不双发」——超时意味着指令可能已经到设备了,再走云端补一次就会出现连按两下,所以超时重试云端,同时给该 did 开 30 秒冷却期(client.py)。明确没执行的错误码则安全重试云端,不用冷却。读操作是幂等的,超时仍走云端兜底。action 使用独立 _UNSET 哨兵值区分「异常」和「返回 None」,防止非幂等操作双发。

    本地调用结果 set/action get
    设备不在本地表 / 冷却期内 直接走云端 直接走云端
    抛异常(连接层没发出去) 云端重试,不开冷却 云端重试,不开冷却
    返回码 -10006(超时) 云端重试,开冷却 云端重试(幂等),开冷却
    返回码 -10004 / -10040 / -10001 云端重试,不开冷却 云端重试,不开冷却
    返回码 0 / 1 本地成功 本地成功
    action 返回 None 返回内部错误,云端重试
  • 主线 5 — 账号和家庭生命周期。 换账号 / 登出都把本地身份整个推倒重建(删证书 + 重新生成虚拟设备号 + 拆掉中枢连接),再去拿新账号信息(reset_central_identity_async)。切换家庭只有作用域真的变了才重算一遍该连哪些网关。首次连接失败另有每 20 秒的巡检任务重试。网关回 Not authorized 单独识别并打一条可操作提示(一个账号只允许一个中枢客户端),且每个网关只打一次不刷屏。首登 / 换号后 list_homes 兜底自动选家时也会刷新中枢 scope。

  • 主线 6 — macOS LNP 绕过。 让后端作为签名空壳 app 的子进程运行,系统把局域网访问归属到该 app,授权一次即通(miloco_launcher.c)。空壳刻意不含业务逻辑,字节稳定 → 签名摘要稳定 → 授权跨版本持久。它如实翻译子进程的死法:正常退出同码退出,被信号打死则重新对自己放同一信号,让 launchd 看到 WIFSIGNALED。mac 进程托管从 supervisord 整体换成 launchd(service.py),唯一的平台判断在 CLI 的 service 命令里,上游无感。保活用 KeepAlive={SuccessfulExit:False},崩溃循环由 CLI 侧在健康探测窗口内检测 pid 变化补上"放弃"语义。端口清理加 _is_miloco_backend_proc 守卫,只清理 miloco 残留,不误杀用户其它进程。

  • 主线 7 — 打包、部署、诊断。 构建脚本把预签名的 app 原样打成 tar,只塞 darwin 归档。部署路径都落地该 app,mac 上跳过 supervisor。Hermes 插件自检改走 miloco-cli service status,跨平台一致。cloud.py 修正了设备列表解析中 local_ip 字段的 API key(local_iplocalip),修复了此前永远返回 None 的 bug。

关键设计原则

  1. 总开关先行。 整个本地中枢挂在 central_hub_enabled 配置开关下,关掉就完全不签证书、不发现、不连网关。
  2. 失败不双发优先于尽力送达。 超时路主动放弃云端补发,宁可这次控制失败也不冒重复执行的风险。
  3. 归属判断 fail-open、身份判断 fail-closed。 家庭集合算不出来时选择放行;证书身份不匹配时一律拒绝。
  4. 平台差异只在一处分叉。 mac 用 launchd、Linux 用 supervisord 的判断只写在 CLI service 命令里。
  5. 签名空壳保持零逻辑。 不含业务代码,字节和签名摘要跨版本稳定,用户一次授权永久有效。
  6. 路由下沉 SDK、业务层薄委托。 本地/云端分流在 MIoTClient 里,保证所有调用方拿到一致的降级行为。

测试覆盖

主线 测试文件 用例摘要
1 发现网关 test_mdns_unicast.py PTR 查询报文构造、两种真实网关应答解析、垃圾输入返回空、profile 解析与拒绝非法输入、子网广播地址推导
2 身份与准入 test_cert.py CA 落盘与指纹校验(含自愈)、用户证书剩余有效期、CN 与虚拟设备号匹配
2 身份与准入 test_miot_identity_reset.py 虚拟设备号持久化稳定、切号/登出触发身份重置、云端身份不受影响
2 身份与准入 test_miot_parse_gateways.py 静态网关字符串解析:默认端口、显式端口、空白裁剪、坏值跳过、IPv6 优雅降级
3 本地通道 test_mips_local_tlv.py TLV 打包解包往返、字符串长度含 NUL、Unicode payload
3 本地通道 test_mips_local_pipeline.py 假 MQTT 驱动的完整请求-应答链路、超时、重连后重新订阅、future 竞态、deinit 失败在途请求
3 本地通道 test_central_hub.py 设备表对账增删、归属过滤、静态网关家庭未启用判 false、drop 连带清设备表且回调 removed、重连巡检只挑没连上的
4 路由降级 test_client_control_routing.py 各返回码分流矩阵、超时不双发+开冷却、批量请求按下标合并、整批云端失败保持抛异常契约、云端返回短了用 error 补位
5 首登 scope test_miot_list_homes_scope_refresh.py 启用集为空时兜底自动选家并刷新中枢 scope、已有时不刷
6 macOS 权限 test_service_launchd.py plist 形状(启动器在前、保活策略、环境变量含 HOME/PATH/homebrew)、内容不变不重写、EIO 竞态重试与放弃、崩溃循环检测、清理残留 supervisord

复核上轮 ci-bot 的问题

上轮 ci-bot(2026-07-30)的 3 条 🟡 + 4 条 🔵 均已在当前代码中修复验证,不重复报

上轮问题 现状
🟡 _fire_connect_future 竞态(mips_local.py) _fire_connect_future 已改用 _complete_future(与 _resolve_future 同型原子模式),connect 路径和 deinit 路径均已修复
🟡 证书续签失败不重试(central_hub.py) except 分支 已加 self.__schedule_cert_refresh(_CERT_REFRESH_RETRY_BACKOFF)(300s 退避,__schedule_cert_refreshmax(delay, 60) 兜底 ≥ 60s)
🟡 action_asyncNone 哨兵(client.py) ✅ 已引入 _UNSET = object() 哨兵;local_result is None 直接返回内部错误不云端重试;local_result is _UNSET(异常路径)fall through 到云端
🔵 deinit_async 没清理状态字典 deinit_async 已清 _ensure_locks / _auth_rejected / _local_cooldown
🔵 _LOCAL_SET_OK_CODES 命名误导 ✅ 已重命名为 _LOCAL_OK_CODES
🔵 _central_hub_virtual_did 跨模块直接访问私有属性 ✅ SDK 侧已有 property getter + setter;MiotProxy 的 reset_central_identity_async 已改用 client.central_hub_virtual_did = ...
🔵 生产代码路径中使用 assert get_props_async 已替换为 if code is None + _LOGGER.error + continue

复核 Zirconi 的 2 条 P1

均已修复:

Zirconi P1 现状
首登/换号后 list_homes 兜底自动选家不刷新中枢 scope service.py:1114-1122 在兜底路径加 refresh_central_hub_scope_async();附 test_miot_list_homes_scope_refresh.py 覆盖正反两条路径
service start 静默 kill 任意占用端口进程 _launchd_start_is_miloco_backend_proc 守卫(token 级正则 -m\s+miloco\.main\b),非 miloco 进程不 terminate,改报 port already in use_launchd_stop_launchd_kill 同一守卫

问题

无新的 🔴 / 🟡 / 🔵 问题。

经完整复核:

  • 上轮 ci-bot 的 3 🟡 + 4 🔵 全部以代码证据验证修复
  • Zirconi 的 2 P1 全部以代码证据验证修复
  • 独立扫描 A/B/C 三维度(代码逻辑 / 文档一致性 / 跨层一致性)未发现新问题
  • cloud.pylocal_iplocalip 修正是一个有价值的 bug fix(API 返回 "localip" 无下划线,旧代码永远得到 None
  • mips_cloud.pyCallbackAPIVersion import 修正与 paho-mqtt>=2.1.0 兼容
  • zeroconf 依赖移除干净(pyproject.toml + uv.lock + mdns.py 全部对齐)
  • 测试覆盖 ~1860 行新测试对 ~4100 行新生产代码,比率健康

结论

LGTM — 上轮 ci-bot 和 Zirconi 发现的全部问题均已修复验证,代码质量高,测试覆盖全面,可以合入。


由 review-pr skill v1.6 生成

@HCl8
HCl8 force-pushed the feat/mac-launchagent-lnp branch 2 times, most recently from 5ea2bd2 to aafefc7 Compare July 30, 2026 10:49
@HCl8
HCl8 requested review from Ada-xia and Zirconi July 30, 2026 12:34
@HCl8
HCl8 force-pushed the feat/mac-launchagent-lnp branch from aafefc7 to 50ecf59 Compare July 30, 2026 13:17
@HCl8
HCl8 force-pushed the feat/mac-launchagent-lnp branch 4 times, most recently from 6c48ffa to d1cb57a Compare July 31, 2026 07:27
Comment thread backend/miot/src/miot/mdns.py Fixed
@HCl8
HCl8 force-pushed the feat/mac-launchagent-lnp branch 6 times, most recently from fc9b3ef to 7e96afa Compare July 31, 2026 11:07
@HCl8

HCl8 commented Aug 1, 2026

Copy link
Copy Markdown
Collaborator Author

/allow-dependencies-change 4111ab4

@Zirconi
Zirconi requested a review from yangbaofu007 August 3, 2026 02:11
@Zirconi

Zirconi commented Aug 3, 2026

Copy link
Copy Markdown
Collaborator

Review 结论:整体方向 OK,但建议先修 2 个 P1 问题再合。

PR 主要内容

  • 接入米家本地中枢:新增 mDNS 发现、mTLS 证书、mips_local MQTT/RPC、本地 set/get/action/push 路由和云端 fallback。
  • Miloco 侧接入:新增中枢配置、虚拟 did 持久化、账号切换时重置本地证书/身份,并按启用家庭过滤中枢网关。
  • macOS 迁移:Darwin 下从 supervisord 切到 launchd -> miloco.app 签名启动器 -> python -m miloco.main,用于绕过 LNP 本地网络授权归属问题。
  • 补了安装/构建/同步脚本、文档、i18n 和一批中枢/LaunchAgent 单测。

Findings

P1:首登/换号后本地中枢不会连接自动选中的家庭

authorize_with_code() 先清空家庭 scope 并触发 SDK 初始化;SDK 在 _setup_mips_async() 里先初始化本地中枢,而此时 allowed_home_ids() 为空,所以 CentralHubManager.__refresh_owned_group_ids() 会算出空的 _owned_group_ids。随后 list_homes() 虽然会自动写入第一个家庭,但这个路径没有刷新 central hub scope;相比之下,显式 switch_home() 才会调用 refresh_central_hub_scope_async()

影响:新用户首登或换号后,mDNS 发现到的中枢网关会因为 group_id 不在 _owned_group_ids 中被跳过,本地控制/推送可能一直不可用,直到用户手动切家或重启 backend。

建议:list_homes() 自动选家且 scope 发生变化时,也触发 refresh_central_hub_scope_async();或者在 authorize_with_code()list_homes() 后显式刷新一次中枢 scope,并补对应单测。

相关位置:

  • backend/miloco/src/miloco/miot/service.py:305
  • backend/miot/src/miot/client.py:789
  • backend/miot/src/miot/central_hub.py:405
  • backend/miloco/src/miloco/miot/service.py:1101
  • backend/miloco/src/miloco/miot/service.py:1167

P1:macOS service start 会静默 kill 任意占用端口的进程

_launchd_start() 在启动前通过端口查 PID 后直接 _terminate(port_pid),没有校验该进程是不是旧 Miloco backend 或 legacy supervisord 子进程。这样如果用户配置的端口被其他服务占用,执行 miloco-cli service start 会先把对方进程杀掉;旧的非 macOS start 路径只是返回 port already in use

建议:只清理可证明属于 Miloco legacy 管理链路的残留进程;否则保持原语义,直接报 port already in use,避免误杀用户进程。可以为 _launchd_start() 加一个“非托管端口占用不 terminate”的单测。

相关位置:

  • cli/src/miloco_cli/commands/service.py:621
  • cli/src/miloco_cli/commands/service.py:628
  • cli/src/miloco_cli/commands/service.py:795

验证

  • git diff --check origin/main...origin/feat/mac-launchagent-lnp 通过。
  • 后端目标测试通过:51 passed
  • CLI LaunchAgent 测试通过:14 passed
  • 相关 ruff check 通过。

@HCl8
HCl8 force-pushed the feat/mac-launchagent-lnp branch 2 times, most recently from 774d608 to 5249422 Compare August 3, 2026 07:40
Comment thread backend/miot/src/miot/central_hub.py Fixed
@HCl8
HCl8 force-pushed the feat/mac-launchagent-lnp branch 5 times, most recently from 60015a0 to ba47a69 Compare August 3, 2026 08:20
HCl8 added 2 commits August 3, 2026 16:36
在 miot SDK 接入小米本地中枢网关,实现设备的局域网低延迟控制与实时推送、云端兜底:
- mDNS 发现:纯 Python legacy-unicast 子网定向广播、逐网卡(取代 zeroconf/dns-sd,跨平台)
- 家庭归属过滤:只连 Miloco 启用的家庭,静态网关也校验归属;切换家庭重置作用域
- mTLS MQTT + MIPS TLV RPC 本地控制/推送;控制路由下沉到 MIoTClient,业务层薄委托
- 本地失败按类型分流(对齐 xiaomi-ha:可用则本地、失败不双发、冷却期走云端)
- 切号/登出清理本地身份;0x87 Not authorized 给可操作提示;首连失败周期性重连自愈
- 含 P0 单元/集成 + 边界场景单测
macOS Local Network Privacy 拦用户态后端连中枢网关的 TCP(Errno 65),仅 root 例外。
让 python 作为签名 app(com.xiaomi.miloco.backend)的子进程运行,LNP 按 app 身份授权、
一次即通、跨重启/发版持久。

- launcher/:通用签名 C stub → miloco.app(通用二进制 arm64+x86_64,adhoc 签名,vendored 入库)
- cli service.py:darwin 用 launchd 全功能替代 supervisord(start/stop/restart/status/kill
  走 launchctl;EnvironmentVariables 复刻并补 HOME/PATH;处理 bootout→bootstrap 的 EIO 竞态)
- openclaw/hermes 调 miloco-cli service 透明驱动同一 LaunchAgent,运行时无需改
- build.sh/sync-to-remote.sh/install.py:打包 + 部署落地 miloco.app;darwin 跳过 supervisor
- install-hermes.sh --diagnose:改走 miloco-cli service status(兼容 launchd)
- 文档 knowledge/03-features/macos-launchagent-lnp.md(含 mermaid 流程图)

真机(cat@mac)E2E:sync + 完整安装器两条路径均通,中枢 CONNACK success + device table +8。
@HCl8
HCl8 force-pushed the feat/mac-launchagent-lnp branch from ba47a69 to 4111ab4 Compare August 3, 2026 08:36
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants