fix(scenes): restore cinema seat controls and ordered romantic lighting
This commit is contained in:
@@ -1,5 +1,25 @@
|
|||||||
# Web Console
|
# Web Console
|
||||||
|
|
||||||
|
## 浪漫相关模式的外设灯光
|
||||||
|
|
||||||
|
浪漫、约会、纪念日及浪漫影院等模式使用当前持久授权的红外目录:打开灯笼、蜡烛和灯带,
|
||||||
|
所有具有红色指令的外设灯光统一设置为红色。每台设备均先发送开启指令,再发送红色指令;
|
||||||
|
开启下发失败时终止后续序列。缺少开启能力的设备不能只发送颜色指令,缺少红色能力时
|
||||||
|
不以其他颜色替代。新模式生成、车端卡片导入及已有卡片的执行入口均使用该规则,
|
||||||
|
原生座椅、空调等场景动作保持原有定义。
|
||||||
|
|
||||||
|
## 车端影院模式的副驾姿态
|
||||||
|
|
||||||
|
从 `from_n7/scenes` 同步的 `local_scene.seat_preset` 会在执行时按 N7 的 M0–M3
|
||||||
|
标定转换为副驾前后、坐盆、靠背、腿托四项位置控制,沿用现有座椅安全校验和执行器。
|
||||||
|
例如 `[246, 112, 405, 342]` 脉冲转换为当前百分比协议的 `[51, 30, 44, 53]`。
|
||||||
|
原始脉冲保留在卡片中,已保存的影院模式及继承该预设的本地模式无需重新上传。
|
||||||
|
不完整、越界或错误座位的预设会被拒绝;带延时的预设继续交由车端处理。
|
||||||
|
视频启动等车端专属动作仍走 `transto_n7/ctl`,不会重复转发已由 PC 处理的四轴姿态。
|
||||||
|
|
||||||
|
模式卡片区分完整成功、部分处理、指令已提交待车端确认及模拟校验,消息入队不会显示为
|
||||||
|
车辆执行完成。
|
||||||
|
|
||||||
## 座椅固件开发工作台
|
## 座椅固件开发工作台
|
||||||
|
|
||||||
“开发指令”支持输入“增加动态木马功能”,也可点击位于位置控制、座椅通风、座椅加热之后的
|
“开发指令”支持输入“增加动态木马功能”,也可点击位于位置控制、座椅通风、座椅加热之后的
|
||||||
|
|||||||
+40
-14
@@ -7049,12 +7049,16 @@ function setModeExecutionState(modeProfileId, modeState = "idle") {
|
|||||||
? "已停止"
|
? "已停止"
|
||||||
: active && modeState === "succeeded"
|
: active && modeState === "succeeded"
|
||||||
? "已完成"
|
? "已完成"
|
||||||
|
: active && modeState === "submitted"
|
||||||
|
? "待车端确认"
|
||||||
|
: active && modeState === "partial"
|
||||||
|
? "部分处理"
|
||||||
: active && modeState === "failed"
|
: active && modeState === "failed"
|
||||||
? "执行失败"
|
? "执行失败"
|
||||||
: "点击执行";
|
: "点击执行";
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
if (modeProfileId && ["succeeded", "failed", "started", "ended", "stopped"].includes(modeState)) {
|
if (modeProfileId && ["succeeded", "failed", "started", "ended", "stopped", "submitted", "partial"].includes(modeState)) {
|
||||||
state.modeRunTimer = window.setTimeout(() => setModeExecutionState(null), 2800);
|
state.modeRunTimer = window.setTimeout(() => setModeExecutionState(null), 2800);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -7073,6 +7077,35 @@ function hasExecutableModeCommandFlow(mode) {
|
|||||||
|| (plans && typeof plans === "object" && Object.keys(plans).length > 0);
|
|| (plans && typeof plans === "object" && Object.keys(plans).length > 0);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function savedModeExecutionView(mode, result) {
|
||||||
|
const name = mode?.name || "功能模式";
|
||||||
|
if (modeScheduleExecutionView(result)?.state === "processing") {
|
||||||
|
return { state: "started", title: `${name}时间轴已启动`, toast: "时间轴已启动,可输入“停止动态模式”提前结束" };
|
||||||
|
}
|
||||||
|
if (!["succeeded", "partially_succeeded"].includes(result.status)) {
|
||||||
|
return { state: "failed", title: `${name}执行未完成`, toast: `${name}未完成,请查看执行结果` };
|
||||||
|
}
|
||||||
|
const preview = result.execution_mode === "dry_run";
|
||||||
|
const waiting = !preview && executionVerificationStatus(result) === "submitted_unverified";
|
||||||
|
if (result.status === "partially_succeeded") {
|
||||||
|
return {
|
||||||
|
state: "partial",
|
||||||
|
title: `${name}${preview ? "模拟校验部分通过" : waiting ? "部分指令已提交,待车端确认" : "部分完成"}`,
|
||||||
|
toast: preview
|
||||||
|
? `${name}模拟校验部分通过,仍有未由 PC 执行的动作`
|
||||||
|
: `${name}仅部分处理成功,请查看各项指令和车端转发状态`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
if (waiting) {
|
||||||
|
return { state: "submitted", title: `${name}指令已提交,待车端确认`, toast: "指令已提交,尚未确认车辆执行完成" };
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
state: "succeeded",
|
||||||
|
title: `${name}${preview ? "模拟校验完成" : "执行完成"}`,
|
||||||
|
toast: `${name}${preview ? "模拟校验完成" : "已完成保存的控制指令流"}`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
async function executeSavedModeProfile(mode, { command = "" } = {}) {
|
async function executeSavedModeProfile(mode, { command = "" } = {}) {
|
||||||
if (!hasExecutableModeCommandFlow(mode)) return false;
|
if (!hasExecutableModeCommandFlow(mode)) return false;
|
||||||
const modeCommand = String(command || `进入${mode.name || mode.id}`).trim();
|
const modeCommand = String(command || `进入${mode.name || mode.id}`).trim();
|
||||||
@@ -7125,14 +7158,9 @@ async function executeSavedModeProfile(mode, { command = "" } = {}) {
|
|||||||
);
|
);
|
||||||
if (!payload) throw new Error("模式卡片未返回执行结果");
|
if (!payload) throw new Error("模式卡片未返回执行结果");
|
||||||
renderResult(payload);
|
renderResult(payload);
|
||||||
const scheduleRunning = modeScheduleExecutionView(payload)?.state === "processing";
|
const executionView = savedModeExecutionView(mode, payload);
|
||||||
const completed = ["succeeded", "partially_succeeded"].includes(payload.status);
|
$("#resultTitle").textContent = executionView.title;
|
||||||
$("#resultTitle").textContent = scheduleRunning
|
setModeExecutionState(mode.id, executionView.state);
|
||||||
? `${mode.name}时间轴已启动`
|
|
||||||
: completed
|
|
||||||
? `${mode.name}执行完成`
|
|
||||||
: `${mode.name}执行未完成`;
|
|
||||||
setModeExecutionState(mode.id, scheduleRunning ? "started" : completed ? "succeeded" : "failed");
|
|
||||||
if (assistantModeHandle && assistant?.completeModeExecution) {
|
if (assistantModeHandle && assistant?.completeModeExecution) {
|
||||||
assistant.completeModeExecution(assistantModeHandle, mode, payload, {
|
assistant.completeModeExecution(assistantModeHandle, mode, payload, {
|
||||||
command: modeCommand,
|
command: modeCommand,
|
||||||
@@ -7141,11 +7169,7 @@ async function executeSavedModeProfile(mode, { command = "" } = {}) {
|
|||||||
assistant?.announceModeExecution?.(mode, payload, { command: modeCommand });
|
assistant?.announceModeExecution?.(mode, payload, { command: modeCommand });
|
||||||
}
|
}
|
||||||
if (state.executionMode === "hardware") await synchronizePhysicalSeatState();
|
if (state.executionMode === "hardware") await synchronizePhysicalSeatState();
|
||||||
showToast(scheduleRunning
|
showToast(executionView.toast);
|
||||||
? "时间轴已启动,可输入“停止动态模式”提前结束"
|
|
||||||
: completed
|
|
||||||
? `${mode.name}已直接执行保存的控制指令流`
|
|
||||||
: `${mode.name}未完成,请查看执行结果`);
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
setModeExecutionState(mode.id, "failed");
|
setModeExecutionState(mode.id, "failed");
|
||||||
setRunStatus("failed", "MODE FAILED");
|
setRunStatus("failed", "MODE FAILED");
|
||||||
@@ -8926,6 +8950,8 @@ function renderResult(result) {
|
|||||||
? `${result.mode_profile?.name ? `${result.mode_profile.name} · ` : ""}${scheduleExecution.title}`
|
? `${result.mode_profile?.name ? `${result.mode_profile.name} · ` : ""}${scheduleExecution.title}`
|
||||||
: peripheralRoute && executionSucceeded
|
: peripheralRoute && executionSucceeded
|
||||||
? `${result.peripheral?.name || "第三方外设"} · ${result.peripheral?.action || "动作已执行"}`
|
? `${result.peripheral?.name || "第三方外设"} · ${result.peripheral?.action || "动作已执行"}`
|
||||||
|
: savedModeExecution && result.mode_profile
|
||||||
|
? savedModeExecutionView(result.mode_profile, result).title
|
||||||
: partiallySucceeded
|
: partiallySucceeded
|
||||||
? `${domains.length} 域场景部分完成`
|
? `${domains.length} 域场景部分完成`
|
||||||
: executionSucceeded
|
: executionSucceeded
|
||||||
|
|||||||
@@ -39,7 +39,7 @@
|
|||||||
<script src="/vendor/three/0.147.0/fflate.min.js" defer></script>
|
<script src="/vendor/three/0.147.0/fflate.min.js" defer></script>
|
||||||
<script src="/vendor/three/0.147.0/FBXLoader.js" defer></script>
|
<script src="/vendor/three/0.147.0/FBXLoader.js" defer></script>
|
||||||
<script src="/network.js?v=android-pad-2" defer></script>
|
<script src="/network.js?v=android-pad-2" defer></script>
|
||||||
<script src="/app.js?v=dual-seat-147-video-media" defer></script>
|
<script src="/app.js?v=dual-seat-148-seat-preset" defer></script>
|
||||||
<script src="/speech-input.js?v=sensevoice-13" defer></script>
|
<script src="/speech-input.js?v=sensevoice-13" defer></script>
|
||||||
<script type="module" src="/gesture-control.js?v=android-pad-9"></script>
|
<script type="module" src="/gesture-control.js?v=android-pad-9"></script>
|
||||||
<script src="/pwa.js?v=android-pad-3" defer></script>
|
<script src="/pwa.js?v=android-pad-3" defer></script>
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
const CACHE_VERSION = "cognitive-agent-pad-v335-firmware-rocking-horse";
|
const CACHE_VERSION = "cognitive-agent-pad-v336-seat-preset";
|
||||||
const SHELL_CACHE = `${CACHE_VERSION}-shell`;
|
const SHELL_CACHE = `${CACHE_VERSION}-shell`;
|
||||||
const RUNTIME_CACHE = `${CACHE_VERSION}-runtime`;
|
const RUNTIME_CACHE = `${CACHE_VERSION}-runtime`;
|
||||||
const APP_SHELL = [
|
const APP_SHELL = [
|
||||||
@@ -6,7 +6,7 @@ const APP_SHELL = [
|
|||||||
"/index.html",
|
"/index.html",
|
||||||
"/styles.css?v=dual-seat-24-dialog-size",
|
"/styles.css?v=dual-seat-24-dialog-size",
|
||||||
"/network.js?v=android-pad-2",
|
"/network.js?v=android-pad-2",
|
||||||
"/app.js?v=dual-seat-147-video-media",
|
"/app.js?v=dual-seat-148-seat-preset",
|
||||||
"/gesture-control.js?v=android-pad-9",
|
"/gesture-control.js?v=android-pad-9",
|
||||||
"/pwa.js?v=android-pad-3",
|
"/pwa.js?v=android-pad-3",
|
||||||
"/console-v2.css?v=delivery-state-69",
|
"/console-v2.css?v=delivery-state-69",
|
||||||
|
|||||||
@@ -1760,6 +1760,9 @@ class AssistantCapabilityCatalog:
|
|||||||
peripheral_actions.append(
|
peripheral_actions.append(
|
||||||
{
|
{
|
||||||
"ref": peripheral_capability_ref(raw),
|
"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_label": device_name,
|
||||||
"device_type": device_type,
|
"device_type": device_type,
|
||||||
"object_names": _safe_string_list(raw.get("object_names"), limit=12),
|
"object_names": _safe_string_list(raw.get("object_names"), limit=12),
|
||||||
@@ -2443,7 +2446,12 @@ class AssistantCapabilityCatalog:
|
|||||||
continue
|
continue
|
||||||
try:
|
try:
|
||||||
profile = self._vehicle.mode_profiles.get_profile(mode_id, vehicle_id)
|
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
|
except Exception: # noqa: BLE001 - a stale user mode is not executable
|
||||||
requests = []
|
requests = []
|
||||||
operation_ids = [
|
operation_ids = [
|
||||||
@@ -2491,7 +2499,7 @@ class AssistantCapabilityCatalog:
|
|||||||
"aliases": _safe_string_list(raw.get("aliases"), limit=16),
|
"aliases": _safe_string_list(raw.get("aliases"), limit=16),
|
||||||
"description": _safe_text(raw.get("description"), limit=240),
|
"description": _safe_text(raw.get("description"), limit=240),
|
||||||
"domains": _safe_string_list(raw.get("domains"), limit=16),
|
"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),
|
"origin": _safe_text(raw.get("origin"), limit=32),
|
||||||
"operation_ids": operation_ids,
|
"operation_ids": operation_ids,
|
||||||
"operations": operations,
|
"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 import ModeSchedule
|
||||||
from cognitive_agent.application.mode_schedule_service import public_schedule_status
|
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 (
|
from cognitive_agent.application.scene_composer import (
|
||||||
SCENE_COMPOSER_INSTRUCTIONS,
|
SCENE_COMPOSER_INSTRUCTIONS,
|
||||||
SCENE_PLANNER_VERSION,
|
SCENE_PLANNER_VERSION,
|
||||||
@@ -1517,6 +1522,17 @@ class CodexAssistantService:
|
|||||||
requests = normalized.get("requests")
|
requests = normalized.get("requests")
|
||||||
if not isinstance(requests, list):
|
if not isinstance(requests, list):
|
||||||
raise SceneCompositionError("scene plan requests must be an array")
|
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 = {
|
existing_refs = {
|
||||||
str(parameter.get("value") or "").strip()
|
str(parameter.get("value") or "").strip()
|
||||||
for request in requests
|
for request in requests
|
||||||
@@ -4317,13 +4333,17 @@ class CodexAssistantService:
|
|||||||
secret=secret,
|
secret=secret,
|
||||||
)
|
)
|
||||||
actions: list[dict[str, object]] = []
|
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")
|
intent_ir = result.get("intent_ir")
|
||||||
if isinstance(intent_ir, dict):
|
if isinstance(intent_ir, dict):
|
||||||
spec = intent_ir.get("spec")
|
spec = intent_ir.get("spec")
|
||||||
if isinstance(spec, dict) and isinstance(spec.get("actions"), list):
|
if isinstance(spec, dict) and isinstance(spec.get("actions"), list):
|
||||||
for item in spec["actions"][
|
for item in spec["actions"][:action_limit]:
|
||||||
:96 if result.get("schedule") else MAX_PLAN_REQUESTS
|
|
||||||
]:
|
|
||||||
if not isinstance(item, dict):
|
if not isinstance(item, dict):
|
||||||
continue
|
continue
|
||||||
safe_action = {
|
safe_action = {
|
||||||
@@ -4351,7 +4371,7 @@ class CodexAssistantService:
|
|||||||
infrared_actions: list[dict[str, object]] = []
|
infrared_actions: list[dict[str, object]] = []
|
||||||
raw_infrared = result.get("infrared_actions")
|
raw_infrared = result.get("infrared_actions")
|
||||||
if isinstance(raw_infrared, list):
|
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):
|
if not isinstance(item, dict):
|
||||||
continue
|
continue
|
||||||
infrared_actions.append(
|
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,
|
normalized_seat_scope,
|
||||||
)
|
)
|
||||||
from cognitive_agent.application.mode_schedule import ModeSchedule
|
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_OUTPUT_SCHEMA_BYTES = 64 * 1024
|
||||||
_MAX_SCHEDULE_PHASE_REQUESTS = 12
|
_MAX_SCHEDULE_PHASE_REQUESTS = 12
|
||||||
@@ -392,6 +393,10 @@ def scene_required_peripheral_refs(
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
clean_name = " ".join(str(mode_name or "").strip().split())[:64]
|
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
|
themed_scene = _REQUIRED_DECORATIVE_LIGHT_SCENE.search(clean_name) is not None
|
||||||
if not themed_scene and not include_default_bundle:
|
if not themed_scene and not include_default_bundle:
|
||||||
return []
|
return []
|
||||||
|
|||||||
@@ -60,6 +60,7 @@ from cognitive_agent.application.mode_device_additions import ( # noqa: E402
|
|||||||
mode_device_additions,
|
mode_device_additions,
|
||||||
)
|
)
|
||||||
from cognitive_agent.application.mode_profile import ( # noqa: E402
|
from cognitive_agent.application.mode_profile import ( # noqa: E402
|
||||||
|
ModeOperation,
|
||||||
ModeProfile,
|
ModeProfile,
|
||||||
ModeProfileRegistry,
|
ModeProfileRegistry,
|
||||||
ModeTimeline,
|
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
|
from cognitive_agent.application.mqtt_ota_seat_executor import ( # noqa: E402
|
||||||
MqttNativeSeatExecutor,
|
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
|
from cognitive_agent.application.native_can_feedback import ( # noqa: E402
|
||||||
VehicleSafetyPreflightError,
|
VehicleSafetyPreflightError,
|
||||||
authoritative_physical_ch1_feedback_state,
|
authoritative_physical_ch1_feedback_state,
|
||||||
@@ -975,6 +982,9 @@ class SeatDemoService(ModeScheduleServiceMixin):
|
|||||||
|
|
||||||
selected_devices: set[str] = set()
|
selected_devices: set[str] = set()
|
||||||
result: list[dict[str, Any]] = []
|
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)
|
color_patterns = self._cinema_preferred_color_patterns(scene)
|
||||||
for category, identity_pattern in (
|
for category, identity_pattern in (
|
||||||
("flashing-light", N7_FLASHING_LIGHT_PATTERN),
|
("flashing-light", N7_FLASHING_LIGHT_PATTERN),
|
||||||
@@ -1121,6 +1131,14 @@ class SeatDemoService(ModeScheduleServiceMixin):
|
|||||||
|
|
||||||
document = deepcopy(dict(scene))
|
document = deepcopy(dict(scene))
|
||||||
cinema_augmentation = self._augment_n7_cinema_scene(document)
|
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")
|
actions = document.get("actions")
|
||||||
if not isinstance(actions, list) or not actions:
|
if not isinstance(actions, list) or not actions:
|
||||||
raise ValueError("from_n7/scenes card must contain executable actions")
|
raise ValueError("from_n7/scenes card must contain executable actions")
|
||||||
@@ -1440,10 +1458,16 @@ class SeatDemoService(ModeScheduleServiceMixin):
|
|||||||
def _mode_requests_and_n7_transfers(
|
def _mode_requests_and_n7_transfers(
|
||||||
self,
|
self,
|
||||||
profile: ModeProfile,
|
profile: ModeProfile,
|
||||||
|
vehicle_id: str | None = None,
|
||||||
) -> tuple[list[dict[str, Any]], list[dict[str, Any]]]:
|
) -> tuple[list[dict[str, Any]], list[dict[str, Any]]]:
|
||||||
"""Separate locally understood operations from N7-only capabilities."""
|
"""Separate locally understood operations from N7-only capabilities."""
|
||||||
|
|
||||||
requests = self.mode_profiles.requests_for_profiles([profile])
|
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:
|
if profile.sync_origin != MQTT_MODE_SCENE_INGRESS_TOPIC:
|
||||||
stored_transfer_actions = (
|
stored_transfer_actions = (
|
||||||
profile.command_flow.get("n7_transfer_actions")
|
profile.command_flow.get("n7_transfer_actions")
|
||||||
@@ -1473,6 +1497,7 @@ class SeatDemoService(ModeScheduleServiceMixin):
|
|||||||
for action in transfer_actions
|
for action in transfer_actions
|
||||||
}
|
}
|
||||||
local_requests = []
|
local_requests = []
|
||||||
|
adapted_selectors = set()
|
||||||
for operation, request in zip(
|
for operation, request in zip(
|
||||||
profile.operations,
|
profile.operations,
|
||||||
requests,
|
requests,
|
||||||
@@ -1490,9 +1515,35 @@ class SeatDemoService(ModeScheduleServiceMixin):
|
|||||||
operation.operation,
|
operation.operation,
|
||||||
operation.parameters,
|
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:
|
if selector in transfer_selectors:
|
||||||
continue
|
continue
|
||||||
local_requests.append(request)
|
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
|
return local_requests, transfer_actions
|
||||||
source_scene = (
|
source_scene = (
|
||||||
profile.command_flow.get("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.
|
# The server's durable NFC allowlist owns this local TCP path.
|
||||||
# Authorization failures must not be converted into N7 retries.
|
# Authorization failures must not be converted into N7 retries.
|
||||||
continue
|
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(
|
registered = self.model_plan_adapter.capabilities.get(
|
||||||
operation.capability
|
operation.capability
|
||||||
)
|
)
|
||||||
@@ -1532,20 +1599,21 @@ class SeatDemoService(ModeScheduleServiceMixin):
|
|||||||
):
|
):
|
||||||
local_requests.append(request)
|
local_requests.append(request)
|
||||||
continue
|
continue
|
||||||
raw_action = (
|
transfer_actions.append(deepcopy(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)
|
|
||||||
return local_requests, transfer_actions
|
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
|
@staticmethod
|
||||||
def _has_local_mode_peripheral(profile: ModeProfile) -> bool:
|
def _has_local_mode_peripheral(profile: ModeProfile) -> bool:
|
||||||
return any(
|
return any(
|
||||||
@@ -3783,7 +3851,7 @@ class SeatDemoService(ModeScheduleServiceMixin):
|
|||||||
)
|
)
|
||||||
if direct_mode_profile is not None:
|
if direct_mode_profile is not None:
|
||||||
mode_requests, n7_transfer_actions = (
|
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:
|
if mode_creation_scopes is not None:
|
||||||
mode_requests = project_seed_operations(mode_requests, mode_creation_scopes)
|
mode_requests = project_seed_operations(mode_requests, mode_creation_scopes)
|
||||||
@@ -5147,7 +5215,7 @@ class SeatDemoService(ModeScheduleServiceMixin):
|
|||||||
|
|
||||||
matched_profile = matched_modes[0]
|
matched_profile = matched_modes[0]
|
||||||
mode_requests, n7_transfer_actions = (
|
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(
|
selected_seat_scope = self._explicit_mode_seat_selection(
|
||||||
matched_profile, utterance,
|
matched_profile, utterance,
|
||||||
|
|||||||
@@ -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.pet_cards import PetCardService # noqa: E402
|
||||||
from cognitive_agent.application.mode_schedule_service import public_schedule_status # 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
|
from cognitive_agent.application.seat_service import ( # noqa: E402
|
||||||
LOCAL_CONTROL_BUDGET_MS,
|
LOCAL_CONTROL_BUDGET_MS,
|
||||||
SeatDemoService,
|
SeatDemoService,
|
||||||
@@ -1802,8 +1808,27 @@ class SeatDemoHandler(BaseHTTPRequestHandler):
|
|||||||
"source_span": source_utterance,
|
"source_span": source_utterance,
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
if len(actions) > MAX_PLAN_REQUESTS:
|
# These are validated execution actions, not the model's raw requests.
|
||||||
raise ExtensionIntegrationError("模式草稿包含过多设备动作。")
|
# 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["actions"] = actions
|
||||||
spec["requests"] = requests
|
spec["requests"] = requests
|
||||||
detected_domains = [
|
detected_domains = [
|
||||||
@@ -4241,10 +4266,21 @@ class SeatDemoHandler(BaseHTTPRequestHandler):
|
|||||||
if isinstance(extension_config, dict)
|
if isinstance(extension_config, dict)
|
||||||
else []
|
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:
|
if not operations:
|
||||||
return []
|
return []
|
||||||
|
|
||||||
catalog = self.extension.list_peripheral_capabilities()
|
|
||||||
entries: list[dict[str, object]] = []
|
entries: list[dict[str, object]] = []
|
||||||
for index, operation in enumerate(operations):
|
for index, operation in enumerate(operations):
|
||||||
if not isinstance(operation, dict) or operation.get("capability") != "infrared.emit":
|
if not isinstance(operation, dict) or operation.get("capability") != "infrared.emit":
|
||||||
|
|||||||
@@ -846,6 +846,45 @@ operations:
|
|||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
|
def test_uploaded_seat_preset_executes_four_local_axes_through_mode_api(self) -> None:
|
||||||
|
scene = {
|
||||||
|
"id": "n7-cinema-seat-test", "name": "影院模式", "label": "影院模式",
|
||||||
|
"aliases": ["影院模式"], "description": "车端副驾预设", "icon": "film",
|
||||||
|
"origin": "headunit", "scope": {"vehicle_ids": ["denza_n7"]},
|
||||||
|
"actions": [{
|
||||||
|
"type": "seat_preset", "at_ms": 0, "domain": "local_vehicle",
|
||||||
|
"capability": "local_scene.seat_preset", "target_scope": "vehicle",
|
||||||
|
"operation": "set", "parameters": {"targets_pulse": [246, 112, 405, 342]},
|
||||||
|
}],
|
||||||
|
}
|
||||||
|
with tempfile.TemporaryDirectory() as directory:
|
||||||
|
registry = ModeProfileRegistry(Path(directory) / "presets", user_root=Path(directory) / "modes")
|
||||||
|
with patch.object(self.service, "mode_profiles", registry), patch.object(
|
||||||
|
self.service._vehicle_mqtt_transport, "publish_n7_control_transfer",
|
||||||
|
side_effect=AssertionError("dry run must not publish"),
|
||||||
|
):
|
||||||
|
self.service.import_n7_mode_scene(scene)
|
||||||
|
request = urllib.request.Request(
|
||||||
|
f"{self.base_url}/api/mode-profiles/n7-cinema-seat-test/execute",
|
||||||
|
method="POST", headers={"Content-Type": "application/json"},
|
||||||
|
data=json.dumps({
|
||||||
|
"vehicle_id": "denza_n7", "execution_mode": "dry_run",
|
||||||
|
"control_transport": "wireless_mqtt",
|
||||||
|
"client_type": "web_console_mode_card",
|
||||||
|
}).encode("utf-8"),
|
||||||
|
)
|
||||||
|
with self.opener.open(request, timeout=5) as response:
|
||||||
|
result = json.loads(response.read().decode("utf-8"))
|
||||||
|
self.assertEqual(result["status"], "succeeded")
|
||||||
|
self.assertEqual(result.get("n7_transfer_candidates", []), [])
|
||||||
|
operations = result["function_configs"]["seat"]["spec"]["operations"]
|
||||||
|
self.assertEqual(
|
||||||
|
[(op["capability"], op["parameters"]["position_pct"]) for op in operations],
|
||||||
|
[("seat.set_longitudinal_position", 51), ("seat.set_cushion_tilt", 30),
|
||||||
|
("seat.set_backrest_recline", 44), ("seat.set_legrest_height", 53)],
|
||||||
|
)
|
||||||
|
self.assertTrue(all(op["target"]["instance"] == "passenger" for op in operations))
|
||||||
|
|
||||||
def test_saved_mode_accepts_prefixed_self_extension_flow_key(self) -> None:
|
def test_saved_mode_accepts_prefixed_self_extension_flow_key(self) -> None:
|
||||||
with tempfile.TemporaryDirectory() as directory:
|
with tempfile.TemporaryDirectory() as directory:
|
||||||
extension = SelfExtensionService(
|
extension = SelfExtensionService(
|
||||||
@@ -997,7 +1036,7 @@ operations:
|
|||||||
def runtime_url(file_name: str, source_url: str) -> str:
|
def runtime_url(file_name: str, source_url: str) -> str:
|
||||||
hashed = runtime_assets.get(file_name)
|
hashed = runtime_assets.get(file_name)
|
||||||
return f"/{hashed}" if hashed else source_url
|
return f"/{hashed}" if hashed else source_url
|
||||||
with self.opener.open(f"{self.base_url}/app.js?v=dual-seat-147-video-media", timeout=3) as response:
|
with self.opener.open(f"{self.base_url}/app.js?v=dual-seat-148-seat-preset", timeout=3) as response:
|
||||||
app_script = response.read().decode("utf-8")
|
app_script = response.read().decode("utf-8")
|
||||||
with self.opener.open(
|
with self.opener.open(
|
||||||
f"{self.base_url}/gesture-control.js?v=android-pad-9", timeout=3
|
f"{self.base_url}/gesture-control.js?v=android-pad-9", timeout=3
|
||||||
@@ -1084,7 +1123,7 @@ operations:
|
|||||||
self.assertIn("CMD 0x431 · FB 0x388 · CH1", template)
|
self.assertIn("CMD 0x431 · FB 0x388 · CH1", template)
|
||||||
self.assertNotIn("透明车体 · 实体设备", template)
|
self.assertNotIn("透明车体 · 实体设备", template)
|
||||||
self.assertIn(
|
self.assertIn(
|
||||||
f'src="{runtime_url("app.js", "/app.js?v=dual-seat-147-video-media")}"',
|
f'src="{runtime_url("app.js", "/app.js?v=dual-seat-148-seat-preset")}"',
|
||||||
html,
|
html,
|
||||||
)
|
)
|
||||||
self.assertIn('import "../styles.css";', vite_entry)
|
self.assertIn('import "../styles.css";', vite_entry)
|
||||||
@@ -1433,7 +1472,7 @@ operations:
|
|||||||
self.assertIn(f'/{runtime_assets[asset_name]}', service_worker)
|
self.assertIn(f'/{runtime_assets[asset_name]}', service_worker)
|
||||||
else:
|
else:
|
||||||
self.assertIn(
|
self.assertIn(
|
||||||
'const CACHE_VERSION = "cognitive-agent-pad-v334-video-media"',
|
'const CACHE_VERSION = "cognitive-agent-pad-v336-seat-preset"',
|
||||||
service_worker,
|
service_worker,
|
||||||
)
|
)
|
||||||
self.assertIn('/console-v2.css?v=delivery-state-69', service_worker)
|
self.assertIn('/console-v2.css?v=delivery-state-69', service_worker)
|
||||||
@@ -1442,7 +1481,7 @@ operations:
|
|||||||
self.assertIn('/assistant-chat.js?v=codex-assistant-39-video-media', service_worker)
|
self.assertIn('/assistant-chat.js?v=codex-assistant-39-video-media', service_worker)
|
||||||
self.assertIn('/energy-monitor.css?v=energy-monitor-5-dialog-size', service_worker)
|
self.assertIn('/energy-monitor.css?v=energy-monitor-5-dialog-size', service_worker)
|
||||||
self.assertIn('/energy-monitor.js?v=energy-monitor-5-performance', service_worker)
|
self.assertIn('/energy-monitor.js?v=energy-monitor-5-performance', service_worker)
|
||||||
self.assertIn('"/app.js?v=dual-seat-147-video-media"', service_worker)
|
self.assertIn('"/app.js?v=dual-seat-148-seat-preset"', service_worker)
|
||||||
self.assertIn('/firmware-workbench-entry.css?v=2', service_worker)
|
self.assertIn('/firmware-workbench-entry.css?v=2', service_worker)
|
||||||
self.assertIn('/firmware-workbench-entry.js?v=3', service_worker)
|
self.assertIn('/firmware-workbench-entry.js?v=3', service_worker)
|
||||||
self.assertIn('/firmware-workbench-theme.js?v=1', service_worker)
|
self.assertIn('/firmware-workbench-theme.js?v=1', service_worker)
|
||||||
@@ -1910,7 +1949,7 @@ operations:
|
|||||||
) as response:
|
) as response:
|
||||||
extension_script = response.read().decode("utf-8")
|
extension_script = response.read().decode("utf-8")
|
||||||
with self.opener.open(
|
with self.opener.open(
|
||||||
f"{self.base_url}/app.js?v=dual-seat-147-video-media", timeout=3
|
f"{self.base_url}/app.js?v=dual-seat-148-seat-preset", timeout=3
|
||||||
) as response:
|
) as response:
|
||||||
app_script = response.read().decode("utf-8")
|
app_script = response.read().decode("utf-8")
|
||||||
|
|
||||||
|
|||||||
@@ -125,8 +125,10 @@ def _received_cinema_scene() -> dict[str, object]:
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("full_scene", [False, True])
|
||||||
def test_romantic_cinema_template_preserves_cinema_and_adds_authorized_light(
|
def test_romantic_cinema_template_preserves_cinema_and_adds_authorized_light(
|
||||||
tmp_path: Path,
|
tmp_path: Path,
|
||||||
|
full_scene: bool,
|
||||||
) -> None:
|
) -> None:
|
||||||
service = SeatDemoService(REPOSITORY_ROOT)
|
service = SeatDemoService(REPOSITORY_ROOT)
|
||||||
service.artifact_root = tmp_path / "runs"
|
service.artifact_root = tmp_path / "runs"
|
||||||
@@ -138,10 +140,41 @@ def test_romantic_cinema_template_preserves_cinema_and_adds_authorized_light(
|
|||||||
SimulationGateway(),
|
SimulationGateway(),
|
||||||
catalog_db_path=tmp_path / "infrared.sqlite3",
|
catalog_db_path=tmp_path / "infrared.sqlite3",
|
||||||
)
|
)
|
||||||
extension.sync_peripheral_catalog([_learned_light_power_on()])
|
|
||||||
received_cinema = _received_cinema_scene()
|
received_cinema = _received_cinema_scene()
|
||||||
|
lights = [_learned_light_power_on()]
|
||||||
|
if full_scene:
|
||||||
|
extra = [
|
||||||
|
*(('window', 'window.set_position', target, {"position_pct": 0})
|
||||||
|
for target in ('front_right', 'rear_left', 'rear_right')),
|
||||||
|
('roof', 'roof.set_shade_position', 'vehicle', {"position_pct": 0}),
|
||||||
|
('seat', 'seat.set_ventilation', 'passenger', {"state": "off"}),
|
||||||
|
('climate', 'climate.set_power', 'vehicle', {"state": "on"}),
|
||||||
|
('climate', 'climate.set_fan_level', 'vehicle', {"level": 2}),
|
||||||
|
('climate', 'climate.set_outlet_mode', 'vehicle', {"mode": "face"}),
|
||||||
|
('climate', 'climate.set_cycle_mode', 'vehicle', {"mode": "internal"}),
|
||||||
|
('climate', 'climate.set_control_mode', 'vehicle', {"mode": "manual"}),
|
||||||
|
('interior_light', 'interior_light.set_reading_power', 'all', {"state": "off"}),
|
||||||
|
('ambient_light', 'ambient_light.set_power', 'vehicle', {"state": "on"}),
|
||||||
|
('ambient_light', 'ambient_light.set_color_index', 'vehicle', {"color_index": 36}),
|
||||||
|
('ambient_light', 'ambient_light.set_brightness_level', 'vehicle', {"brightness_level": 4}),
|
||||||
|
]
|
||||||
|
received_cinema["actions"].extend({
|
||||||
|
"type": "control", "at_ms": 0, "domain": domain, "capability": capability,
|
||||||
|
"target_scope": target, "operation": "set", "parameters": parameters,
|
||||||
|
} for domain, capability, target, parameters in extra)
|
||||||
|
received_cinema["operation_count"] = len(received_cinema["actions"])
|
||||||
|
lights = [{
|
||||||
|
**_learned_light_power_on(), "id": f"ir-slot:{device}:{slot}",
|
||||||
|
"device_id": device, "device_name": name, "object_names": [name],
|
||||||
|
"action": action, "action_names": [action], "slot": slot,
|
||||||
|
} for device, name, action, slot in [
|
||||||
|
("lantern", "灯笼", "打开", 2), ("lantern", "灯笼", "红色", 3),
|
||||||
|
("strip", "灯带", "打开", 22), ("strip", "灯带", "红色", 23),
|
||||||
|
("candle", "蜡烛", "打开", 41),
|
||||||
|
]]
|
||||||
|
extension.sync_peripheral_catalog(lights)
|
||||||
service.import_n7_mode_scene(received_cinema)
|
service.import_n7_mode_scene(received_cinema)
|
||||||
stale_profile = service.mode_profiles.save_mode(
|
stale_profile = None if full_scene else service.mode_profiles.save_mode(
|
||||||
"浪漫影院模式",
|
"浪漫影院模式",
|
||||||
[
|
[
|
||||||
{
|
{
|
||||||
@@ -203,7 +236,7 @@ def test_romantic_cinema_template_preserves_cinema_and_adds_authorized_light(
|
|||||||
side_effect=AssertionError("mode creation must not emit infrared"),
|
side_effect=AssertionError("mode creation must not emit infrared"),
|
||||||
):
|
):
|
||||||
response = assistant.stream(
|
response = assistant.stream(
|
||||||
"创建浪漫影院模式",
|
"创建一个浪漫影院模式",
|
||||||
conversation_id="conv-romantic-cinema-template",
|
conversation_id="conv-romantic-cinema-template",
|
||||||
principal="romantic-cinema-template-test",
|
principal="romantic-cinema-template-test",
|
||||||
history=[],
|
history=[],
|
||||||
@@ -221,8 +254,11 @@ def test_romantic_cinema_template_preserves_cinema_and_adds_authorized_light(
|
|||||||
client.start_turn.assert_not_called()
|
client.start_turn.assert_not_called()
|
||||||
assert result["status"] == "succeeded"
|
assert result["status"] == "succeeded"
|
||||||
assert result["mode_profile_draft"]["generation_source"] == "scene_template_copy"
|
assert result["mode_profile_draft"]["generation_source"] == "scene_template_copy"
|
||||||
assert result["mode_profile_draft"]["existing_mode_id"] == stale_profile.id
|
if stale_profile is not None:
|
||||||
assert result["mode_profile_draft"]["operation_count"] == 3
|
assert result["mode_profile_draft"]["existing_mode_id"] == stale_profile.id
|
||||||
|
assert result["mode_profile_draft"]["operation_count"] == (25 if full_scene else 7)
|
||||||
|
safe_result = CodexAssistantService._safe_vehicle_result(result)
|
||||||
|
assert len(safe_result["actions"]) == (25 if full_scene else 7)
|
||||||
|
|
||||||
native_actions = {
|
native_actions = {
|
||||||
(
|
(
|
||||||
@@ -233,21 +269,35 @@ def test_romantic_cinema_template_preserves_cinema_and_adds_authorized_light(
|
|||||||
if action["domain"] != "self_extension"
|
if action["domain"] != "self_extension"
|
||||||
}
|
}
|
||||||
assert native_actions == {
|
assert native_actions == {
|
||||||
(action["capability"], action["target_scope"]): action["parameters"]
|
**{
|
||||||
for action in received_cinema["actions"]
|
(action["capability"], action["target_scope"]): action["parameters"]
|
||||||
if action["domain"] in {"window", "climate"}
|
for action in received_cinema["actions"]
|
||||||
|
if action["domain"] not in {"local_vehicle", "android"}
|
||||||
|
},
|
||||||
|
("seat.set_longitudinal_position", "passenger"): {"position_pct": 51},
|
||||||
|
("seat.set_cushion_tilt", "passenger"): {"position_pct": 30},
|
||||||
|
("seat.set_backrest_recline", "passenger"): {"position_pct": 44},
|
||||||
|
("seat.set_legrest_height", "passenger"): {"position_pct": 53},
|
||||||
}
|
}
|
||||||
assert result["n7_transfer_candidates"] == received_cinema["actions"][2:]
|
video = [action for action in received_cinema["actions"] if action["domain"] == "android"]
|
||||||
assert [action["slot"] for action in result["infrared_actions"]] == [1]
|
assert result["n7_transfer_candidates"] == video
|
||||||
|
slots = [action["slot"] for action in result["infrared_actions"]]
|
||||||
|
assert sorted(slots) == ([2, 3, 22, 23, 41] if full_scene else [1])
|
||||||
|
if full_scene:
|
||||||
|
assert slots.index(2) < slots.index(3)
|
||||||
|
assert slots.index(22) < slots.index(23)
|
||||||
|
|
||||||
draft = result["mode_profile_draft"]
|
draft = result["mode_profile_draft"]
|
||||||
saved = service.save_mode_profile_card(
|
arguments = (
|
||||||
stale_profile.id,
|
|
||||||
draft["name"],
|
draft["name"],
|
||||||
draft["instruction"],
|
draft["instruction"],
|
||||||
draft["source_request_id"],
|
draft["source_request_id"],
|
||||||
draft["vehicle_id"],
|
draft["vehicle_id"],
|
||||||
)
|
)
|
||||||
|
saved = (
|
||||||
|
service.save_mode_profile_card(stale_profile.id, *arguments)
|
||||||
|
if stale_profile is not None else service.create_mode_profile_card(*arguments)
|
||||||
|
)
|
||||||
profile = service.get_mode_profile_card(saved["mode_profile"]["id"], "denza_n7")
|
profile = service.get_mode_profile_card(saved["mode_profile"]["id"], "denza_n7")
|
||||||
saved_selectors = {
|
saved_selectors = {
|
||||||
(operation.capability, operation.target_scope)
|
(operation.capability, operation.target_scope)
|
||||||
@@ -256,13 +306,45 @@ def test_romantic_cinema_template_preserves_cinema_and_adds_authorized_light(
|
|||||||
assert saved_selectors == {
|
assert saved_selectors == {
|
||||||
(action["capability"], action["target_scope"])
|
(action["capability"], action["target_scope"])
|
||||||
for action in received_cinema["actions"]
|
for action in received_cinema["actions"]
|
||||||
} | {("infrared.emit", "romance-light")}
|
if action["capability"] != "local_scene.seat_preset"
|
||||||
|
} | {
|
||||||
|
("seat.set_longitudinal_position", "passenger"),
|
||||||
|
("seat.set_cushion_tilt", "passenger"),
|
||||||
|
("seat.set_backrest_recline", "passenger"),
|
||||||
|
("seat.set_legrest_height", "passenger"),
|
||||||
|
} | {("infrared.emit", item["device_id"]) for item in lights}
|
||||||
_local_requests, transfer_actions = service._mode_requests_and_n7_transfers(profile)
|
_local_requests, transfer_actions = service._mode_requests_and_n7_transfers(profile)
|
||||||
assert transfer_actions == received_cinema["actions"][2:]
|
assert transfer_actions == video
|
||||||
finally:
|
finally:
|
||||||
service.close()
|
service.close()
|
||||||
|
|
||||||
|
|
||||||
|
def test_expanded_mode_overflow_cannot_leave_a_saveable_partial_draft() -> None:
|
||||||
|
handler = object.__new__(SeatDemoHandler)
|
||||||
|
handler.service = SimpleNamespace(
|
||||||
|
_scrub_failed_scene_preview=SeatDemoService._scrub_failed_scene_preview,
|
||||||
|
persist_composed_result=MagicMock(),
|
||||||
|
)
|
||||||
|
native = {
|
||||||
|
"request_id": "req-expanded-overflow", "status": "succeeded",
|
||||||
|
"execution_mode": "dry_run", "mode_profile_draft": {"operation_count": 256},
|
||||||
|
"intent_ir": {"spec": {"actions": [
|
||||||
|
{"domain": "seat", "capability": "seat.set_ventilation"}
|
||||||
|
] * 256}},
|
||||||
|
}
|
||||||
|
infrared = {"function_configs": {"self_extension": {"spec": {"operations": [{
|
||||||
|
"target": {"device": "lamp"}, "parameters": {"action": "打开", "slot": 1},
|
||||||
|
}]}}}}
|
||||||
|
with patch.object(handler, "_merge_can_and_peripheral_results", return_value=native):
|
||||||
|
with pytest.raises(ExtensionIntegrationError, match="257.*256"):
|
||||||
|
handler._merge_scene_peripheral_preview("创建浪漫模式", native, infrared, [])
|
||||||
|
failed = handler.service.persist_composed_result.call_args.args[0]
|
||||||
|
assert failed["status"] == "validation_failed"
|
||||||
|
assert failed["mode_profile_draft"] is None
|
||||||
|
assert failed["command_plans"] == {}
|
||||||
|
assert failed["intent_ir"]["spec"]["actions"] == []
|
||||||
|
|
||||||
|
|
||||||
def test_scene_composer_mixed_can_ir_preview_save_and_revalidate(
|
def test_scene_composer_mixed_can_ir_preview_save_and_revalidate(
|
||||||
tmp_path: Path,
|
tmp_path: Path,
|
||||||
) -> None:
|
) -> None:
|
||||||
|
|||||||
@@ -27,7 +27,7 @@ def test_console_contains_ai_assistant_surface():
|
|||||||
def test_codex_assistant_assets_are_versioned_together_in_the_service_worker():
|
def test_codex_assistant_assets_are_versioned_together_in_the_service_worker():
|
||||||
service_worker = (ROOT / "apps/web-console/service-worker.js").read_text(encoding="utf-8")
|
service_worker = (ROOT / "apps/web-console/service-worker.js").read_text(encoding="utf-8")
|
||||||
assert (
|
assert (
|
||||||
'const CACHE_VERSION = "cognitive-agent-pad-v334-video-media"'
|
'const CACHE_VERSION = "cognitive-agent-pad-v336-seat-preset"'
|
||||||
in service_worker
|
in service_worker
|
||||||
)
|
)
|
||||||
assert '"/assistant-chat.css?v=codex-assistant-30"' in service_worker
|
assert '"/assistant-chat.css?v=codex-assistant-30"' in service_worker
|
||||||
|
|||||||
@@ -41,7 +41,7 @@ def test_energy_monitor_assets_are_versioned_and_cached() -> None:
|
|||||||
"/energy-monitor.js?v=energy-monitor-5-performance"
|
"/energy-monitor.js?v=energy-monitor-5-performance"
|
||||||
)
|
)
|
||||||
assert (
|
assert (
|
||||||
'const CACHE_VERSION = "cognitive-agent-pad-v334-video-media"'
|
'const CACHE_VERSION = "cognitive-agent-pad-v336-seat-preset"'
|
||||||
in service_worker
|
in service_worker
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -92,12 +92,12 @@ def test_performance_assets_have_a_new_cache_generation() -> None:
|
|||||||
worker = source("service-worker.js")
|
worker = source("service-worker.js")
|
||||||
|
|
||||||
for asset in (
|
for asset in (
|
||||||
"/app.js?v=dual-seat-147-video-media",
|
"/app.js?v=dual-seat-148-seat-preset",
|
||||||
"/console-v2.js?v=local-first-79-cinema-status",
|
"/console-v2.js?v=local-first-79-cinema-status",
|
||||||
"/self-extension.js?v=peripheral-cards-59",
|
"/self-extension.js?v=peripheral-cards-59",
|
||||||
"/energy-monitor.js?v=energy-monitor-5-performance",
|
"/energy-monitor.js?v=energy-monitor-5-performance",
|
||||||
):
|
):
|
||||||
assert asset in html
|
assert asset in html
|
||||||
assert f'"{asset}"' in worker
|
assert f'"{asset}"' in worker
|
||||||
assert 'const CACHE_VERSION = "cognitive-agent-pad-v334-video-media"' in worker
|
assert 'const CACHE_VERSION = "cognitive-agent-pad-v336-seat-preset"' in worker
|
||||||
assert '"/codepen-glass-theme.css?v=creative-cloud-6-performance"' in worker
|
assert '"/codepen-glass-theme.css?v=creative-cloud-6-performance"' in worker
|
||||||
|
|||||||
@@ -54,7 +54,7 @@ def test_theme_asset_versions_are_consistent_with_the_pwa_shell() -> None:
|
|||||||
assert source_asset in main
|
assert source_asset in main
|
||||||
assert f'"{cached_asset}"' in service_worker
|
assert f'"{cached_asset}"' in service_worker
|
||||||
assert 'import "./styles/design-system.css"' in main
|
assert 'import "./styles/design-system.css"' in main
|
||||||
assert 'const CACHE_VERSION = "cognitive-agent-pad-v334-video-media"' in service_worker
|
assert 'const CACHE_VERSION = "cognitive-agent-pad-v336-seat-preset"' in service_worker
|
||||||
|
|
||||||
|
|
||||||
def test_reference_glass_theme_covers_the_complete_workspace() -> None:
|
def test_reference_glass_theme_covers_the_complete_workspace() -> None:
|
||||||
|
|||||||
@@ -103,6 +103,45 @@ def node_runtime() -> str:
|
|||||||
raise AssertionError("Node.js is required to execute the mode gallery grouping contract")
|
raise AssertionError("Node.js is required to execute the mode gallery grouping contract")
|
||||||
|
|
||||||
|
|
||||||
|
def test_saved_mode_status_distinguishes_partial_submission_and_completion() -> None:
|
||||||
|
app = (WEB / "app.js").read_text(encoding="utf-8")
|
||||||
|
start = app.index("function savedModeExecutionView(")
|
||||||
|
end = app.index("\nasync function executeSavedModeProfile", start)
|
||||||
|
cases = [
|
||||||
|
{"status": "partially_succeeded", "execution_mode": "hardware"},
|
||||||
|
{"status": "partially_succeeded", "execution_mode": "hardware",
|
||||||
|
"execution_outcome": "submitted_unverified"},
|
||||||
|
{"status": "succeeded", "execution_mode": "hardware",
|
||||||
|
"execution_outcome": "submitted_unverified"},
|
||||||
|
{"status": "succeeded", "execution_mode": "hardware"},
|
||||||
|
{"status": "succeeded", "execution_mode": "dry_run",
|
||||||
|
"execution_outcome": "submitted_unverified"},
|
||||||
|
{"status": "partially_succeeded", "execution_mode": "dry_run"},
|
||||||
|
{"status": "failed", "execution_mode": "hardware"},
|
||||||
|
]
|
||||||
|
script = (
|
||||||
|
"function modeScheduleExecutionView() { return null; }\n"
|
||||||
|
"function executionVerificationStatus(result) { return result.execution_outcome; }\n"
|
||||||
|
+ app[start:end]
|
||||||
|
+ "\nconsole.log(JSON.stringify("
|
||||||
|
+ json.dumps(cases)
|
||||||
|
+ '.map(result => savedModeExecutionView({name: "影院模式"}, result))));'
|
||||||
|
)
|
||||||
|
completed = subprocess.run(
|
||||||
|
[node_runtime(), "-e", script], check=True, capture_output=True,
|
||||||
|
text=True, encoding="utf-8",
|
||||||
|
)
|
||||||
|
result = json.loads(completed.stdout)
|
||||||
|
assert [item["state"] for item in result] == [
|
||||||
|
"partial", "partial", "submitted", "succeeded", "succeeded", "partial", "failed",
|
||||||
|
]
|
||||||
|
assert [item["title"] for item in result] == [
|
||||||
|
"影院模式部分完成", "影院模式部分指令已提交,待车端确认",
|
||||||
|
"影院模式指令已提交,待车端确认", "影院模式执行完成",
|
||||||
|
"影院模式模拟校验完成", "影院模式模拟校验部分通过", "影院模式执行未完成",
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
def test_mode_gallery_classifies_profiles_into_exclusive_custom_static_dynamic_groups() -> None:
|
def test_mode_gallery_classifies_profiles_into_exclusive_custom_static_dynamic_groups() -> None:
|
||||||
app = (WEB / "app.js").read_text(encoding="utf-8")
|
app = (WEB / "app.js").read_text(encoding="utf-8")
|
||||||
start = app.index("const MODE_GALLERY_GROUPS")
|
start = app.index("const MODE_GALLERY_GROUPS")
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ from cognitive_agent.application.assistant_capabilities import (
|
|||||||
AssistantCapabilityCatalog,
|
AssistantCapabilityCatalog,
|
||||||
)
|
)
|
||||||
from cognitive_agent.application.mode_profile import ModeProfileRegistry
|
from cognitive_agent.application.mode_profile import ModeProfileRegistry
|
||||||
|
from cognitive_agent.application.n7_seat_preset import N7_SEAT_PRESET_CAPABILITIES
|
||||||
from cognitive_agent.application.mode_scene_ingress import (
|
from cognitive_agent.application.mode_scene_ingress import (
|
||||||
ModeSceneIngressConsumer,
|
ModeSceneIngressConsumer,
|
||||||
ModeSceneIngressError,
|
ModeSceneIngressError,
|
||||||
@@ -714,6 +715,8 @@ def test_mixed_n7_scene_compiles_every_local_action_and_preserves_transfer(
|
|||||||
) -> None:
|
) -> None:
|
||||||
service = configured_service(tmp_path)
|
service = configured_service(tmp_path)
|
||||||
scene = cinema_scene_card()
|
scene = cinema_scene_card()
|
||||||
|
scene["actions"].append(cinema_video_launch_action())
|
||||||
|
scene["operation_count"] = len(scene["actions"])
|
||||||
service.import_n7_mode_scene(scene)
|
service.import_n7_mode_scene(scene)
|
||||||
|
|
||||||
result = service.execute_mode_profile_card(
|
result = service.execute_mode_profile_card(
|
||||||
@@ -733,7 +736,21 @@ def test_mixed_n7_scene_compiles_every_local_action_and_preserves_transfer(
|
|||||||
for config in result["function_configs"].values()
|
for config in result["function_configs"].values()
|
||||||
for operation in config["spec"]["operations"]
|
for operation in config["spec"]["operations"]
|
||||||
]
|
]
|
||||||
assert len(compiled_operations) == 16
|
assert len(compiled_operations) == 20
|
||||||
|
positions = {
|
||||||
|
operation["capability"]: operation["parameters"]["position_pct"]
|
||||||
|
for operation in compiled_operations
|
||||||
|
if operation["capability"] in N7_SEAT_PRESET_CAPABILITIES
|
||||||
|
}
|
||||||
|
assert positions == dict(zip(N7_SEAT_PRESET_CAPABILITIES, [51, 30, 44, 53], strict=True))
|
||||||
|
hardware_plan = service.hardware_compiler.compile(result["function_configs"]["seat"])
|
||||||
|
moves = [
|
||||||
|
command["signals"] for command in hardware_plan["spec"]["commands"]
|
||||||
|
if command["message"] == "T2G_Seat_Move_Command_300"
|
||||||
|
]
|
||||||
|
assert [(move["Motor_Selector"], move["Opcode"], move["Param_A"]) for move in moves] == [
|
||||||
|
(0, 1, 5100), (1, 1, 3000), (2, 1, 4400), (3, 1, 5300),
|
||||||
|
]
|
||||||
assert all(
|
assert all(
|
||||||
operation.get("parse_source") == "saved_mode_command_flow"
|
operation.get("parse_source") == "saved_mode_command_flow"
|
||||||
for operation in compiled_operations
|
for operation in compiled_operations
|
||||||
@@ -743,15 +760,15 @@ def test_mixed_n7_scene_compiles_every_local_action_and_preserves_transfer(
|
|||||||
for operation in compiled_operations
|
for operation in compiled_operations
|
||||||
if operation["capability"] == "window.set_position"
|
if operation["capability"] == "window.set_position"
|
||||||
} == {"front_left", "front_right", "rear_left", "rear_right"}
|
} == {"front_left", "front_right", "rear_left", "rear_right"}
|
||||||
transferred = scene["actions"][5]
|
transferred = cinema_video_launch_action()
|
||||||
assert result["n7_transfer_candidates"] == [transferred]
|
assert result["n7_transfer_candidates"] == [transferred]
|
||||||
assert result["deferred_requests"] == [
|
assert result["deferred_requests"] == [
|
||||||
{
|
{
|
||||||
"domain": "local_vehicle",
|
"domain": "android",
|
||||||
"capability": "local_scene.seat_preset",
|
"capability": "android.launch_app",
|
||||||
"target_scope": "vehicle",
|
"target_scope": "headunit",
|
||||||
"desired_change": "set",
|
"desired_change": "launch",
|
||||||
"parameters": {"targets_pulse": [246, 112, 405, 342]},
|
"parameters": transferred["parameters"],
|
||||||
"reason": "当前 PC 未注册该能力,将由 N7 端继续处理。",
|
"reason": "当前 PC 未注册该能力,将由 N7 端继续处理。",
|
||||||
"execution_backend": "n7_vehicle_transfer",
|
"execution_backend": "n7_vehicle_transfer",
|
||||||
"rejection_code": "PC_CAPABILITY_UNAVAILABLE",
|
"rejection_code": "PC_CAPABILITY_UNAVAILABLE",
|
||||||
@@ -760,7 +777,7 @@ def test_mixed_n7_scene_compiles_every_local_action_and_preserves_transfer(
|
|||||||
]
|
]
|
||||||
assert len(compiled_operations) + len(result["n7_transfer_candidates"]) == len(
|
assert len(compiled_operations) + len(result["n7_transfer_candidates"]) == len(
|
||||||
scene["actions"]
|
scene["actions"]
|
||||||
)
|
) + 3
|
||||||
|
|
||||||
text_result = service.process(
|
text_result = service.process(
|
||||||
"进入Cinema mode模式",
|
"进入Cinema mode模式",
|
||||||
@@ -774,6 +791,10 @@ def test_mixed_n7_scene_compiles_every_local_action_and_preserves_transfer(
|
|||||||
assert text_result["mode_profile"] is None
|
assert text_result["mode_profile"] is None
|
||||||
assert [item["id"] for item in text_result["matched_modes"]] == ["cinema"]
|
assert [item["id"] for item in text_result["matched_modes"]] == ["cinema"]
|
||||||
assert len(text_result["n7_transfer_candidates"]) == 1
|
assert len(text_result["n7_transfer_candidates"]) == 1
|
||||||
|
assert {
|
||||||
|
operation["capability"]
|
||||||
|
for operation in text_result["function_configs"]["seat"]["spec"]["operations"]
|
||||||
|
} >= set(N7_SEAT_PRESET_CAPABILITIES)
|
||||||
assert service.mode_profiles.match_text(
|
assert service.mode_profiles.match_text(
|
||||||
"氛围灯设置为Cinema mode模式",
|
"氛围灯设置为Cinema mode模式",
|
||||||
vehicle_id="denza_n7",
|
vehicle_id="denza_n7",
|
||||||
@@ -786,8 +807,9 @@ def test_mixed_n7_scene_compiles_every_local_action_and_preserves_transfer(
|
|||||||
)
|
)
|
||||||
assert catalog_mode["assistant_callable"] is True
|
assert catalog_mode["assistant_callable"] is True
|
||||||
assert catalog_mode["n7_transfer_operation_ids"] == [
|
assert catalog_mode["n7_transfer_operation_ids"] == [
|
||||||
"local_scene.seat_preset"
|
"android.launch_app"
|
||||||
]
|
]
|
||||||
|
assert set(catalog_mode["operation_ids"]) >= set(N7_SEAT_PRESET_CAPABILITIES)
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.parametrize(
|
@pytest.mark.parametrize(
|
||||||
@@ -826,6 +848,59 @@ def test_registered_but_invalid_action_is_not_forwarded_to_n7(
|
|||||||
assert transfer_actions == []
|
assert transfer_actions == []
|
||||||
|
|
||||||
|
|
||||||
|
def test_existing_saved_cinema_cards_adapt_on_execution_without_reimport(tmp_path: Path) -> None:
|
||||||
|
service = configured_service(tmp_path)
|
||||||
|
scene = cinema_scene_card()
|
||||||
|
scene["actions"].append(cinema_video_launch_action())
|
||||||
|
service.import_n7_mode_scene(scene)
|
||||||
|
imported = service.get_mode_profile_card("cinema", "denza_n7")
|
||||||
|
legacy = service.mode_profiles.save_mode(
|
||||||
|
"旧版浪漫影院模式", [op.to_yaml() for op in imported.operations],
|
||||||
|
vehicle_id="denza_n7", instruction="进入旧版浪漫影院模式",
|
||||||
|
command_flow={
|
||||||
|
"plans": {"saved": {"spec": {"commands": [{"message": "saved"}]}}},
|
||||||
|
"n7_transfer_actions": [scene["actions"][5], cinema_video_launch_action()],
|
||||||
|
},
|
||||||
|
)
|
||||||
|
before = {path: path.read_bytes() for path in (tmp_path / "profiles").rglob("*.yaml")}
|
||||||
|
service.mode_profiles = ModeProfileRegistry(
|
||||||
|
tmp_path / "presets", user_root=tmp_path / "profiles"
|
||||||
|
)
|
||||||
|
publisher = MagicMock(side_effect=AssertionError("preview must not publish"))
|
||||||
|
service._vehicle_mqtt_transport.publish_n7_control_transfer = publisher
|
||||||
|
try:
|
||||||
|
for mode_id in ("cinema", legacy.id):
|
||||||
|
result = service.execute_mode_profile_card(
|
||||||
|
mode_id, execution_mode="dry_run", control_transport="wireless_mqtt"
|
||||||
|
)
|
||||||
|
assert result["status"] == "partially_succeeded"
|
||||||
|
assert result["n7_transfer_candidates"] == [cinema_video_launch_action()]
|
||||||
|
operations = result["function_configs"]["seat"]["spec"]["operations"]
|
||||||
|
assert [op["parameters"]["position_pct"] for op in operations
|
||||||
|
if op["capability"] in N7_SEAT_PRESET_CAPABILITIES] == [51, 30, 44, 53]
|
||||||
|
assert all(path.read_bytes() == value for path, value in before.items())
|
||||||
|
publisher.assert_not_called()
|
||||||
|
finally:
|
||||||
|
service.close()
|
||||||
|
|
||||||
|
|
||||||
|
def test_invalid_preset_blocks_the_card_instead_of_transferring_it(tmp_path: Path) -> None:
|
||||||
|
service = configured_service(tmp_path)
|
||||||
|
scene = cinema_scene_card()
|
||||||
|
scene["actions"][5]["parameters"]["targets_pulse"][0] = 9999
|
||||||
|
service.import_n7_mode_scene(scene)
|
||||||
|
publisher = MagicMock(side_effect=AssertionError("invalid motion must not publish"))
|
||||||
|
service._vehicle_mqtt_transport.publish_n7_control_transfer = publisher
|
||||||
|
try:
|
||||||
|
with pytest.raises(ValueError, match="0–480"):
|
||||||
|
service.execute_mode_profile_card(
|
||||||
|
"cinema", execution_mode="hardware", control_transport="wireless_mqtt"
|
||||||
|
)
|
||||||
|
publisher.assert_not_called()
|
||||||
|
finally:
|
||||||
|
service.close()
|
||||||
|
|
||||||
|
|
||||||
def test_frontend_marks_n7_synchronized_cards() -> None:
|
def test_frontend_marks_n7_synchronized_cards() -> None:
|
||||||
script = (REPOSITORY_ROOT / "apps" / "web-console" / "app.js").read_text(
|
script = (REPOSITORY_ROOT / "apps" / "web-console" / "app.js").read_text(
|
||||||
encoding="utf-8"
|
encoding="utf-8"
|
||||||
|
|||||||
@@ -0,0 +1,88 @@
|
|||||||
|
from copy import deepcopy
|
||||||
|
from dataclasses import replace
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from cognitive_agent.application.n7_seat_preset import (
|
||||||
|
N7_SEAT_PRESET_CAPABILITIES,
|
||||||
|
expand_n7_seat_preset,
|
||||||
|
)
|
||||||
|
from cognitive_agent.application.vehicle_profile import VehicleProfileRegistry
|
||||||
|
|
||||||
|
ROOT = Path(__file__).resolve().parents[2]
|
||||||
|
|
||||||
|
|
||||||
|
def preset(targets=None):
|
||||||
|
return {
|
||||||
|
"domain": "local_vehicle", "capability": "local_scene.seat_preset",
|
||||||
|
"target_scope": "vehicle", "operation": "set", "at_ms": 0,
|
||||||
|
"parameters": {"targets_pulse": [246, 112, 405, 342] if targets is None else targets},
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def vehicle():
|
||||||
|
return VehicleProfileRegistry(ROOT / "config" / "vehicles").get("denza_n7")
|
||||||
|
|
||||||
|
|
||||||
|
def test_preset_uses_vehicle_calibration_and_keeps_original_pulses(vehicle):
|
||||||
|
action = preset()
|
||||||
|
original = deepcopy(action)
|
||||||
|
operations = expand_n7_seat_preset(action, vehicle)
|
||||||
|
assert [item["capability"] for item in operations] == list(N7_SEAT_PRESET_CAPABILITIES)
|
||||||
|
assert [item["parameters"]["position_pct"] for item in operations] == [51, 30, 44, 53]
|
||||||
|
assert all(item["target_scope"] == "passenger" for item in operations)
|
||||||
|
assert action == original
|
||||||
|
calibrated = replace(vehicle, seat_motors={
|
||||||
|
**vehicle.seat_motors,
|
||||||
|
"longitudinal": replace(vehicle.seat_motors["longitudinal"], hall_total_pulses=500),
|
||||||
|
})
|
||||||
|
assert expand_n7_seat_preset(action, calibrated)[0]["parameters"] == {"position_pct": 49}
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("targets", [
|
||||||
|
[], [1, 2, 3], [1, 2, 3, 4, 5], "246,112,405,342",
|
||||||
|
[True, 112, 405, 342], [246.5, 112, 405, 342], ["246", 112, 405, 342],
|
||||||
|
[-1, 112, 405, 342], [481, 112, 405, 342], [246, 375, 405, 342],
|
||||||
|
[246, 112, 918, 342], [246, 112, 405, 648],
|
||||||
|
])
|
||||||
|
def test_invalid_pulses_are_rejected_without_clamping(vehicle, targets):
|
||||||
|
with pytest.raises(ValueError):
|
||||||
|
expand_n7_seat_preset(preset(targets), vehicle)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("field,value", [
|
||||||
|
("target_scope", "driver"), ("domain", "seat"), ("operation", "toggle"),
|
||||||
|
("parameters", {"targets_pulse": [246, 112, 405, 342], "speed": 100}),
|
||||||
|
])
|
||||||
|
def test_unrecognized_target_or_contract_cannot_move_a_seat(vehicle, field, value):
|
||||||
|
action = preset()
|
||||||
|
action[field] = value
|
||||||
|
with pytest.raises(ValueError):
|
||||||
|
expand_n7_seat_preset(action, vehicle)
|
||||||
|
|
||||||
|
|
||||||
|
def test_zero_and_small_targets_do_not_accidentally_home(vehicle):
|
||||||
|
assert [item["parameters"]["position_pct"] for item in expand_n7_seat_preset(
|
||||||
|
preset([0, 0, 0, 0]), vehicle
|
||||||
|
)] == [0, 0, 0, 0]
|
||||||
|
assert [item["parameters"]["position_pct"] for item in expand_n7_seat_preset(
|
||||||
|
preset([1, 1, 1, 1]), vehicle
|
||||||
|
)] == [1, 1, 1, 1]
|
||||||
|
assert [item["parameters"]["position_pct"] for item in expand_n7_seat_preset(
|
||||||
|
preset([480, 374, 917, 647]), vehicle
|
||||||
|
)] == [100, 100, 100, 100]
|
||||||
|
|
||||||
|
|
||||||
|
def test_delayed_actions_keep_the_vehicle_timing(vehicle):
|
||||||
|
action = preset()
|
||||||
|
action["at_ms"] = 3000
|
||||||
|
assert expand_n7_seat_preset(action, vehicle) is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_wrong_vehicle_or_missing_calibration_is_rejected(vehicle):
|
||||||
|
with pytest.raises(ValueError):
|
||||||
|
expand_n7_seat_preset(preset(), replace(vehicle, id="other_vehicle"))
|
||||||
|
with pytest.raises(ValueError):
|
||||||
|
expand_n7_seat_preset(preset(), replace(vehicle, seat_motors={}))
|
||||||
@@ -0,0 +1,134 @@
|
|||||||
|
from copy import deepcopy
|
||||||
|
from pathlib import Path
|
||||||
|
from types import SimpleNamespace
|
||||||
|
from unittest.mock import patch
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from cognitive_agent.application.codex_assistant import CodexAssistantService
|
||||||
|
from cognitive_agent.application.romantic_lighting import romantic_light_actions
|
||||||
|
from cognitive_agent.application.scene_composer import scene_required_peripheral_refs
|
||||||
|
from cognitive_agent.application.mode_profile import ModeProfileRegistry
|
||||||
|
from cognitive_agent.application.seat_service import REPOSITORY_ROOT, SeatDemoService
|
||||||
|
from cognitive_agent.application.self_extension import GatewayError, SelfExtensionService, SimulationGateway
|
||||||
|
from cognitive_agent.server import SeatDemoHandler
|
||||||
|
|
||||||
|
|
||||||
|
def light(device, name, action, slot, **extra):
|
||||||
|
return {
|
||||||
|
"id": f"ir-slot:{device}:{slot}", "status": "learned", "enabled": True,
|
||||||
|
"device_id": device, "device_name": name, "device_type": "other",
|
||||||
|
"card_uid": f"{int(device[-1]):08X}", "slot": slot, "action": action,
|
||||||
|
"object_names": [name], "action_names": [action], "risk": "low", **extra,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def catalog():
|
||||||
|
return [
|
||||||
|
light("light1", "灯笼", "关闭", 1), light("light1", "灯笼", "打开", 2),
|
||||||
|
light("light1", "灯笼", "切换为红色", 3), light("light1", "灯笼", "切换为蓝色", 5),
|
||||||
|
light("light2", "灯带", "打开", 22), light("light2", "灯带", "设置为红色", 23),
|
||||||
|
light("light3", "蜡烛", "打开", 41), light("light3", "蜡烛", "关闭", 42),
|
||||||
|
light("light4", "球形灯", "打开", 51), light("light4", "球形灯", "设置为红色", 52),
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def test_romantic_lights_all_use_on_then_red_and_preserve_on_only_candles():
|
||||||
|
entries = catalog()
|
||||||
|
entries += [
|
||||||
|
light("light5", "落地灯", "红色", 61), # No known power-on command.
|
||||||
|
light("light6", "投影灯", "打开", 71),
|
||||||
|
light("light6", "投影灯", "蓝色", 72), # Never substitute blue for red.
|
||||||
|
light("light7", "灯笼", "打开", 81, enabled=False),
|
||||||
|
light("light7", "灯笼", "红色", 82, enabled=False),
|
||||||
|
]
|
||||||
|
before = deepcopy(entries)
|
||||||
|
assert [item["slot"] for item in romantic_light_actions(entries)] == [2, 3, 22, 23, 41, 51, 52]
|
||||||
|
assert entries == before
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("name", ["浪漫模式", "浪漫影院模式", "约会模式", "Romantic cinema"])
|
||||||
|
def test_creation_replaces_old_colors_and_orders_all_required_lights(name):
|
||||||
|
actions = [{
|
||||||
|
"ref": f"ir:{item['slot']}", "device_key": item["device_id"],
|
||||||
|
"device_label": item["device_name"], "action_label": item["action"],
|
||||||
|
"object_names": item["object_names"], "action_names": item["action_names"],
|
||||||
|
} for item in catalog()]
|
||||||
|
context = {"peripheral_actions": actions}
|
||||||
|
required = scene_required_peripheral_refs(name, context)
|
||||||
|
assert required == ["ir:2", "ir:3", "ir:22", "ir:23", "ir:41", "ir:51", "ir:52"]
|
||||||
|
context["scene_semantics"] = {"mode_name": name, "peripheral_policy": {"required_refs": required}}
|
||||||
|
plan = {"requests": [{
|
||||||
|
"domain": "self_extension", "capability": "infrared.emit",
|
||||||
|
"target_scope": ref, "operation": "emit",
|
||||||
|
"parameters": [{"name": "capability_ref", "value": ref}],
|
||||||
|
} for ref in ["ir:5", "ir:3", "ir:2"]], "detected_domains": ["self_extension"]}
|
||||||
|
result = CodexAssistantService._with_required_scene_peripherals(plan, context)
|
||||||
|
assert [item["parameters"][0]["value"] for item in result["requests"]] == required
|
||||||
|
|
||||||
|
|
||||||
|
def test_same_named_lights_remain_separate_devices():
|
||||||
|
actions = [{
|
||||||
|
"ref": f"ir:{device}-{action}", "device_key": device,
|
||||||
|
"device_label": "灯笼", "action_label": label,
|
||||||
|
} for device in ["one", "two"] for action, label in [("on", "打开"), ("red", "红色")]]
|
||||||
|
assert [item["ref"] for item in romantic_light_actions(actions)] == [
|
||||||
|
"ir:one-on", "ir:one-red", "ir:two-on", "ir:two-red",
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def test_romantic_import_adds_ordered_lights_without_requiring_cinema_base(tmp_path: Path):
|
||||||
|
service = SeatDemoService(REPOSITORY_ROOT)
|
||||||
|
service.mode_profiles = ModeProfileRegistry(tmp_path / "presets", user_root=tmp_path / "modes")
|
||||||
|
service.set_mode_scene_peripheral_catalog_provider(catalog)
|
||||||
|
climate = {"type": "control", "at_ms": 0, "domain": "climate",
|
||||||
|
"capability": "climate.set_temperature", "target_scope": "vehicle",
|
||||||
|
"operation": "set", "parameters": {"temperature_c": 25}}
|
||||||
|
scene = {"id": "romantic-date", "name": "浪漫约会模式", "label": "浪漫约会模式",
|
||||||
|
"origin": "headunit", "actions": [climate], "scope": {"vehicle_ids": ["denza_n7"]}}
|
||||||
|
try:
|
||||||
|
for _ in range(2):
|
||||||
|
service.import_n7_mode_scene(scene)
|
||||||
|
profile = service.get_mode_profile_card("romantic-date", "denza_n7")
|
||||||
|
actions = profile.command_flow["source_scene"]["actions"]
|
||||||
|
assert actions[0] == climate
|
||||||
|
assert [item["parameters"]["slot"] for item in actions[1:]] == [2, 3, 22, 23, 41, 51, 52]
|
||||||
|
scene = profile.command_flow["source_scene"]
|
||||||
|
assert len(profile.operations) == 8
|
||||||
|
finally:
|
||||||
|
service.close()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("fail_power", [False, True])
|
||||||
|
def test_saved_romantic_mode_orders_real_dispatch_and_stops_on_power_failure(tmp_path: Path, fail_power):
|
||||||
|
extension = SelfExtensionService(SimulationGateway(), catalog_db_path=tmp_path / "ir.sqlite3")
|
||||||
|
extension.sync_peripheral_catalog(catalog())
|
||||||
|
handler = object.__new__(SeatDemoHandler)
|
||||||
|
handler.extension = extension
|
||||||
|
profile = SimpleNamespace(name="浪漫影院模式", command_flow={"function_configs": {
|
||||||
|
"self_extension": {"spec": {"domain": "self_extension", "operations": [{
|
||||||
|
"capability": "infrared.emit", "target": {"device": "light1", "card_uid": "00000001"},
|
||||||
|
"parameters": {"action": "切换为蓝色", "slot": 5},
|
||||||
|
}]}}
|
||||||
|
}})
|
||||||
|
entries = handler._saved_mode_infrared_entries(profile)
|
||||||
|
capabilities = [entry["capability"] for entry in entries]
|
||||||
|
assert [item["slot"] for item in capabilities] == [2, 3, 22, 23, 41, 51, 52]
|
||||||
|
sent = []
|
||||||
|
|
||||||
|
def send(_command, parameters):
|
||||||
|
sent.append(parameters["slot"])
|
||||||
|
if fail_power:
|
||||||
|
raise GatewayError("power-on transport failed")
|
||||||
|
return {"transport": "tcp", "gateway_acknowledged": True, "request": "mock emit"}
|
||||||
|
|
||||||
|
with patch.object(extension, "execute_gateway", side_effect=send), patch(
|
||||||
|
"cognitive_agent.application.self_extension.time.sleep"
|
||||||
|
):
|
||||||
|
result = extension.execute_peripheral_plan_request(
|
||||||
|
"执行浪漫影院模式", {"steps": [
|
||||||
|
{"kind": "peripheral", "capability_id": item["id"]} for item in capabilities
|
||||||
|
]}, capabilities, conversation_id="romantic-order-test", execution_mode="hardware",
|
||||||
|
)
|
||||||
|
assert sent == ([2] if fail_power else [2, 3, 22, 23, 41, 51, 52])
|
||||||
|
assert result["status"] == ("failed" if fail_power else "succeeded")
|
||||||
@@ -695,8 +695,10 @@ def test_romantic_template_always_adds_authorized_decorative_lights() -> None:
|
|||||||
)
|
)
|
||||||
assert _peripheral_refs(observed[0]) == [
|
assert _peripheral_refs(observed[0]) == [
|
||||||
"ir:flash-open-000000000001",
|
"ir:flash-open-000000000001",
|
||||||
"ir:ambient-open-0000000001",
|
"ir:flash-red-0000000000002",
|
||||||
"ir:strip-open-000000000001",
|
"ir:strip-open-000000000001",
|
||||||
|
"ir:strip-red-0000000000002",
|
||||||
|
"ir:ambient-open-0000000001",
|
||||||
]
|
]
|
||||||
assert "ir:flash-close-00000000003" not in _peripheral_refs(observed[0])
|
assert "ir:flash-close-00000000003" not in _peripheral_refs(observed[0])
|
||||||
|
|
||||||
@@ -740,8 +742,10 @@ def test_romantic_cinema_template_keeps_native_base_and_adds_lights_without_mode
|
|||||||
assert native_requests == template["plan"]["requests"]
|
assert native_requests == template["plan"]["requests"]
|
||||||
assert _peripheral_refs(observed[0]) == [
|
assert _peripheral_refs(observed[0]) == [
|
||||||
"ir:flash-open-000000000001",
|
"ir:flash-open-000000000001",
|
||||||
"ir:ambient-open-0000000001",
|
"ir:flash-red-0000000000002",
|
||||||
"ir:strip-open-000000000001",
|
"ir:strip-open-000000000001",
|
||||||
|
"ir:strip-red-0000000000002",
|
||||||
|
"ir:ambient-open-0000000001",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -520,20 +520,23 @@ def test_romantic_and_mid_autumn_require_decorative_light_actions(
|
|||||||
context["scene_semantics"] = semantics
|
context["scene_semantics"] = semantics
|
||||||
schema = build_scene_output_schema(context)
|
schema = build_scene_output_schema(context)
|
||||||
|
|
||||||
assert required == [
|
romantic_refs = [
|
||||||
|
"ir:flash-open", "ir:flash-red", "ir:strip-open", "ir:strip-red", "ir:ambient-open",
|
||||||
|
]
|
||||||
|
assert required == (romantic_refs if "浪漫" in mode_name else [
|
||||||
"ir:flash-open",
|
"ir:flash-open",
|
||||||
"ir:strip-open",
|
"ir:strip-open",
|
||||||
]
|
])
|
||||||
assert scene_required_peripheral_refs(
|
assert scene_required_peripheral_refs(
|
||||||
mode_name,
|
mode_name,
|
||||||
context,
|
context,
|
||||||
include_default_bundle=True,
|
include_default_bundle=True,
|
||||||
) == [
|
) == (romantic_refs if "浪漫" in mode_name else [
|
||||||
"ir:flash-open",
|
"ir:flash-open",
|
||||||
"ir:ambient-open",
|
"ir:ambient-open",
|
||||||
"ir:strip-open",
|
"ir:strip-open",
|
||||||
]
|
])
|
||||||
assert schema["properties"]["requests"]["maxItems"] == 18
|
assert schema["properties"]["requests"]["maxItems"] == 20 - len(required)
|
||||||
if "中秋" in mode_name:
|
if "中秋" in mode_name:
|
||||||
assert semantics["family"] == "festival_celebration"
|
assert semantics["family"] == "festival_celebration"
|
||||||
|
|
||||||
|
|||||||
@@ -31,7 +31,7 @@ def test_vue_vite_application_is_the_primary_frontend_entry() -> None:
|
|||||||
assert package["scripts"]["build"] == "vite build"
|
assert package["scripts"]["build"] == "vite build"
|
||||||
assert 'import vue from "@vitejs/plugin-vue"' in vite
|
assert 'import vue from "@vitejs/plugin-vue"' in vite
|
||||||
assert 'outDir: outputRoot' in vite
|
assert 'outDir: outputRoot' in vite
|
||||||
assert 'const CACHE_VERSION = "cognitive-agent-pad-v334-video-media"' in service_worker
|
assert 'const CACHE_VERSION = "cognitive-agent-pad-v336-seat-preset"' in service_worker
|
||||||
assert 'createHash("sha256")' in vite
|
assert 'createHash("sha256")' in vite
|
||||||
assert 'runtime-assets.json' in vite
|
assert 'runtime-assets.json' in vite
|
||||||
assert 'rewriteRuntimeReferences' in vite
|
assert 'rewriteRuntimeReferences' in vite
|
||||||
|
|||||||
Reference in New Issue
Block a user