Skip to content

feat(web): PerceptionDeviceTable 独立摄像头视频/音频开关 - #398

Open
n0tssss wants to merge 8 commits into
XiaoMi:mainfrom
n0tssss:feat/web-perception-toggles
Open

feat(web): PerceptionDeviceTable 独立摄像头视频/音频开关#398
n0tssss wants to merge 8 commits into
XiaoMi:mainfrom
n0tssss:feat/web-perception-toggles

Conversation

@n0tssss

@n0tssss n0tssss commented Jul 6, 2026

Copy link
Copy Markdown
Contributor

web 端摄像头感知列表重构:每个摄像头支持独立的视频感知和音频感知开关。

背景

之前 web 端只有一个总开关(全部开/全部关)。现在改为 per-camera per-modality 矩阵——每台摄像头可以单独开关视频感知或音频感知,互不影响。全关时预览不受影响。

做了什么

前端

  • PerceptionDeviceTable 组件:表格 + per-camera video/audio toggle + 测试按钮
  • PerceptionDeviceTable.helpers:纯函数(排序、computeInUse、masterSwitchState)
  • HeroNow:删除旧 CamSwitch,改用新表格
  • API 签名:toggleScopeCamera(dids[], inUse)toggleScopeCamera(CameraToggleItem[])
  • 全关时测试按钮置灰 + hover 提示
  • toggle 按开关跳过已关模态

后端

  • filter.py:video/audio 独立黑名单 + denied_video/audio_camera_dids
  • schema.py:CameraToggleItem 新增 video_enabled/audio_enabled
  • router.py:透传三字段到 service
  • service.py:toggle_camera per-modality + 新上限口径 + in_use None 修复
  • database/kv_repo.py:ScopeConfigKeys 新增 video/audio blacklist key
  • camera_adapter.py:video/audio 订阅门控 + resync_subscriptions
  • select_active_camera_dids:去黑名单过滤(toggle 不影响预览)

测试:backend 79/79 + perception helpers 27/27 + web 207/207

验证

  • toggle API 即时生效(resync_subscriptions)
  • 全关→perceive 返回 No valid active sources
  • 单开 video→有视频、无音频描述 ✅
  • 单开 audio→有音频、无视频描述 ✅
  • 开关不影响相机连接(connected 始终 True)

@github-actions

github-actions Bot commented Jul 6, 2026

Copy link
Copy Markdown

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

提交前请确认:

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

Comment thread backend/miloco/src/miloco/agent_platform/loader.py Fixed
Comment thread backend/miloco/src/miloco/agent_platform/loader.py Fixed
Comment thread backend/miloco/src/miloco/perception/processor.py Fixed
@n0tssss n0tssss closed this Jul 6, 2026
@n0tssss n0tssss reopened this Jul 6, 2026
Comment thread backend/miloco/src/miloco/perception/processor.py Fixed
@github-actions

github-actions Bot commented Jul 6, 2026

Copy link
Copy Markdown

PR #398: feat(web) PerceptionDeviceTable 独立摄像头视频/音频开关

作者: n0tssss
范围: feat/web-perception-toggles → main(PR head 6beecd6origin/main(f156d34)...HEAD 三点 diff:+1002 −1260 / 19 文件)

⚠️ 本轮相对上一轮(head ed268c7)的变化:新增 2 个 commit ——
c9550cb merge upstream/main;② 6beecd6 style: 表格对齐上游 bench 列表样式。

merge 已核对干净c9550cb 的两个 parent 是 ed268c7(PR 侧)+ f156d34(upstream)。合并结果相对 PR 侧只多出 task_repo.py / ActivityFeed.tsx / eventText.ts / event_text_builder.py纯 upstream 侧文件,PR 自有的 miot/ · camera_adapter.py · PerceptionDeviceTable.tsx 一行未被 clobber;反向也核对了 upstream 在这段时间没碰过 PR 触及的文件(git log f156d34 --not ed268c7 -- <PR 文件集> 仅命中一个与本 PR 无关的 merge commit)。且 merge-base(origin/main, HEAD) == f156d34 == origin/main tip —— PR 已完整合入当前 main,无 phantom deletion,Molly 提到的冲突已解决

style commit 纯外观px-5px-4items-baselineitems-center、房间名改 badge(加 border rounded px-1.5)、行加 hover:bg-bg-tertiary transition-colors、离线行设备名改 text-text-tertiary。无逻辑改动。

上一轮 🟡 复核仍为已修复状态——requestMastertsx:118 cam.voiceInUse === false)与 runBulktsx:162 online.some((c) => !c.voiceInUse))两处 mic 知情弹窗判据仍走 voiceInUse(默认 false → 默认态必弹),未被本轮 merge/style 回退。

CI:19 项全绿(backend-test / web-test / lint / CodeQL / Quality 均 pass),仅 pr-review pending(即本任务)。mergeable=MERGEABLEmergeStateStatus=BLOCKED(卡审批,非文本冲突)。

修改方案

要解决的问题:上游 #439 把双摄每通道升为独立一等相机后,web 端控制粒度粗、开关全堆在预览卡片上,5+ 路时按钮拥挤、小屏更糟。目标是把「预览」与「控制」分离——上区卡片只放实时画面,下区一张表统管每台相机的「视频感知 / 音频感知」独立开关,且关掉感知不等于断开连接(预览继续可用)。

整体方案(6 条主线)

主线 1 — 在原有「连接黑名单」之外,另开视频、音频两个正交黑名单

  • 原来只有一张「这台相机整个不接入」的停用名单。这一版另加两张名单,分别记「哪些相机不要送画面给 AI」「哪些相机不要送声音给 AI」,与原来那张互不干扰(CAMERA_VIDEO_BLACK_LIST_KEY / CAMERA_AUDIO_BLACK_LIST_KEY)。
  • 两张新名单都按整台相机记(物理 did),不细到双摄的某一路——因为表格本身就是一行一台,没有分路入口。
  • 读侧各包一个查询函数,写侧各包一个批量开关函数,都复用既有的「JSON array 存一个集合」惯例(denied_video/audio_camera_didsset_cameras_video/audio_in_use)。
  • 为什么用「黑名单」而不是白名单:默认值要是「开」——新接入的相机不配置就该正常感知,空集合天然表示「全部开」,零迁移成本。这跟拾音那张表刻意相反(拾音是白名单 opt-in,默认关,因为涉及隐私)。

主线 2 — 把开关下沉到「订不订这路流」,而不是「连不连这台相机」

  • 连接一台相机时,先查这两张名单,决定要不要订它的画面流、要不要订它的声音流;命中名单就跳过那一路并留一条 log(connect_device 的 per-modality gate)。
  • 关键在于相机连接本身不动:底层会话还在,所以预览照旧能看,只是不再往感知引擎投喂那一路数据。这是「关感知 ≠ 断连接」的落点。
  • 原来的健康检查是「两路流都没订上就把这台从已连表里剔除、等下轮重试」(防止底层 manager 没建成时留个"已连"假象)。现在改成「按用户开的那几路算」——只在该订的都没订上时才剔除,用户主动关掉的那一路不再被误判成订阅失败(has_active 判据)。
connect_device(合成 did)
  → 读 视频黑名单 / 音频黑名单(按整台物理 did 查)
  → 该送画面吗? ─是→ 订 decode_video 流   ─否→ 跳过 + log
  → 该送声音吗? ─是→ 订 decode_audio 流   ─否→ 跳过 + log
  → 「该订的」一路都没订上 → 从已连表剔除,等下轮 sync 重试

主线 3 — 请求契约从「一个必填布尔」扩成「三个可选布尔」,service 拆成四路写入

  • 原来一次操作只能说「这台开」或「这台关」。现在一项里可以分别说画面、声音、或者用一个便捷别名一次管两路,没提到的字段就是不改CameraToggleItem)。
  • 「没提到 = 不改」靠两层实现:pydantic 侧三个字段默认 None;router 把值为 None 的键整个丢掉再往下传,这样 service 用 "video_enabled" in it 就能区分「传了 false」和「压根没传」(toggle_scope_camera 的 None 过滤)。若不丢键,false 和「没传」都会被 bool() 压成同一个值,串改用户没碰的开关。
  • 前端侧同样依赖这个语义:把 camelCase 映射成 snake_case 时,没设的字段是 undefinedJSON.stringify 会自动把它从 body 里省掉(realToggleScopeCamera)。
  • service 收到后拆成四张待写表:连接、视频、音频、拾音;四张各自算完再统一落库(toggle_camera)。
  • 落库时先写关、后写开——因为中间要过「最多 4 路」的上限校验,先关能腾出名额,反过来会在中途假超限。
  • 两个刻意的联动:① 开画面或开声音时,如果调用方没同时指定连接态,就自动把整台通道激活(否则相机没连上,开了也没画面);② 声音开关同时写「音频流黑名单」和「拾音白名单」,把"让 AI 听这台相机"收敛成单一开关(作者已在 PR 评论中说明这是有意设计)。
请求里带了哪些字段 连接黑名单 视频黑名单 音频黑名单 拾音白名单
只有 in_use 按值写该通道 跟随 in_use 跟随 in_use 跟随 in_use
只有 video_enabled 开时自动激活整台通道 按值写 不动 不动
只有 audio_enabled 开时自动激活整台通道 不动 按值写 跟随 audio_enabled
三个都带(前端总开关 / 批量按钮走这条) in_use video_enabled audio_enabled 跟随 audio_enabled
收尾判定:视频、音频都已进黑名单 整台全通道停用(释放连接)

主线 4 — 改完开关立刻重算订阅,不等相机离线重连

  • 光改 KV 不够——已经连着的相机不会自己重新读名单。所以写完库后遍历所有已连设备,把「现在该订什么」跟「实际订了什么」比一遍:该订没订的补订,不该订还订着的退订(resync_subscriptions)。
  • 两路都不该订的,直接从已连表里移除。
  • 下游收敛动作按「改了什么」分流:只有连接态变了才需要重建 / 销毁底层会话(这一步会停掉原生会话和解码线程,比较重);只改了模态开关就跳过它,只跑一次订阅重算。
PUT /api/miot/scope/cameras
  → router 丢掉值为 None 的字段(没传 = 不改)
  → service 拆成 4 张待写表(连接 / 视频 / 音频 / 拾音)
  → 校验:未知 did · 越界通道 · 云端离线 · 局域网不可达 · 镜头关 · 4 路上限
  → 落库(先关后开,避免中途假超限)
  → both-off 收尾:视频+音频全关 → 整台通道停用
  → 有变化? ┬ 连接态变了 → refresh_cameras(建 / 销原生会话)
            └───────────→ sync_devices + resync_subscriptions(补订 / 退订)
  → 返回 list_cameras_with_state() 中受影响的那几台

主线 5 — 老数据迁移

主线 6 — 前端:卡片只留预览,控制全搬进表格

  • 上区卡片删掉全部开关(原相机总开关、拾音开关、感知须知编辑入口),只留实时画面,净删约 990 行(HeroNow.tsx)。父层原来那几个回调也一并删掉,只留一个"用户改了状态 → 重新拉数据"的 reload 触发器(App.tsx)。
  • 下区新表格按物理 did 去重、一行一台,每行有画面开关、声音开关、整机总开关(两路同开同关)和「测试」按钮(PerceptionDeviceTable.tsx)。
  • 首次开声音前弹知情确认框,讲清风险 / 适用 / 不适用场景,带「不再提醒」勾选(存 localStorage)。判据是「转写是否将被新授权」:声音列直接看开的方向,总开关和批量按钮看后端返回的拾音偏好是否为 false。
  • 「测试」按钮按当前开着的模态分别发一次即时感知查询,两路都关时置灰并给 hover 说明;表格还带手动刷新按钮(绕过列相机的 8 秒节流)和满额时的动态标题。

关键设计原则

  1. 正交而非替换:新增两张模态名单,不动原有连接黑名单和拾音白名单的语义。好处是 feat(dual-camera): 双摄多通道双流感知——每通道升为独立一等相机 #439 的分路连接控制、拾音 opt-in 隐私姿态都原样保留,回滚只需停止读新 key;代价是同一台相机的状态现在散在 4 张 KV 表里,一致性要靠 service 层的四路写入自己维护(下面 🟡 第 1 条正是这个代价的体现)。
  2. 默认开(黑名单)vs 默认关(白名单)分场景选择:画面 / 声音流订阅用黑名单,空集合 = 全开,新相机零配置即可感知;拾音转写授权继续用白名单 opt-in,默认关。区分的依据是隐私影响面——订阅音频流只在本机内存里,转写 / 上云才是外发。
  3. 关感知与断连接解耦,但两路全关时仍回收连接:单独关一路时保留连接(预览可用);两路都关说明用户对这台没有任何感知诉求,此时释放原生会话省资源。这条是"省资源"与"预览可用"之间的折中点。
  4. 下游收敛动作按变更类型分级:只有连接态变化才触发较重的会话重建,纯模态变化只跑订阅重算。避免每次拨一个开关都把原生会话推倒重来。
  5. 迁移做成幂等 + 非致命 + 多点兜底:包 try/except 只打 warning,引擎启动前和每次列相机 / 同步时都跑。好处是任何一条路径先到都能完成迁移、迁移失败不阻塞启动;代价是它没有"已完成"落库标记,幂等性完全依赖状态推断(🟡 第 1 条)。

测试覆盖

主线 测试文件::用例 覆盖的 case
主线 3(契约拆分) test_miot_filter_and_cameras.py::test_per_modality_video_only_toggle_does_not_affect_audio 关画面不动声音
主线 3 ::test_per_modality_audio_only_toggle_does_not_affect_video 关声音不动画面
主线 3(in_use 别名) ::test_toggle_camera_off_on_preserves_voice_preference断言方向本轮被反转 in_use 关 → 拾音白名单清空;再开 → 恢复
主线 6(字段映射) realScopeCamerasV2.test.ts::realListScopeCameras — GET v2 契约 video_enabled / audio_enabled 映射、name 为 null 时回退 did
主线 6(纯函数) PerceptionDeviceTable.helpers.test.ts::sortCamerasByDid 升序、不改原数组、空数组
主线 1 / 2 / 4 / 5 无覆盖(详见 🔵 第 3 条)

问题

🟡 重要(应当修复)

  • backend/miloco/src/miloco/miot/filter.py:384 — 迁移函数自称「一次性」但没有完成标记,会在特定状态下反复重跑并静默推翻调用方显式请求的值

    • 背景: 这个 v1→v2 迁移在两个热路径上被无条件调用——每次 GET /api/miot/scope/cameras 列相机时(list_cameras_with_state:1209第一行)和每次 scope 变更后同步适配器时(_sync_camera_adapter:1634)。而 toggle_camera最后一步恰好就是调 list_cameras_with_state() 来组装返回值(service.py:1471),所以每次 toggle 写完库、返回前,迁移都会再跑一遍。

    • 问题: 它没有把"已迁移"落库成一个标记,而是靠推断当前状态判断该不该跑:

      existing_v = denied_video_camera_dids(kv_repo)
      existing_a = denied_audio_camera_dids(kv_repo)
      if existing_v or existing_a:
          return          # ← 唯一的"已迁移过"判据:两张模态名单只要有一个非空就跳过
      legacy = sorted({d for d in old_dids if ":ch" not in d})
      physical = sorted({physical_camera_did(d) for d in legacy})
      set_cameras_video_in_use(kv_repo, physical, False)   # ← 无条件写"关"
      set_cameras_audio_in_use(kv_repo, physical, False)

      只要撞上「连接黑名单里有裸 did」+「两张模态名单空」,它就会认定这是没迁移过的老数据,把那些 did 重新写成两路全关。而这个状态用已文档化的契约就能构造出来——schema 明确写了 video_enabled 「优先级高于 in_use」(schema.py:243),所以「断开这台相机,但记住画面 / 声音感知偏好是开」是个合法请求。对单通道相机发:

      PUT /api/miot/scope/cameras
      {"items":[{"did":"camA","in_use":false,"video_enabled":true,"audio_enabled":true}]}
      
      步骤 代码做了什么 三张名单的状态
      拆请求 "in_use" in it → 该通道置 False;因 video_enabled/audio_enabled 都显式传了,跳过别名赋值(service.py:1322
      落库 连接黑名单加 camA(单通道走 synthetic_camera_did(did,0,1) = 裸 did);视频 / 音频名单按请求移除 camA 连接=["camA"]、视频=[]、音频=[]
      both-off 收尾 视频 / 音频名单都不含 camA → 不触发(service.py:1451 同上
      组装返回值 list_cameras_with_state() 第一行跑迁移;old_raw='["camA"]' 非空、两张模态名单都空 → 判定为未迁移
      迁移写入 ":ch" not in "camA" 成立 → 当作 v1 残余 → 两张名单都写入 camA 视频=["camA"]、音频=["camA"]
      调用方看到 同一个响应里返回 video_enabled: false, audio_enabled: false 与请求的 true 完全相反

      也就是说:调用方明确要求「画面感知保持开」,API 在同一次响应里回「关」,且用户偏好被静默改写落库。之后每次列相机都会稳定复现这个状态(因为名单已非空,迁移转为跳过)。
      当前 web 前端不会踩到——总开关和批量按钮三个字段永远传同一个值(runMaster:131executeBulk:145),单列开关只传一个字段。所以这是 REST 契约层的问题,影响按 schema 文档调用的 agent / CLI / 三方集成,而非当前 UI。

    • 改进: 给迁移一个真正的一次性标记,别再靠状态推断。新增一个 KV key 并在函数开头短路:

      # kv_repo.py,与其它 scope key 放一起
      class ScopeConfigKeys:
          ...
          # per-modality 迁移完成标记("1" = 已迁移)。迁移是一次性的,靠标记而非
          # 推断状态——两张模态名单"都空"是合法运行态(用户两路都开),不能当作"没迁移过"。
          MODALITY_MIGRATION_DONE_KEY = "MODALITY_MIGRATION_DONE_KEY"
      def migrate_v1_blacklist(kv_repo: KVRepo) -> None:
          """一次性 v1→v2 migration:把旧黑名单里的**裸 did**复制到 per-modality 双 key。
      
          #439 全拆后 ``CAMERA_BLACK_LIST_KEY`` 同时存裸 did(单摄/旧 v1)和 ``:chN``
          合成 did(#439 per-channel)。只迁移裸 did——``:chN`` 是 #439 的精细连接控制,
          不应被放大成整台模态关闭。
      
          幂等靠 ``MODALITY_MIGRATION_DONE_KEY`` 标记,**不靠推断模态名单是否为空**:
          「两张名单都空」是合法运行态(用户两路都开着),一旦当成"未迁移"就会把
          连接黑名单里的裸 did 反复写回模态名单,静默推翻调用方显式传的
          ``video_enabled`` / ``audio_enabled``。
          """
          try:
              if not hasattr(kv_repo, "get"):
                  return
              if kv_repo.get(ScopeConfigKeys.MODALITY_MIGRATION_DONE_KEY) == "1":
                  return
              old_raw = kv_repo.get(ScopeConfigKeys.CAMERA_BLACK_LIST_KEY)
              old_dids = json.loads(old_raw) if old_raw else []
              # 只迁裸 did(真 v1 残余),跳过 #439 的 :chN 条目
              legacy = sorted({d for d in old_dids if ":ch" not in d})
              if legacy:
                  physical = sorted({physical_camera_did(d) for d in legacy})
                  set_cameras_video_in_use(kv_repo, physical, False)
                  set_cameras_audio_in_use(kv_repo, physical, False)
                  logger.info("v1 blacklist migrated: %d dids → v2", len(physical))
              # 无论有没有 legacy 条目都落标记——空黑名单同样算"迁移完成",
              # 否则日后用户停用一台单摄又会被误判成未迁移。
              kv_repo.set(ScopeConfigKeys.MODALITY_MIGRATION_DONE_KEY, "1")
          except Exception:
              logger.warning("v1 migration failed (non-fatal)", exc_info=True)

      顺带把 kv_repo 补上 : KVRepo 类型标注(同文件其它函数都有),hasattr 那道防御可保留给测试用的 fake KV。

  • web/src/lib/types.ts:215 — 前端契约注释仍写「拾音偏好保留、不落库」,而后端本轮已改成 in_use / audio_enabled 会清空拾音白名单

    • 背景: voiceInUse 是隐私敏感字段——它决定相机声音是否被转写、是否上云。前端靠它决定「首次开麦是否要弹知情确认框」:requestMaster 只在 cam.voiceInUse === false 时弹框(tsx:118),runBulk 只在 online.some((c) => !c.voiceInUse) 时弹框(tsx:162)。这两处判据正是上一轮 🟡(默认态 mic fail-open)的修复。

    • 问题: 本轮把后端行为反转了,但前端这条注释还在描述旧行为:

      // web/src/lib/types.ts:213-216
      // 拾音「存储偏好」(PUT /api/miot/scope/cameras/voice)。false = mic-off:该相机
      // 声音完全不被处理(引擎入口剥离音频,不转写、不上云)。与 inUse 正交:
      // 生效态 = inUse && voiceInUse(关掉相机感知时拾音自动失效,但偏好保留、不落库)。
      //                                              ^^^^^^^^^^^^^^^^^^ ← 已不成立
      voiceInUse: boolean;

      后端现在 in_use / audio_enabled 都会同步写拾音白名单(service.py:1327/1341voice_updates[pdid]service.py:1440 落库)。PR 自己的测试就把这个断言方向反转了,等于承认旧契约作废:

      # test_miot_filter_and_cameras.py::test_toggle_camera_off_on_preserves_voice_preference
      - """关相机不改写语音白名单:off→on 循环后语音偏好原样保留(存储偏好不落库为「自动关」)。"""
      + """in_use 作为音频别名同步拾音白名单,关→开循环后拾音偏好恢复。"""
        await svc.toggle_camera([{"did": "c1", "in_use": False}])
      - assert json.loads(kv.get(ScopeConfigKeys.CAMERA_VOICE_ALLOW_LIST_KEY)) == ["c1"]
      + assert json.loads(kv.get(ScopeConfigKeys.CAMERA_VOICE_ALLOW_LIST_KEY)) == []   # ← 反转

      后端 docstring 已经在 42973a0 里同步更新了(list_cameras_with_state:1205 现写「通过 toggle_cameraaudio_enabled / in_use 别名操作时同步写入拾音白名单」),只有前端这一层漏了——两层现在对同一个字段给出相反的语义说明。
      可观察的坏后果:维护者按注释推理「总开关关掉再打开,拾音偏好本来就保留着,voiceInUse 不会变」,于是判断 tsx:118 / tsx:162 那两处 voiceInUse 判据是多余的防御并删掉 —— 而实际上 in_use: false 已经把 voiceInUse 清成了 false,删掉判据就等于把上一轮刚修好的 mic fail-open 重新引回来:用户在从未针对某台相机确认过麦克风的情况下,点一次「全部启用」就静默开启了该相机的音频转写与上云。

    • 改进: 前端向后端对齐(后端是新语义的定义方):

        // 拾音「存储偏好」(PUT /api/miot/scope/cameras/voice)。false = mic-off:该相机
        // 声音完全不被处理(引擎入口剥离音频,不转写、不上云)。
        // ⚠️ v2 起与 audioEnabled / inUse **联动而非正交**:后端 toggle_camera 的
        // audio_enabled 与 in_use 别名都会同步写拾音白名单(开则加入、关则移除),
        // 目的是让「让 AI 听这台相机」收敛为单一开关。因此总开关 / 批量关闭会把
        // voiceInUse 清成 false —— 开麦知情弹窗**必须**以 voiceInUse 为判据
        // (见 PerceptionDeviceTable 的 requestMaster / runBulk),不要改用
        // audioEnabled(默认 true,会导致默认态不弹框 = mic fail-open)。
        voiceInUse: boolean;
  • backend/miloco/src/miloco/database/kv_repo.py:216 — rebase 丢失的 prompt 说明性注释没随代码一起补回,类 docstring 现在对自己的成员给出错误的数据类型

    • 背景: 这条链路有前情:rebase 时 feat(perception): 每摄像头专属感知须知 prompt #440 的 per-camera「感知须知」prompt 代码被丢掉,作者随后用三个 commit(d2a2cc0 补 key、e39b5e6 恢复 router/service/schema、581523b 补回函数和长度上限常量)把代码修回来了。但同一批 rebase 删掉的说明性注释没有一起恢复,形成了「代码在、文档不在甚至相反」的状态。

    • 问题: 三处:

      ① 类 docstring 声称所有值都是 JSON array,而 CAMERA_PROMPT_MAP_KEY 仍是这个类的成员、且实际存 JSON object

      class ScopeConfigKeys:
          """miloco 接入范围限定(家庭启用集 / 摄像头停用集)。
      
          值统一为 JSON array 字符串,``"[]"`` / ``NULL`` 都表示空集。
          """                       # ← 被删掉的原文是:「CAMERA_PROMPT_MAP_KEY 是唯一例外
                                    #    ——JSON object(did→prompt),非集合。」
          ...
          CAMERA_VIDEO_BLACK_LIST_KEY = "CAMERA_VIDEO_BLACK_LIST_KEY"
          CAMERA_AUDIO_BLACK_LIST_KEY = "CAMERA_AUDIO_BLACK_LIST_KEY"
          CAMERA_PROMPT_MAP_KEY = "CAMERA_PROMPT_MAP_KEY"   # ← 仍在类里,且不是 array

      代码这边确实是 object,读它走的是 str-map 而非 list 读取器:

      # filter.py:350
      def camera_prompts(kv_repo: KVRepo) -> dict[str, str]:
          """全部摄像头「感知须知」自定义 prompt(did→文本);空表示无任何自定义。"""
          return _load_str_map(kv_repo, ScopeConfigKeys.CAMERA_PROMPT_MAP_KEY)
          #      ^^^^^^^^^^^^^^ 不是 _load_list

      可观察的坏后果:维护者按 docstring 的「值统一为 JSON array」对这个 key 用 _load_list,不会报错也不会抛异常——_load_list 走到 isinstance(value, list) 判断失败,打一条 KV CAMERA_PROMPT_MAP_KEY holds non-list-JSON value, treating as empty 的 warning 然后返回 []filter.py:29-38)。于是全部 per-camera 感知须知被静默丢弃、omni 拿不到机位场景指导,只在日志里留一行 warning。

      ② 长度上限常量现在完全没有说明,读者无从知道它约束什么、在哪一层生效:

      MAX_ENABLED_CAMERAS = 4
      MAX_CAMERA_PROMPT_LEN = 500   # ← 原有的两行说明注释被删,现在裸着

      filter.py 模块 docstring 里介绍这个 key 语义的整段被删,而 set_camera_prompt / clear_camera_prompt / camera_prompts 三个函数都还在文件里。

    • 改进: 把三处说明补回(代码不用动):

      # kv_repo.py — 类 docstring
      class ScopeConfigKeys:
          """miloco 接入范围限定(家庭启用集 / 摄像头停用集)。
      
          ``*_LIST_KEY`` 值统一为 JSON array 字符串(``"[]"`` / ``NULL`` 都表示空集);
          ``CAMERA_PROMPT_MAP_KEY`` 是唯一例外——JSON object(did→prompt),非集合,
          读取走 ``_load_str_map`` 而非 ``_load_list``。
          """
      # kv_repo.py — 成员上方
          # 每摄像头「感知须知」自定义 prompt 映射(did→文本)。JSON object,缺省 = 无自定义。
          # 与上面几个集合类 key 结构不同(map 而非 list):每台内容各异,需按 did 精确取值。
          # 该 prompt 作为**场景指导**注入 omni 的 **system prompt 尾部**(低频变动放尾部,前面
          # 共享前缀稳定、利于 prefix cache),video / audio 路由均注入;引擎每感知窗实时读取,
          # 改动下一窗即生效、不重启。用途:给模型补充该机位的环境描述 / 关注点 / 忽略项,
          # 消除固定误识(如门口机位把公共走廊电梯门误当自家入户门)。读取失败按「无自定义」处理。
          CAMERA_PROMPT_MAP_KEY = "CAMERA_PROMPT_MAP_KEY"
      # filter.py — 常量上方
      # 每摄像头「感知须知」自定义 prompt 长度上限(字符数)。filter 层截断作为纵深防御,
      # service/schema 层已有校验。
      MAX_CAMERA_PROMPT_LEN = 500
      # filter.py — 模块 docstring 末尾补回
      另有 ``CAMERA_PROMPT_MAP_KEY``每摄像头自定义感知须知promptdid文本)——
      唯一的 map 语义 keyJSON object非集合),供逐设备注入 omni 场景指导

🔵 建议(可选优化)

  • web/src/components/PerceptionDeviceTable.tsx:57 — 满额置灰口径(物理台数)与后端上限口径(流 / 通道数)不一致,多摄场景漏挡

    • 背景: 前端 activeCount 数的是物理 did 去重后的台数,atCapacity 据此置灰「全部启用」按钮:

      const activeCount = deduped.filter((c) => c.videoEnabled || c.audioEnabled).length;  // ← 台数
      const atCapacity = activeCount >= maxEnabledCameras;
    • 问题: 后端数的是流 / 通道数,一台双摄吃 2 个名额:

      # service.py:1400-1413
      for ch in range(cc):
          if ch in disabled or lens.get(ch) is False:
              continue
          streams_after += 1        # ← 逐通道累加,双摄 +2
      if streams_after > MAX_ENABLED_CAMERAS:
          raise ValidationException(f"最多同时启用 {MAX_ENABLED_CAMERAS} 条摄像头视频流"...)

      4 台双摄、已启用 2 台时后端已占 4 条流满额,前端 activeCount=2 < 4 → 不置灰 → 点了被后端整批拒。有 atCapacityHint toast 兜底(tsx:159),不致命。作者已在 PR 评论中提到双摄吃 2 名额,属已知张力。

    • 改进: activeCount 改按启用台的 channelCount 求和与后端同口径,或加注释点明这是台数口径非流口径:

      // 与后端 MAX_ENABLED_CAMERAS 同口径:数「流 / 通道数」而非「物理台数」——
      // 双摄一台吃 2 个名额(后端 streams_after 逐通道累加)。
      const activeCount = deduped
        .filter((c) => c.videoEnabled || c.audioEnabled)
        .reduce((n, c) => n + Math.max(1, c.channelCount), 0);
  • backend/miloco/src/miloco/miot/service.py:1457 — 整台粒度模态开关在 both-off→重开循环后清掉 feat(dual-camera): 双摄多通道双流感知——每通道升为独立一等相机 #439 的分路连接偏好

    • 背景: 模态名单是整台粒度;两路全关时把该台全部通道写进连接黑名单(service.py:1457 {c: False for c in range(_cc(p))}),重开某模态时自动激活又把全部通道置回 True(service.py:1332/1343)。

    • 问题: 用户先用 feat(dual-camera): 双摄多通道双流感知——每通道升为独立一等相机 #439 的分路控制停了双摄 ch1,再在表里关掉这台的画面 + 声音(触发 both-off → 全通道停用),之后重开画面 → 自动激活把 ch0 + ch1 全置 True → 原本刻意禁用的 ch1 被静默重新订阅、重吃一个名额。作者 PR 评论也提到「是否让用户选双摄哪个通道参与感知」,属已知粒度张力。

    • 改进: 记录为已知限制;若后续保留 feat(dual-camera): 双摄多通道双流感知——每通道升为独立一等相机 #439 的分路 UI,自动激活时应保留该台既有的分路停用集,而不是无脑全通道置 True:

      if video and "in_use" not in it:
          # 保留该台既有的 per-channel 停用集(#439 精细控制),只补齐"没被显式停用"的通道,
          # 不要无脑全通道置 True——否则用户刻意关掉的 ch1 会被静默重新订阅。
          already_denied = denied_channels_of(denied_camera_dids(self._kv_repo), pdid, cc)
          for c in chans:
              if c not in already_denied:
                  updates.setdefault(pdid, {})[c] = True
  • backend/miloco/tests/ — 主线 1 / 2 / 4 / 5 全部零覆盖:模态名单读写、订阅门控、即时重算、v1 迁移都没有测试锁定

    • 背景: PR 新增了 2 条 per-modality 用例,但它们只走 toggle_camera 的返回值断言(c["video_enabled"] is False),没有触到 KV 层、适配器层和迁移逻辑。

    • 问题: 全仓 grep 在整个 backend/miloco/tests/零命中

      git grep -n "migrate_v1_blacklist"        -- backend/miloco/tests/   → 0
      git grep -n "CAMERA_VIDEO_BLACK_LIST"     -- backend/miloco/tests/   → 0
      git grep -n "CAMERA_AUDIO_BLACK_LIST"     -- backend/miloco/tests/   → 0
      git grep -n "denied_video_camera_dids"    -- backend/miloco/tests/   → 0
      git grep -n "denied_audio_camera_dids"    -- backend/miloco/tests/   → 0
      git grep -n "resync_subscriptions"        -- backend/miloco/tests/   → 0
      git grep -n "both_off"                    -- backend/miloco/tests/   → 0
      

      也就是说:迁移里最微妙的 :ch 特判、both-off 落连接黑名单的收尾、以及「开关即时生效」的核心机制 resync_subscriptions(PR 描述里的卖点之一),CI 都不会因为它们坏掉而变红。前端侧同样:realToggleScopeCamera 的 camelCase→snake_case 映射和「undefined 字段被 JSON.stringify 省掉 = 不改」这个载荷契约也没有测试,作者在测试文件末尾明确放弃了:

      // web/tests/realScopeCamerasV2.test.ts 末尾
      // PUT 测试在 CI 的 vitest+jsdom 环境下 fetch 无法正确 mock(syntaxerror:undefined body)
      // 业务逻辑由 backend pytest 75/75 覆盖,此处保留 GET 测试确保 TypeScript 字段映射正确

      但 backend pytest 覆盖不到前端的字段映射 —— 如果哪天有人把 in_use: i.inUse 改成 in_use: i.inUse ?? false,「没传 = 不改」立刻退化成「没传 = 关」,单拨画面开关会顺手关掉声音,而两边测试都不会红。

    • 改进: 补 4 条关键用例(各 5-10 行,都用现成的 _FakeKV):

      def test_migrate_v1_blacklist_skips_channel_dids():
          """#439 的 :chN 条目不应被放大成整台模态关闭。"""
          kv = _FakeKV({ScopeConfigKeys.CAMERA_BLACK_LIST_KEY: json.dumps(["cam1:ch1"])})
          miot_filter.migrate_v1_blacklist(kv)
          assert miot_filter.denied_video_camera_dids(kv) == set()
          assert miot_filter.denied_audio_camera_dids(kv) == set()
      
      
      def test_migrate_v1_blacklist_is_idempotent_after_reenable():
          """迁移只跑一次:模态名单被用户清空后不得把连接黑名单里的裸 did 再写回来。"""
          kv = _FakeKV({ScopeConfigKeys.CAMERA_BLACK_LIST_KEY: json.dumps(["camA"])})
          miot_filter.migrate_v1_blacklist(kv)
          assert miot_filter.denied_video_camera_dids(kv) == {"camA"}
          # 用户重新开启两路 → 模态名单清空,但连接黑名单仍留着裸 did
          miot_filter.set_cameras_video_in_use(kv, ["camA"], True)
          miot_filter.set_cameras_audio_in_use(kv, ["camA"], True)
          miot_filter.migrate_v1_blacklist(kv)          # 第二次调用
          assert miot_filter.denied_video_camera_dids(kv) == set()   # 不得被重新写关
      
      
      @pytest.mark.asyncio
      async def test_both_modalities_off_deactivates_all_channels():
          """两路全关 → 该台全部通道落连接黑名单(释放连接)。"""
          cam = _camera("dual", home_id="H1"); cam.channel_count = 2
          kv = _FakeKV({ScopeConfigKeys.HOME_WHITE_LIST_KEY: json.dumps(["H1"])})
          svc = _make_service(devices={"dual": cam}, cameras={"dual": cam}, kv=kv)
          await svc.toggle_camera([{"did": "dual", "video_enabled": False}])
          await svc.toggle_camera([{"did": "dual", "audio_enabled": False}])
          assert set(json.loads(kv.get(ScopeConfigKeys.CAMERA_BLACK_LIST_KEY))) == {
              "dual:ch0", "dual:ch1"
          }
      // web/tests/realScopeCamerasV2.test.ts —— 不 mock fetch,直接锁 body 形状
      it("PUT body 省略未设字段(omitted = 不改)", async () => {
        const calls: string[] = [];
        globalThis.fetch = vi.fn(async (_u: unknown, init?: RequestInit) => {
          calls.push(String(init?.body));
          return new Response(JSON.stringify({ code: 0, message: "ok", data: null }),
            { status: 200, headers: { "Content-Type": "application/json" } });
        }) as unknown as typeof fetch;
        await realToggleScopeCamera([{ did: "c1", videoEnabled: false }]);
        const sent = JSON.parse(calls[0]).items[0];
        expect(sent).toEqual({ did: "c1", video_enabled: false });  // in_use/audio_enabled 必须缺席
      });
  • web/src/components/PerceptionDeviceTable.tsx:188 — 「关掉最后一路」toast 实际是全表所有相机都关才触发,与作者对 Molly 承诺的「单台」粒度不符

    • 背景: Molly 问「视频/音频只剩一个 on 时关掉另一个 → 该摄像机总开关关闭,是否加提示」,作者答「是,加 toast」。

    • 问题: 判据用的是全表计数跨零,不是单台跨零:

      const activeCount = deduped.filter((c) => c.videoEnabled || c.audioEnabled).length;  // ← 全表
      ...
      if (wasActive !== undefined && wasActive > 0 && activeCount === 0) {   // ← 只在全表熄灭时弹
        toast(t("hero.modalitiesLastOffToast"), "info");
      }

      多相机场景下关掉某一台最后一路、其它台仍活跃时 activeCount 只从 N 降到 N−1、不跨零 → 不弹 toast。本 PR 的核心场景恰恰是「5+ 路」,用户关掉某台最后一路看不到任何反馈。

    • 改进: 二选一。兑现单台语义(比对上一帧各 did 的活跃状态,找出跨零的那台):

      const activeByDid = useMemo(
        () => new Map(deduped.map((c) => [c.did, c.videoEnabled || c.audioEnabled] as const)),
        [deduped],
      );
      const prevActiveByDid = usePrevious(activeByDid);
      useEffect(() => {
        if (!prevActiveByDid) return;
        for (const [did, active] of activeByDid) {
          // 该台从「有活跃模态」跨到「两路全关」→ 带相机名提示一次
          if (prevActiveByDid.get(did) === true && !active) {
            const name = deduped.find((c) => c.did === did)?.name ?? did;
            toast(t("hero.modalitiesLastOffToast", { name }), "info");
          }
        }
      }, [activeByDid, prevActiveByDid, deduped, t]);

      对应 i18n 改成带占位符:"modalitiesLastOffToast": "{{name}} 的所有感知已停用,预览仍可用"
      或者只想保留「全表熄灭」语义,就把注释和文案统一成「全部相机两路都关时」,别让人以为是单台。

  • web/src/api/index.ts:328 + backend/miloco/tests/ — prompt 链路仍是「后端代码在、前端无 UI 入口、CRUD 测试全删」的三不管孤儿

    • 背景: HeroNow 重写后已无 prompt 编辑 UI(git grep 'setScopeCameraPrompt' web/src/components/ 零命中),但 api 层封装仍在(index.ts:328/336)、ScopeCamera.perceptionPrompt 映射仍在(types.ts:225);后端 prompt 代码(filter / router / service / engine 注入)全部保留,POST / DELETE /scope/cameras/prompt 仍是活 endpoint。
    • 问题: 本 PR 删掉了 test_miot_filter_and_cameras.py 里全部 10 条 prompt 用例(test_camera_prompts_emptytest_camera_prompts_with_valuestest_camera_prompts_invalid_json_treated_as_emptytest_camera_prompts_filters_null_valuestest_filter_set_camera_prompt_writes_and_clearstest_clear_camera_prompt_only_touches_targettest_set_camera_prompt_no_op_skips_kv_writetest_list_cameras_with_state_prompt_fieldtest_set_camera_prompt_writestest_set_camera_prompt_dual_camera_per_channel),且未恢复git grep 'set_camera_prompt' backend/miloco/tests/ 现在只命中 test_prompt_builder.py 的注入侧,CRUD 侧零覆盖。作者跨 d2a2cc0/e39b5e6/581523b 三个 commit 辛苦恢复的 feat(perception): 每摄像头专属感知须知 prompt #440 链路,现在既无 UI 入口、CRUD 又零测试——属「测试删了但生产代码留着」的失衡信号,日后重构破坏它 CI 不会红。这也正是上面 🟡 第 3 条(注释没跟着补回)的同源问题。
    • 改进: 二选一并在 PR 描述里明确记录——① 若计划后续在表格加回「感知须知」入口:保留代码,把上面 10 条用例挑 3-4 条核心的补回(至少 set_camera_prompt 写入 + strip + 清除、camera_prompts 非法 JSON 回落、list_cameras_with_state 透出字段);② 若确定 web 不再暴露:删掉 setScopeCameraPrompt / clearScopeCameraPrompt 前端封装与 ScopeCamera.perceptionPrompt 映射,后端 prompt 代码作为 CLI / agent 能力保留并补最小测试。
  • PR 描述有 3 处声明找不到代码证据,另有 1 处重要改动未提及

    • 背景: PR body 的 Key changes 段每条子 bullet 都是可验证的代码声明。

    • 问题: 逐条核对后 3 条无证据:

      PR 描述里的声明 核对结果
      select_active_camera_dids:去黑名单过滤(toggle 不影响预览)」 git diff origin/main...HEAD -- filter.py没有任何 hunk 触及这个函数——它一行未改。(且不改是对的:该函数按自身 docstring 同时驱动「感知投喂」和「原生会话建销」,若真按模态名单过滤,预览会跟着断,与「关感知不影响预览」的目标相反。所以要修的是描述,不是代码。)
      service.py:…… 新上限口径 ……」 上限检查块(service.py:1396-1418)在三点 diff 里是纯 context 行,未改动;该逻辑来自上游 feat(dual-camera): 双摄多通道双流感知——每通道升为独立一等相机 #439
      PerceptionDeviceTable.helpers:纯函数(排序、computeInUsemasterSwitchState)」 git grep 'computeInUse|masterSwitchState' web/ 零命中;helpers 实际只导出 sortCamerasByDidonlineCamerashelpers.ts:10/16),后者还没有测试

      另外 migrate_v1_blacklist —— 一个会改写用户持久化状态、且在引擎启动前和每次列相机 / 同步时都跑的数据迁移 —— PR 描述里完全没提。这是审阅者最需要知情的一类改动(也是上面 🟡 第 1 条的所在),不该只出现在代码里。

    • 改进: 描述是 GitHub 文本框,随时可改,成本最低。把三条无证据的删掉或改写,并补一条迁移说明:

      **后端**- `filter.py`:video/audio 独立黑名单 + `denied_video/audio_camera_dids` + `set_cameras_video/audio_in_use`
      - `filter.py`:新增 `migrate_v1_blacklist` —— v1 单黑名单 → v2 双模态黑名单的一次性迁移,
        只迁裸 did(跳过 #439`:chN`),幂等 + 非致命;在 engine start 前及每次 list/sync 时兜底执行
      - `schema.py``CameraToggleItem` 新增 `video_enabled` / `audio_enabled`(均可选,omitted = 不改)
      - `router.py`:过滤 None 字段后透传三字段到 service
      - `service.py``toggle_camera` 拆 per-modality 四路写入(连接 / 视频 / 音频 / 拾音)+ both-off 收尾释放连接
      - `database/kv_repo.py``ScopeConfigKeys` 新增 video/audio blacklist key
      - `camera_adapter.py`:video/audio 订阅门控 + `resync_subscriptions` 即时重算
      - 注:`select_active_camera_dids` 与上限校验逻辑**未改动** —— 前者同时驱动预览会话建销,
        按模态名单过滤会连带断掉预览,与「关感知不影响预览」相悖
      
      **前端**- `PerceptionDeviceTable.helpers`:纯函数 `sortCamerasByDid` / `onlineCameras`(抽出便于 vitest)
  • web/src/components/PerceptionDeviceTable.tsx:186 — 注释引用已被本 PR 删除的卡片 CamSwitch,与 HeroNow 的注释自相矛盾

    • 背景: 本 PR 的核心动作之一就是把卡片上的相机总开关 CamSwitch 整个删掉、控制搬进表格,作者在 HeroNow 里也留了说明。

    • 问题: 两个注释对同一件事给出相反的事实:

      // web/src/components/HeroNow.tsx:192
       *  v2 移除了 CamSwitch(开关统一搬到 PerceptionDeviceTable)。           正确
      
      // web/src/components/PerceptionDeviceTable.tsx:186
      // 副作用: 关 modality 时 inUse 会跟着 false,让卡片 CamSwitch 也跟着关。  ← 引用已删除的组件

      git grep CamSwitch web/src 只剩这两处注释,没有任何实现。读者按 tsx:186 去找「卡片上那个会跟着关的开关」会找不到,进而怀疑自己漏看了什么。属结构性过期引用,不影响运行时。

    • 改进:

        // 关掉最后一路时弹 toast —— 防止用户困惑「我只关了一个 modality,整机怎么都关了」。
        // 背景: 两路全关时后端会把整台通道落连接黑名单(both-off 收尾),该台 inUse 随之变 false、
        // 连接释放、预览消失,所以这里要显式告知一声。(v2 已无卡片 CamSwitch,控制全在本表。)
        // 这里用 useEffect 在 activeCount 跨零时触发一次性提示。

结论

需要修改 — 3 条 🟡:① 迁移函数自称「一次性」但用状态推断代替完成标记,用已文档化的请求(in_use: false + video_enabled: true)就能让它在同一次响应里把显式要求的 true 静默改写成 false 并落库,影响按 schema 调用的 agent / CLI / 三方(当前 web UI 三字段同值,踩不到);② types.ts:215 仍写「拾音偏好保留、不落库」,与本轮后端反转后的行为相反(PR 自己的测试断言已从 ["c1"] 改成 [],后端 docstring 也在 42973a0 更新了,只有前端这层漏改)——照旧注释推理会把上一轮刚修好的 mic 知情弹窗判据当成多余防御删掉,重新引回 fail-open;③ rebase 丢失的 prompt 说明注释没随三个恢复 commit 一起补回,ScopeConfigKeys 类 docstring 现在对自己的成员 CAMERA_PROMPT_MAP_KEY 给出错误的数据类型(声称 JSON array,实为 JSON object),按它写代码会命中 _load_list 的静默回落 []、丢掉全部机位 prompt。

三条都是「文档 / 一致性」类而非功能性缺陷,改动量都很小(一个 KV 标记 + 三段注释),不涉及重构。per-modality 功能主体设计自洽且已核对通过:请求契约的「omitted = 不改」两层实现(pydantic None → router 丢键 → in 判断)严密;订阅门控与 resync_subscriptions 的 want/has 比对逻辑正确;先关后开的落库顺序避免了中途假超限;both-off 收尾与下游收敛动作的分级(仅连接态变化才重建原生会话)合理;上游 merge 干净无 clobber、origin/main 已完整合入;CI 19 项全绿。7 条 🔵 中有 4 条是作者已在 PR 评论中知情的粒度张力,另 3 条(零测试覆盖、PR 描述失准、过期注释)建议合并前顺手处理。


由 review-pr skill v1.6 生成

@n0tssss
n0tssss force-pushed the feat/web-perception-toggles branch from d624ebd to 8b639b9 Compare July 6, 2026 14:15
@n0tssss
n0tssss force-pushed the feat/web-perception-toggles branch 3 times, most recently from 18d3df4 to 643743e Compare July 6, 2026 17:00
@HCl8
HCl8 requested review from Ada-xia and HCl8 July 7, 2026 06:20
Comment thread backend/miloco/src/miloco/miot/filter.py Fixed
Comment thread backend/miloco/src/miloco/miot/filter.py Fixed
Comment thread backend/miloco/src/miloco/miot/filter.py Fixed
@yangbaofu007

Copy link
Copy Markdown
Collaborator

/review

@Molly-3000
Molly-3000 self-requested a review July 13, 2026 07:43
@Molly-3000

Molly-3000 commented Jul 15, 2026

Copy link
Copy Markdown
Collaborator

hi 感谢 PR!

当前分支和主分支存在冲突,请解决一下~

我这里有两个问题,想讨论一下:
目前主分支已经合入了摄像头音频的开关,并且实现了整个摄像头的开关。
image

  1. 是否将视频的开关也并排放到画面的右上角比较好呢?
  2. 在当前分支的实现下,也有一个“总开关”,与画面右上角的开关似乎存在功能的重复?

另外还有一个建议:
当视频/音频只剩一个on时,关闭另一个感知输入,该摄像机的总开关将被关闭。是不是需要加一个提示信息呢?

期待听听你的想法:)

@Molly-3000 Molly-3000 left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

please resolve conflicts

@n0tssss

n0tssss commented Jul 15, 2026

Copy link
Copy Markdown
Contributor Author

hi Molly,

收到,先 rebase。设计上的事我想先聊清楚再动手——否则容易改一半才发现方向不一致。

我这版的设计(背景)

我观察你截图里 main 是「上区卡片堆叠按钮 + 下区只列未感知设备」。
我这版改成「上区卡片只放预览,下区表里管所有控制」:

  • 卡片:刻意去掉了 CamSwitch、voice toggle 等所有按钮,只留实时流预览。
    理由:5+ 路摄像头时按钮挤、小屏更糟;用户既然在看预览,操控切到下表更聚焦。
  • PerceptionDeviceTable:列出全部相机(含已感知),per-row 含视频/音频/总开关/测试按钮。
    把 main 「miloco 未感知设备」那一区整个并进去,用户一眼能看到所有相机感知状态,不必翻找。

三条具体回应

Q1: 视频开关是否并排到画面右上角?
不挪。视频开关已在表里 per-row 第二列;卡片刻意做成纯预览。
理由如上设计哲学:卡片按钮多 → 横向滚动找预览、来回切焦点找开关,体验割裂。

Q2: 总开关与画面右上角开关是否重复?
不重复。本 PR 把卡片的 CamSwitch 整个删掉了
表里的总开关(per-row 第 4 列)= video+audio 一键同开同关,
是 main 卡片 CamSwitch 在「卡片→表格」迁移后的等价物。
你截图里看到的「右上角开关」是 main 的,本 PR 已不存在。

Q3: 关掉最后一路是否加提示?
是。当前 inUse = videoEnabled || audio_enabled
关最后一路 inUse 自动 false → 即使在表里,视觉上也等于「整机已停」。
要加 toast:「该相机所有感知已停用,预览仍可用」。
i18n key 加 hero.modalitiesLastOffToast 即可。

下一步

rebase 之前我先坦白我这版的几个已知缺口(review-pr 已标过 🔵):

  1. mic opt-in 确认弹窗——用户误开 mic 没保护,要加 pendingVoiceOn 状态机(参考 main HeroNow)
  2. atCapacity 置灰——>4 路点「全部启用」被后端整批拒绝,要接 maxEnabledCameras
  3. 不可用相机 hover 气泡——cameraAvailable 三态 + blockedReasonKey 没接
  4. 手动刷新——LAN 状态变化得等 8s,要加 IconRefresh
  5. 下区标题动态化——「未感知 / 已满 {{n}}」切换没做

这些不在 PR #398 范围内但和 Molly 你提到的 UX 直接相关
要不你看完确认设计哲学 OK,我再 rebase + 顺手把 5 个缺口补掉,最后再跑 pr-review?

期待 ack :)

n0tssss added a commit to n0tssss/xiaomi-miloco that referenced this pull request Jul 15, 2026
**重新提交** PR XiaoMi#398 — 之前已 ack Molly 的设计哲学评论 + 补了 5 个 UX 缺口
(mic opt-in / atCapacity / 不可用相机 / 手动刷新 / 关最后一路 toast),
现在 rebase 上游 main(冲突解决策略: 前后端都保留双方,把 v1 / v2 / voice 三套
schema 合并,0 改 product API)。

合并要点:
- 后端: CAMERA_BLACK_LIST_KEY (v1) + CAMERA_VIDEO/AUDIO_BLACK_LIST_KEY (v2)
  + CAMERA_VOICE_ALLOW_LIST_KEY (mic-opt-in) 三 KV 全部保留,语义清晰分层
- toggle_camera 走 v2 路径 (per-modality) + tri-state 校验 (cloud/lan/lens) +
  v1→v2 兼容迁移 (enable 方向自动从 v1 删该 did)
- toggle_camera_voice 独立端点保留 (mic-off 语义)
- 前端: PerceptionDeviceTable 不再 hardcode OpenAI wire format, 通过
  toggleScopeCameraV2 (per-modality) + voice toggle (mic-off) 双路
- list_cameras_with_state 返 8 字段: cloud_online/lan_reachable/awake/is_online
  /voice_in_use/connected + v2 video_enabled/audio_enabled (in_use 仍走
  select_active 的活跃集判定,与 v1 in_use 口径一致)

**测试**: 103/103 miot_filter+cameras 测试过 (前 commit 是 27 fail),其余
backend 测试 67 fail 是 upstream main 预存的 test_task_router_e2e 等,
跟本 PR 无关。

web: 220/220 过 (5 个新回归测试 for gap 1-5)。

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
@n0tssss
n0tssss force-pushed the feat/web-perception-toggles branch from 605c8b7 to 3f3ca6a Compare July 15, 2026 10:11
Comment thread backend/miloco/src/miloco/miot/service.py Fixed
@Molly-3000

Copy link
Copy Markdown
Collaborator

我觉得预览和控制分离这个方向是OK的,表格统管控制确实会更清晰。后续开发请rebase到最新的main分支~

@n0tssss
n0tssss force-pushed the feat/web-perception-toggles branch from 08009d4 to 78b4838 Compare July 22, 2026 04:24
@n0tssss

n0tssss commented Jul 22, 2026

Copy link
Copy Markdown
Contributor Author

Rebased 到 upstream/main(包含 #439 双摄多通道全拆架构)并做了以下适配和调整:

核心设计

  • 预览与控制分离:HeroNow 上区实时画面纯预览(卡片无开关),下方 PerceptionDeviceTable 表格统管所有摄像头的视频/音频感知开关
  • per-modality 矩阵:每台相机可独立开关视频和音频感知,操作互不干扰
  • 关感知 ≠ 断连接:关闭某一模态只跳过对应流的订阅,相机仍保持连接、预览正常
  • 开关即时生效resync_subscriptions 遍历已连设备重算订阅,无需等离线重连

适配全拆架构的调整

  • 后端开关按物理 did 存储(整台粒度),前端表格去重(同 did 只显示一行)
  • per-modality 开启时自动激活对应通道(否则没实时画面)

一个问题请教

上游 #439 全拆后每通道升为独立一等相机,双摄一台吃掉 2 个 MAX_ENABLED_CAMERAS 名额。而且两路会各自独立订阅视频流、独立投喂 AI——如果两个镜头指向同一场景,可能造成重复帧冗余。

是否考虑进一步精细化控制?例如让用户选择双摄的哪个通道参与感知,而不是两路无差别全开。这样既能节省连接名额,也避免 AI 收到重复画面。如果方向 OK 我可以继续做。

@n0tssss

n0tssss commented Jul 22, 2026

Copy link
Copy Markdown
Contributor Author

Rebase 完成,已适配 #439 全拆多通道架构。本轮变更要点:

预览与控制分离:HeroNow 上区只做纯预览(card 无开关),所有控制统一收进新增的 PerceptionDeviceTable 表格。每台相机一行(按物理 did 去重),video / audio 独立 toggle + 整机开关 + 测试按钮。

per-modality 感知开关:后端新增 CAMERA_VIDEO_BLACK_LIST_KEY / CAMERA_AUDIO_BLACK_LIST_KEY 两个 KV key,与连接层黑名单和拾音白名单正交。前端 toggle 发 video_enabled / audio_enabled,router 过滤 Pydantic None 默认值防串扰。

开关自动激活通道:per-modality 开启时自动激活对应相机通道(否则无实时画面),两路全关时自动停用释放连接。三态门和上限检查对 per-modality 路径同样生效。

音频双闸统一audio_enabled 同步写入拾音白名单(CAMERA_VOICE_ALLOW_LIST_KEY),in_use 别名也同步三路(video / audio / voice),消除「显示开但引擎剥离」的断连。

关于 @Molly-3000 之前提到的双摄精细化控制:目前表格按物理 did 去重(一行一台),per-modality 按整台控制。双摄两路独立投喂 AI 可能冗余的问题,如果方向 OK 我可以继续做——让用户选择启用哪路镜头,而不是无差别全开。另外感谢你每轮 review 的细致建议,帮了不少忙 🙏

我继续看 CI 还有没有问题需要修,有的话补上。

@n0tssss
n0tssss force-pushed the feat/web-perception-toggles branch from acd8b16 to 0009eea Compare July 22, 2026 10:16
@n0tssss

n0tssss commented Jul 23, 2026

Copy link
Copy Markdown
Contributor Author

关于 🟡 音频双闸联动:这是有意设计。audio_enabled 开关同时控制音频流订阅(CAMERA_AUDIO_BLACK_LIST_KEY)和拾音转写授权(CAMERA_VOICE_ALLOW_LIST_KEY)。开则两路都开,关则两路都关。in_use 便捷别名同样同步三路(video / audio / voice)。

这样设计是为了避免普通用户困惑"音频开了但 AI 听不见"——把"让 AI 听这台相机"收敛为单一开关,首次开启有知情确认弹窗。

@Molly-3000

Copy link
Copy Markdown
Collaborator

/review

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.

4 participants