Skip to content
Open
15 changes: 7 additions & 8 deletions backend/miloco/src/miloco/database/kv_repo.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 列表
Expand All @@ -228,12 +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 路由均注入;引擎每感知窗实时读取,
# 改动下一窗即生效、不重启。用途:给模型补充该机位的环境描述 / 关注点 / 忽略项,
# 消除固定误识(如门口机位把公共走廊电梯门误当自家入户门)。读取失败按「无自定义」处理。
# 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"
CAMERA_PROMPT_MAP_KEY = "CAMERA_PROMPT_MAP_KEY"

class OnboardingKeys:
Expand Down
65 changes: 58 additions & 7 deletions backend/miloco/src/miloco/miot/filter.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -26,9 +23,6 @@
# 同时投喂给 miloco 感知的摄像头数量上限(前端展示上限也以此为唯一来源,经
# /api/miot/status 下发)。用户主动 enable 超限直接报错(service.toggle_camera 校验)。
MAX_ENABLED_CAMERAS = 4

# 每摄像头「感知须知」自定义 prompt 长度上限(字符数)。filter 层截断作为纵深防御,
# service/schema 层已有校验。
MAX_CAMERA_PROMPT_LEN = 500


Expand Down Expand Up @@ -86,6 +80,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)
Expand Down Expand Up @@ -283,12 +285,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:
Expand Down Expand Up @@ -361,3 +380,35 @@ def clear_camera_prompt(kv_repo: KVRepo, did: str) -> tuple[dict[str, str], bool
del new[did]
kv_repo.set(ScopeConfigKeys.CAMERA_PROMPT_MAP_KEY, json.dumps(new, ensure_ascii=False))
return new, True


def migrate_v1_blacklist(kv_repo) -> None:
"""一次性 v1→v2 migration:把旧黑名单里的**裸 did**复制到 per-modality 双 key。

#439 全拆后 ``CAMERA_BLACK_LIST_KEY`` 同时存裸 did(单摄/旧 v1)和 ``:chN``
合成 did(#439 per-channel)。只迁移裸 did——``:chN`` 是 #439 的精细连接控制,
不应被放大成整台模态关闭。
"""
try:
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)
10 changes: 9 additions & 1 deletion backend/miloco/src/miloco/miot/router.py
Original file line number Diff line number Diff line change
Expand Up @@ -521,7 +521,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)

Expand Down
21 changes: 18 additions & 3 deletions backend/miloco/src/miloco/miot/schema.py
Original file line number Diff line number Diff line change
Expand Up @@ -229,11 +229,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。",
)


Expand Down
122 changes: 104 additions & 18 deletions backend/miloco/src/miloco/miot/service.py
Original file line number Diff line number Diff line change
Expand Up @@ -38,14 +38,18 @@
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,
Expand Down Expand Up @@ -286,6 +290,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_VIDEO_BLACK_LIST_KEY)
self._kv_repo.delete(ScopeConfigKeys.CAMERA_AUDIO_BLACK_LIST_KEY)
self._kv_repo.delete(ScopeConfigKeys.CAMERA_PROMPT_MAP_KEY)
self._lru.clear()

Expand Down Expand Up @@ -1196,10 +1202,14 @@ async def list_cameras_with_state(self) -> list[dict]:
``in_use``=**当下真正开启**(= 该相机在 select_active 的活跃集里:默认开·未拉黑 +
三态满足 + 上限≤4)——离线/不可达/镜头关的相机 in_use=false,不显示为开;超上限的
也不算开。兼容字段 ``is_online`` = ``cloud_online and lan_reachable``(纯连通性)。
``voice_in_use`` 是**存储的拾音偏好**(在拾音白名单即 True,**默认 False**),与
``in_use`` 正交;「生效态」= ``in_use and voice_in_use`` 由前端派生,此处不合并。
``voice_in_use`` 是**存储的拾音偏好**(在拾音白名单即 True,**默认 False**);
「生效态」= ``in_use and voice_in_use`` 由前端派生。通过 ``toggle_camera`` 的
``audio_enabled`` / ``in_use`` 别名操作时同步写入拾音白名单,保持两路一致。
"""
self._migrate_v1_blacklist_if_needed()
voice_allowed = voice_allowed_camera_dids(self._kv_repo)
video_denied = denied_video_camera_dids(self._kv_repo)
audio_denied = denied_audio_camera_dids(self._kv_repo)
prompt_map = camera_prompts(self._kv_repo)
connected = self._connected_camera_dids()
cameras = filter_by_home(
Expand Down Expand Up @@ -1242,6 +1252,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)
Expand All @@ -1262,11 +1275,13 @@ async def list_cameras_with_state(self) -> list[dict]:
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 {}

Expand All @@ -1279,6 +1294,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:
Expand All @@ -1301,9 +1319,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())}"
Expand Down Expand Up @@ -1379,18 +1417,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]:
Expand Down Expand Up @@ -1525,6 +1605,10 @@ async def clear_camera_prompt(self, dids: list[str]) -> list[dict]:
affected = [cam for cam in all_cameras if cam["did"] in touched_physical]
return affected

def _migrate_v1_blacklist_if_needed(self) -> None:
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."""
try:
Expand All @@ -1540,12 +1624,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)

Expand Down
Loading
Loading