fix(scenes): restore cinema seat controls and ordered romantic lighting

This commit is contained in:
Miayui
2026-09-22 13:01:46 +08:00
parent 4d99702acd
commit 6b9ee0f349
24 changed files with 908 additions and 83 deletions
@@ -1760,6 +1760,9 @@ class AssistantCapabilityCatalog:
peripheral_actions.append(
{
"ref": peripheral_capability_ref(raw),
"device_key": hashlib.sha256(
str(raw.get("device_id") or device_name).encode("utf-8")
).hexdigest()[:16],
"device_label": device_name,
"device_type": device_type,
"object_names": _safe_string_list(raw.get("object_names"), limit=12),
@@ -2443,7 +2446,12 @@ class AssistantCapabilityCatalog:
continue
try:
profile = self._vehicle.mode_profiles.get_profile(mode_id, vehicle_id)
requests = self._vehicle.mode_profiles.requests_for_profiles([profile])
resolve_requests = getattr(self._vehicle, "mode_profile_execution_requests", None)
requests = (
resolve_requests(profile, vehicle_id)
if callable(resolve_requests)
else self._vehicle.mode_profiles.requests_for_profiles([profile])
)
except Exception: # noqa: BLE001 - a stale user mode is not executable
requests = []
operation_ids = [
@@ -2491,7 +2499,7 @@ class AssistantCapabilityCatalog:
"aliases": _safe_string_list(raw.get("aliases"), limit=16),
"description": _safe_text(raw.get("description"), limit=240),
"domains": _safe_string_list(raw.get("domains"), limit=16),
"operation_count": int(raw.get("operation_count") or 0),
"operation_count": len(operation_ids),
"origin": _safe_text(raw.get("origin"), limit=32),
"operation_ids": operation_ids,
"operations": operations,
@@ -34,6 +34,11 @@ from cognitive_agent.application.mode_creation_seat_policy import (
)
from cognitive_agent.application.mode_schedule import ModeSchedule
from cognitive_agent.application.mode_schedule_service import public_schedule_status
from cognitive_agent.application.mode_scene_ingress import MAX_MODE_SCENE_ACTIONS
from cognitive_agent.application.romantic_lighting import (
romantic_light_override_refs,
romantic_scene,
)
from cognitive_agent.application.scene_composer import (
SCENE_COMPOSER_INSTRUCTIONS,
SCENE_PLANNER_VERSION,
@@ -1517,6 +1522,17 @@ class CodexAssistantService:
requests = normalized.get("requests")
if not isinstance(requests, list):
raise SceneCompositionError("scene plan requests must be an array")
semantics = scene_context.get("scene_semantics")
if isinstance(semantics, Mapping) and romantic_scene(semantics.get("mode_name")):
replaced_refs = romantic_light_override_refs(scene_context.get("peripheral_actions", []))
requests[:] = [request for request in requests if not (
isinstance(request, Mapping) and request.get("capability") == "infrared.emit"
and any(isinstance(parameter, Mapping)
and parameter.get("name") == "capability_ref"
and isinstance(parameter.get("value"), str)
and parameter.get("value") in replaced_refs
for parameter in request.get("parameters", []))
)]
existing_refs = {
str(parameter.get("value") or "").strip()
for request in requests
@@ -4317,13 +4333,17 @@ class CodexAssistantService:
secret=secret,
)
actions: list[dict[str, object]] = []
action_limit = (
MAX_MODE_SCENE_ACTIONS
if isinstance(result.get("mode_profile_draft"), Mapping)
or isinstance(result.get("mode_profile"), Mapping)
else 96 if result.get("schedule") else MAX_PLAN_REQUESTS
)
intent_ir = result.get("intent_ir")
if isinstance(intent_ir, dict):
spec = intent_ir.get("spec")
if isinstance(spec, dict) and isinstance(spec.get("actions"), list):
for item in spec["actions"][
:96 if result.get("schedule") else MAX_PLAN_REQUESTS
]:
for item in spec["actions"][:action_limit]:
if not isinstance(item, dict):
continue
safe_action = {
@@ -4351,7 +4371,7 @@ class CodexAssistantService:
infrared_actions: list[dict[str, object]] = []
raw_infrared = result.get("infrared_actions")
if isinstance(raw_infrared, list):
for item in raw_infrared[:MAX_PLAN_REQUESTS]:
for item in raw_infrared[:action_limit]:
if not isinstance(item, dict):
continue
infrared_actions.append(
@@ -0,0 +1,69 @@
"""Translate the N7 passenger-seat preset contract into local seat operations."""
from __future__ import annotations
from collections.abc import Mapping
from typing import Any
from cognitive_agent.application.vehicle_profile import VehicleProfile
# N7 targets_pulse uses the ECU's M0, M1, M2, M3 ordering.
N7_SEAT_PRESET_AXES = (
"longitudinal", "cushion_tilt", "backrest_recline", "legrest_height",
)
N7_SEAT_PRESET_CAPABILITIES = (
"seat.set_longitudinal_position", "seat.set_cushion_tilt",
"seat.set_backrest_recline", "seat.set_legrest_height",
)
def expand_n7_seat_preset(
action: Mapping[str, Any], vehicle: VehicleProfile,
) -> list[dict[str, Any]] | None:
"""Expand an immediate preset; retain unrelated/timed actions for N7 routing.
The existing seat contract accepts whole percentages. Retain the source
pulses in the stored card, and round only at this execution boundary.
Never round a nonzero pulse target into the ECU's explicit homing command.
"""
if action.get("capability") != "local_scene.seat_preset":
return None
if (
vehicle.id != "denza_n7"
or action.get("domain") != "local_vehicle"
or action.get("target_scope") not in {"vehicle", "passenger", "front_right"}
or action.get("operation") != "set"
):
raise ValueError("车端座椅预设仅支持腾势 N7 副驾的绝对位置设置。")
parameters = action.get("parameters")
if not isinstance(parameters, Mapping) or set(parameters) != {"targets_pulse"}:
raise ValueError("车端座椅预设必须且只能包含 targets_pulse 参数。")
targets = parameters["targets_pulse"]
if not isinstance(targets, list) or len(targets) != 4:
raise ValueError("车端座椅预设需要按 M0–M3 顺序提供四个脉冲目标。")
operations = []
for axis, capability, pulse in zip(
N7_SEAT_PRESET_AXES, N7_SEAT_PRESET_CAPABILITIES, targets, strict=True
):
motor = vehicle.seat_motors.get(axis)
maximum = motor.hall_total_pulses if motor is not None else 0
if maximum <= 0:
raise ValueError(f"车端座椅预设缺少 {axis} 的有效霍尔标定。")
if isinstance(pulse, bool) or not isinstance(pulse, int) or not 0 <= pulse <= maximum:
raise ValueError(f"车端座椅预设 {axis} 脉冲目标必须为 0–{maximum} 的整数。")
position_pct = round(pulse * 100 / maximum)
if 0 < pulse < maximum:
position_pct = max(1, min(99, position_pct))
operations.append({
"domain": "seat",
"capability": capability,
"target_scope": "passenger",
"operation": "set",
"parameters": {"position_pct": position_pct},
"reason": f"车端副驾预设 {axis}:{pulse}/{maximum} 脉冲转换为 {position_pct}%。",
})
# A delayed vehicle-owned action must not become an immediate PC movement.
if action.get("at_ms", 0) != 0:
return None
return operations
@@ -0,0 +1,109 @@
"""Deterministic romantic lighting from the current authorized IR catalog."""
from __future__ import annotations
import re
from collections.abc import Mapping, Sequence
from copy import deepcopy
from typing import Any
ROMANTIC_SCENE = re.compile(r"浪漫|约会|七夕|情人节|纪念日|romantic|romance|date[ _-]*night|anniversary", re.I)
_LIGHT = re.compile(r"灯|蜡烛|light|lamp|lantern|candle", re.I)
_REQUIRED_POWER = re.compile(r"灯笼|蜡烛|灯带|灯条|闪烁灯|氛围灯|lantern|candle|(?:light|led)\s*strip|ambient\s*light", re.I)
_ON = re.compile(r"^(?:打开|开启|启动|点亮|on|power\s*on)$", re.I)
_OFF = re.compile(r"关闭|关掉|熄灭|\boff\b", re.I)
_COLOR = re.compile(r"红色|绿色|蓝色|白色|紫色|黄色|橙色|粉色|\b(?:red|green|blue|white|purple|yellow|orange|pink)\b", re.I)
_RED = re.compile(r"红色|\bred\b", re.I)
def romantic_scene(value: object) -> bool:
return ROMANTIC_SCENE.search(str(value or "")) is not None
def light_action_names(item: Mapping[str, Any]) -> list[str]:
parameters = item.get("parameters")
names = [item.get("action_label"), item.get("action")]
if isinstance(parameters, Mapping):
names.append(parameters.get("action"))
for key in ("action_names", "aliases"):
if isinstance(item.get(key), list):
names.extend(item[key])
return [str(name).strip() for name in names if name]
def light_power_or_color(item: Mapping[str, Any]) -> bool:
names = light_action_names(item)
text = " ".join(names)
return any(_ON.fullmatch(name) for name in names) or bool(_OFF.search(text) or _COLOR.search(text))
def romantic_light_actions(catalog: Sequence[Mapping[str, Any]]) -> list[dict[str, Any]]:
"""Select every eligible light, with power before red and no color fallback."""
if not isinstance(catalog, Sequence) or isinstance(catalog, (str, bytes)):
return []
grouped: dict[str, list[Mapping[str, Any]]] = {}
identities: dict[str, str] = {}
for item in catalog:
if not isinstance(item, Mapping) or item.get("enabled", True) is False:
continue
if item.get("status", "learned") != "learned":
continue
symbolic = bool(item.get("ref"))
if not symbolic and (
not item.get("card_uid") or not item.get("device_id")
or isinstance(item.get("slot"), bool) or not isinstance(item.get("slot"), int)
or not 1 <= item["slot"] <= 248
):
continue
name = str(item.get("device_label") or item.get("device_name") or "")
aliases = item.get("object_names", [])
aliases = aliases if isinstance(aliases, list) else []
identity = " ".join([name, *(str(x) for x in aliases if isinstance(x, str))])
if not _LIGHT.search(identity):
continue
key = str(item.get("device_key") or item.get("device_id") or name).casefold()
grouped.setdefault(key, []).append(item)
identities[key] = identity
selected = []
for key, actions in grouped.items():
red = next((item for item in actions
if _RED.search(" ".join(light_action_names(item)))
and not _OFF.search(" ".join(light_action_names(item)))), None)
if red is None and not _REQUIRED_POWER.search(identities[key]):
continue
activation = next((item for item in actions
if any(_ON.fullmatch(name) for name in light_action_names(item))
and not _OFF.search(" ".join(light_action_names(item)))
and not _COLOR.search(" ".join(light_action_names(item)))), None)
if activation is None:
continue
selected.append(deepcopy(dict(activation)))
if red is not None:
selected.append(deepcopy(dict(red)))
return selected
def romantic_light_override_refs(catalog: Sequence[Mapping[str, Any]]) -> set[str]:
"""Power/color refs superseded by the ordered romantic policy."""
refs = set()
for item in catalog:
if not isinstance(item, Mapping):
continue
identity = str(item.get("device_label") or item.get("device_name") or "")
if _LIGHT.search(identity) and light_power_or_color(item) and item.get("ref"):
refs.add(str(item["ref"]))
return refs
def replace_romantic_light_actions(
existing: list[dict[str, Any]], required: list[dict[str, Any]],
) -> list[dict[str, Any]]:
"""Replace old power/color commands for the selected devices, retaining other actions."""
def target(item):
return str(item.get("target_scope") or (item.get("target") or {}).get("device") or "")
devices = {target(item) for item in required}
retained = [item for item in existing if not (
item.get("capability") == "infrared.emit"
and target(item) in devices and light_power_or_color(item)
)]
return [*deepcopy(retained), *deepcopy(required)]
@@ -20,8 +20,9 @@ from cognitive_agent.application.mode_creation_seat_policy import (
normalized_seat_scope,
)
from cognitive_agent.application.mode_schedule import ModeSchedule
from cognitive_agent.application.romantic_lighting import romantic_light_actions, romantic_scene
SCENE_PLANNER_VERSION = "n7web-scene-composer/v9"
SCENE_PLANNER_VERSION = "n7web-scene-composer/v10"
_MAX_OUTPUT_SCHEMA_BYTES = 64 * 1024
_MAX_SCHEDULE_PHASE_REQUESTS = 12
@@ -392,6 +393,10 @@ def scene_required_peripheral_refs(
"""
clean_name = " ".join(str(mode_name or "").strip().split())[:64]
if romantic_scene(clean_name):
return [str(item["ref"]) for item in romantic_light_actions(
context.get("peripheral_actions", [])
) if item.get("ref")]
themed_scene = _REQUIRED_DECORATIVE_LIGHT_SCENE.search(clean_name) is not None
if not themed_scene and not include_default_bundle:
return []
+82 -14
View File
@@ -60,6 +60,7 @@ from cognitive_agent.application.mode_device_additions import ( # noqa: E402
mode_device_additions,
)
from cognitive_agent.application.mode_profile import ( # noqa: E402
ModeOperation,
ModeProfile,
ModeProfileRegistry,
ModeTimeline,
@@ -95,6 +96,12 @@ from cognitive_agent.application.model_plan_adapter import ( # noqa: E402
from cognitive_agent.application.mqtt_ota_seat_executor import ( # noqa: E402
MqttNativeSeatExecutor,
)
from cognitive_agent.application.n7_seat_preset import expand_n7_seat_preset # noqa: E402
from cognitive_agent.application.romantic_lighting import ( # noqa: E402
romantic_light_actions,
romantic_scene,
replace_romantic_light_actions,
)
from cognitive_agent.application.native_can_feedback import ( # noqa: E402
VehicleSafetyPreflightError,
authoritative_physical_ch1_feedback_state,
@@ -975,6 +982,9 @@ class SeatDemoService(ModeScheduleServiceMixin):
selected_devices: set[str] = set()
result: list[dict[str, Any]] = []
if romantic_scene(self._n7_scene_identity_text(scene)):
return [as_scene_action(item, category="romantic-light", kind="required")
for item in romantic_light_actions(raw_catalog)]
color_patterns = self._cinema_preferred_color_patterns(scene)
for category, identity_pattern in (
("flashing-light", N7_FLASHING_LIGHT_PATTERN),
@@ -1121,6 +1131,14 @@ class SeatDemoService(ModeScheduleServiceMixin):
document = deepcopy(dict(scene))
cinema_augmentation = self._augment_n7_cinema_scene(document)
if romantic_scene(self._n7_scene_identity_text(document)):
document["actions"] = replace_romantic_light_actions(
document.get("actions", []), self._n7_cinema_peripheral_actions(document)
)
document["operation_count"] = len(document["actions"])
document["domains"] = list(dict.fromkeys(
str(action.get("domain") or "") for action in document["actions"]
))
actions = document.get("actions")
if not isinstance(actions, list) or not actions:
raise ValueError("from_n7/scenes card must contain executable actions")
@@ -1440,10 +1458,16 @@ class SeatDemoService(ModeScheduleServiceMixin):
def _mode_requests_and_n7_transfers(
self,
profile: ModeProfile,
vehicle_id: str | None = None,
) -> tuple[list[dict[str, Any]], list[dict[str, Any]]]:
"""Separate locally understood operations from N7-only capabilities."""
requests = self.mode_profiles.requests_for_profiles([profile])
vehicle = (
self.vehicle_profiles.get(vehicle_id)
if any(op.capability == "local_scene.seat_preset" for op in profile.operations)
else None
)
if profile.sync_origin != MQTT_MODE_SCENE_INGRESS_TOPIC:
stored_transfer_actions = (
profile.command_flow.get("n7_transfer_actions")
@@ -1473,6 +1497,7 @@ class SeatDemoService(ModeScheduleServiceMixin):
for action in transfer_actions
}
local_requests = []
adapted_selectors = set()
for operation, request in zip(
profile.operations,
requests,
@@ -1490,9 +1515,35 @@ class SeatDemoService(ModeScheduleServiceMixin):
operation.operation,
operation.parameters,
)
source_action = next((
action for action in transfer_actions
if self._n7_transfer_operation_selector(
action.get("domain"), action.get("capability"),
action.get("target_scope"), action.get("operation"),
action.get("parameters"),
) == selector
), operation.to_yaml())
expanded = (
expand_n7_seat_preset(source_action, vehicle) if vehicle is not None else None
)
if expanded is not None:
local_requests.extend(
ModeOperation.from_dict(item).to_request(profile.name)
for item in expanded
)
adapted_selectors.add(selector)
continue
if selector in transfer_selectors:
continue
local_requests.append(request)
transfer_actions = [
action for action in transfer_actions
if self._n7_transfer_operation_selector(
action.get("domain"), action.get("capability"),
action.get("target_scope"), action.get("operation"),
action.get("parameters"),
) not in adapted_selectors
]
return local_requests, transfer_actions
source_scene = (
profile.command_flow.get("source_scene")
@@ -1517,6 +1568,22 @@ class SeatDemoService(ModeScheduleServiceMixin):
# The server's durable NFC allowlist owns this local TCP path.
# Authorization failures must not be converted into N7 retries.
continue
raw_action = (
source_actions[index]
if index < len(source_actions)
and isinstance(source_actions[index], dict)
else self._fallback_scene_action(operation, index=index)
)
# Use the saved operation as authority; source metadata supplies timing only.
expanded = expand_n7_seat_preset(
{**operation.to_yaml(), "at_ms": raw_action.get("at_ms", 0)}, vehicle
) if vehicle is not None else None
if expanded is not None:
local_requests.extend(
ModeOperation.from_dict(item).to_request(profile.name)
for item in expanded
)
continue
registered = self.model_plan_adapter.capabilities.get(
operation.capability
)
@@ -1532,20 +1599,21 @@ class SeatDemoService(ModeScheduleServiceMixin):
):
local_requests.append(request)
continue
raw_action = (
source_actions[index]
if index < len(source_actions)
and isinstance(source_actions[index], dict)
else None
)
action = (
deepcopy(raw_action)
if raw_action is not None
else self._fallback_scene_action(operation, index=index)
)
transfer_actions.append(action)
transfer_actions.append(deepcopy(raw_action))
return local_requests, transfer_actions
def mode_profile_execution_requests(
self, profile: ModeProfile, vehicle_id: str | None = None,
) -> list[dict[str, Any]]:
"""Expose the same expanded capability view used when executing a card."""
local, transfers = self._mode_requests_and_n7_transfers(profile, vehicle_id)
return [
*local,
*(ModeOperation.from_dict(item).to_request(profile.name) for item in transfers),
*(operation.to_request(profile.name) for operation in profile.operations
if operation.domain == "self_extension" and operation.capability == "infrared.emit"),
]
@staticmethod
def _has_local_mode_peripheral(profile: ModeProfile) -> bool:
return any(
@@ -3783,7 +3851,7 @@ class SeatDemoService(ModeScheduleServiceMixin):
)
if direct_mode_profile is not None:
mode_requests, n7_transfer_actions = (
self._mode_requests_and_n7_transfers(direct_mode_profile)
self._mode_requests_and_n7_transfers(direct_mode_profile, vehicle_profile.id)
)
if mode_creation_scopes is not None:
mode_requests = project_seed_operations(mode_requests, mode_creation_scopes)
@@ -5147,7 +5215,7 @@ class SeatDemoService(ModeScheduleServiceMixin):
matched_profile = matched_modes[0]
mode_requests, n7_transfer_actions = (
self._mode_requests_and_n7_transfers(matched_profile)
self._mode_requests_and_n7_transfers(matched_profile, vehicle_profile.id)
)
selected_seat_scope = self._explicit_mode_seat_selection(
matched_profile, utterance,
+40 -4
View File
@@ -74,6 +74,12 @@ from cognitive_agent.application.mode_scene_catalog import ( # noqa: E402
)
from cognitive_agent.application.pet_cards import PetCardService # noqa: E402
from cognitive_agent.application.mode_schedule_service import public_schedule_status # noqa: E402
from cognitive_agent.application.romantic_lighting import ( # noqa: E402
romantic_light_actions,
romantic_scene,
replace_romantic_light_actions,
)
from cognitive_agent.application.mode_scene_ingress import MAX_MODE_SCENE_ACTIONS # noqa: E402
from cognitive_agent.application.seat_service import ( # noqa: E402
LOCAL_CONTROL_BUDGET_MS,
SeatDemoService,
@@ -1802,8 +1808,27 @@ class SeatDemoHandler(BaseHTTPRequestHandler):
"source_span": source_utterance,
}
)
if len(actions) > MAX_PLAN_REQUESTS:
raise ExtensionIntegrationError("模式草稿包含过多设备动作。")
# These are validated execution actions, not the model's raw requests.
# A saved seat preset expands to four axes, and IR colors need power-on
# prerequisites. Keep the model's 20-request gate at the input boundary.
if len(actions) > MAX_MODE_SCENE_ACTIONS:
message = f"模式草稿展开后包含 {len(actions)} 项动作,超过 {MAX_MODE_SCENE_ACTIONS} 项上限。"
failed = deepcopy(native_result)
self.service._scrub_failed_scene_preview(failed)
failed.update({
"status": "validation_failed", "mode_profile_draft": None,
"mode_creation_failure": {
"code": "MODE_DRAFT_ACTION_LIMIT", "reason": message,
"next_step": "请减少模式中的设备动作后重新生成。",
},
"decision_summary": [message],
"validation": [*list(native_result.get("validation") or []), {
"severity": "error", "code": "MODE_DRAFT_ACTION_LIMIT",
"path": "intent_ir.spec.actions", "message": message,
}],
})
self.service.persist_composed_result(failed)
raise ExtensionIntegrationError(message)
spec["actions"] = actions
spec["requests"] = requests
detected_domains = [
@@ -4241,10 +4266,21 @@ class SeatDemoHandler(BaseHTTPRequestHandler):
if isinstance(extension_config, dict)
else []
)
catalog = self.extension.list_peripheral_capabilities()
if romantic_scene(getattr(profile, "name", "")):
required = [{
"id": f"romantic-light-{item['slot']}",
"domain": "self_extension", "capability": "infrared.emit",
"operation": "emit",
"target": {
"device": item["device_id"], "name": item.get("device_name", ""),
"card_uid": item["card_uid"],
},
"parameters": {"action": item["action"], "slot": item["slot"]},
} for item in romantic_light_actions(catalog)]
operations = replace_romantic_light_actions(operations, required)
if not operations:
return []
catalog = self.extension.list_peripheral_capabilities()
entries: list[dict[str, object]] = []
for index, operation in enumerate(operations):
if not isinstance(operation, dict) or operation.get("capability") != "infrared.emit":