feat: improve mode and peripheral orchestration

This commit is contained in:
Miayui
2026-09-21 00:13:47 +08:00
parent 89707a42e2
commit 448c8b31a0
39 changed files with 3068 additions and 277 deletions
+506 -8
View File
@@ -104,6 +104,7 @@ from cognitive_agent.application.native_can_feedback import ( # noqa: E402
)
from cognitive_agent.application.native_can_intent import ( # noqa: E402
DRIVER_SEAT_MASSAGE_CONTINUATION_PATTERN,
DRIVER_SEAT_THERMAL_CONTINUATION_PATTERN,
merge_native_can_intent,
)
from cognitive_agent.application.native_can_relative import ( # noqa: E402
@@ -202,6 +203,39 @@ LOCAL_CONTROL_BUDGET_MS = 1000
VEHICLE_CONTROL_TRANSPORTS = frozenset(
{WIRED_CAN_TRANSPORT, WIRELESS_MQTT_TRANSPORT}
)
N7_CINEMA_RELATED_PATTERN = re.compile(
r"影院|观影|电影|看电影|cinema|movie|theater|theatre",
re.IGNORECASE,
)
N7_CINEMA_BASE_NAMES = frozenset(
{"影院", "影院模式", "观影", "观影模式", "cinema", "cinemamode"}
)
N7_FLASHING_LIGHT_PATTERN = re.compile(
r"闪烁灯|闪光灯|闪灯玩具|flashing\s*light",
re.IGNORECASE,
)
N7_LIGHT_STRIP_PATTERN = re.compile(
r"灯带|灯条|light\s*strip|led\s*strip",
re.IGNORECASE,
)
N7_PERIPHERAL_ACTIVATE_PATTERN = re.compile(
r"打开|开启|启动|点亮|\bon\b|power\s*on",
re.IGNORECASE,
)
N7_PERIPHERAL_OFF_PATTERN = re.compile(
r"关闭|关掉|熄灭|\boff\b|power\s*off",
re.IGNORECASE,
)
N7_PERIPHERAL_COLOR_PATTERN = re.compile(
r"红色|绿色|蓝色|白色|紫色|黄色|橙色|粉色|"
r"\bred\b|\bgreen\b|\bblue\b|\bwhite\b|\bpurple\b|"
r"\byellow\b|\borange\b|\bpink\b",
re.IGNORECASE,
)
N7_CINEMA_VIDEO_APP_PATTERN = re.compile(
r"视频|媒体|爱奇艺|video|media|videoplay|iqiyi",
re.IGNORECASE,
)
def _wireless_mqtt_publish_preflight() -> dict[str, Any]:
@@ -472,6 +506,9 @@ class SeatDemoService(ModeScheduleServiceMixin):
lambda scene: self._vehicle_mqtt_transport.publish_mode_scene(scene),
topic=MQTT_MODE_SCENE_TOPIC,
)
self._mode_scene_peripheral_catalog_provider: Callable[
[], list[dict[str, Any]]
] = lambda: []
self._mode_scene_ingress = ModeSceneIngressConsumer(
self.import_n7_mode_scene,
)
@@ -637,6 +674,378 @@ class SeatDemoService(ModeScheduleServiceMixin):
return self.publish_saved_mode_scene(mode_id, vehicle_id)
@staticmethod
def _n7_scene_identity_text(scene: Mapping[str, Any]) -> str:
aliases = scene.get("aliases", [])
return " ".join(
str(value or "")
for value in (
scene.get("id"),
scene.get("name"),
scene.get("label"),
scene.get("description"),
*(aliases if isinstance(aliases, list) else []),
)
)
@classmethod
def _is_n7_cinema_scene(cls, scene: Mapping[str, Any]) -> bool:
return N7_CINEMA_RELATED_PATTERN.search(
cls._n7_scene_identity_text(scene)
) is not None
@staticmethod
def _is_n7_cinema_base_scene(scene: Mapping[str, Any]) -> bool:
scene_id = re.sub(r"\s+", "", str(scene.get("id") or "")).casefold()
names = {
re.sub(r"\s+", "", str(scene.get(field) or "")).casefold()
for field in ("name", "label")
}
return scene_id == "cinema" or bool(names & N7_CINEMA_BASE_NAMES)
@staticmethod
def _n7_scene_action_selector(action: Mapping[str, Any]) -> tuple[object, ...]:
parameters = action.get("parameters")
slot = (
parameters.get("slot")
if isinstance(parameters, Mapping)
and action.get("capability") == "infrared.emit"
else None
)
return (
str(action.get("domain") or ""),
str(action.get("capability") or ""),
str(action.get("target_scope") or ""),
str(action.get("operation") or "set"),
int(action.get("at_ms") or 0),
slot,
)
@classmethod
def _merge_n7_scene_actions(
cls,
base_actions: object,
overriding_actions: object,
) -> list[dict[str, Any]]:
merged: list[dict[str, Any]] = []
positions: dict[tuple[object, ...], int] = {}
for source in (base_actions, overriding_actions):
if not isinstance(source, list):
continue
for raw_action in source:
if not isinstance(raw_action, Mapping):
continue
action = deepcopy(dict(raw_action))
selector = cls._n7_scene_action_selector(action)
existing = positions.get(selector)
if existing is None:
positions[selector] = len(merged)
merged.append(action)
else:
merged[existing] = action
return merged
def _n7_cinema_base_scene(
self,
incoming_scene: Mapping[str, Any],
) -> dict[str, Any] | None:
if self._is_n7_cinema_base_scene(incoming_scene):
return None
vehicle_id = self.vehicle_profiles.default().id
candidates: list[tuple[int, dict[str, Any]]] = []
for public_profile in self.mode_profiles.public_modes(vehicle_id):
try:
profile = self.mode_profiles.get_profile(
str(public_profile.get("id") or ""),
vehicle_id,
)
except (KeyError, ValueError):
continue
if profile.sync_origin != MQTT_MODE_SCENE_INGRESS_TOPIC:
continue
command_flow = profile.command_flow
source_scene = (
command_flow.get("source_scene")
if isinstance(command_flow, Mapping)
else None
)
if not isinstance(source_scene, Mapping) or not self._is_n7_cinema_base_scene(
source_scene
):
continue
priority = 0 if str(source_scene.get("id") or "") == "cinema" else 1
candidates.append((priority, deepcopy(dict(source_scene))))
if not candidates:
return None
candidates.sort(key=lambda item: (item[0], str(item[1].get("id") or "")))
return candidates[0][1]
@staticmethod
def _is_n7_cinema_video_launch_action(action: Mapping[str, Any]) -> bool:
parameters = action.get("parameters")
if (
action.get("domain") != "android"
or action.get("capability") != "android.launch_app"
or action.get("target_scope") != "headunit"
or action.get("operation") != "launch"
or not isinstance(parameters, Mapping)
):
return False
package_name = str(parameters.get("package_name") or "").strip()
activity_name = str(parameters.get("activity_name") or "").strip()
if (
not package_name
or not activity_name
or len(package_name) > 256
or len(activity_name) > 256
):
return False
return N7_CINEMA_VIDEO_APP_PATTERN.search(
" ".join(
(
str(action.get("type") or ""),
package_name,
activity_name,
)
)
) is not None
def _local_cinema_n7_transfer_actions(
self,
mode_name: str,
instruction: str = "",
) -> list[dict[str, Any]]:
"""Reuse the N7 cinema card's video launch for a local cinema variant."""
scene_identity = {
"id": "local-user-mode-variant",
"name": f"本地扩展 {str(mode_name or '').strip()}",
"label": f"本地扩展 {str(mode_name or '').strip()}",
"description": str(instruction or ""),
}
if not self._is_n7_cinema_scene(scene_identity):
return []
base_scene = self._n7_cinema_base_scene(scene_identity)
if base_scene is None:
return []
actions = base_scene.get("actions", [])
if not isinstance(actions, list):
return []
return [
deepcopy(dict(action))
for action in actions
if isinstance(action, Mapping)
and self._is_n7_cinema_video_launch_action(action)
][:1]
@staticmethod
def _n7_transfer_operation_selector(
domain: object,
capability: object,
target_scope: object,
operation: object,
parameters: object,
) -> tuple[str, str, str, str, str]:
clean_parameters = dict(parameters) if isinstance(parameters, Mapping) else {}
return (
str(domain or ""),
str(capability or ""),
str(target_scope or ""),
str(operation or "set"),
json.dumps(
clean_parameters,
ensure_ascii=False,
sort_keys=True,
separators=(",", ":"),
),
)
@classmethod
def _cinema_preferred_color_patterns(
cls,
scene: Mapping[str, Any],
) -> list[re.Pattern[str]]:
text = cls._n7_scene_identity_text(scene)
colors = (
(r"蓝色|\bblue\b",),
(r"紫色|\bpurple\b",),
(r"红色|\bred\b",),
(r"白色|\bwhite\b",),
(r"绿色|\bgreen\b",),
(r"黄色|\byellow\b",),
(r"橙色|\borange\b",),
(r"粉色|\bpink\b",),
)
explicit = [
re.compile(patterns[0], re.IGNORECASE)
for patterns in colors
if re.search(patterns[0], text, re.IGNORECASE)
]
defaults = [re.compile(patterns[0], re.IGNORECASE) for patterns in colors]
return [*explicit, *(item for item in defaults if item not in explicit)]
def _n7_cinema_peripheral_actions(
self,
scene: Mapping[str, Any],
) -> list[dict[str, Any]]:
try:
raw_catalog = self._mode_scene_peripheral_catalog_provider()
except Exception: # noqa: BLE001 - an unavailable catalog must not block import
return []
if not isinstance(raw_catalog, list):
return []
grouped: dict[str, list[dict[str, Any]]] = {}
identities: dict[str, str] = {}
for raw in raw_catalog:
if not isinstance(raw, Mapping):
continue
device_id = str(raw.get("device_id") or "").strip()
card_uid = str(raw.get("card_uid") or "").strip()
slot = raw.get("slot")
if (
not device_id
or not card_uid
or raw.get("status") != "learned"
or raw.get("enabled", True) is False
or isinstance(slot, bool)
or not isinstance(slot, int)
or not 1 <= slot <= 248
):
continue
item = deepcopy(dict(raw))
grouped.setdefault(device_id, []).append(item)
names = [
raw.get("device_name"),
*(raw.get("device_aliases", []) if isinstance(raw.get("device_aliases"), list) else []),
*(raw.get("object_names", []) if isinstance(raw.get("object_names"), list) else []),
]
identities[device_id] = " ".join(str(value or "") for value in names)
def action_text(item: Mapping[str, Any]) -> str:
return " ".join(
[
str(item.get("action") or ""),
*(item.get("aliases", []) if isinstance(item.get("aliases"), list) else []),
*(item.get("action_names", []) if isinstance(item.get("action_names"), list) else []),
]
)
def as_scene_action(
item: Mapping[str, Any],
*,
category: str,
kind: str,
) -> dict[str, Any]:
device_id = str(item.get("device_id") or "")
device_name = str(item.get("device_name") or device_id)
slot = int(item["slot"])
action_name = str(item.get("action") or "").strip()
return {
"type": "infrared_emit",
"at_ms": 0,
"domain": "self_extension",
"capability": "infrared.emit",
"target_scope": device_id,
"target": {
"device": device_id,
"name": device_name,
"card_uid": str(item.get("card_uid") or ""),
},
"operation": "emit",
"parameters": {"action": action_name, "slot": slot},
"operation_ref": f"pc-cinema-{category}-{kind}-{slot}",
}
selected_devices: set[str] = set()
result: list[dict[str, Any]] = []
color_patterns = self._cinema_preferred_color_patterns(scene)
for category, identity_pattern in (
("flashing-light", N7_FLASHING_LIGHT_PATTERN),
("light-strip", N7_LIGHT_STRIP_PATTERN),
):
device_id = next(
(
key
for key in sorted(grouped)
if key not in selected_devices
and identity_pattern.search(identities.get(key, key))
),
None,
)
if device_id is None:
continue
selected_devices.add(device_id)
actions = sorted(grouped[device_id], key=lambda item: int(item["slot"]))
activation = next(
(
item
for item in actions
if N7_PERIPHERAL_ACTIVATE_PATTERN.search(action_text(item))
and N7_PERIPHERAL_OFF_PATTERN.search(action_text(item)) is None
and N7_PERIPHERAL_COLOR_PATTERN.search(action_text(item)) is None
),
None,
)
color = next(
(
item
for pattern in color_patterns
for item in actions
if pattern.search(action_text(item))
and N7_PERIPHERAL_COLOR_PATTERN.search(action_text(item))
),
None,
)
if color is None:
color = next(
(
item
for item in actions
if N7_PERIPHERAL_COLOR_PATTERN.search(action_text(item))
),
None,
)
if activation is not None:
result.append(
as_scene_action(activation, category=category, kind="activate")
)
if color is not None and (
activation is None or color.get("id") != activation.get("id")
):
result.append(as_scene_action(color, category=category, kind="color"))
return result
def _augment_n7_cinema_scene(
self,
scene: dict[str, Any],
) -> dict[str, object] | None:
if not self._is_n7_cinema_scene(scene):
return None
base_scene = self._n7_cinema_base_scene(scene)
if base_scene is None:
return None
base_actions = base_scene.get("actions", [])
incoming_actions = scene.get("actions", [])
inherited = self._merge_n7_scene_actions(base_actions, incoming_actions)
peripheral_actions = self._n7_cinema_peripheral_actions(scene)
augmented = self._merge_n7_scene_actions(inherited, peripheral_actions)
if len(augmented) > 256:
raise ValueError("影院相关模式合并后的动作数量超过 256 项。")
scene["actions"] = augmented
scene["operation_count"] = len(augmented)
scene["domains"] = list(
dict.fromkeys(str(action.get("domain") or "") for action in augmented)
)
return {
"base_scene_id": str(base_scene.get("id") or ""),
"base_action_count": len(base_actions) if isinstance(base_actions, list) else 0,
"incoming_action_count": (
len(incoming_actions) if isinstance(incoming_actions, list) else 0
),
"peripheral_action_count": len(peripheral_actions),
"merged_action_count": len(augmented),
}
@staticmethod
def _imported_scene_function_configs(
scene: dict[str, Any],
@@ -694,6 +1103,7 @@ class SeatDemoService(ModeScheduleServiceMixin):
"""Upsert one validated N7 card without publishing it back to the broker."""
document = deepcopy(dict(scene))
cinema_augmentation = self._augment_n7_cinema_scene(document)
actions = document.get("actions")
if not isinstance(actions, list) or not actions:
raise ValueError("from_n7/scenes card must contain executable actions")
@@ -764,7 +1174,7 @@ class SeatDemoService(ModeScheduleServiceMixin):
sync_origin=MQTT_MODE_SCENE_INGRESS_TOPIC,
upstream_origin=str(document.get("origin") or ""),
)
return {
result = {
"status": "imported",
"scene_id": saved.id,
"scene_name": saved.name,
@@ -772,6 +1182,9 @@ class SeatDemoService(ModeScheduleServiceMixin):
"profile_revision": self.mode_profiles.profile_revision(saved),
"storage": "persistent_yaml",
}
if cinema_augmentation is not None:
result["cinema_augmentation"] = cinema_augmentation
return result
@property
def driver_only_ch1_execution_active(self) -> bool:
@@ -1015,14 +1428,42 @@ class SeatDemoService(ModeScheduleServiceMixin):
requests = self.mode_profiles.requests_for_profiles([profile])
if profile.sync_origin != MQTT_MODE_SCENE_INGRESS_TOPIC:
return [
request
for operation, request in zip(profile.operations, requests, strict=True)
if not (
transfer_actions = self._local_cinema_n7_transfer_actions(
profile.name,
profile.instruction,
)
transfer_selectors = {
self._n7_transfer_operation_selector(
action.get("domain"),
action.get("capability"),
action.get("target_scope"),
action.get("operation"),
action.get("parameters"),
)
for action in transfer_actions
}
local_requests = []
for operation, request in zip(
profile.operations,
requests,
strict=True,
):
if (
operation.domain == "self_extension"
and operation.capability == "infrared.emit"
):
continue
selector = self._n7_transfer_operation_selector(
operation.domain,
operation.capability,
operation.target_scope,
operation.operation,
operation.parameters,
)
], []
if selector in transfer_selectors:
continue
local_requests.append(request)
return local_requests, transfer_actions
source_scene = (
profile.command_flow.get("source_scene")
if isinstance(profile.command_flow, dict)
@@ -1109,7 +1550,7 @@ class SeatDemoService(ModeScheduleServiceMixin):
execution_mode: str,
local_preview: bool,
) -> dict[str, Any]:
"""Forward only N7-originated actions never submitted by this PC."""
"""Forward validated N7-only actions never submitted by this PC."""
actions = result.get("n7_transfer_candidates")
scene = result.get("n7_source_scene")
@@ -1579,6 +2020,48 @@ class SeatDemoService(ModeScheduleServiceMixin):
raise ValueError("动态模式更新必须保留完整时间轴,请按阶段重新生成预览。")
if schedule is not None:
operations = [deepcopy(op) for phase in schedule.phases for op in phase.operations]
n7_transfer_actions = (
self._local_cinema_n7_transfer_actions(
clean_name,
clean_instruction,
)
if schedule is None
else []
)
if n7_transfer_actions:
command_flow["n7_transfer_actions"] = deepcopy(n7_transfer_actions)
existing_operation_selectors = {
self._n7_transfer_operation_selector(
operation.get("domain"),
operation.get("capability"),
operation.get("target_scope"),
operation.get("operation"),
operation.get("parameters"),
)
for operation in operations
if isinstance(operation, Mapping)
}
for action in n7_transfer_actions:
selector = self._n7_transfer_operation_selector(
action.get("domain"),
action.get("capability"),
action.get("target_scope"),
action.get("operation"),
action.get("parameters"),
)
if selector in existing_operation_selectors:
continue
operations.append(
{
"domain": str(action.get("domain") or ""),
"capability": str(action.get("capability") or ""),
"target_scope": str(action.get("target_scope") or ""),
"operation": str(action.get("operation") or "launch"),
"parameters": deepcopy(dict(action.get("parameters") or {})),
"reason": "继承车机基础影院模式的视频媒体启动动作。",
}
)
existing_operation_selectors.add(selector)
saved = self.mode_profiles.save_mode(
clean_name,
operations,
@@ -1843,6 +2326,16 @@ class SeatDemoService(ModeScheduleServiceMixin):
self._vehicle_mqtt_transport.set_extension_control_rejection_handler(handler)
def set_mode_scene_peripheral_catalog_provider(
self,
provider: Callable[[], list[dict[str, Any]]] | None,
) -> None:
"""Supply the durable NFC-authorized IR catalog for N7 scene imports."""
if provider is not None and not callable(provider):
raise TypeError("mode scene peripheral catalog provider must be callable")
self._mode_scene_peripheral_catalog_provider = provider or (lambda: [])
def _synchronize_vehicle_state_once(
self,
conversation_id: str,
@@ -8372,6 +8865,11 @@ class SeatDemoService(ModeScheduleServiceMixin):
and "按摩" in clauses[-1]
and DRIVER_SEAT_MASSAGE_CONTINUATION_PATTERN.search(fragment)
)
thermal_continuation = bool(
clauses
and re.search(r"通风|加热", clauses[-1])
and DRIVER_SEAT_THERMAL_CONTINUATION_PATTERN.search(fragment)
)
# An exclusion is a qualifier of the control immediately before it,
# not an independent command. Keeping both pieces together lets
# the deterministic window parser remove the excluded targets
@@ -8379,7 +8877,7 @@ class SeatDemoService(ModeScheduleServiceMixin):
exclusion_qualifier = bool(
clauses and re.search(r"除外|除去|不包括|不含", fragment)
)
if massage_continuation or exclusion_qualifier:
if massage_continuation or thermal_continuation or exclusion_qualifier:
clauses[-1] = f"{clauses[-1]},{fragment}"
else:
clauses.append(fragment)