Skip to content

fix(camera): allow PPCS when OT discovery fails - #444

Open
lixiangnlp wants to merge 6 commits into
XiaoMi:mainfrom
lixiangnlp:fix/camera-stream-ot-gate
Open

fix(camera): allow PPCS when OT discovery fails#444
lixiangnlp wants to merge 6 commits into
XiaoMi:mainfrom
lixiangnlp:fix/camera-stream-ot-gate

Conversation

@lixiangnlp

@lixiangnlp lixiangnlp commented Jul 17, 2026

Copy link
Copy Markdown

Summary

  • stop treating MiOT/OT LAN discovery as a hard prerequisite for camera activation
  • allow cloud-online cameras to create the native manager and attempt direct-IP/PPCS streaming
  • keep raw OT discovery as lan_detected, while reporting established PPCS sessions as reachable
  • retain strict OT filtering for callers that explicitly pass require_lan=True

Problem

On platforms such as macOS, a camera can fail MiOT/OT discovery while libmiss can still connect using the cloud-provided local IP or PPCS relay. The previous LAN gate prevented the native manager from being created, so the video handshake never had a chance to recover and the camera remained permanently disconnected.

Tests

  • uv run pytest miloco/tests/perception/test_online_connected_separation.py miloco/tests/test_miot_filter_and_cameras.py -q
  • 107 passed

Dashboard 与失败流轮换补齐

本 PR 现已补齐 AI Review 指出的跨层缺口,并处理超过 4 路时失败摄像机长期占位的问题:

  • Web 单台开关与一键全开都只以“云端在线 + 镜头未关”为开启硬门;OT/LAN 未发现不再阻止 direct-IP/PPCS 握手。
  • 前端保留并区分 lan_detected(原始 OT 发现)与 lan_reachable(OT 已发现或 PPCS/native 已连接),仅用于状态展示和诊断。
  • 新增 Dashboard 单开、批量开启及 backend→frontend 诊断字段映射测试。
  • native/PPCS 未连接时每 10 秒推进一次健康检查;连续 3 次失败后,仅在候选超过 4 路时降权让位,冷却 3 轮后重新尝试;已连接摄像机始终优先,≤4 路场景不淘汰。

新增验证:

  • 摄像机相关后端:119 passed
  • Web:202 passed, 1 skipped
  • TypeScript typecheck、Vite production build、Ruff:passed

@github-actions

Copy link
Copy Markdown

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

提交前请确认:

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

@CLAassistant

CLAassistant commented Jul 17, 2026

Copy link
Copy Markdown

CLA assistant check
All committers have signed the CLA.

@github-actions

github-actions Bot commented Jul 17, 2026

Copy link
Copy Markdown

PR #444: fix(camera): allow PPCS when OT discovery fails

作者: lixiangnlp
范围: fix/camera-stream-ot-gate → main

修改方案

要解决的问题:部分平台(尤其 macOS)上摄像头答不上 MiOT/OT 局域网探测(lan_online=false),但 libmiss 仍能用云端下发的 local_ip 直连、或走 PPCS 中继拉流。旧逻辑把「OT 发现成功」当作创建 native manager / 进入活跃集 / 允许开启的硬门,这类相机永久卡在「已启用却连不上」、无法自愈。

整体方案(四条正交主线):

  • 主线 1:OT 发现从硬门降级为诊断信号。选活跃集 / 投喂过滤的所有生产调用方都显式传 require_lan=False,云端在线的相机即便 OT 没应答也进活跃集去创建 manager 尝试握手(client.py:784service.py:1060camera_adapter.py:128)。select_active_camera_dids 仍保留 require_lan=True 默认值供想严格过滤的调用方显式选用;本轮核了全部调用方——无一依赖旧的 True 默认(rule-target 枚举 service.py:268 不传该参、自动吃到放宽后的口径,把 PPCS-only 相机也纳入可选目标,方向正确)。

  • 主线 2:开启校验拆掉局域网硬拒。后端唯一执法点(toggle_camera)删除对 lan_online=false 抛「局域网不可达」异常,开启门只剩「云端在线 + 该路镜头未关」;上限「可用集」谓词也去掉局域网维,避免把仍会真占一条解码线程的 PPCS 相机算作不占名额(service.py:1180)。

  • 主线 3:lan_reachable 语义改为「视频链路可达」并新增 lan_detected 诊断字段。列表接口保留原始 OT 结果到 lan_detected,对外 lan_reachable = lan_detected or info.connectedinfo.connectedCameraInfo 的派生 property,取 camera_status==CONNECTED,见 schema.py:92),即「OT 已发现 native/PPCS 会话真的建立」(service.py:1083)。

  • 主线 4:失败流轮换——让「云端在线但真的连不上」的相机在超过 4 路时把稀缺名额让给尚未尝试的相机,且让位有限期、能自愈

    • 先给每台相机在内存里记两笔状态:连续未连计数与冷却剩余轮数,进程重启即忘,只影响超额排序、不改 scope/online/awake 过滤(client.py:13 常量与字段)。
    • 每轮刷新先推进这两笔状态:读 miot SDK 现取的真实连接态(manager.camera_info.connected,非 OT 发现)——已连上的清零计数并标「优先」,未连上的计数 +1;计数达阈值(3)或还在冷却期的标「降权」(_advance_camera_selection_health)。
    • 截断到上限时按三档稳定排序:已连上 > 普通候选 > 持续失败/冷却,组内按 did 升序,健康集合按物理 did 记(同一台两路一起优先/让位)(filter.py 的 _rank)。
    • 一台相机因降权被挤出活跃集(deprioritized - active)时才给它设有限冷却(3 轮)并清空失败计数,随后在收敛段销毁其 manager 让出名额(client.py:819 newly_demoted + client.py:878 destroy);冷却期内 _advance 每轮递减、耗尽后回落普通候选可再抢名额。
    • 建连本身就失败(manager 创建返回 None)的相机在创建处单独补记一次失败计数,避免它因「没有 manager 就不计数」而永远占名额(client.py:853)。
    • 感知侧按需刷新触发条件从「应连数 > 已连数」放宽为「manager 缺失 native/PPCS 未连接」,按 10 秒最小间隔节流;判断是否断流走 is_camera_stream_connected,函数内部先归一合成 did 再查物理 did 键(sync_devices)。
    • Dashboard 侧对齐:list_cameras_with_state 读同一份内存里的优先/降权集合,用同一口径重算 in_useservice.py:1051)。

    名额竞争决策表(MAX_ENABLED_CAMERAS=4,仅在候选 >4 时生效):

    相机当前状态 排序档位 >4 路时结果
    native 会话已 CONNECTED 0(优先) 保留,永不因超额被淘汰
    普通候选(未达失败阈值 / 冷却已过) 1 有名额则保留、尝试握手
    连续失败 ≥3 或冷却期内 2(降权) 名额紧张时让位给档位 0/1
  • 前端配套:Web 单台开关 switchBlockedReasonKey 与一键全开候选 cameraAvailable 都去掉 lanReachable 硬门,只留「云端在线 + 镜头未关」;lanReachable 仅用于状态点灯,并拆成 lan_detected(OT 已发现)/ PPCS 已连接 / 待尝试 三态文案(HeroNow ChannelStateDotshero.json)。

关键设计原则

  1. 诊断信息不丢lan_online 原样保留为 lan_detectedlan_reachable 变派生态,排障时仍能区分「OT 没发现」和「PPCS 也连不上」。
  2. 降权可自愈,非永久拉黑:有限冷却 + 冷却后回落普通候选;≤4 路一律不淘汰(失败的也留着让 PPCS 自愈)。
  3. 健康状态纯内存、进程级:不写 KV、不碰黑名单,重启即忘。

本轮 ci 闭环

  • 上一轮唯一 🟡(is_camera_stream_connected 拿合成 did 查物理 did 键 → 已连接的多摄相机被误判断流、每 10 秒空转 refresh_cameras已在 273cd70 修复:归一收敛进函数内部(client.py:657),并补了不 mock 该函数的真实多摄回归用例 test_no_refresh_for_connected_multi_channel_cameratest_camera_adapter_reconnect.py:592),恰好锁死「单摄用例覆盖不到合成 did≠物理 did 分叉」盲区。
  • 作者在评论区确认采纳「选项 A:在 list_cameras_with_state docstring 说明 connectedlan_reachable 口径不同」——该 docstring 本 PR 已落地service.py:1031-1036),核对属实:输出字段 connectedservice.py:1118)取自 _connected_camera_dids()(感知已订阅该路、per-channel),lan_reachable 取自 info.connected(camera_status、per-camera),二者确为两个正交信号,docstring 准确。
  • 新增知识沉淀(273cd70 / ef75f90 / 0059974):perception-pipeline.md 记录「物理 did / 合成通道 did 两级身份」不变量,dev-guide.md 记录「把刚修的行故意改回错的、跑用例确认它失败」的假绿自查法——均为文档,无代码影响,内容准确。

问题

本轮无新增 🔴 / 🟡 / 🔵。上一轮唯一 🟡 已修复并有真实回归用例守护。

补充核验(均通过,未构成问题):

  • 失败流状态机无 off-by-one:冷却 tick 只在相机无 manager 时递减、min() 封顶失败计数、newly_demoteddid not in cooldowns 防重置冷却;demote 段(deprioritized - active)随后在收敛段 destroy manager,冷却「无 manager」前提成立——路径与 test_camera_retry_cooldown_is_finite / test_refresh_cameras_failed_manager_yields_slot_to_untried_camera 一致。
  • connected 是真派生 property 非幽灵字段CameraInfo.connected(schema.py:92)取自 SDK camera_statusgetattr(info, "connected", False) 读的是活值。
  • require_lan 默认翻转不破调用方:全部 discover_devices / select_active_camera_dids 生产调用方要么显式传 require_lan=False、要么吃到放宽后的默认且方向正确;select_active_camera_dids 严格路径经 test_require_lan_true_retains_strict_ot_filter 保留。
  • 删除的 i18n 键stateLanOk / stateLanOffline / disabledLanHint)全仓无残留引用;新增三态键 en/zh 两 locale 齐备。
  • 前端签名收窄干净cameraAvailable / switchBlockedReasonKey 去掉 lanReachable 入参后,lanReachable 仅剩 HeroNow 状态点灯与 real.ts 映射使用,无其它调用方读旧口径。

结论

LGTM — 后端 OT-gate 放宽 + 失败流轮换主逻辑自洽(健康度取自 SDK 现取真实连接态、冷却有限可自愈、≤4 路不淘汰,边界均有测试守护),前端开关/批量口径已与后端对齐、删除的 i18n 键全仓无残留。上一轮多摄空转 🟡 已在 273cd70 按「函数内部归一」方案修复并补了不 mock 的真实回归用例;作者采纳的 connected/lan_reachable docstring 澄清(选项 A)本 PR 已落地且核对准确。本轮无新增问题。


由 review-pr skill v1.6 生成

@lixiangnlp

Copy link
Copy Markdown
Author

感谢详细审查!对于 connectedlan_reachable 口径差异的建议,采纳选项 A:在 list_cameras_with_state 的 docstring 补充说明二者含义不同(connected = 感知已订阅、lan_reachable = 链路已建立),避免误读。后续维护时会补充该说明。

XiaoMi#439(双摄多通道全拆)的 per-channel 模型上重新表达 OT-gate 放宽与失败流轮换。

冲突解决要点:
- filter.py: select_active 保留 main 的通道展开 + 合成 did 返回;健康度三档排序
  叠加在其上。健康集合按**物理 did** 记(native/PPCS 会话整台一条),_rank 里把
  合成 did 归一回物理 did 再查——否则降权对多摄相机静默失效。
- client.py: refresh_cameras 先算 eligible_channels(cap=False) → 归一为物理 did
  喂给 _advance_camera_selection_health,再带健康集合算 active_channels,最后
  归一为 active 做 manager 建销。newly_demoted 全程按物理 did。
- service.py: 删掉 main 的「局域网不可达,无法开启」硬拒;上限统计的可用谓词去掉
  _lan 一维(OT 未发现的相机仍会尝试 PPCS、仍真占一条解码线程,算作不占名额会
  让实际拉流数超上限)。list_cameras_with_state 在 per-channel 行上补 lan_detected,
  lan_reachable 改为 OT已发现 or PPCS已连接。
- HeroNow.tsx: 状态点三态逻辑移入 XiaoMi#439 抽出的 ChannelStateDots;单开/全开去掉
  lanReachable 硬门(switchBlockedReasonKey 已不接受该字段)。

补测试 test_health_sets_are_keyed_by_physical_did_across_channels:多摄整台降权时
两路一起让位、整台已连通时两路一起占名额。已用变异测试确认该用例能抓到回归。

顺带补上 AI Review 的 🔵:list_cameras_with_state docstring 点明 connected(感知已订阅,
per-channel)与 lan_reachable(native 会话已建立,per-camera)口径不同、可短暂不一致。

验证:
- backend 相机相关 92 passed;全量 2599 passed / 81 failed,其中 81 个失败在干净的
  origin/main 上同样复现(schedule/task_record/observability/node_monitor 的环境问题),
  本次合并未引入新失败。
- web 221 passed / 1 skipped;tsc --noEmit、vite build、ruff check 均通过。
- 已删 i18n 键 stateLanOk / stateLanOffline / disabledLanHint 全仓无残留引用。

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@github-actions github-actions Bot added size/L and removed size/M labels Jul 20, 2026
lixiangnlp and others added 2 commits July 20, 2026 11:11
CI review 抓到的跨层缺口:sync_devices 的 `expected` 键是**合成 did**(XiaoMi#439 全拆后
discover_devices 按通道展开),而 `_camera_img_managers` 的键是**物理 did**
(client.py:620 用 camera_info.did 建键)。`is_camera_stream_connected` 直接拿合成
did 查字典,对任何 channel_count>1 的相机恒返回 False:

  managers = {"dual": <connected manager>}
  is_camera_stream_connected("dual:ch0") -> managers.get("dual:ch0") -> None -> False

后果是一台**已 PPCS 连上、健康拉流**的双摄相机,两路都被判进 disconnected,
gate 恒真 → 每满 10 秒空转一次 refresh_cameras(含 get_cameras_async 网络往返),
与本 PR 自陈的「已连接相机不触发刷新」直接矛盾。

修在 is_camera_stream_connected 内部做归一(而非调用点),杜绝其他调用方重蹈——
这与 filter._rank 里健康集合按物理 did 查是同一条不变量:native/PPCS 会话每物理
相机一条,凡是拿会话状态说事的地方,合成 did 都要先归一。

健康记账本身不受此 bug 影响:_advance_camera_selection_health 直接遍历
_camera_img_managers.items() 拿物理 did,不会误降权;坏的只是 sync 侧触发判据。

补 test_no_refresh_for_connected_multi_channel_camera:**不 mock**
is_camera_stream_connected(原有用例全 mock 掉了它,且都是单摄裸 did,恰好等于
物理 did,测不到这条分叉),manager 按物理 did 建键 + connected=True,断言
refresh_cameras 未被 await。已变异测试确认:还原成 .get(did) 时该用例失败。

验证:相机相关 1196 passed;ruff check 通过。

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
本轮 XiaoMi#444 合并进 XiaoMi#439(双摄全拆)时,同一条不变量被踩中两次——活跃集健康排序拿
合成 did 查物理 did 键的健康集合、拉流健康查询拿合成 did 查物理 did 键的 manager
字典。两次都不抛异常、不打日志,单摄用例全绿,只在多摄相机上静默失配。

perception-pipeline.md 加一条关键设计决策,写清两个 id 的分界线(按镜头算 vs
按会话算)与归一函数位置,并点明「单摄恰好相等」正是它难被发现的原因;
「如果我要修改」表补一行指向 select_active_camera_dids 这个单一口径。

dev-guide.md 开发工作流加一条自查:全绿不等于有覆盖。两种典型假绿——mock 掉被测
对象本身、只覆盖退化场景——都在本轮真实发生过(守护该路径的用例既 mock 了
is_camera_stream_connected 又只用单摄)。给出成本极低的判定手段:把刚修的那行故意
改回错的,确认新增用例会失败。

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@github-actions github-actions Bot added the docs label Jul 20, 2026
docs job 跑 prettier --check "knowledge/**/*.md",新增表格行的尾部空格数与
prettier 的列对齐不符。纯格式,无内容变化。

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
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.

2 participants