From 0009eea563ca1ab61fd497ee60ea804b5eb1629d Mon Sep 17 00:00:00 2001 From: N0ts Date: Wed, 22 Jul 2026 18:16:09 +0800 Subject: [PATCH 1/7] =?UTF-8?q?rebase:=20=E4=BB=8E=20upstream/main=20?= =?UTF-8?q?=E9=87=8D=E5=BB=BA=EF=BC=8C=E4=BF=9D=E7=95=99=20per-modality=20?= =?UTF-8?q?=E5=85=A8=E9=83=A8=E6=94=B9=E5=8A=A8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/miloco/src/miloco/database/kv_repo.py | 16 +- backend/miloco/src/miloco/miot/filter.py | 108 +- backend/miloco/src/miloco/miot/router.py | 44 +- backend/miloco/src/miloco/miot/schema.py | 56 +- backend/miloco/src/miloco/miot/service.py | 200 ++-- .../miloco/src/miloco/perception/__init__.py | 4 + .../perception/collect/camera_adapter.py | 126 ++- .../tests/test_miot_filter_and_cameras.py | 255 ++--- web/src/App.tsx | 54 +- web/src/api/index.ts | 14 +- web/src/api/real.ts | 38 +- web/src/components/HeroNow.tsx | 990 +----------------- .../PerceptionDeviceTable.helpers.ts | 18 + web/src/components/PerceptionDeviceTable.tsx | 404 +++++++ web/src/i18n/locales/en/hero.json | 34 +- web/src/i18n/locales/zh/hero.json | 34 +- web/src/lib/types.ts | 3 + .../PerceptionDeviceTable.helpers.test.ts | 28 + web/tests/realScopeCamerasV2.test.ts | 27 + 19 files changed, 994 insertions(+), 1459 deletions(-) create mode 100644 web/src/components/PerceptionDeviceTable.helpers.ts create mode 100644 web/src/components/PerceptionDeviceTable.tsx create mode 100644 web/tests/PerceptionDeviceTable.helpers.test.ts create mode 100644 web/tests/realScopeCamerasV2.test.ts diff --git a/backend/miloco/src/miloco/database/kv_repo.py b/backend/miloco/src/miloco/database/kv_repo.py index a3134c311..5e527ec08 100644 --- a/backend/miloco/src/miloco/database/kv_repo.py +++ b/backend/miloco/src/miloco/database/kv_repo.py @@ -215,8 +215,7 @@ class DeviceInfoKeys: class ScopeConfigKeys: """miloco 接入范围限定(家庭启用集 / 摄像头停用集)。 - ``*_LIST_KEY`` 值统一为 JSON array 字符串(``"[]"`` / ``NULL`` 都表示空集); - ``CAMERA_PROMPT_MAP_KEY`` 是唯一例外——JSON object(did→prompt),非集合。 + 值统一为 JSON array 字符串,``"[]"`` / ``NULL`` 都表示空集。 """ HOME_WHITE_LIST_KEY = "HOME_WHITE_LIST_KEY" # 已启用的家庭 home_id 列表 @@ -228,13 +227,12 @@ class ScopeConfigKeys: # 音频才会被处理(转写 / 语音派生 / 上云);不在集内 = 引擎入口整批剥离音频。 # KV 读取失败时按空集处理(fail-closed:宁可不处理,也不擅自开启未授权相机的音频)。 CAMERA_VOICE_ALLOW_LIST_KEY = "CAMERA_VOICE_ALLOW_LIST_KEY" - # 每摄像头「感知须知」自定义 prompt 映射(did→文本)。JSON object,缺省 = 无自定义。 - # 与上面几个集合类 key 结构不同(map 而非 list):每台内容各异,需按 did 精确取值。 - # 该 prompt 作为**场景指导**注入 omni 的 **system prompt 尾部**(低频变动放尾部,前面 - # 共享前缀稳定、利于 prefix cache),video / audio 路由均注入;引擎每感知窗实时读取, - # 改动下一窗即生效、不重启。用途:给模型补充该机位的环境描述 / 关注点 / 忽略项, - # 消除固定误识(如门口机位把公共走廊电梯门误当自家入户门)。读取失败按「无自定义」处理。 - CAMERA_PROMPT_MAP_KEY = "CAMERA_PROMPT_MAP_KEY" + # v2 per-modality 感知黑名单(与 CAMERA_BLACK_LIST_KEY 正交): + # - 视频感知黑名单:在此集内 = 跳过 video stream 订阅(预览仍可用) + # - 音频感知黑名单:在此集内 = 跳过 audio stream 订阅(voice_in_use 只管转写) + # 值统一为 JSON array of physical did(整台粒度,走物理 did,不对齐通道的 :chN)。 + CAMERA_VIDEO_BLACK_LIST_KEY = "CAMERA_VIDEO_BLACK_LIST_KEY" + CAMERA_AUDIO_BLACK_LIST_KEY = "CAMERA_AUDIO_BLACK_LIST_KEY" class OnboardingKeys: """主动 onboarding 邀请的一次性标记。 diff --git a/backend/miloco/src/miloco/miot/filter.py b/backend/miloco/src/miloco/miot/filter.py index 47d0f2c19..d0253bbd4 100644 --- a/backend/miloco/src/miloco/miot/filter.py +++ b/backend/miloco/src/miloco/miot/filter.py @@ -6,9 +6,6 @@ 数据落在 SQLite ``kv`` 表的 ``HOME_WHITE_LIST_KEY``(启用的家庭集合)、 ``CAMERA_BLACK_LIST_KEY``(停用的相机集合)和 ``CAMERA_VOICE_ALLOW_LIST_KEY`` (**开启**拾音的相机集合,opt-in / 默认关语义),JSON array 字符串,由 :class:`KVRepo` 缓存。 - -另有 ``CAMERA_PROMPT_MAP_KEY``(每摄像头自定义「感知须知」prompt,did→文本)—— -唯一的 map 语义 key(JSON object,非集合),供逐设备注入 omni 场景指导。 """ from __future__ import annotations @@ -27,10 +24,6 @@ # /api/miot/status 下发)。用户主动 enable 超限直接报错(service.toggle_camera 校验)。 MAX_ENABLED_CAMERAS = 4 -# 每摄像头「感知须知」自定义 prompt 长度上限(字符数)。filter 层截断作为纵深防御, -# service/schema 层已有校验。 -MAX_CAMERA_PROMPT_LEN = 500 - def _load_list(kv_repo: KVRepo, key: str) -> list[str]: raw = kv_repo.get(key) or "[]" @@ -86,6 +79,14 @@ def voice_allowed_camera_dids(kv_repo: KVRepo) -> set[str]: return set(_load_list(kv_repo, ScopeConfigKeys.CAMERA_VOICE_ALLOW_LIST_KEY)) +def denied_video_camera_dids(kv_repo: KVRepo) -> set[str]: + return set(_load_list(kv_repo, ScopeConfigKeys.CAMERA_VIDEO_BLACK_LIST_KEY)) + + +def denied_audio_camera_dids(kv_repo: KVRepo) -> set[str]: + return set(_load_list(kv_repo, ScopeConfigKeys.CAMERA_AUDIO_BLACK_LIST_KEY)) + + def is_home_allowed(kv_repo: KVRepo, home_id: str | None) -> bool: """单条 ``home_id`` 是否被允许。空集合表示未启用任何家庭。""" allow = allowed_home_ids(kv_repo) @@ -283,12 +284,29 @@ def set_cameras_voice_in_use( ) +def set_cameras_video_in_use( + kv_repo: KVRepo, dids: list[str], in_use: bool +) -> tuple[list[str], bool]: + """批量切换相机视频感知开关。``in_use=False`` 加入视频黑名单(跳过视频流订阅)。""" + return _toggle_members( + kv_repo, ScopeConfigKeys.CAMERA_VIDEO_BLACK_LIST_KEY, dids, include=not in_use + ) + + +def set_cameras_audio_in_use( + kv_repo: KVRepo, dids: list[str], in_use: bool +) -> tuple[list[str], bool]: + """批量切换相机音频感知开关。``in_use=False`` 加入音频黑名单(跳过音频流订阅)。""" + return _toggle_members( + kv_repo, ScopeConfigKeys.CAMERA_AUDIO_BLACK_LIST_KEY, dids, include=not in_use + ) + + def _toggle_members( kv_repo: KVRepo, key: str, items: list[str], *, include: bool ) -> tuple[list[str], bool]: """批量版本的 _toggle_member;一次性写入,返回 ``(new_list, changed)``。""" current = _load_list(kv_repo, key) - # 去重,保持输入顺序 seen: set[str] = set() ordered: list[str] = [] for item in items: @@ -311,53 +329,33 @@ def _toggle_members( return new, True -def _load_str_map(kv_repo: KVRepo, key: str) -> dict[str, str]: - """读取存 JSON object(str→str)的 KV;缺省 / 非法 / 非 object 一律回落空 dict。 +def migrate_v1_blacklist(kv_repo) -> None: + """一次性 v1→v2 migration:把旧黑名单里的**裸 did**复制到 per-modality 双 key。 - 跳过 JSON ``null`` 值(避免 ``str(None) → "None"`` 注入业务逻辑)。 + #439 全拆后 ``CAMERA_BLACK_LIST_KEY`` 同时存裸 did(单摄/旧 v1)和 ``:chN`` + 合成 did(#439 per-channel)。只迁移裸 did——``:chN`` 是 #439 的精细连接控制, + 不应被放大成整台模态关闭。 """ - raw = kv_repo.get(key) or "{}" try: - value = json.loads(raw) - except json.JSONDecodeError: - logger.warning("KV %s 不是合法 JSON,视为空: %r", key, raw) - return {} - if isinstance(value, dict): - return {str(k): str(v) for k, v in value.items() if v is not None} - logger.warning("KV %s 不是 JSON object,视为空: %r", key, raw) - return {} - - -def camera_prompts(kv_repo: KVRepo) -> dict[str, str]: - """全部摄像头「感知须知」自定义 prompt(did→文本);空表示无任何自定义。""" - return _load_str_map(kv_repo, ScopeConfigKeys.CAMERA_PROMPT_MAP_KEY) - - -def set_camera_prompt(kv_repo: KVRepo, did: str, prompt: str) -> tuple[dict[str, str], bool]: - """设置 / 清除单台相机的自定义感知 prompt。 - - 超长截断:仅存储前 ``MAX_CAMERA_PROMPT_LEN`` 字符(service/schema 层已有校验, - 此处防御直接调用 filter 的内部路径)。 - """ - current = _load_str_map(kv_repo, ScopeConfigKeys.CAMERA_PROMPT_MAP_KEY) - text = prompt.strip()[:MAX_CAMERA_PROMPT_LEN] - new = dict(current) - if text: - new[did] = text - else: - new.pop(did, None) - if new == current: - return current, False - kv_repo.set(ScopeConfigKeys.CAMERA_PROMPT_MAP_KEY, json.dumps(new, ensure_ascii=False)) - return new, True - - -def clear_camera_prompt(kv_repo: KVRepo, did: str) -> tuple[dict[str, str], bool]: - """清除单台相机的自定义感知 prompt(直接从 map 中 del)。""" - current = _load_str_map(kv_repo, ScopeConfigKeys.CAMERA_PROMPT_MAP_KEY) - if did not in current: - return current, False - new = dict(current) - del new[did] - kv_repo.set(ScopeConfigKeys.CAMERA_PROMPT_MAP_KEY, json.dumps(new, ensure_ascii=False)) - return new, True + if not hasattr(kv_repo, "get"): + return + old_raw = kv_repo.get(ScopeConfigKeys.CAMERA_BLACK_LIST_KEY) + if not old_raw or old_raw == "[]": + return + old_dids = json.loads(old_raw) + if not old_dids: + return + existing_v = denied_video_camera_dids(kv_repo) + existing_a = denied_audio_camera_dids(kv_repo) + if existing_v or existing_a: + return + # 只迁裸 did(真 v1 残余),跳过 #439 的 :chN 条目 + legacy = sorted({d for d in old_dids if ":ch" not in d}) + if not legacy: + return + 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)) + except Exception: + logger.warning("v1 migration failed (non-fatal)", exc_info=True) diff --git a/backend/miloco/src/miloco/miot/router.py b/backend/miloco/src/miloco/miot/router.py index 5d2e12781..8623ae40f 100644 --- a/backend/miloco/src/miloco/miot/router.py +++ b/backend/miloco/src/miloco/miot/router.py @@ -25,7 +25,6 @@ from miloco.middleware.exceptions import HTTPException from miloco.miot.schema import ( AuthorizeRequest, - CameraPromptRequest, CameraToggleRequest, CameraVoiceToggleRequest, DeviceControlRequest, @@ -521,7 +520,15 @@ async def toggle_scope_camera( request: CameraToggleRequest, current_user: str = Depends(verify_token) ): data = await manager.miot_service.toggle_camera( - [{"did": i.did, "in_use": i.in_use} for i in request.items] + [ + {k: v for k, v in { + "did": i.did, + "in_use": i.in_use, + "video_enabled": i.video_enabled, + "audio_enabled": i.audio_enabled, + }.items() if v is not None} + for i in request.items + ] ) return NormalResponse(code=0, message="ok", data=data) @@ -542,39 +549,6 @@ async def toggle_scope_camera_voice( return NormalResponse(code=0, message="ok", data=data) -@router.put( - path="/scope/cameras/prompt", - summary="Batch set per-camera perception prompt (scene guidance)", - response_model=NormalResponse, -) -async def set_scope_camera_prompt( - request: CameraPromptRequest, current_user: str = Depends(verify_token) -): - # 设置自定义感知 prompt:与启用/拾音开关正交,不重启感知引擎(引擎逐窗实时读 KV)。 - # prompt 非空(空字符串在 schema 层被拒)。 - data = await manager.miot_service.set_camera_prompt( - [{"did": i.did, "prompt": i.prompt} for i in request.items] - ) - return NormalResponse(code=0, message="ok", data=data) - - -@router.delete( - path="/scope/cameras/prompt", - summary="Batch clear per-camera perception prompt", - response_model=NormalResponse, -) -async def clear_scope_camera_prompt( - did: list[str] = Query( - ..., description="要清除的相机 did,可重复传多个批量清除(?did=a&did=b)" - ), - current_user: str = Depends(verify_token), -): - # 清除自定义感知 prompt:显式 DELETE,did 走 query 参数、不带 body(DELETE body - # 语义未定义、易被代理丢弃)。与启用/拾音开关正交,不重启感知引擎。 - data = await manager.miot_service.clear_camera_prompt(did) - return NormalResponse(code=0, message="ok", data=data) - - @router.post( "/record_clip", summary="Record N seconds from a camera and return mp4", diff --git a/backend/miloco/src/miloco/miot/schema.py b/backend/miloco/src/miloco/miot/schema.py index ec7538612..39302eb63 100644 --- a/backend/miloco/src/miloco/miot/schema.py +++ b/backend/miloco/src/miloco/miot/schema.py @@ -13,7 +13,6 @@ from miot.types import MIoTCameraInfo, MIoTCameraStatus from pydantic import BaseModel, Field, ValidationInfo, field_validator -from miloco.miot.filter import MAX_CAMERA_PROMPT_LEN from miloco.utils.media import image_bytes_to_base64, image_manager @@ -229,11 +228,26 @@ class HomeSwitchRequest(BaseModel): class CameraToggleItem(BaseModel): - """单个相机的启用/停用操作。""" + """单个相机的感知开关操作(v2:per-camera × per-modality 矩阵)。 + + 三个开关字段都可选(omitted = 不改): + - ``in_use``:便捷别名,true=同时启用视频+音频感知;false=同时关闭两路 + - ``video_enabled``:显式只改视频感知(优先级高于 in_use) + - ``audio_enabled``:显式只改音频感知(优先级高于 in_use) + """ did: str = Field(..., min_length=1, description="相机 did") - in_use: bool = Field( - ..., description="true = 启用(恢复接入);false = 停用(不接入)" + in_use: bool | None = Field( + default=None, + description="便捷别名:true=同时启用视频+音频感知;false=同时关闭两路。omitted = 不改。", + ) + video_enabled: bool | None = Field( + default=None, + description="显式改视频感知开关。omitted = 不改。优先级高于 in_use。", + ) + audio_enabled: bool | None = Field( + default=None, + description="显式改音频感知开关。omitted = 不改。优先级高于 in_use。", ) @@ -265,40 +279,6 @@ class CameraVoiceToggleRequest(BaseModel): items: list[CameraVoiceToggleItem] = Field(..., min_length=1) -class CameraPromptItem(BaseModel): - """单个相机的自定义「感知须知」prompt 设置。""" - - did: str = Field(..., min_length=1, description="相机 did") - prompt: str = Field( - ..., - max_length=MAX_CAMERA_PROMPT_LEN, - description=( - "该机位专属感知指导(环境说明 / 关注 / 忽略);逐窗注入 omni system prompt 尾部(video / audio 路由均注入)。" - f"非空,上限 {MAX_CAMERA_PROMPT_LEN} 字。" - ), - ) - - @field_validator("prompt", mode="after") - @classmethod - def _not_blank(cls, v: str) -> str: - stripped = v.strip() - if not stripped: - raise ValueError("感知须知不能为空(清除请用 DELETE 端点)") - return stripped - - -class CameraPromptRequest(BaseModel): - """批量设置相机自定义感知 prompt。每项独立指定 did + prompt(非空)。 - - 与拾音 / 启用开关正交:不从属感知开关,关着的相机也可预配(仅在被感知时注入生效)。 - """ - - items: list[CameraPromptItem] = Field(..., min_length=1) - - -# 清除走 DELETE + query 参数(?did=a&did=b),不带 body,故无对应 Request 模型。 - - class AuthorizeRequest(BaseModel): """User-pasted OAuth result from the Xiaomi redirect page.""" diff --git a/backend/miloco/src/miloco/miot/service.py b/backend/miloco/src/miloco/miot/service.py index 6092a727b..f6bb6da49 100644 --- a/backend/miloco/src/miloco/miot/service.py +++ b/backend/miloco/src/miloco/miot/service.py @@ -33,19 +33,19 @@ ) from miloco.miot.client import MiotProxy, build_sub_device_names from miloco.miot.filter import ( - MAX_CAMERA_PROMPT_LEN, MAX_ENABLED_CAMERAS, allowed_home_ids, - camera_prompts, - clear_camera_prompt, + denied_audio_camera_dids, denied_camera_dids, denied_channels_of, + denied_video_camera_dids, filter_by_home, is_home_allowed, physical_camera_did, select_active_camera_dids, - set_camera_prompt, + set_cameras_audio_in_use, set_cameras_channels_in_use, + set_cameras_video_in_use, set_cameras_voice_in_use, set_homes_in_use, synthetic_camera_did, @@ -286,7 +286,8 @@ def _clear_account_scope_state(self) -> None: self._kv_repo.delete(ScopeConfigKeys.HOME_WHITE_LIST_KEY) self._kv_repo.delete(ScopeConfigKeys.CAMERA_BLACK_LIST_KEY) self._kv_repo.delete(ScopeConfigKeys.CAMERA_VOICE_ALLOW_LIST_KEY) - self._kv_repo.delete(ScopeConfigKeys.CAMERA_PROMPT_MAP_KEY) + self._kv_repo.delete(ScopeConfigKeys.CAMERA_VIDEO_BLACK_LIST_KEY) + self._kv_repo.delete(ScopeConfigKeys.CAMERA_AUDIO_BLACK_LIST_KEY) self._lru.clear() @property @@ -1199,8 +1200,10 @@ async def list_cameras_with_state(self) -> list[dict]: ``voice_in_use`` 是**存储的拾音偏好**(在拾音白名单即 True,**默认 False**),与 ``in_use`` 正交;「生效态」= ``in_use and voice_in_use`` 由前端派生,此处不合并。 """ + self._migrate_v1_blacklist_if_needed() voice_allowed = voice_allowed_camera_dids(self._kv_repo) - prompt_map = camera_prompts(self._kv_repo) + video_denied = denied_video_camera_dids(self._kv_repo) + audio_denied = denied_audio_camera_dids(self._kv_repo) connected = self._connected_camera_dids() cameras = filter_by_home( self._kv_repo, await self._miot_proxy.get_cameras() or {} @@ -1242,6 +1245,9 @@ async def list_cameras_with_state(self) -> list[dict]: # 存储偏好:在拾音白名单 = 拾音开启(**默认关闭**,opt-in)。拾音按整台存 # (只球机/ch0 有 mic),前端在无 mic 的通道上隐藏该开关。 "voice_in_use": did in voice_allowed, + # v2 per-modality 感知开关(整台粒度,物理 did): + "video_enabled": did not in video_denied, + "audio_enabled": did not in audio_denied, } for ch in range(channel_count): syn_did = synthetic_camera_did(did, ch, channel_count) @@ -1254,19 +1260,18 @@ async def list_cameras_with_state(self) -> list[dict]: # per-channel 启用(活跃集按合成 did)+ per-channel 真投喂 "in_use": syn_did in active, "connected": syn_did in connected, - # 每摄像头的自定义「感知须知」prompt(无则 "")。 - # 按合成 did 存取,双摄每路可有独立须知。 - "perception_prompt": prompt_map.get(syn_did, ""), } ) return out async def toggle_camera(self, items: list[dict]) -> list[dict]: - """批量切换相机**某通道**的启用状态。每项 {"did": str, "in_use": bool}。 + """批量切换相机感知开关(全拆通道级 + v2 per-modality)。 - 全拆语义:启停按**通道**走(每路一台独立相机)。``did`` 可为合成通道 did - (``cam:ch1``,前端逐路开关)或裸物理 did;裸 did 对多通道相机 = 该台所有通道一起, - 对单摄 = 它自己。全部校验通过后按 did 整台重算+覆盖写黑名单(D3)。 + 每项形如 ``{"did": str, "in_use"?: bool, "video_enabled"?: bool, "audio_enabled"?: bool}``。 + - ``in_use``:控制该通道(合成 did / 裸 did → 整台全通道)的连接激活态(写 CAMERA_BLACK_LIST_KEY) + - ``video_enabled``:per-modality 视频感知开关(写 CAMERA_VIDEO_BLACK_LIST_KEY,整台粒度) + - ``audio_enabled``:per-modality 音频感知开关(写 CAMERA_AUDIO_BLACK_LIST_KEY,整台粒度) + 三个字段都可选;omitted = 不改。全拆语义:启停按**通道**走(每路一台独立相机)。 """ cameras = await self._miot_proxy.get_cameras() or {} @@ -1279,6 +1284,9 @@ def _cc(pdid: str) -> int: # (`:ch` 后为空/非数字)或**越界通道**(≥ channel_count)都当非法 did 拒——否则前者 # int() 崩 500、后者会把死条目写进黑名单(读侧只遍历 range(cc) 永远清不掉)。 updates: dict[str, dict[int, bool]] = {} + video_updates: dict[str, bool] = {} + audio_updates: dict[str, bool] = {} + voice_updates: dict[str, bool] = {} unknown: list[str] = [] bad_channel: list[str] = [] for it in items: @@ -1301,9 +1309,29 @@ def _cc(pdid: str) -> int: chans = [ch] else: chans = list(range(cc)) - in_use = bool(it["in_use"]) - for c in chans: - updates.setdefault(pdid, {})[c] = in_use + if "in_use" in it: + in_use = bool(it["in_use"]) + for c in chans: + updates.setdefault(pdid, {})[c] = in_use + if "video_enabled" not in it: + video_updates[pdid] = in_use + if "audio_enabled" not in it: + audio_updates[pdid] = in_use + voice_updates[pdid] = in_use + if "video_enabled" in it: + video = bool(it["video_enabled"]) + video_updates[pdid] = video + # 开启视频感知时自动激活通道(否则相机不连接、无实时画面) + if video and "in_use" not in it: + for c in chans: + updates.setdefault(pdid, {})[c] = True + if "audio_enabled" in it: + audio = bool(it["audio_enabled"]) + audio_updates[pdid] = audio + voice_updates[pdid] = audio + if audio and "in_use" not in it: + for c in chans: + updates.setdefault(pdid, {})[c] = True if unknown: raise ValidationException( f"Unknown camera did(s) {unknown}; valid: {sorted(cameras.keys())}" @@ -1379,18 +1407,60 @@ def _lan(pdid: str) -> bool: f"请先禁用一路再启用新的" ) - _, changed = set_cameras_channels_in_use( - self._kv_repo, updates, {p: _cc(p) for p in updates} - ) + channels_changed = False + if updates: + _, channels_changed = set_cameras_channels_in_use( + self._kv_repo, updates, {p: _cc(p) for p in updates} + ) + video_changed = False + if video_updates: + enable_v = [p for p, v in video_updates.items() if v] + disable_v = [p for p, v in video_updates.items() if not v] + if disable_v: + _, video_changed = set_cameras_video_in_use(self._kv_repo, disable_v, False) + if enable_v: + _, c = set_cameras_video_in_use(self._kv_repo, enable_v, True) + video_changed = video_changed or c + audio_changed = False + if audio_updates: + enable_a = [p for p, v in audio_updates.items() if v] + disable_a = [p for p, v in audio_updates.items() if not v] + if disable_a: + _, audio_changed = set_cameras_audio_in_use(self._kv_repo, disable_a, False) + if enable_a: + _, c = set_cameras_audio_in_use(self._kv_repo, enable_a, True) + audio_changed = audio_changed or c + if voice_updates: + enable_voice = [p for p, v in voice_updates.items() if v] + disable_voice = [p for p, v in voice_updates.items() if not v] + if disable_voice: + set_cameras_voice_in_use(self._kv_repo, disable_voice, False) + if enable_voice: + set_cameras_voice_in_use(self._kv_repo, enable_voice, True) + # 如果某摄像头两路感知都关了,自动停用通道(释放连接资源、预览消失) + all_affected = set(video_updates) | set(audio_updates) + if all_affected: + video_denied = denied_video_camera_dids(self._kv_repo) + audio_denied = denied_audio_camera_dids(self._kv_repo) + both_off = [p for p in all_affected if p in video_denied and p in audio_denied] + if both_off: + both_updates = {p: {c: False for c in range(_cc(p))} for p in both_off} + _, c = set_cameras_channels_in_use( + self._kv_repo, both_updates, {p: _cc(p) for p in both_off} + ) + channels_changed = channels_changed or c + changed = channels_changed or video_changed or audio_changed if changed: - # 先 refresh_cameras:按新 KV(黑名单)建/销 camera manager——两路都关的相机 - # 销毁 manager,停掉 native PPCS 会话+解码线程;仍有活跃路的保留。 - # 再 _sync_camera_adapter:perception 按新集连/断订阅。顺序不可换。 - await self._miot_proxy.refresh_cameras() + if channels_changed: + # 先 refresh_cameras:按新 KV(黑名单)建/销 camera manager——两路都关的相机 + # 销毁 manager,停掉 native PPCS 会话+解码线程;仍有活跃路的保留。 + await self._miot_proxy.refresh_cameras() + # _sync_camera_adapter:perception 按新集连/断订阅 + resync per-modality 订阅。 await self._sync_camera_adapter() # 返回受影响的相机(按物理 did),结构与 list_cameras_with_state 一致。 all_cameras = await self.list_cameras_with_state() - affected = [cam for cam in all_cameras if cam["did"] in set(updates)] + affected_dids = set(updates) | set(video_updates) | set(audio_updates) + affected = [cam for cam in all_cameras if cam["did"] in affected_dids] return affected async def toggle_camera_voice(self, items: list[dict]) -> list[dict]: @@ -1450,80 +1520,10 @@ def _cc(pdid: str) -> int: affected = [cam for cam in all_cameras if cam["did"] in set(all_dids)] return affected - def _resolve_prompt_target_dids(self, raw: str, cameras: dict) -> list[str]: - """把一个 raw did(合成通道 did ``cam:chN`` / 裸物理 did)解析成**存储用的合成 - did 列表**——与 ``list_cameras_with_state`` / 感知注入侧的 key 口径一致。 - - - 未知物理 did → ValidationException(防 typo)。 - - ``:chN`` 越界或格式非法 → ValidationException(后端唯一执法点,同 toggle_camera)。 - - 裸多通道 did → 展成全部通道(该台各路设同一条须知);单摄 → 裸 did(cc=1)。 - """ - pdid = physical_camera_did(raw) - if pdid not in cameras: - raise ValidationException( - f"Unknown camera did(s) ['{raw}']; valid: {sorted(cameras.keys())}" - ) - cc = getattr(cameras.get(pdid), "channel_count", None) or 1 - if ":ch" in raw: - _, ch_str = raw.rsplit(":ch", 1) - try: - ch = int(ch_str) - except ValueError: - raise ValidationException(f"非法通道号(格式错误): {raw}") - if not (0 <= ch < cc): - raise ValidationException(f"非法通道号(越界): {raw}") - chans = [ch] - else: - chans = list(range(cc)) - return [synthetic_camera_did(pdid, c, cc) for c in chans] - - async def set_camera_prompt(self, items: list[dict]) -> list[dict]: - """批量设置相机自定义「感知须知」prompt。每项 {"did": str, "prompt": str}。 - - ``did`` 可为合成通道 did(``cam:chN``,双摄逐路)或裸物理 did(多通道 = 全通道设 - 同一条,单摄 = 它自己)。``prompt`` strip 后须非空(空串在 schema 层已拒)。校验: - 未知 did、越界通道、超 ``MAX_CAMERA_PROMPT_LEN`` 全部通过才写。 - - **不**从属于感知 / 拾音开关——关着的相机也可预配 prompt,只在被感知时逐窗注入生效。 - 不 refresh / _sync / _restart——引擎每感知窗按合成 did 实时读 KV。 - """ - cameras = await self._miot_proxy.get_cameras() or {} - - too_long = [ - i["did"] for i in items if len((i.get("prompt") or "").strip()) > MAX_CAMERA_PROMPT_LEN - ] - if too_long: - raise ValidationException( - f"感知须知过长(超过 {MAX_CAMERA_PROMPT_LEN} 字):{too_long}" - ) - - # 先全解析 + 校验(任一非法整批不写),再统一落库。 - resolved = [ - (self._resolve_prompt_target_dids(i["did"], cameras), i.get("prompt") or "") - for i in items - ] - touched_physical = {physical_camera_did(i["did"]) for i in items} - for syn_dids, prompt in resolved: - for syn in syn_dids: - set_camera_prompt(self._kv_repo, syn, prompt) - all_cameras = await self.list_cameras_with_state() - affected = [cam for cam in all_cameras if cam["did"] in touched_physical] - return affected - - async def clear_camera_prompt(self, dids: list[str]) -> list[dict]: - """批量清除相机自定义「感知须知」prompt。参数只传 did 列表(合成 / 裸物理均可)。 - - 校验未知 did / 越界通道;裸多通道 did 清全部通道。不 refresh / _sync / _restart。 - """ - cameras = await self._miot_proxy.get_cameras() or {} - resolved = [self._resolve_prompt_target_dids(d, cameras) for d in dids] - touched_physical = {physical_camera_did(d) for d in dids} - for syn_dids in resolved: - for syn in syn_dids: - clear_camera_prompt(self._kv_repo, syn) - all_cameras = await self.list_cameras_with_state() - affected = [cam for cam in all_cameras if cam["did"] in touched_physical] - return affected + def _migrate_v1_blacklist_if_needed(self) -> None: + """v1→v2 migration 委托给 filter.migrate_v1_blacklist。""" + from miloco.miot.filter import migrate_v1_blacklist + migrate_v1_blacklist(self._kv_repo) def _camera_adapter(self): """Lazily fetch the perception camera adapter; returns None if unavailable.""" @@ -1540,12 +1540,14 @@ def _connected_camera_dids(self) -> set[str]: return set(adapter.get_connected_devices().keys()) if adapter else set() async def _sync_camera_adapter(self) -> None: - """Hot-sync camera connections after a scope change.""" + """Hot-sync camera connections after a scope change (channel-level + per-modality).""" + self._migrate_v1_blacklist_if_needed() adapter = self._camera_adapter() if adapter is None: return try: await adapter.sync_devices() + await adapter.resync_subscriptions() except Exception as e: logger.warning("Camera adapter sync after scope change failed: %s", e) diff --git a/backend/miloco/src/miloco/perception/__init__.py b/backend/miloco/src/miloco/perception/__init__.py index 73ec6f64f..1e87c013e 100644 --- a/backend/miloco/src/miloco/perception/__init__.py +++ b/backend/miloco/src/miloco/perception/__init__.py @@ -75,6 +75,10 @@ def _emit_omni_health(snap): get_omni_circuit_breaker().register_listener(_emit_omni_health) + # 7.2 v1→v2 黑名单迁移(必须跑在 engine start 之前) + from miloco.miot.filter import migrate_v1_blacklist + migrate_v1_blacklist(kv_repo) + # 8. 启动引擎 —— 尊重用户「休息」意图:上次被手动暂停则不自动拉起, # 否则后台每次重启都会无视暂停、继续烧 token。 from miloco.perception.engine_state import is_perception_enabled diff --git a/backend/miloco/src/miloco/perception/collect/camera_adapter.py b/backend/miloco/src/miloco/perception/collect/camera_adapter.py index f3d0bd83e..285ae368a 100644 --- a/backend/miloco/src/miloco/perception/collect/camera_adapter.py +++ b/backend/miloco/src/miloco/perception/collect/camera_adapter.py @@ -253,37 +253,66 @@ async def connect_device( ) self._devices[did] = state - # Subscribe decoded video frame stream (multi-reg) - try: - reg_id = await self._miot_proxy.start_camera_decode_video_stream( - physical_did, channel, self._make_decoded_video_callback(did) + # v2 per-modality gate:读 KV 决定是否订阅视频/音频流 + video_enabled = self._video_enabled_for(physical_did) + audio_enabled = self._audio_enabled_for(physical_did) + + if video_enabled: + try: + reg_id = await self._miot_proxy.start_camera_decode_video_stream( + physical_did, channel, self._make_decoded_video_callback(did) + ) + state.decoded_video_reg_id = reg_id + except Exception as e: + logger.error("Failed to subscribe decoded video for %s: %s", did, e) + else: + logger.info( + "Skipping decoded_video subscribe for %s (video_enabled=false)", did ) - state.decoded_video_reg_id = reg_id - except Exception as e: - logger.error("Failed to subscribe decoded video for %s: %s", did, e) - # Subscribe decoded audio frame stream (multi-reg) - try: - reg_id = await self._miot_proxy.start_camera_decode_audio_stream( - physical_did, channel, self._make_decoded_audio_callback(did) + if audio_enabled: + try: + reg_id = await self._miot_proxy.start_camera_decode_audio_stream( + physical_did, channel, self._make_decoded_audio_callback(did) + ) + state.decoded_audio_reg_id = reg_id + except Exception as e: + logger.error("Failed to subscribe decoded audio for %s: %s", did, e) + else: + logger.info( + "Skipping decoded_audio subscribe for %s (audio_enabled=false)", did ) - state.decoded_audio_reg_id = reg_id - except Exception as e: - logger.error("Failed to subscribe decoded audio for %s: %s", did, e) - # 两路流都没订上 = camera_img_manager 缺失(典型:登录时相机 LAN 未就绪, - # refresh_cameras 没建成 manager,start_*_stream 返回 -1 静默失败)。保留该 - # device 只会让 active_sources 报「已连」假象,且 did 留在 _devices 使后续 - # sync 早退、永不重试。剔除它,交给 sync_devices 的按需补建在下轮重连。 - if state.decoded_video_reg_id < 0 and state.decoded_audio_reg_id < 0: + has_active = (video_enabled and state.decoded_video_reg_id >= 0) or ( + audio_enabled and state.decoded_audio_reg_id >= 0 + ) + if not has_active: self._devices.pop(did, None) logger.warning( - "Camera %s stream subscribe failed (manager missing?), " - "will retry on next sync", - did, + "Camera %s no active stream (video=%s reg=%d, audio=%s reg=%d), will retry on next sync", + did, video_enabled, state.decoded_video_reg_id, + audio_enabled, state.decoded_audio_reg_id, ) return + def _video_enabled_for(self, physical_did: str) -> bool: + try: + from miloco.miot.filter import denied_video_camera_dids + kv = self._miot_proxy._kv_repo + return physical_did not in denied_video_camera_dids(kv) + except Exception as e: + logger.warning("Video-enabled lookup failed for %s (defaulting to enabled): %s", physical_did, e) + return True + + def _audio_enabled_for(self, physical_did: str) -> bool: + try: + from miloco.miot.filter import denied_audio_camera_dids + kv = self._miot_proxy._kv_repo + return physical_did not in denied_audio_camera_dids(kv) + except Exception as e: + logger.warning("Audio-enabled lookup failed for %s (defaulting to enabled): %s", physical_did, e) + return True + async def disconnect_device(self, did: str) -> None: state = self._devices.pop(did, None) if not state: @@ -310,6 +339,59 @@ async def disconnect_device(self, did: str) -> None: state.sync_buffer.clear() + async def resync_subscriptions(self) -> None: + """按最新 KV 开关重算已连接设备的视频/音频订阅。 + + toggle_camera 后调用此方法使 per-modality 开关即时生效(无需等待设备离线重连)。 + 对每台已连设备比对 want/has 状态:缺的补订,多的退订。 + """ + for did, state in list(self._devices.items()): + physical_did, channel = split_channel_did(did) + want_video = self._video_enabled_for(physical_did) + want_audio = self._audio_enabled_for(physical_did) + has_video = state.decoded_video_reg_id >= 0 + has_audio = state.decoded_audio_reg_id >= 0 + + if want_video and not has_video: + try: + state.decoded_video_reg_id = ( + await self._miot_proxy.start_camera_decode_video_stream( + physical_did, channel, self._make_decoded_video_callback(did) + ) + ) + except Exception as e: + logger.error("resync video subscribe failed for %s: %s", did, e) + elif not want_video and has_video: + try: + await self._miot_proxy.stop_camera_decode_video_stream( + physical_did, channel, state.decoded_video_reg_id + ) + state.decoded_video_reg_id = -1 + except Exception as e: + logger.error("resync video unsubscribe failed for %s: %s", did, e) + + if want_audio and not has_audio: + try: + state.decoded_audio_reg_id = ( + await self._miot_proxy.start_camera_decode_audio_stream( + physical_did, channel, self._make_decoded_audio_callback(did) + ) + ) + except Exception as e: + logger.error("resync audio subscribe failed for %s: %s", did, e) + elif not want_audio and has_audio: + try: + await self._miot_proxy.stop_camera_decode_audio_stream( + physical_did, channel, state.decoded_audio_reg_id + ) + state.decoded_audio_reg_id = -1 + except Exception as e: + logger.error("resync audio unsubscribe failed for %s: %s", did, e) + + if not want_video and not want_audio: + self._devices.pop(did, None) + logger.info("Camera %s both modalities disabled, removed", did) + def collect(self, did: str, *, drain: bool = True) -> DeviceData | None: """Collect multimodal data from the device's sync buffer. diff --git a/backend/miloco/tests/test_miot_filter_and_cameras.py b/backend/miloco/tests/test_miot_filter_and_cameras.py index eece9ed11..bea1efa7e 100644 --- a/backend/miloco/tests/test_miot_filter_and_cameras.py +++ b/backend/miloco/tests/test_miot_filter_and_cameras.py @@ -587,24 +587,23 @@ async def test_toggle_camera_voice_rejected_when_camera_disabled(): @pytest.mark.asyncio async def test_toggle_camera_off_on_preserves_voice_preference(): - """关相机不改写语音白名单:off→on 循环后语音偏好原样保留(存储偏好不落库为「自动关」)。""" + """in_use 作为音频别名同步拾音白名单,关→开循环后拾音偏好恢复。""" kv = _FakeKV({ScopeConfigKeys.HOME_WHITE_LIST_KEY: json.dumps(["H1"])}) svc = _make_service( devices={"c1": _camera("c1")}, cameras={"c1": _camera("c1")}, kv=kv ) - # 先把 c1 语音打开(此时感知仍开) await svc.toggle_camera_voice([{"did": "c1", "voice_in_use": True}]) assert json.loads(kv.get(ScopeConfigKeys.CAMERA_VOICE_ALLOW_LIST_KEY)) == ["c1"] - # 关相机感知 → 语音白名单不应被改写 + # in_use=false → 同步清拾音白名单 await svc.toggle_camera([{"did": "c1", "in_use": False}]) - assert json.loads(kv.get(ScopeConfigKeys.CAMERA_VOICE_ALLOW_LIST_KEY)) == ["c1"] - # 重新开相机感知 → 旧语音偏好仍在(voice_in_use=True) + assert json.loads(kv.get(ScopeConfigKeys.CAMERA_VOICE_ALLOW_LIST_KEY)) == [] + # in_use=true → 同步恢复拾音白名单 await svc.toggle_camera([{"did": "c1", "in_use": True}]) assert json.loads(kv.get(ScopeConfigKeys.CAMERA_VOICE_ALLOW_LIST_KEY)) == ["c1"] out = await svc.list_cameras_with_state() by_did = {c["did"]: c for c in out} assert by_did["c1"]["in_use"] is True - assert by_did["c1"]["voice_in_use"] is True # 偏好保留 + assert by_did["c1"]["voice_in_use"] is True @pytest.mark.asyncio @@ -1792,208 +1791,86 @@ async def test_refresh_cameras_gap_fills_awake_for_current_home(_scope_proxy_env assert "c3" not in proxy._camera_awake_cache # 别的家庭 → 不补 -# ─── 每摄像头感知须知 prompt(本 PR 新增)───────────────────────────── - -def test_camera_prompts_empty(): - kv = _FakeKV() - assert miot_filter.camera_prompts(kv) == {} - - -def test_camera_prompts_with_values(): - kv = _FakeKV( - {ScopeConfigKeys.CAMERA_PROMPT_MAP_KEY: json.dumps({"c1": "门口机位", "c2": "书房"})} - ) - assert miot_filter.camera_prompts(kv) == {"c1": "门口机位", "c2": "书房"} - assert miot_filter.camera_prompts(kv).get("ghost") is None - - -def test_camera_prompts_invalid_json_treated_as_empty(caplog): - # 非 object(存了 list)→ 回落空 map,不炸。 - kv = _FakeKV({ScopeConfigKeys.CAMERA_PROMPT_MAP_KEY: json.dumps(["c1"])}) - assert miot_filter.camera_prompts(kv) == {} - - -def test_camera_prompts_filters_null_values(): - """JSON null 值被过滤掉,不会变成字符串 "None"。""" - kv = _FakeKV( - {ScopeConfigKeys.CAMERA_PROMPT_MAP_KEY: json.dumps({"c1": None, "c2": "ok"})} - ) - prompts = miot_filter.camera_prompts(kv) - assert "c1" not in prompts # null 被跳过 - assert prompts == {"c2": "ok"} - - -def test_filter_set_camera_prompt_writes_and_clears(): - kv = _FakeKV() - new, changed = miot_filter.set_camera_prompt(kv, "c1", " 门口机位 ") - assert changed is True - assert new == {"c1": "门口机位"} # strip 后写入 - assert json.loads(kv.get(ScopeConfigKeys.CAMERA_PROMPT_MAP_KEY)) == {"c1": "门口机位"} - # 空串 → 清除该条目 - new, changed = miot_filter.set_camera_prompt(kv, "c1", " ") - assert changed is True - assert new == {} - # 清除已不存在的 did → no-op - new, changed = miot_filter.set_camera_prompt(kv, "ghost", "") - assert changed is False - - -def test_clear_camera_prompt_only_touches_target(): - kv = _FakeKV( - {ScopeConfigKeys.CAMERA_PROMPT_MAP_KEY: json.dumps({"c1": "a", "c2": "b"})} - ) - new, changed = miot_filter.clear_camera_prompt(kv, "c1") - assert changed is True - assert new == {"c2": "b"} # 只删 c1,c2 保留 - - -def test_set_camera_prompt_no_op_skips_kv_write(): - kv = _FakeKV({ScopeConfigKeys.CAMERA_PROMPT_MAP_KEY: json.dumps({"c1": "x"})}) - calls = {"n": 0} - original_set = kv.set - - def counting_set(key, value): - calls["n"] += 1 - return original_set(key, value) - - kv.set = counting_set # type: ignore[assignment] - # 写入与现状相同(strip 后仍是 "x")→ 不写 KV - _, changed = miot_filter.set_camera_prompt(kv, "c1", " x ") - assert changed is False - assert calls["n"] == 0 - - -@pytest.mark.asyncio -async def test_list_cameras_with_state_prompt_field(): - """perception_prompt 是存储偏好:有自定义即透出,无则 "";与 in_use/voice 正交。""" - cameras = {"c1": _camera("c1"), "c2": _camera("c2")} - kv = _FakeKV({ - ScopeConfigKeys.HOME_WHITE_LIST_KEY: json.dumps(["H1"]), - ScopeConfigKeys.CAMERA_PROMPT_MAP_KEY: json.dumps({"c1": "门口机位须知"}), - }) - svc = _make_service(devices=dict(cameras), cameras=cameras, kv=kv) - out = await svc.list_cameras_with_state() - by_did = {c["did"]: c for c in out} - assert by_did["c1"]["perception_prompt"] == "门口机位须知" - assert by_did["c2"]["perception_prompt"] == "" # 无自定义 +# ─── Per-modality toggle (v2 video/audio) ──────────────────────────────────── @pytest.mark.asyncio -async def test_set_camera_prompt_writes(): +async def test_per_modality_video_only_toggle_does_not_affect_audio(): + """关视频感知不应影响音频感知状态。""" + cameras = {"c1": _camera("c1", home_id="H1")} kv = _FakeKV({ScopeConfigKeys.HOME_WHITE_LIST_KEY: json.dumps(["H1"])}) - svc = _make_service( - devices={"c1": _camera("c1")}, cameras={"c1": _camera("c1")}, kv=kv - ) - res = await svc.set_camera_prompt([{"did": "c1", "prompt": "电梯门不是自家门"}]) - assert any(c["did"] == "c1" and c["perception_prompt"] == "电梯门不是自家门" for c in res) - assert json.loads(kv.get(ScopeConfigKeys.CAMERA_PROMPT_MAP_KEY)) == { - "c1": "电梯门不是自家门" - } + svc = _make_service(devices=cameras, cameras=cameras, kv=kv) + + res = await svc.toggle_camera([{"did": "c1", "video_enabled": False}]) + assert isinstance(res, list) + c = next(c for c in res if c["did"] == "c1") + assert c["video_enabled"] is False + assert c["audio_enabled"] is True @pytest.mark.asyncio -async def test_set_camera_prompt_dual_camera_per_channel(): - """双摄:合成 did ``cam:chN`` 精确设某一路(按合成 did 存),越界通道被拒。""" - cam = _camera("dual", home_id="H1") - cam.channel_count = 2 +async def test_per_modality_audio_only_toggle_does_not_affect_video(): + """关音频感知不应影响视频感知状态。""" + cameras = {"c1": _camera("c1", home_id="H1")} kv = _FakeKV({ScopeConfigKeys.HOME_WHITE_LIST_KEY: json.dumps(["H1"])}) - svc = _make_service(devices={"dual": cam}, cameras={"dual": cam}, kv=kv) - - # 合成 did 精确到 ch0,不影响 ch1 - await svc.set_camera_prompt([{"did": "dual:ch0", "prompt": "球机须知"}]) - assert json.loads(kv.get(ScopeConfigKeys.CAMERA_PROMPT_MAP_KEY)) == {"dual:ch0": "球机须知"} + svc = _make_service(devices=cameras, cameras=cameras, kv=kv) - # 越界通道拒绝、不落库 - with pytest.raises(ValidationException): - await svc.set_camera_prompt([{"did": "dual:ch9", "prompt": "x"}]) - assert json.loads(kv.get(ScopeConfigKeys.CAMERA_PROMPT_MAP_KEY)) == {"dual:ch0": "球机须知"} - - # 裸物理 did → 展成全通道(两路都设) - await svc.set_camera_prompt([{"did": "dual", "prompt": "整台须知"}]) - stored = json.loads(kv.get(ScopeConfigKeys.CAMERA_PROMPT_MAP_KEY)) - assert stored == {"dual:ch0": "整台须知", "dual:ch1": "整台须知"} + res = await svc.toggle_camera([{"did": "c1", "audio_enabled": False}]) + assert isinstance(res, list) + c = next(c for c in res if c["did"] == "c1") + assert c["audio_enabled"] is False + assert c["video_enabled"] is True @pytest.mark.asyncio -async def test_set_camera_prompt_rejects_empty(): - """prompt 为空 → 被拒(schema 层的 field_validator _not_blank)。""" - from miloco.miot.schema import CameraPromptItem - from pydantic import ValidationError - - # 空字符串 - with pytest.raises(ValidationError, match="感知须知不能为空"): - CameraPromptItem(did="c1", prompt=" ") - # 纯空白 - with pytest.raises(ValidationError, match="感知须知不能为空"): - CameraPromptItem(did="c1", prompt=" ") +async def test_per_modality_independent_round_trip(): + """视频和音频开关完全独立:先后操作不互相影响。""" + cameras = {"c1": _camera("c1", home_id="H1")} + kv = _FakeKV({ScopeConfigKeys.HOME_WHITE_LIST_KEY: json.dumps(["H1"])}) + svc = _make_service(devices=cameras, cameras=cameras, kv=kv) + # Step 1: 关视频 + await svc.toggle_camera([{"did": "c1", "video_enabled": False}]) + out = await svc.list_cameras_with_state() + c = next(c for c in out if c["did"] == "c1") + assert c["video_enabled"] is False + assert c["audio_enabled"] is True -@pytest.mark.asyncio -async def test_clear_camera_prompt_deletes(): - """clear_camera_prompt 只需要 did 列表,不传 prompt,直接 del。""" - kv = _FakeKV({ - ScopeConfigKeys.HOME_WHITE_LIST_KEY: json.dumps(["H1"]), - ScopeConfigKeys.CAMERA_PROMPT_MAP_KEY: json.dumps({"c1": "须知", "c2": "其他"}), - }) - svc = _make_service( - devices={"c1": _camera("c1"), "c2": _camera("c2")}, - cameras={"c1": _camera("c1"), "c2": _camera("c2")}, - kv=kv, - ) - res = await svc.clear_camera_prompt(["c1"]) - assert any(c["did"] == "c1" and c["perception_prompt"] == "" for c in res) - assert json.loads(kv.get(ScopeConfigKeys.CAMERA_PROMPT_MAP_KEY)) == {"c2": "其他"} + # Step 2: 关音频 — 视频应仍为 off + await svc.toggle_camera([{"did": "c1", "audio_enabled": False}]) + out = await svc.list_cameras_with_state() + c = next(c for c in out if c["did"] == "c1") + assert c["video_enabled"] is False + assert c["audio_enabled"] is False + # Step 3: 开视频 — 音频应仍为 off + await svc.toggle_camera([{"did": "c1", "video_enabled": True}]) + out = await svc.list_cameras_with_state() + c = next(c for c in out if c["did"] == "c1") + assert c["video_enabled"] is True + assert c["audio_enabled"] is False -@pytest.mark.asyncio -async def test_set_camera_prompt_rejects_unknown(): - svc = _make_service(cameras={"c1": _camera("c1")}) - with pytest.raises(ValidationException): - await svc.set_camera_prompt([{"did": "ghost", "prompt": "x"}]) + # Step 4: 开音频 — 两者都 on + await svc.toggle_camera([{"did": "c1", "audio_enabled": True}]) + out = await svc.list_cameras_with_state() + c = next(c for c in out if c["did"] == "c1") + assert c["video_enabled"] is True + assert c["audio_enabled"] is True @pytest.mark.asyncio -async def test_set_camera_prompt_rejects_too_long(): - from miloco.miot.filter import MAX_CAMERA_PROMPT_LEN - +async def test_per_modality_in_use_alias_applies_to_both(): + """in_use 便捷别名应同时作用视频和音频。""" + cameras = {"c1": _camera("c1", home_id="H1")} kv = _FakeKV({ScopeConfigKeys.HOME_WHITE_LIST_KEY: json.dumps(["H1"])}) - svc = _make_service( - devices={"c1": _camera("c1")}, cameras={"c1": _camera("c1")}, kv=kv - ) - with pytest.raises(ValidationException, match="过长"): - await svc.set_camera_prompt( - [{"did": "c1", "prompt": "字" * (MAX_CAMERA_PROMPT_LEN + 1)}] - ) - # 拒绝后不落库 - assert kv.get(ScopeConfigKeys.CAMERA_PROMPT_MAP_KEY) is None + svc = _make_service(devices=cameras, cameras=cameras, kv=kv) + res = await svc.toggle_camera([{"did": "c1", "in_use": False}]) + c = next(c for c in res if c["did"] == "c1") + assert c["video_enabled"] is False + assert c["audio_enabled"] is False -@pytest.mark.asyncio -async def test_set_camera_prompt_allowed_when_camera_disabled(): - """感知须知与感知开关正交:相机感知已关闭(在黑名单)也允许预配 prompt。""" - kv = _FakeKV({ - ScopeConfigKeys.HOME_WHITE_LIST_KEY: json.dumps(["H1"]), - ScopeConfigKeys.CAMERA_BLACK_LIST_KEY: json.dumps(["c1"]), # c1 感知已关闭 - }) - svc = _make_service( - devices={"c1": _camera("c1")}, cameras={"c1": _camera("c1")}, kv=kv - ) - # 不抛:关着的相机也能预配 - await svc.set_camera_prompt([{"did": "c1", "prompt": "预配须知"}]) - assert json.loads(kv.get(ScopeConfigKeys.CAMERA_PROMPT_MAP_KEY)) == {"c1": "预配须知"} - - -@pytest.mark.asyncio -async def test_set_camera_prompt_does_not_restart_or_refresh(): - kv = _FakeKV({ScopeConfigKeys.HOME_WHITE_LIST_KEY: json.dumps(["H1"])}) - svc = _make_service( - devices={"c1": _camera("c1")}, cameras={"c1": _camera("c1")}, kv=kv - ) - svc._miot_proxy.refresh_cameras = AsyncMock() - svc._sync_camera_adapter = AsyncMock() # type: ignore[assignment] - svc._restart_perception_engine = AsyncMock() # type: ignore[assignment] - await svc.set_camera_prompt([{"did": "c1", "prompt": "x"}]) - svc._miot_proxy.refresh_cameras.assert_not_awaited() - svc._sync_camera_adapter.assert_not_awaited() - svc._restart_perception_engine.assert_not_awaited() - + res = await svc.toggle_camera([{"did": "c1", "in_use": True}]) + c = next(c for c in res if c["did"] == "c1") + assert c["video_enabled"] is True + assert c["audio_enabled"] is True diff --git a/web/src/App.tsx b/web/src/App.tsx index c8d4c01f7..f778807ed 100644 --- a/web/src/App.tsx +++ b/web/src/App.tsx @@ -17,10 +17,6 @@ import { refreshCameraOnline, pausePerception, resumePerception, - setScopeCameraPrompt, - clearScopeCameraPrompt, - toggleScopeCamera, - toggleScopeCameraVoice, switchScopeHome, } from "./api"; import { useAsync } from "./hooks/useAsync"; @@ -224,16 +220,10 @@ function MainApp() { 不传 onPersonClick → PersonChip 降级成 div(无 hover/点击反馈), 防住户看到可点 button 形态点了无反馈以为系统坏。 */ onJumpUsage={() => setActiveTab("usage")} - onToggleCameras={async (dids, inUse) => { - try { - await toggleScopeCamera(dids, inUse); - } catch (e) { - toast( - e instanceof Error ? e.message : t("common.switchFailed"), - "warn", - ); - } - // 三个 reload —— LivePlayer iframe src 用 useRef 按 cameraDid 锁住, + // v2:PerceptionDeviceTable 内部自己调 toggleScopeCamera(per-modality), + // 这里只挂一个 reload trigger 给"用户改了感知状态后同步刷新"。 + onToggleCameras={() => { + // LivePlayer iframe src 用 useRef 按 cameraDid 锁住, // channelByDid useMemo + iframe React diff 双层防 src 变化触发 // iframe 重 mount,reload cameras 安全。新接入 cam 时 cameras.reload // 才能拿到 channel,不 reload 会让多通道 cam 永远兜底 channel=0。 @@ -241,42 +231,6 @@ function MainApp() { cameras.reload(); status.reload(); }} - onToggleCameraVoice={async (did, voiceInUse) => { - try { - await toggleScopeCameraVoice([did], voiceInUse); - } catch (e) { - toast( - e instanceof Error ? e.message : t("common.switchFailed"), - "warn", - ); - } - // 拾音开关只改 KV 偏好,不动投喂/流(音频在引擎入口按 KV 实时剥离), - // 只需 reload scopeCameras 拿新 voiceInUse。 - scopeCameras.reload(); - }} - onSetCameraPrompt={async (did, text) => { - try { await setScopeCameraPrompt(did, text); } - catch (e) { - toast(e instanceof Error ? e.message : t("common.switchFailed"), "warn"); - throw e; - } - scopeCameras.reload(); - }} - onClearCameraPrompt={async (did) => { - try { await clearScopeCameraPrompt(did); } - catch (e) { - toast(e instanceof Error ? e.message : t("common.switchFailed"), "warn"); - throw e; - } - scopeCameras.reload(); - }} - onRefresh={async () => { - // 手动刷新:force 绕过 8s 节流打后端刷相机状态,再 await 列表重拉落地—— - // reload() 的 Promise 在 listScopeCameras settle 后 resolve,故 onRefresh 完成 - // = 列表已更新到位,刷新按钮转圈据此精确覆盖全程(不被其他 reload 借用)。 - await refreshCameraOnline(homeId, true).catch(() => {}); - await scopeCameras.reload(); - }} /> ); diff --git a/web/src/api/index.ts b/web/src/api/index.ts index 56f443c57..2dbaaa94e 100644 --- a/web/src/api/index.ts +++ b/web/src/api/index.ts @@ -7,6 +7,8 @@ */ import * as realImpl from "./real"; + +export type CameraToggleItem = realImpl.CameraToggleItem; import { apiFetch } from "./client"; import type { ActivityEvent, @@ -307,10 +309,9 @@ export async function refreshCameraOnline( } export async function toggleScopeCamera( - dids: string[], - inUse: boolean, + items: CameraToggleItem[], ): Promise { - return impl.realToggleScopeCamera(dids, inUse); + return impl.realToggleScopeCamera(items); } // 切换相机拾音开关(PUT /api/miot/scope/cameras/voice;关闭 = 声音完全不被处理)。 @@ -649,3 +650,10 @@ export async function updateSchedulerConfig( ); return r.data; } + +export async function perceiveQuery( + sources: string[], + query: string, +): Promise { + return realImpl.realPerceiveQuery(sources, query); +} diff --git a/web/src/api/real.ts b/web/src/api/real.ts index ef4354c3c..2beb7aef7 100644 --- a/web/src/api/real.ts +++ b/web/src/api/real.ts @@ -989,6 +989,8 @@ interface BackendScopeCamera { // 拾音存储偏好(在拾音白名单即 true,**默认 false**,opt-in)。false = 该相机声音 // 完全不被处理。旧后端无此字段时兜底 false(默认关,与后端默认姿态一致)。 voice_in_use?: boolean; + video_enabled?: boolean; + audio_enabled?: boolean; perception_prompt?: string; connected: boolean; channel?: number; // 通道号,用于多通道摄像头 @@ -1009,6 +1011,8 @@ export async function realListScopeCameras(): Promise { awake: c.awake ?? null, inUse: c.in_use, voiceInUse: c.voice_in_use ?? false, + videoEnabled: c.video_enabled ?? c.in_use ?? true, + audioEnabled: c.audio_enabled ?? c.in_use ?? true, perceptionPrompt: c.perception_prompt ?? "", connected: c.connected, channel: c.channel ?? 0, // 传递通道号,默认为 0 @@ -1034,13 +1038,26 @@ export async function realRefreshCameraOnline(force = false): Promise { await apiFetch>("/api/miot/refresh_camera_online"); } +export interface CameraToggleItem { + did: string; + inUse?: boolean; + videoEnabled?: boolean; + audioEnabled?: boolean; +} + export async function realToggleScopeCamera( - dids: string[], - inUse: boolean, + items: CameraToggleItem[], ): Promise { await apiFetch>("/api/miot/scope/cameras", { method: "PUT", - body: JSON.stringify({ items: dids.map((did) => ({ did, in_use: inUse })) }), + body: JSON.stringify({ + items: items.map((i) => ({ + did: i.did, + in_use: i.inUse, + video_enabled: i.videoEnabled, + audio_enabled: i.audioEnabled, + })), + }), }); // 写后立即 invalidate + 主动 prefetch homeCache(同 switchScopeHome 同款消 race)。 invalidateMiotHomeCache(); @@ -1764,3 +1781,18 @@ export async function realUpdateTaskDescription( }, ); } + +interface PerceiveResult { + answer: string; +} + +export async function realPerceiveQuery( + sources: string[], + query: string, +): Promise { + const r = await apiFetch>("/api/perception/perceive", { + method: "POST", + body: JSON.stringify({ sources, query }), + }); + return r.data.answer; +} diff --git a/web/src/components/HeroNow.tsx b/web/src/components/HeroNow.tsx index 62b480dc9..9f89b83af 100644 --- a/web/src/components/HeroNow.tsx +++ b/web/src/components/HeroNow.tsx @@ -2,7 +2,10 @@ * 「家里此刻」Hero 区(v3 Mi Console 视觉) * * 整面板视觉重量最高的卡片:家人 chips + 摄像头实时画面 + - * 全开/全关批量切换 + inUse 单卡 toggle。 + * 感知设备列表(v2:per-camera × per-modality 矩阵)。 + * + * 上方"实时画面"区只展示 connected=true 的相机,卡片上不再有开关(v2 改动)。 + * 下方"感知设备列表"统一管理所有米家摄像头的视频 / 音频感知开关。 */ import type { @@ -10,74 +13,29 @@ import type { ScopeCamera, UsageStats, } from "@/lib/types"; -import { cameraAvailable } from "@/lib/types"; import { PersonChip } from "./PersonChip"; import { LivePlayerPlaceholder } from "./LivePlayerPlaceholder"; +import { PerceptionDeviceTable } from "./PerceptionDeviceTable"; import { getUsageStats } from "@/api"; import { useAsync } from "@/hooks/useAsync"; import { humanTokens } from "@/lib/formatTokens"; -import { useId, useMemo, useState, type ReactNode } from "react"; +import { useMemo, type ReactNode } from "react"; import { useTranslation } from "react-i18next"; -import { toast } from "./Toast"; -import { switchBlockedReasonKey } from "@/lib/cameraSwitch"; -import { - channelHasMic, - feedDid as synthFeedDid, - lensLabelKey, -} from "@/lib/cameraChannel"; -import { IconRefresh, IconPencil } from "@/lib/icons"; - -// 每摄像头「感知须知」prompt 长度上限(与 backend MAX_CAMERA_PROMPT_LEN 对齐)。 -const CAMERA_PROMPT_MAX_LEN = 500; - -// 「关声音」确认弹窗的「不再提醒」持久化标记(与 web:theme / web:lang 同命名空间)。 -// 复位说明:清除站点数据 / localStorage 即恢复弹窗;本分支不做设置项 UI——将来若加 -// 隐私设置面板,一句 localStorage.removeItem(VOICE_ON_CONFIRMED_KEY) 即可重置。 -const VOICE_ON_CONFIRMED_KEY = "web:voiceOnConfirmed"; - -function isVoiceOnConfirmed(): boolean { - try { - return localStorage.getItem(VOICE_ON_CONFIRMED_KEY) === "1"; - } catch { - return false; // localStorage 不可用(隐私模式/测试桩)→ 每次仍确认,无害 - } -} - -function setVoiceOnConfirmed(): void { - try { - localStorage.setItem(VOICE_ON_CONFIRMED_KEY, "1"); - } catch { - /* 写不了就算了:本会话每次仍弹确认,不影响功能 */ - } -} interface Props { persons: Person[]; - /** 米家全集(含被禁用 / 离线),用于渲染所有摄像头卡片 + Switch。多通道相机每条 - * 通道一条记录(did 相同、channel 区分),channel 直接带在每条上供播放 / 分行。 */ + /** 米家全集(含被禁用 / 离线),用于渲染所有摄像头卡片 + Switch */ scopeCameras: ScopeCamera[]; /** miot 上是否有 camera 类设备——区分两种空态 */ miotHasCamera: boolean; - /** 最多投喂给 miloco 的摄像头数(后端 MAX_ENABLED_CAMERAS,经 /api/miot/status 下发)。 - * 上区展示数受 connected 自然约束,此值只用于下区「启用」按钮的满额置灰判断。 */ + /** 后端 MAX_ENABLED_CAMERAS,经 /api/miot/status 下发。下沉到 PerceptionDeviceTable 用作 atCapacity 检测。 */ maxStreamCams: number; + /** 占位 prop,v2 后表格自己管自己的 toggle,这里保留签名只供上层触发 reload 用 */ + onToggleCameras?: () => void; /** undefined → chip 渲染成 div(无 hover/点击反馈),概览页用 */ onPersonClick?: (p: Person) => void; /** 点击"今日用量"小卡片跳到用量 tab。 */ onJumpUsage?: () => void; - /** 切换摄像头启用(PUT /api/miot/scope/cameras);批量传 dids */ - onToggleCameras: (dids: string[], inUse: boolean) => void | Promise; - /** 切换单台摄像头拾音(PUT /api/miot/scope/cameras/voice)。关闭 = 该相机声音完全 - * 不被处理(mic-off:不转写、不上云)。从属于感知开关:仅当该相机 inUse=true 时 - * 可设,感知关时前端置灰。 */ - onToggleCameraVoice: (did: string, voiceInUse: boolean) => void | Promise; - /** 设置某摄像头自定义「感知须知」(PUT /api/miot/scope/cameras/prompt)。 - * 给该机位补环境说明 / 关注 / 忽略,指导感知消解固定误识。 */ - onSetCameraPrompt: (did: string, text: string) => void | Promise; - /** 清除某摄像头自定义「感知须知」(DELETE /api/miot/scope/cameras/prompt)。 */ - onClearCameraPrompt: (did: string) => void | Promise; - /** 手动刷新未感知设备状态(force 刷新相机在线 / 镜头 + await 列表重拉落地)。 */ - onRefresh?: () => void | Promise; } // 排序:已认识在前,未认识统一靠后 @@ -88,45 +46,25 @@ function sortPersons(ps: Person[]): Person[] { }); } -// 把「一台相机的若干通道行」按物理 did 聚成一组(单摄一条、双摄两条),保持传入顺序 -// (调用方已按 did、channel 排序)。方案 A:启停按整台走,故分组后每组只出一套开关, -// 画面 / 状态仍按通道逐条展示。每组即一台相机的若干通道行。 export function HeroNow({ persons, scopeCameras, miotHasCamera, maxStreamCams, + onToggleCameras, onPersonClick, onJumpUsage, - onToggleCameras, - onToggleCameraVoice, - onSetCameraPrompt, - onClearCameraPrompt, - onRefresh, }: Props) { const { t } = useTranslation(); const sorted = sortPersons(persons); - // 上区 = miloco **当前真正在投喂视频** 的相机。判据用后端权威字段 `connected` - // (= MiotService._connected_camera_dids() = 感知 camera_adapter.get_connected_devices(), - // 即真正建连、在喂解码帧给感知的那几路),而不是 `inUse`(只是 KV 里的"想启用"意图—— - // 启用了但 LAN 拉不起来时 inUse=true 却没真投喂)。再 **按 (did, channel) 稳定排序**: - // toggle 某路时其余卡 key+DOM 位置不变,React 复用其 iframe,不会连带把其它路的 watch - // 流断开重连。其余相机(未投喂:未启用 / 启用中未连上 / 超出上限)进下区「无流」横向列表。 - const { streamingCams, benchCams } = useMemo(() => { - // 多通道相机两条记录 did 相同,需按 channel 二级排序 + 用 (did, channel) 复合键 - // 判定归属,否则同名两行会互相顶掉(同一 did 只留一条)。 - const key = (c: ScopeCamera) => `${c.did}|${c.channel}`; - const byDidChannel = (a: ScopeCamera, b: ScopeCamera) => { - if (a.did !== b.did) return a.did < b.did ? -1 : 1; - return a.channel - b.channel; - }; - const sorted = [...scopeCameras].sort(byDidChannel); - // 不再前端截断:connected 集天然受后端 MAX_ENABLED_CAMERAS 约束(感知接入层按流路数 - // 截断、只连前 N 路;主动 enable 超限也被 toggle_camera 挡下),展示集即真实投喂集。 - const streaming = sorted.filter((c) => c.connected); - const sset = new Set(streaming.map(key)); - const bench = sorted.filter((c) => !sset.has(key(c))); - return { streamingCams: streaming, benchCams: bench }; + // 实时画面:任一模态开启即显示预览。全拆后按通道返多行,需按 did 去重。 + const streamingCams = useMemo(() => { + const byDid = (a: ScopeCamera, b: ScopeCamera) => + a.did < b.did ? -1 : a.did > b.did ? 1 : 0; + const seen = new Set(); + return [...scopeCameras] + .filter((c) => (c.videoEnabled || c.audioEnabled) && !seen.has(c.did) && seen.add(c.did)) + .sort(byDid); }, [scopeCameras]); // 今日 token 用量小入口(omni 计费) const todayUsage = useAsync( @@ -189,720 +127,70 @@ export function HeroNow({ )} - {/* 摄像头 */} + {/* 摄像头实时画面区(v2:卡上无开关,只展示 connected=true 的相机) */} + {t("hero.liveLabel")} + {/* 感知设备列表(v2):always-all 米家摄像头 + per-modality 开关 + 批量按钮 */} + onToggleCameras?.()} /> ); } interface CameraSectionProps { - scopeCameras: ScopeCamera[]; - /** 上区:带实时流的相机(inUse=true,≤4,按 did 稳定排序) */ + /** 上区:带实时流的相机(video 解码真连上,connected=true)。按 did 稳定排序。 */ streamingCams: ScopeCamera[]; - /** 下区:无流横向列出的相机(未启用 / 超出上限) */ - benchCams: ScopeCamera[]; - /** 最多投喂数(后端 MAX_ENABLED_CAMERAS),用于满额置灰下区「启用」 */ - maxStreamCams: number; miotHasCamera: boolean; - onToggleCameras: (dids: string[], inUse: boolean) => void | Promise; - onToggleCameraVoice: (did: string, voiceInUse: boolean) => void | Promise; - onSetCameraPrompt: (did: string, text: string) => void | Promise; - onClearCameraPrompt: (did: string) => void | Promise; - onRefresh?: () => void | Promise; } +/** 上区实时画面区:只展示 connected=true 的相机,卡片右上角的开关已移除(v2 表格统一管)。 */ function CameraSection({ - scopeCameras, streamingCams, - benchCams, - maxStreamCams, miotHasCamera, - onToggleCameras, - onToggleCameraVoice, - onSetCameraPrompt, - onClearCameraPrompt, - onRefresh, }: CameraSectionProps) { const { t } = useTranslation(); - // 手动刷新未感知设备状态:in-flight 期间转圈 + disable 防连点(force 刷新本身绕过 8s 节流)。 - const [refreshing, setRefreshing] = useState(false); - const runRefresh = async () => { - if (refreshing || !onRefresh) return; - setRefreshing(true); - try { - await onRefresh(); - } finally { - setRefreshing(false); - } - }; - const total = scopeCameras.length; - // 出现多于一条记录的物理 did = 多通道相机;这些相机每卡/每行才拼镜头标签,好让同名 - // 同房间的两条彼此区分(单通道不显示,免噪声)。逻辑收在 @/lib/cameraChannel(可单测)。 - // 多通道判据用后端权威信号 channelCount>1(与 backend/CLI 同口径),不用「同 did 出现几行」代理。 - const isMulti = (c: ScopeCamera): boolean => c.channelCount > 1; - // 每路显镜头名(ch0=移动画面 / ch1=固定画面;ch≥2 兜底「通道 N」);单摄不显示。 - const channelLabelOf = (c: ScopeCamera): string | undefined => { - if (!isMulti(c)) return undefined; - const key = lensLabelKey(c.channel); - return key ? t(key) : t("hero.channelLabel", { n: c.channel }); - }; - // 全拆:每路逐通道渲染、各控自己那路。投喂开关目标 = 该路合成 did(多摄 `did:ch{n}`、单摄 - // 裸 did);拾音是相机级(mic 只在球机/ch0)、按物理 did。 - const feedDidOf = (c: ScopeCamera): string => - synthFeedDid(c.did, c.channel, isMulti(c)); - const hasMic = (c: ScopeCamera): boolean => channelHasMic(c.channel); - const activeCount = scopeCameras.filter((c) => c.inUse).length; - const allOn = total > 0 && activeCount === total; - const allOff = activeCount === 0; - // 满额判断按 inUse(=活跃集:未拉黑 + 三态好 + 上限内)计数,与后端 toggle_camera 的 - // 上限校验同口径——后端也数「可用集」(离线/局域网不可达/镜头关的不占名额)。所以 - // 面板显示的名额 = 后端认的名额,不会出现「看着有位、点开启却被后端拒」。 - const atCapacity = activeCount >= maxStreamCams; - // 「全开」只能开「可用且未投喂」的**通道**——不可用(云端离线/局域网不可达/该路镜头关) - // 后端 toggle_camera 会整批拒绝。全拆按通道走:每路一个合成 did,各自独立(不去重物理 did)。 - const enableableDids = scopeCameras - .filter((c) => !c.inUse && cameraAvailable(c)) - .map(feedDidOf); - // bulkBusy 锁防"全开/全关"连点;singleBusyDids 跟踪单卡 in-flight,让住户切单卡 A - // 时只 disable A 卡,B/C/D 仍可点。bulk 操作进行时仍 disable 所有(防交叠)。 - const [bulkBusy, setBulkBusy] = useState(false); - const [singleBusyDids, setSingleBusyDids] = useState>(new Set()); - // 拾音开关独立 in-flight 集:拾音 PUT 走独立端点,与投喂 PUT 互不阻塞,分开跟踪。 - const [voiceBusyDids, setVoiceBusyDids] = useState>(new Set()); - const runBulk = async (dids: string[], inUse: boolean) => { - if (bulkBusy) return; - setBulkBusy(true); - try { - await onToggleCameras(dids, inUse); - } finally { - setBulkBusy(false); - } - }; - const runSingle = async (did: string, inUse: boolean) => { - if (bulkBusy || singleBusyDids.has(did)) return; - setSingleBusyDids((s) => new Set(s).add(did)); - try { - await onToggleCameras([did], inUse); - } finally { - setSingleBusyDids((s) => { - const n = new Set(s); - n.delete(did); - return n; - }); - } - }; - const runSingleVoice = async (did: string, voiceInUse: boolean) => { - if (voiceBusyDids.has(did)) return; - setVoiceBusyDids((s) => new Set(s).add(did)); - try { - await onToggleCameraVoice(did, voiceInUse); - } finally { - setVoiceBusyDids((s) => { - const n = new Set(s); - n.delete(did); - return n; - }); - } - }; - // 声音默认关(opt-in):开启方向先弹一次知情提示,讲清可能的问题与适用场景;关闭 - // 方向无害(只是停止处理声音),直接执行。待确认的相机存这里。用户勾「不再提醒」并 - // 确认后,落 localStorage 标记,之后开声音直接执行、不再弹(批量开多台安静机位时不啰嗦)。 - const [pendingVoiceOn, setPendingVoiceOn] = useState<{ - did: string; - name: string; - } | null>(null); - const [dontRemind, setDontRemind] = useState(false); - const requestVoiceToggle = (did: string, name: string, next: boolean) => { - if (voiceBusyDids.has(did)) return; - if (!next) { - void runSingleVoice(did, false); // 关闭声音无需确认(无害) - } else if (isVoiceOnConfirmed()) { - void runSingleVoice(did, true); // 已选「不再提醒」→ 直接开 - } else { - setDontRemind(false); // 每次开框默认不勾 - setPendingVoiceOn({ did, name }); // 开启 → 知情提示 - } - }; - - // ── 感知须知编辑 ── - const [promptEditing, setPromptEditing] = useState<{ - did: string; - name: string; - } | null>(null); - const [promptDraft, setPromptDraft] = useState(""); - const [promptSaving, setPromptSaving] = useState(false); - // 记录打开弹窗时该机位是否已有须知——用于区分「新建」和「编辑」 - const [hadPrompt, setHadPrompt] = useState(false); - const openPromptEditor = (did: string, name: string, current: string) => { - setPromptDraft(current); - setHadPrompt(!!current); - setPromptEditing({ did, name }); - }; - const savePrompt = async () => { - if (!promptEditing || promptSaving) return; - const text = promptDraft.trim(); - if (!text) { - // 清空了:如果原来有须知 → 走 DELETE;原来就没有 → 无操作 - if (!hadPrompt) return; - return doClearPrompt(); - } - setPromptSaving(true); - try { await onSetCameraPrompt(promptEditing.did, text); setPromptEditing(null); } - catch { /* error already toasted, keep modal open */ } - finally { setPromptSaving(false); } - }; - const doClearPrompt = async () => { - if (!promptEditing || promptSaving) return; - setPromptSaving(true); - try { await onClearCameraPrompt(promptEditing.did); setPromptEditing(null); } - catch { /* error already toasted */ } - finally { setPromptSaving(false); } - }; - return ( - <> -
-
- {t("hero.liveLabel")} -
- {total > 0 && ( -
- - {t("hero.perceivingCount", { n: activeCount })} - - - + if (streamingCams.length === 0) { + if (miotHasCamera) { + return ( +
+
+ {t("hero.cameraOfflineTitle")}
- )} -
- {total === 0 ? ( -
- {miotHasCamera ? ( - <> -
- {t("hero.cameraOfflineTitle")} -
-
- {t("hero.cameraOfflineHint")} -
- - ) : ( - <>{t("hero.cameraEmpty")} - )} -
- ) : ( - <> - {/* 上区:最多 4 路「带实时流」的相机,按物理 did 分组——每台一张卡(卡内每通道 - 一个小窗),整台一套开关。卡 key=物理 did,稳定排序,toggle 某台时其余卡 - key+位置不变,React 复用其 iframe,不会连带把其它台的流断开重连。 */} - {streamingCams.length > 0 ? ( -
- {streamingCams.map((c) => { - const feedDid = feedDidOf(c); - return ( - runSingle(feedDid, v)} - // 投喂开关 in-flight(按该路合成 did)时拾音也置灰,防交叠竞态。拾音是 - // 相机级,按物理 did 跟踪。 - voiceBusy={ - voiceBusyDids.has(c.did) || - bulkBusy || - singleBusyDids.has(feedDid) - } - onToggleVoice={(v) => requestVoiceToggle(c.did, c.name, v)} - hasPrompt={!!c.perceptionPrompt} - onEditPrompt={() => - openPromptEditor(feedDid, channelLabelOf(c) ? `${c.name} · ${channelLabelOf(c)}` : c.name, c.perceptionPrompt) - } - /> - ); - })} -
- ) : ( -
- {t("hero.noStreaming")} -
- )} - {/* 下区:未投喂给 miloco 的相机(未启用 / 超出上限)。不拉流、不展示小窗, - 改成日志页风格的横条行:每行 摄像头信息 + 一个开关,开关直接控制是否投喂。 */} - {benchCams.length > 0 && ( -
-
- - {atCapacity - ? t("hero.benchTitleFull", { n: maxStreamCams }) - : t("hero.benchTitle")} - - {onRefresh && ( - - )} -
-
    - {benchCams.map((c) => { - const feedDid = feedDidOf(c); - return ( - runSingle(feedDid, v)} - voiceBusy={ - voiceBusyDids.has(c.did) || - bulkBusy || - singleBusyDids.has(feedDid) - } - onToggleVoice={(v) => requestVoiceToggle(c.did, c.name, v)} - hasPrompt={!!c.perceptionPrompt} - onEditPrompt={() => - openPromptEditor(feedDid, channelLabelOf(c) ? `${c.name} · ${channelLabelOf(c)}` : c.name, c.perceptionPrompt) - } - /> - ); - })} -
-
- )} - - )} - - {/* 开声音知情提示:opt-in。讲清可能的问题 + 适用/不适用场景。复用居中弹窗形态。 */} - {pendingVoiceOn && ( -
setPendingVoiceOn(null) - } - > -
e.stopPropagation()} - > -

- {t("hero.voiceOnConfirmTitle", { name: pendingVoiceOn.name })} -

-

- {t("hero.voiceOnConfirmIntro")} -

- {/* 可能的问题 + 适用/不适用场景:三行图标标记,一眼可辨。 */} -
    -
  • - - - {t("hero.voiceOnConfirmRisk")} - -
  • -
  • - - - {t("hero.voiceOnConfirmRecommend")} - -
  • -
  • - - - {t("hero.voiceOnConfirmAvoid")} - -
  • -
- {/* 不再提醒:勾选并确认后落 localStorage,之后开声音直接执行、不再弹框。 */} - -
- - -
-
-
- )} - - {/* 感知须知编辑弹窗:给该机位补环境说明 / 关注 / 忽略,指导感知消解固定误识。 */} - {promptEditing && ( -
setPromptEditing(null)} - > -
e.stopPropagation()} - > -

- {t("hero.camPromptTitle", { name: promptEditing.name })} -

-

{t("hero.camPromptIntro")}

-