1734 lines
71 KiB
Python
1734 lines
71 KiB
Python
"""Trusted compilation and execution support for CAN_DEV vehicle controls."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import hashlib
|
|
import json
|
|
import os
|
|
import time
|
|
from dataclasses import dataclass
|
|
from datetime import UTC, datetime
|
|
from pathlib import Path
|
|
from typing import Any, Callable
|
|
|
|
import yaml
|
|
|
|
from cognitive_agent.application.native_can_feedback import (
|
|
decode_feedback,
|
|
feedback_matches,
|
|
)
|
|
from cognitive_agent.infrastructure.can.gateway import CanBus, CanFrame
|
|
|
|
|
|
def _now() -> str:
|
|
return datetime.now(UTC).isoformat().replace("+00:00", "Z")
|
|
|
|
|
|
def _set_bits(payload: bytearray, start: int, length: int, value: int) -> None:
|
|
if length <= 0 or start < 0 or start + length > len(payload) * 8:
|
|
raise ValueError("invalid bit range")
|
|
if value < 0 or value >= (1 << length):
|
|
raise ValueError(f"value {value} does not fit in {length} bits")
|
|
for offset in range(length):
|
|
bit = start + offset
|
|
mask = 1 << (bit % 8)
|
|
if value & (1 << offset):
|
|
payload[bit // 8] |= mask
|
|
else:
|
|
payload[bit // 8] &= ~mask
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class NativeFrameSpec:
|
|
frame_id: int
|
|
payload: bytes
|
|
is_fd: bool = False
|
|
bitrate_switch: bool = False
|
|
repeat_count: int = 1
|
|
period_ms: int = 0
|
|
feedback_ids: tuple[int, ...] = ()
|
|
expected_feedback: str = ""
|
|
special_handler: str = ""
|
|
special_parameters: dict[str, Any] | None = None
|
|
|
|
|
|
class NativeCanCompileError(ValueError):
|
|
"""Raised when a request is outside the CAN_DEV verified vocabulary."""
|
|
|
|
|
|
DRIVER_SEAT_CAPABILITIES = frozenset(
|
|
{
|
|
"seat.set_driver_ventilation_native",
|
|
"seat.set_driver_heating_native",
|
|
"seat.set_driver_massage_native",
|
|
"seat.set_driver_axis_native",
|
|
"seat.set_driver_position_native",
|
|
"seat.recall_driver_memory_native",
|
|
}
|
|
)
|
|
DRIVER_SEAT_TARGETS = frozenset({"driver", "front_left"})
|
|
UNSUPPORTED_CH1_DOOR_LOCK_CAPABILITIES = frozenset(
|
|
{"door.set_vehicle_lock"}
|
|
)
|
|
|
|
|
|
def _unsupported_ch1_door_lock_message(capability: str) -> str:
|
|
if capability == "door.set_vehicle_lock":
|
|
return (
|
|
"door.set_vehicle_lock is unsupported for physical execution on the "
|
|
"current CH1; the 0x496 smoke request caused no 0x407 vehicle-lock or "
|
|
"four-door transition, so CH1 door-lock support is observational only"
|
|
)
|
|
return f"{capability} is unsupported for physical execution on the current CH1"
|
|
|
|
|
|
class NativeCanRuntime:
|
|
"""Compile named capabilities into byte-exact CAN_DEV frames."""
|
|
|
|
compiler_version = "1.0.0"
|
|
mapping_version = "can-dev-verified-body-can-v5"
|
|
|
|
def __init__(self, repository_root: Path):
|
|
self.root = repository_root
|
|
calibration_path = (
|
|
repository_root
|
|
/ "platforms"
|
|
/ "t2g_cabin_bench"
|
|
/ "calibration"
|
|
/ "body_can.yaml"
|
|
)
|
|
self.calibration_path = calibration_path
|
|
self.calibration = yaml.safe_load(calibration_path.read_text(encoding="utf-8"))
|
|
self.capabilities: dict[str, dict[str, Any]] = {}
|
|
catalog_paths = [
|
|
*(repository_root / "domains").glob("*/config/capabilities.yaml"),
|
|
*(repository_root / "config" / "native_can_capabilities").glob("*.yaml"),
|
|
]
|
|
for path in sorted(catalog_paths):
|
|
catalog = yaml.safe_load(path.read_text(encoding="utf-8"))
|
|
for capability in catalog.get("spec", {}).get("capabilities", []):
|
|
self.capabilities[capability["name"]] = capability
|
|
|
|
@property
|
|
def supported_capabilities(self) -> frozenset[str]:
|
|
return frozenset(
|
|
{
|
|
"window.set_motion",
|
|
"window.set_position",
|
|
"climate.set_power",
|
|
"climate.set_ac",
|
|
"climate.set_fan_level",
|
|
"climate.set_outlet_mode",
|
|
"climate.set_temperature",
|
|
"climate.set_control_mode",
|
|
"climate.set_cycle_mode",
|
|
"door.set_rear_lock",
|
|
"outlet.set_position_percent",
|
|
"outlet.set_calibrated_position",
|
|
*DRIVER_SEAT_CAPABILITIES,
|
|
"interior_light.set_indoor_power",
|
|
"interior_light.set_reading_power",
|
|
"ambient_light.set_power",
|
|
"ambient_light.set_color_index",
|
|
"ambient_light.set_brightness_level",
|
|
"roof.set_shade_motion",
|
|
"roof.set_shade_position",
|
|
}
|
|
)
|
|
|
|
def supports(self, action: dict[str, Any]) -> bool:
|
|
capability = str(action.get("capability"))
|
|
if capability not in self.supported_capabilities:
|
|
return False
|
|
if capability in DRIVER_SEAT_CAPABILITIES:
|
|
target = str(action.get("target", action.get("target_scope", "")))
|
|
return target in DRIVER_SEAT_TARGETS
|
|
if capability == "interior_light.set_reading_power":
|
|
target = str(action.get("target", action.get("target_scope", "")))
|
|
return target == "all"
|
|
return True
|
|
|
|
def build_function_config(
|
|
self,
|
|
domain: str,
|
|
actions: list[dict[str, Any]],
|
|
request_id: str,
|
|
provider: str,
|
|
) -> dict[str, Any]:
|
|
operations = []
|
|
for index, action in enumerate(actions, start=1):
|
|
capability_name = str(action["capability"])
|
|
if capability_name not in self.supported_capabilities:
|
|
if capability_name in UNSUPPORTED_CH1_DOOR_LOCK_CAPABILITIES:
|
|
raise NativeCanCompileError(
|
|
_unsupported_ch1_door_lock_message(capability_name)
|
|
)
|
|
raise NativeCanCompileError(
|
|
f"CAN_DEV does not implement {capability_name}"
|
|
)
|
|
capability = self.capabilities[capability_name]
|
|
target = str(action.get("target", action.get("target_scope", "vehicle")))
|
|
if target not in capability.get("target_scopes", []):
|
|
raise NativeCanCompileError(
|
|
f"{capability_name} does not support target {target}"
|
|
)
|
|
suffix = capability_name.split(".", 1)[1]
|
|
operation_id = f"{target}_{suffix}_{index}"[:63]
|
|
operations.append(
|
|
{
|
|
"id": operation_id,
|
|
"capability": capability_name,
|
|
"target": {
|
|
"device": capability["target_devices"][0],
|
|
"zone": target,
|
|
},
|
|
"parameters": dict(action.get("parameters", {})),
|
|
"parse_source": str(
|
|
action.get("parse_source", "local_rule")
|
|
),
|
|
"timeout_ms": (
|
|
20_000
|
|
if capability_name
|
|
in {
|
|
"window.set_position",
|
|
"roof.set_shade_position",
|
|
"seat.set_driver_position_native",
|
|
"seat.recall_driver_memory_native",
|
|
}
|
|
else 4_000
|
|
),
|
|
"failure_policy": "abort",
|
|
}
|
|
)
|
|
return {
|
|
"apiVersion": "cognitive-agent/v1alpha1",
|
|
"kind": "FunctionConfig",
|
|
"metadata": {
|
|
"id": f"native-{domain}-{request_id}",
|
|
"name": f"{domain.replace('_', ' ').title()} native CAN function",
|
|
"version": "1.0.0",
|
|
"request_id": request_id,
|
|
"generated_at": _now(),
|
|
"labels": {
|
|
"provider": provider,
|
|
"execution_scope": "vehicle_can_channel_1",
|
|
"source_branch": "CAN_DEV",
|
|
},
|
|
},
|
|
"spec": {
|
|
"domain": domain,
|
|
"operations": operations,
|
|
"assumptions": [
|
|
"Only named, range-checked CAN_DEV capabilities may reach the body CAN channel.",
|
|
"The body CAN channel uses ISO CAN FD at 500 kbit/s arbitration and 2 Mbit/s data rate.",
|
|
],
|
|
},
|
|
}
|
|
|
|
def compile(self, function_config: dict[str, Any]) -> dict[str, Any]:
|
|
commands: list[dict[str, Any]] = []
|
|
for operation_index, operation in enumerate(
|
|
function_config["spec"]["operations"], start=1
|
|
):
|
|
specs = self._frame_specs(operation)
|
|
for frame_index, spec in enumerate(specs, start=1):
|
|
signals: dict[str, int | bool | str] = {
|
|
"frame_id": f"0x{spec.frame_id:03X}",
|
|
"data_hex": " ".join(f"{byte:02X}" for byte in spec.payload),
|
|
"is_fd": spec.is_fd,
|
|
"bitrate_switch": spec.bitrate_switch,
|
|
"feedback_ids": ",".join(
|
|
f"0x{frame_id:03X}" for frame_id in spec.feedback_ids
|
|
),
|
|
"expected_feedback": spec.expected_feedback,
|
|
"debug_command": self.operation_debug_command(operation),
|
|
}
|
|
if spec.special_handler:
|
|
signals["special_handler"] = spec.special_handler
|
|
signals["special_parameters"] = json.dumps(
|
|
spec.special_parameters or {},
|
|
ensure_ascii=False,
|
|
sort_keys=True,
|
|
)
|
|
command: dict[str, Any] = {
|
|
"id": f"native_{operation_index}_{frame_index}_control",
|
|
"operation_ref": operation["id"],
|
|
"bus": "body_can",
|
|
"message": f"Native_0x{spec.frame_id:03X}",
|
|
"signals": signals,
|
|
"send_offset_ms": (operation_index - 1) * 20,
|
|
}
|
|
if spec.repeat_count > 1:
|
|
command["repeat"] = {
|
|
"count": spec.repeat_count,
|
|
"period_ms": spec.period_ms,
|
|
}
|
|
commands.append(command)
|
|
|
|
canonical = json.dumps(
|
|
function_config, ensure_ascii=False, sort_keys=True
|
|
).encode("utf-8")
|
|
metadata = function_config["metadata"]
|
|
domain = function_config["spec"]["domain"]
|
|
return {
|
|
"apiVersion": "cognitive-agent/v1alpha1",
|
|
"kind": "CommandPlan",
|
|
"metadata": {
|
|
"id": f"plan-native-{domain}-{metadata['request_id']}",
|
|
"name": f"{domain.replace('_', ' ').title()} native CAN command plan",
|
|
"version": "1.0.0",
|
|
"request_id": metadata["request_id"],
|
|
"labels": {
|
|
"execution_scope": "vehicle_can_channel_1",
|
|
"source_branch": "CAN_DEV",
|
|
},
|
|
},
|
|
"spec": {
|
|
"domain": domain,
|
|
"platform": "t2g_cabin_bench",
|
|
"source": {
|
|
"config_id": metadata["id"],
|
|
"config_version": metadata["version"],
|
|
"config_sha256": hashlib.sha256(canonical).hexdigest(),
|
|
"compiler_version": self.compiler_version,
|
|
"mapping_version": self.mapping_version,
|
|
},
|
|
"commands": commands,
|
|
},
|
|
}
|
|
|
|
def execute_virtual(
|
|
self,
|
|
command_plan: dict[str, Any],
|
|
function_config: dict[str, Any],
|
|
) -> dict[str, Any]:
|
|
started_at = _now()
|
|
operations = {
|
|
operation["id"]: operation
|
|
for operation in function_config["spec"]["operations"]
|
|
}
|
|
results = []
|
|
for command in command_plan["spec"]["commands"]:
|
|
operation = operations[command["operation_ref"]]
|
|
results.append(
|
|
{
|
|
"ref": operation["id"],
|
|
"domain": function_config["spec"]["domain"],
|
|
"status": "succeeded",
|
|
"message": "已完成 CAN_DEV 帧编译;dry-run 未打开物理 CAN。",
|
|
"telemetry": {
|
|
"target": operation["target"]["zone"],
|
|
"requested_parameters": operation["parameters"],
|
|
"command_frame": self.command_frame_public(command),
|
|
"source_branch": "CAN_DEV",
|
|
"simulated": True,
|
|
},
|
|
}
|
|
)
|
|
return self._report(function_config, results, started_at, "dry_run")
|
|
|
|
@staticmethod
|
|
def command_frame_public(command: dict[str, Any]) -> dict[str, Any]:
|
|
signals = command["signals"]
|
|
public = {
|
|
"frame_id": signals["frame_id"],
|
|
"payload_size": len(bytes.fromhex(str(signals["data_hex"]))),
|
|
"data_hex": signals["data_hex"],
|
|
"channel": 1,
|
|
"frame_format": "can_fd" if signals["is_fd"] else "classic_can",
|
|
"bitrate_switch": bool(signals["bitrate_switch"]),
|
|
"repeat": command.get("repeat", {"count": 1, "period_ms": 0}),
|
|
}
|
|
if signals.get("special_handler") == "driver_seat_axis":
|
|
special = json.loads(str(signals["special_parameters"]))
|
|
public["safety"] = {
|
|
"forced_stop": True,
|
|
"stop_data_hex": special["stop_data_hex"],
|
|
"stop_repeat_count": special["stop_repeat_count"],
|
|
"stop_period_ms": special["stop_period_ms"],
|
|
}
|
|
elif signals.get("special_handler") == "driver_seat_memory_recall":
|
|
special = json.loads(str(signals["special_parameters"]))
|
|
public["sequence"] = {
|
|
"frame_count": len(special["frame_sequence"]),
|
|
"period_ms": special["period_ms"],
|
|
"data_hex": list(special["frame_sequence"]),
|
|
"requires_fresh_initialized_0x494": True,
|
|
}
|
|
return public
|
|
|
|
@staticmethod
|
|
def operation_debug_command(operation: dict[str, Any]) -> str:
|
|
"""Return the trusted canonical command represented by one operation."""
|
|
|
|
capability = str(operation["capability"])
|
|
parameters = operation.get("parameters", {})
|
|
target = str(operation.get("target", {}).get("zone", "vehicle")).replace(
|
|
"_", "-"
|
|
)
|
|
if capability == "window.set_motion":
|
|
return f"window {target} {parameters['motion']}"
|
|
if capability == "window.set_position":
|
|
return f"window {target} position {parameters['position_pct']}"
|
|
if capability == "climate.set_power":
|
|
return f"ac {parameters['state']}"
|
|
if capability == "climate.set_ac":
|
|
return f"ac compressor {parameters['state']}"
|
|
if capability == "climate.set_fan_level":
|
|
return f"ac fan {parameters['level']}"
|
|
if capability == "climate.set_outlet_mode":
|
|
return f"ac mode {str(parameters['mode']).replace('_', '-')}"
|
|
if capability == "climate.set_temperature":
|
|
return f"ac temp {target} {parameters['temperature_c']}"
|
|
if capability == "climate.set_control_mode":
|
|
return f"ac control {parameters['mode']}"
|
|
if capability == "climate.set_cycle_mode":
|
|
return f"ac cycle {parameters['mode']}"
|
|
if capability == "door.set_rear_lock":
|
|
return f"door rear {parameters['state']}"
|
|
if capability == "outlet.set_position_percent":
|
|
return (
|
|
f"outlet {target} percent {parameters['horizontal_pct']} "
|
|
f"{parameters['vertical_pct']}"
|
|
)
|
|
if capability == "outlet.set_calibrated_position":
|
|
return (
|
|
f"outlet {target} position {parameters['horizontal_value']} "
|
|
f"{parameters['vertical_value']}"
|
|
)
|
|
if capability in {
|
|
"seat.set_driver_heating_native",
|
|
"seat.set_driver_ventilation_native",
|
|
}:
|
|
feature = (
|
|
"heat"
|
|
if capability == "seat.set_driver_heating_native"
|
|
else "vent"
|
|
)
|
|
setting = "off"
|
|
if parameters["state"] == "on":
|
|
setting = {1: "high", 2: "low"}[int(parameters.get("level", 2))]
|
|
return f"seat driver {feature} {setting}"
|
|
if capability == "seat.set_driver_massage_native":
|
|
action = str(parameters["action"])
|
|
if action == "off":
|
|
return "seat driver massage off"
|
|
if action == "strength":
|
|
strength = {1: "weak", 2: "medium", 3: "strong"}[
|
|
int(parameters["strength"])
|
|
]
|
|
return f"seat driver massage strength {strength}"
|
|
return f"seat driver massage mode {parameters['mode']}"
|
|
if capability == "seat.set_driver_axis_native":
|
|
if parameters["direction"] == "stop":
|
|
return f"seat driver {parameters['axis']} stop"
|
|
return (
|
|
f"seat driver {parameters['axis']} {parameters['direction']} "
|
|
f"{parameters.get('duration_ms', 500)}"
|
|
)
|
|
if capability == "seat.set_driver_position_native":
|
|
return f"seat driver position {parameters['slot']}"
|
|
if capability == "seat.recall_driver_memory_native":
|
|
return f"seat driver memory recall {parameters['slot']}"
|
|
if capability == "interior_light.set_indoor_power":
|
|
return f"light indoor {parameters['state']}"
|
|
if capability == "interior_light.set_reading_power":
|
|
return f"light reading all {parameters['state']}"
|
|
if capability == "ambient_light.set_power":
|
|
return f"light ambient {parameters['state']}"
|
|
if capability == "ambient_light.set_color_index":
|
|
return f"light ambient color {parameters['color_index']}"
|
|
if capability == "ambient_light.set_brightness_level":
|
|
return f"light ambient brightness {parameters['brightness_level']}"
|
|
if capability == "roof.set_shade_motion":
|
|
return f"shade {parameters['motion']}"
|
|
if capability == "roof.set_shade_position":
|
|
return f"shade position {parameters['position_pct']}"
|
|
return capability
|
|
|
|
@staticmethod
|
|
def _report(
|
|
function_config: dict[str, Any],
|
|
results: list[dict[str, Any]],
|
|
started_at: str,
|
|
mode: str,
|
|
) -> dict[str, Any]:
|
|
succeeded = all(item["status"] == "succeeded" for item in results)
|
|
request_id = function_config["metadata"]["request_id"]
|
|
domain = function_config["spec"]["domain"]
|
|
return {
|
|
"apiVersion": "cognitive-agent/v1alpha1",
|
|
"kind": "ExecutionReport",
|
|
"metadata": {
|
|
"id": f"report-native-{domain}-{request_id}",
|
|
"name": f"{domain.replace('_', ' ').title()} native CAN execution report",
|
|
"version": "1.0.0",
|
|
"request_id": request_id,
|
|
"labels": {
|
|
"simulation": "false" if mode == "vehicle" else "true",
|
|
"source_branch": "CAN_DEV",
|
|
},
|
|
},
|
|
"spec": {
|
|
"mode": mode,
|
|
"status": "succeeded" if succeeded else "failed",
|
|
"started_at": started_at,
|
|
"finished_at": _now(),
|
|
"results": results,
|
|
},
|
|
}
|
|
|
|
def _frame_specs(self, operation: dict[str, Any]) -> list[NativeFrameSpec]:
|
|
capability = str(operation["capability"])
|
|
if capability in UNSUPPORTED_CH1_DOOR_LOCK_CAPABILITIES:
|
|
raise NativeCanCompileError(
|
|
_unsupported_ch1_door_lock_message(capability)
|
|
)
|
|
target = operation["target"]["zone"]
|
|
parameters = operation["parameters"]
|
|
|
|
if capability == "door.set_rear_lock":
|
|
self._reject_unexpected_parameters(parameters, {"state"})
|
|
state = self._enum(parameters, "state", {"lock": 1, "unlock": 2})
|
|
payload = bytearray(8)
|
|
payload[0] = 0x0A
|
|
_set_bits(payload, 14, 2, state)
|
|
return [
|
|
self._classic(
|
|
0x32B,
|
|
payload,
|
|
1,
|
|
0,
|
|
(0x407,),
|
|
f"rear_lock:{parameters['state']}",
|
|
)
|
|
]
|
|
|
|
if capability in {"climate.set_power", "climate.set_ac"}:
|
|
state = self._enum(parameters, "state", {"on": 1, "off": 2})
|
|
payload = bytearray(8)
|
|
_set_bits(payload, 28, 2, state)
|
|
return [self._classic(0x1DE, payload, 3, 100, (0x404,), f"climate_power:{parameters['state']}")]
|
|
if capability == "climate.set_fan_level":
|
|
level = self._integer(parameters, "level", 1, 7)
|
|
payload = bytearray(8)
|
|
_set_bits(payload, 4, 4, level)
|
|
return [self._classic(0x1DE, payload, 3, 100, (0x404,), f"climate_fan:{level}")]
|
|
if capability == "climate.set_outlet_mode":
|
|
mode = self._enum(
|
|
parameters,
|
|
"mode",
|
|
{
|
|
"face": 1,
|
|
"face_feet": 2,
|
|
"feet": 3,
|
|
"feet_defrost": 4,
|
|
"defrost": 5,
|
|
"face_feet_defrost": 6,
|
|
"face_defrost": 7,
|
|
},
|
|
)
|
|
payload = bytearray(8)
|
|
_set_bits(payload, 0, 4, mode)
|
|
return [self._classic(0x1DE, payload, 3, 100, (0x404,), f"climate_mode:{mode}")]
|
|
if capability == "climate.set_control_mode":
|
|
mode = self._enum(parameters, "mode", {"manual": 1, "auto": 2})
|
|
payload = bytearray(8)
|
|
_set_bits(payload, 16, 2, mode)
|
|
return [self._classic(0x1DE, payload, 3, 100, (0x404,), f"climate_control:{mode}")]
|
|
if capability == "climate.set_cycle_mode":
|
|
self._reject_unexpected_parameters(parameters, {"mode"})
|
|
mode = self._enum(parameters, "mode", {"internal": 1, "external": 2})
|
|
payload = bytearray(8)
|
|
_set_bits(payload, 19, 2, mode)
|
|
return [
|
|
self._classic(
|
|
0x1DE,
|
|
payload,
|
|
3,
|
|
100,
|
|
(0x404,),
|
|
f"climate_cycle:{parameters['mode']}",
|
|
)
|
|
]
|
|
if capability == "climate.set_temperature":
|
|
temperature = self._integer(parameters, "temperature_c", 18, 32)
|
|
starts = {
|
|
"driver": (32,),
|
|
"passenger": (40,),
|
|
"rear": (48,),
|
|
"rear_row": (48,),
|
|
"rear_left": (48,),
|
|
"rear_right": (48,),
|
|
"vehicle": (32, 40),
|
|
"front_row": (32, 40),
|
|
}
|
|
if target not in starts:
|
|
raise NativeCanCompileError(
|
|
f"climate.set_temperature does not support target {target}"
|
|
)
|
|
specs = []
|
|
for start in starts[target]:
|
|
payload = bytearray(8)
|
|
_set_bits(payload, start, 8, temperature)
|
|
label = "driver" if start == 32 else "passenger" if start == 40 else "rear"
|
|
specs.append(
|
|
self._classic(
|
|
0x1DE,
|
|
payload,
|
|
1 if start == 48 else 3,
|
|
100,
|
|
(0x404,),
|
|
f"climate_temperature:{label}:{temperature}",
|
|
)
|
|
)
|
|
return specs
|
|
|
|
if capability == "window.set_motion":
|
|
motion = self._enum(parameters, "motion", {"open": 1, "close": 2, "stop": 3})
|
|
payload = self._window_payload(target, motion)
|
|
return [self._fd(0x431, payload, 3, 100, (0x388,), f"window_motion:{target}:{parameters['motion']}")]
|
|
if capability == "window.set_position":
|
|
position = self._integer(parameters, "position_pct", 0, 100)
|
|
if target not in {"front_left", "rear_left", "front_right", "rear_right"}:
|
|
raise NativeCanCompileError("absolute window position requires one window")
|
|
return [
|
|
NativeFrameSpec(
|
|
frame_id=0x431,
|
|
payload=self._window_payload(target, 3),
|
|
is_fd=True,
|
|
bitrate_switch=True,
|
|
feedback_ids=(0x388,),
|
|
expected_feedback=f"window_position:{target}:{position}",
|
|
special_handler="window_position",
|
|
special_parameters={"target": target, "position_pct": position},
|
|
)
|
|
]
|
|
|
|
if capability in {
|
|
"seat.set_driver_heating_native",
|
|
"seat.set_driver_ventilation_native",
|
|
}:
|
|
self._require_driver_seat_target(target)
|
|
return [self._driver_seat_thermal_frame(capability, parameters)]
|
|
if capability == "seat.set_driver_massage_native":
|
|
self._require_driver_seat_target(target)
|
|
return [self._driver_seat_massage_frame(parameters)]
|
|
if capability == "seat.set_driver_axis_native":
|
|
self._require_driver_seat_target(target)
|
|
return [self._driver_seat_axis_frame(parameters)]
|
|
if capability in {
|
|
"seat.set_driver_position_native",
|
|
"seat.recall_driver_memory_native",
|
|
}:
|
|
self._require_driver_seat_target(target)
|
|
return [self._driver_seat_memory_frame(parameters)]
|
|
|
|
if capability in {"outlet.set_position_percent", "outlet.set_calibrated_position"}:
|
|
return [self._outlet_frame(capability, target, parameters)]
|
|
|
|
if capability == "interior_light.set_indoor_power":
|
|
return [self._all_reading_light_frame(parameters, "indoor_light")]
|
|
if capability == "interior_light.set_reading_power":
|
|
if target != "all":
|
|
raise NativeCanCompileError(
|
|
"only the all-reading-lights target is verified on CH1"
|
|
)
|
|
return [self._all_reading_light_frame(parameters, "indoor_light")]
|
|
|
|
if capability == "ambient_light.set_power":
|
|
self._reject_unexpected_parameters(parameters, {"state"})
|
|
state = str(parameters.get("state"))
|
|
self._enum(parameters, "state", {"off": 0, "on": 1})
|
|
payload = bytes(
|
|
(0x00, 0x36 if state == "on" else 0x31, 0x00, 0x75, 0x00, 0x80, 0x00, 0x00)
|
|
)
|
|
return [self._fd(0x3FC, payload, 3, 100, (0x42E,), f"ambient_power:{state}")]
|
|
if capability == "ambient_light.set_color_index":
|
|
self._reject_unexpected_parameters(parameters, {"color_index"})
|
|
color = self._integer(parameters, "color_index", 1, 127)
|
|
payload = bytes((color, 0x30, 0x00, 0x75, 0x00, 0x00, 0x00, 0x00))
|
|
return [self._fd(0x3FC, payload, 3, 100, (0x42E,), f"ambient_color:{color}")]
|
|
if capability == "ambient_light.set_brightness_level":
|
|
self._reject_unexpected_parameters(parameters, {"brightness_level"})
|
|
level = self._integer(parameters, "brightness_level", 1, 5)
|
|
payload = bytes((0x00, 0x31 + level, 0x00, 0x75, 0x00, 0x00, 0x00, 0x00))
|
|
return [self._fd(0x3FC, payload, 3, 100, (0x42E,), f"ambient_brightness:{level}")]
|
|
|
|
if capability == "roof.set_shade_motion":
|
|
motion = self._enum(parameters, "motion", {"open": 1, "close": 2, "half": 3, "stop": 4})
|
|
return [self._shade_preset_frame(motion)]
|
|
if capability == "roof.set_shade_position":
|
|
position = self._integer(parameters, "position_pct", 0, 100)
|
|
presets = {0: 2, 50: 3, 100: 1}
|
|
if position in presets:
|
|
return [self._shade_preset_frame(presets[position])]
|
|
return [self._shade_position_frame(position)]
|
|
|
|
raise NativeCanCompileError(f"CAN_DEV does not implement {capability}")
|
|
|
|
@staticmethod
|
|
def _classic(
|
|
frame_id: int,
|
|
payload: bytes | bytearray,
|
|
repeat: int = 1,
|
|
period_ms: int = 0,
|
|
feedback_ids: tuple[int, ...] = (),
|
|
expected: str = "",
|
|
) -> NativeFrameSpec:
|
|
return NativeFrameSpec(
|
|
frame_id,
|
|
bytes(payload),
|
|
repeat_count=repeat,
|
|
period_ms=period_ms,
|
|
feedback_ids=feedback_ids,
|
|
expected_feedback=expected,
|
|
)
|
|
|
|
@staticmethod
|
|
def _fd(
|
|
frame_id: int,
|
|
payload: bytes | bytearray,
|
|
repeat: int = 1,
|
|
period_ms: int = 0,
|
|
feedback_ids: tuple[int, ...] = (),
|
|
expected: str = "",
|
|
) -> NativeFrameSpec:
|
|
return NativeFrameSpec(
|
|
frame_id,
|
|
bytes(payload),
|
|
is_fd=True,
|
|
bitrate_switch=True,
|
|
repeat_count=repeat,
|
|
period_ms=period_ms,
|
|
feedback_ids=feedback_ids,
|
|
expected_feedback=expected,
|
|
)
|
|
|
|
@staticmethod
|
|
def _enum(parameters: dict[str, Any], name: str, values: dict[str, int]) -> int:
|
|
value = str(parameters.get(name))
|
|
try:
|
|
return values[value]
|
|
except KeyError as exc:
|
|
raise NativeCanCompileError(
|
|
f"{name} must be one of {', '.join(values)}"
|
|
) from exc
|
|
|
|
@staticmethod
|
|
def _integer(
|
|
parameters: dict[str, Any], name: str, minimum: int, maximum: int
|
|
) -> int:
|
|
value = parameters.get(name)
|
|
if isinstance(value, bool) or not isinstance(value, (int, float)):
|
|
raise NativeCanCompileError(f"{name} must be an integer")
|
|
integer = int(value)
|
|
if integer != value or not minimum <= integer <= maximum:
|
|
raise NativeCanCompileError(
|
|
f"{name} must be between {minimum} and {maximum}"
|
|
)
|
|
return integer
|
|
|
|
@staticmethod
|
|
def _window_payload(target: str, motion: int) -> bytes:
|
|
masks = {
|
|
"front_left": 0b0001,
|
|
"rear_left": 0b0010,
|
|
"front_right": 0b0100,
|
|
"rear_right": 0b1000,
|
|
"front": 0b0101,
|
|
"rear": 0b1010,
|
|
"all": 0b1111,
|
|
}
|
|
try:
|
|
mask = masks[target]
|
|
except KeyError as exc:
|
|
raise NativeCanCompileError(f"unsupported window target {target}") from exc
|
|
starts = (48, 56, 51, 59)
|
|
payload = bytearray(8)
|
|
for index, start in enumerate(starts):
|
|
if mask & (1 << index):
|
|
_set_bits(payload, start, 3, motion)
|
|
return bytes(payload)
|
|
|
|
def _outlet_frame(
|
|
self, capability: str, target: str, parameters: dict[str, Any]
|
|
) -> NativeFrameSpec:
|
|
ranges = self.calibration["air_outlet"]["ranges"]
|
|
if target not in ranges:
|
|
raise NativeCanCompileError(f"unsupported outlet target {target}")
|
|
if capability == "outlet.set_position_percent":
|
|
horizontal_pct = self._integer(parameters, "horizontal_pct", 0, 100)
|
|
vertical_pct = self._integer(parameters, "vertical_pct", 0, 100)
|
|
horizontal_range = ranges[target]["horizontal"]
|
|
vertical_range = ranges[target]["vertical"]
|
|
horizontal = horizontal_range[0] + (
|
|
(horizontal_range[1] - horizontal_range[0]) * horizontal_pct + 50
|
|
) // 100
|
|
vertical = vertical_range[0] + (
|
|
(vertical_range[1] - vertical_range[0]) * vertical_pct + 50
|
|
) // 100
|
|
else:
|
|
horizontal = self._integer(parameters, "horizontal_value", -128, 126)
|
|
vertical = self._integer(parameters, "vertical_value", -128, 126)
|
|
horizontal_range = ranges[target]["horizontal"]
|
|
vertical_range = ranges[target]["vertical"]
|
|
if not horizontal_range[0] <= horizontal <= horizontal_range[1]:
|
|
raise NativeCanCompileError("horizontal_value exceeds the calibrated range")
|
|
if not vertical_range[0] <= vertical <= vertical_range[1]:
|
|
raise NativeCanCompileError("vertical_value exceeds the calibrated range")
|
|
|
|
side, position = target.split("_", 1)
|
|
sub_id = 0x02 if side == "driver" else 0x03
|
|
starts = {
|
|
"left": (24, 8),
|
|
"right": (32, 16),
|
|
}
|
|
horizontal_start, vertical_start = starts[position]
|
|
payload = bytearray((sub_id, 0xFF, 0xFF, 0xFF, 0xFF, 0, 0, 0))
|
|
if sub_id == 0x02:
|
|
payload[6] = 0xFF
|
|
payload[7] = 0xFF
|
|
_set_bits(payload, horizontal_start, 8, horizontal + 128)
|
|
_set_bits(payload, vertical_start, 8, vertical + 128)
|
|
return self._fd(
|
|
0x4EF,
|
|
payload,
|
|
3,
|
|
100,
|
|
(0x2F5, 0x37B),
|
|
f"outlet:{target}:{horizontal}:{vertical}",
|
|
)
|
|
|
|
@staticmethod
|
|
def _require_driver_seat_target(target: str) -> None:
|
|
if target not in DRIVER_SEAT_TARGETS:
|
|
raise NativeCanCompileError(
|
|
"CH1 driver-seat controls only allow driver/front_left; "
|
|
f"refusing target {target}"
|
|
)
|
|
|
|
@staticmethod
|
|
def _reject_unexpected_parameters(
|
|
parameters: dict[str, Any], allowed: set[str]
|
|
) -> None:
|
|
unexpected = set(parameters) - allowed
|
|
if unexpected:
|
|
raise NativeCanCompileError(
|
|
f"unsupported parameter(s): {', '.join(sorted(unexpected))}"
|
|
)
|
|
|
|
def _driver_seat_thermal_frame(
|
|
self, capability: str, parameters: dict[str, Any]
|
|
) -> NativeFrameSpec:
|
|
self._reject_unexpected_parameters(parameters, {"state", "level"})
|
|
state = str(parameters.get("state"))
|
|
if state not in {"on", "off"}:
|
|
raise NativeCanCompileError("state must be on or off")
|
|
if state == "off":
|
|
if parameters.get("level") is not None:
|
|
raise NativeCanCompileError("level must be omitted when state is off")
|
|
control_value = 3
|
|
feedback_value = 1
|
|
else:
|
|
level = parameters.get("level", 2)
|
|
parameters_with_default = {**parameters, "level": level}
|
|
control_value = self._integer(parameters_with_default, "level", 1, 2)
|
|
feedback_value = 3 if control_value == 1 else 2
|
|
|
|
feature = (
|
|
"heating"
|
|
if capability == "seat.set_driver_heating_native"
|
|
else "ventilation"
|
|
)
|
|
payload = bytearray(8)
|
|
payload[1] = (
|
|
control_value << 4 if feature == "heating" else control_value
|
|
)
|
|
return self._fd(
|
|
0x431,
|
|
payload,
|
|
3,
|
|
100,
|
|
(0x4FA,),
|
|
f"driver_seat_{feature}:{feedback_value}",
|
|
)
|
|
|
|
def _driver_seat_massage_frame(
|
|
self, parameters: dict[str, Any]
|
|
) -> NativeFrameSpec:
|
|
action = str(parameters.get("action"))
|
|
payload = bytearray(8)
|
|
payload[0] = 0x12
|
|
if action == "off":
|
|
self._reject_unexpected_parameters(parameters, {"action"})
|
|
payload[3] = 1
|
|
expected = "driver_seat_massage_mode:1"
|
|
elif action == "strength":
|
|
self._reject_unexpected_parameters(parameters, {"action", "strength"})
|
|
strength = self._integer(parameters, "strength", 1, 3)
|
|
payload[1] = strength
|
|
expected = f"driver_seat_massage_strength:{8 + strength}"
|
|
elif action == "mode":
|
|
self._reject_unexpected_parameters(parameters, {"action", "mode"})
|
|
mode = self._enum(
|
|
parameters,
|
|
"mode",
|
|
{
|
|
"wave": 2,
|
|
"pulse": 3,
|
|
"stretch": 4,
|
|
"slack": 5,
|
|
"relax": 6,
|
|
},
|
|
)
|
|
payload[3] = mode
|
|
expected = f"driver_seat_massage_mode:{mode}"
|
|
else:
|
|
raise NativeCanCompileError("action must be off, strength, or mode")
|
|
return self._fd(0x4C1, payload, 3, 100, (0x4FA,), expected)
|
|
|
|
def _driver_seat_axis_frame(
|
|
self, parameters: dict[str, Any]
|
|
) -> NativeFrameSpec:
|
|
self._reject_unexpected_parameters(
|
|
parameters, {"axis", "direction", "duration_ms"}
|
|
)
|
|
axis = str(parameters.get("axis"))
|
|
starts = {
|
|
"horizontal": 8,
|
|
"height": 10,
|
|
"backrest": 12,
|
|
"basin": 14,
|
|
}
|
|
if axis not in starts:
|
|
raise NativeCanCompileError(
|
|
"axis must be horizontal, height, backrest, or basin"
|
|
)
|
|
direction = str(parameters.get("direction"))
|
|
allowed_directions = (
|
|
{"forward": 1, "backward": 2, "stop": 0}
|
|
if axis in {"horizontal", "backrest"}
|
|
else {"up": 1, "down": 2, "stop": 0}
|
|
)
|
|
if direction not in allowed_directions:
|
|
raise NativeCanCompileError(
|
|
f"direction {direction} is not valid for {axis}"
|
|
)
|
|
duration_parameters = {
|
|
**parameters,
|
|
"duration_ms": parameters.get("duration_ms", 500),
|
|
}
|
|
duration_ms = self._integer(
|
|
duration_parameters, "duration_ms", 100, 2000
|
|
)
|
|
|
|
stop_payload = bytearray(8)
|
|
stop_payload[0] = 0x05
|
|
payload = bytearray(stop_payload)
|
|
_set_bits(payload, starts[axis], 2, allowed_directions[direction])
|
|
expected = f"driver_seat_axis:{axis}:{direction}"
|
|
if direction == "stop":
|
|
return self._fd(
|
|
0x4C1, stop_payload, 3, 100, (0x494,), expected
|
|
)
|
|
|
|
repeat_count = (duration_ms + 99) // 100
|
|
return NativeFrameSpec(
|
|
frame_id=0x4C1,
|
|
payload=bytes(payload),
|
|
is_fd=True,
|
|
bitrate_switch=True,
|
|
repeat_count=repeat_count,
|
|
period_ms=100,
|
|
feedback_ids=(0x494,),
|
|
expected_feedback=expected,
|
|
special_handler="driver_seat_axis",
|
|
special_parameters={
|
|
"duration_ms": duration_ms,
|
|
"final_action_hold_ms": 100,
|
|
"stop_data_hex": " ".join(
|
|
f"{value:02X}" for value in stop_payload
|
|
),
|
|
"stop_repeat_count": 3,
|
|
"stop_period_ms": 100,
|
|
},
|
|
)
|
|
|
|
def _driver_seat_memory_frame(
|
|
self, parameters: dict[str, Any]
|
|
) -> NativeFrameSpec:
|
|
"""Compile one learned PAD memory slot without inventing payload bytes."""
|
|
|
|
self._reject_unexpected_parameters(parameters, {"slot"})
|
|
slot = self._integer(parameters, "slot", 1, 3)
|
|
configured = os.getenv(
|
|
"COGNITIVE_AGENT_DRIVER_SEAT_MEMORY_PATH", ""
|
|
).strip()
|
|
path = (
|
|
Path(configured).expanduser()
|
|
if configured
|
|
else Path("/home/orin/project511/project-code/canfd/driver_seat_memory.json")
|
|
)
|
|
try:
|
|
document = json.loads(path.read_text(encoding="utf-8"))
|
|
except (OSError, json.JSONDecodeError) as exc:
|
|
raise NativeCanCompileError(
|
|
f"cannot load learned driver-seat memory file {path}: {exc}"
|
|
) from exc
|
|
if document.get("version") != 1 or not isinstance(
|
|
document.get("positions"), dict
|
|
):
|
|
raise NativeCanCompileError(
|
|
"driver-seat memory file must use version 1 with positions"
|
|
)
|
|
learned = document["positions"].get(str(slot))
|
|
if not isinstance(learned, dict):
|
|
raise NativeCanCompileError(
|
|
f"driver-seat memory slot {slot} has not been learned from PAD"
|
|
)
|
|
|
|
def learned_payload(name: str, sub_id: int) -> bytes:
|
|
raw = learned.get(name)
|
|
try:
|
|
payload = bytes.fromhex(str(raw))
|
|
except ValueError as exc:
|
|
raise NativeCanCompileError(
|
|
f"driver-seat memory slot {slot} has invalid {name}"
|
|
) from exc
|
|
if len(payload) != 8 or payload[0] != sub_id:
|
|
raise NativeCanCompileError(
|
|
f"driver-seat memory slot {slot} {name} must be an 8-byte "
|
|
f"0x{sub_id:02X} learned frame"
|
|
)
|
|
return payload
|
|
|
|
payload_15 = learned_payload("payload_15", 0x15)
|
|
payload_16 = learned_payload("payload_16", 0x16)
|
|
sequence = [
|
|
value.hex(" ").upper()
|
|
for _ in range(10)
|
|
for value in (payload_16, payload_15)
|
|
]
|
|
return NativeFrameSpec(
|
|
frame_id=0x4C1,
|
|
payload=payload_16,
|
|
is_fd=True,
|
|
bitrate_switch=True,
|
|
feedback_ids=(0x494,),
|
|
expected_feedback=f"driver_seat_memory_status:{slot}",
|
|
special_handler="driver_seat_memory_recall",
|
|
special_parameters={
|
|
"slot": slot,
|
|
"fresh_status_timeout_ms": 2_500,
|
|
"post_status_timeout_ms": 1_200,
|
|
"period_ms": 50,
|
|
"frame_sequence": sequence,
|
|
},
|
|
)
|
|
|
|
def _all_reading_light_frame(
|
|
self, parameters: dict[str, Any], feedback_family: str
|
|
) -> NativeFrameSpec:
|
|
self._reject_unexpected_parameters(parameters, {"state"})
|
|
state = self._enum(parameters, "state", {"off": 1, "on": 2})
|
|
payload = bytearray((0, 0, 3, 0xFF, 0xFF, 0, 0, 0))
|
|
_set_bits(payload, 50, 2, state)
|
|
return self._fd(
|
|
0x4F5,
|
|
payload,
|
|
3,
|
|
100,
|
|
(0x42E,),
|
|
f"{feedback_family}:{parameters['state']}",
|
|
)
|
|
|
|
@staticmethod
|
|
def _shade_payload(motion: int) -> bytes:
|
|
payload = bytearray(8)
|
|
_set_bits(payload, 4, 4, motion)
|
|
return bytes(payload)
|
|
|
|
def _shade_preset_frame(self, motion: int) -> NativeFrameSpec:
|
|
target = {1: 100, 2: 0, 3: 50}.get(motion)
|
|
expected = "shade_stopped" if motion == 4 else f"shade_position:{target}"
|
|
return NativeFrameSpec(
|
|
frame_id=0x431,
|
|
payload=self._shade_payload(motion),
|
|
feedback_ids=(0x41A,),
|
|
expected_feedback=expected,
|
|
special_handler="shade_preset",
|
|
special_parameters={
|
|
"motion": motion,
|
|
"target_position_pct": target,
|
|
"motion_timeout_ms": 15_000,
|
|
"tolerance_pct": 2 if target in {0, 100} else 3,
|
|
},
|
|
)
|
|
|
|
def _shade_position_frame(self, position: int) -> NativeFrameSpec:
|
|
return NativeFrameSpec(
|
|
frame_id=0x431,
|
|
payload=self._shade_payload(4),
|
|
feedback_ids=(0x41A,),
|
|
expected_feedback=f"shade_position:{position}",
|
|
special_handler="shade_position",
|
|
special_parameters={
|
|
"position_pct": position,
|
|
"fresh_feedback_timeout_ms": 2_500,
|
|
"motion_timeout_ms": 15_000,
|
|
"tolerance_pct": 2,
|
|
"open_data_hex": "10 00 00 00 00 00 00 00",
|
|
"close_data_hex": "20 00 00 00 00 00 00 00",
|
|
"stop_data_hex": "40 00 00 00 00 00 00 00",
|
|
},
|
|
)
|
|
|
|
|
|
class NativeCanExecutor:
|
|
"""Submit compiled vehicle frames and capture post-command feedback."""
|
|
|
|
def __init__(
|
|
self,
|
|
bus_factory: Callable[[], CanBus],
|
|
*,
|
|
feedback_timeout_ms: int = 1_200,
|
|
feedback_required: bool = True,
|
|
):
|
|
self.bus_factory = bus_factory
|
|
self.feedback_timeout_ms = feedback_timeout_ms
|
|
self.feedback_required = feedback_required
|
|
|
|
def execute(
|
|
self,
|
|
runtime: NativeCanRuntime,
|
|
command_plan: dict[str, Any],
|
|
function_config: dict[str, Any],
|
|
) -> dict[str, Any]:
|
|
started_at = _now()
|
|
operations = {
|
|
operation["id"]: operation
|
|
for operation in function_config["spec"]["operations"]
|
|
}
|
|
results: list[dict[str, Any]] = []
|
|
bus = self.bus_factory()
|
|
try:
|
|
for command in command_plan["spec"]["commands"]:
|
|
operation = operations[command["operation_ref"]]
|
|
publication_start = len(
|
|
getattr(bus, "published_messages", ())
|
|
)
|
|
try:
|
|
set_debug_command = getattr(bus, "set_debug_command", None)
|
|
if callable(set_debug_command):
|
|
set_debug_command(command["signals"].get("debug_command", ""))
|
|
raw_feedback_ids = str(
|
|
command["signals"].get("feedback_ids", "")
|
|
).strip()
|
|
expected_feedback = str(
|
|
command["signals"].get("expected_feedback", "")
|
|
).strip()
|
|
if not raw_feedback_ids or not expected_feedback:
|
|
raise RuntimeError(
|
|
"physical CH1 execution requires an authoritative "
|
|
"feedback ID and a target-value match contract"
|
|
)
|
|
special_handler = command["signals"].get("special_handler")
|
|
feedback_required = self.feedback_required
|
|
if not feedback_required and special_handler in {
|
|
None,
|
|
"shade_preset",
|
|
}:
|
|
# Wireless MQTT uses the state topic as an asynchronous
|
|
# source of truth. Fixed frames and self-terminating
|
|
# shade presets must not hold the HTTP request open
|
|
# while waiting for the same state stream synchronously.
|
|
sent_frames = self._send_command(bus, command)
|
|
feedback = None
|
|
elif special_handler == "window_position":
|
|
sent_frames, feedback = self._execute_window_position(bus, command)
|
|
elif special_handler == "driver_seat_axis":
|
|
sent_frames, feedback = self._execute_driver_seat_axis(
|
|
bus,
|
|
command,
|
|
wait_for_feedback=feedback_required,
|
|
)
|
|
elif special_handler == "driver_seat_memory_recall":
|
|
sent_frames, feedback = self._execute_driver_seat_memory(
|
|
bus,
|
|
command,
|
|
wait_for_completion=feedback_required,
|
|
)
|
|
elif special_handler == "shade_preset":
|
|
sent_frames, feedback = self._execute_shade_preset(
|
|
bus, command
|
|
)
|
|
elif special_handler == "shade_position":
|
|
sent_frames, feedback = self._execute_shade_position(
|
|
bus, command
|
|
)
|
|
else:
|
|
sent_frames = self._send_command(bus, command)
|
|
feedback = self._wait_feedback(bus, command)
|
|
feedback_match = self._feedback_matches(command, feedback)
|
|
verified = feedback is not None and feedback_match is True
|
|
delivered = bool(sent_frames)
|
|
succeeded = verified or (delivered and not feedback_required)
|
|
physical_channel = (
|
|
feedback.channel
|
|
if feedback is not None
|
|
else sent_frames[-1].channel
|
|
if sent_frames
|
|
else None
|
|
)
|
|
mqtt_messages = list(
|
|
getattr(bus, "published_messages", ())[
|
|
publication_start:
|
|
]
|
|
)
|
|
delivery_status = "submitted"
|
|
if not feedback_required and delivered:
|
|
delivery_status = (
|
|
"published"
|
|
if mqtt_messages
|
|
and all(
|
|
message.get("delivery_status") == "published"
|
|
for message in mqtt_messages
|
|
)
|
|
else "queued"
|
|
)
|
|
results.append(
|
|
{
|
|
"ref": operation["id"],
|
|
"domain": function_config["spec"]["domain"],
|
|
"status": "succeeded" if succeeded else "failed",
|
|
"message": (
|
|
"MQTT 白名单控制帧已由 Broker 确认;车辆效果以状态话题回读为准。"
|
|
if not feedback_required and delivery_status == "published"
|
|
else "MQTT 白名单控制帧已进入客户端发布队列;车辆效果以状态话题回读为准。"
|
|
if not feedback_required
|
|
else "CAN 盒已提交车端控制帧并观察到匹配反馈。"
|
|
if feedback_match is True
|
|
else "CAN 盒已提交车端控制帧,但反馈字段与目标值不一致。"
|
|
if feedback_match is False
|
|
else "CAN 盒已提交车端控制帧并观察到状态帧,但该帧没有可证明动作完成的字段。"
|
|
if feedback is not None
|
|
else "CAN 盒已提交车端控制帧;当前反馈窗口未观察到对应状态帧。"
|
|
),
|
|
"telemetry": {
|
|
"target": operation["target"]["zone"],
|
|
"requested_parameters": operation["parameters"],
|
|
"command_frame": runtime.command_frame_public(command),
|
|
"sent_frames": [frame.public() for frame in sent_frames],
|
|
"feedback_frame": feedback.public() if feedback else None,
|
|
"feedback_observed": feedback is not None,
|
|
"feedback_match": feedback_match,
|
|
"feedback_state": self._feedback_state(feedback),
|
|
"feedback_required": feedback_required,
|
|
"delivery_status": delivery_status,
|
|
"physical_effect_confirmed": verified,
|
|
"control_gate": (
|
|
"compiled_action_and_frame_whitelist"
|
|
if not feedback_required
|
|
else "stationary_vehicle_and_feedback"
|
|
),
|
|
"channel": physical_channel,
|
|
"simulated": False,
|
|
**(
|
|
{"mqtt_messages": mqtt_messages}
|
|
if mqtt_messages
|
|
else {}
|
|
),
|
|
},
|
|
}
|
|
)
|
|
except Exception as exc: # noqa: BLE001 - normalize adapter faults
|
|
results.append(
|
|
{
|
|
"ref": operation["id"],
|
|
"domain": function_config["spec"]["domain"],
|
|
"status": "failed",
|
|
"message": str(exc),
|
|
"telemetry": {
|
|
"target": operation["target"]["zone"],
|
|
"requested_parameters": operation["parameters"],
|
|
"command_frame": runtime.command_frame_public(command),
|
|
"channel": 1,
|
|
"simulated": False,
|
|
},
|
|
}
|
|
)
|
|
break
|
|
finally:
|
|
bus.close()
|
|
return runtime._report(function_config, results, started_at, "vehicle")
|
|
|
|
@staticmethod
|
|
def _send_command(bus: CanBus, command: dict[str, Any]) -> list[CanFrame]:
|
|
signals = command["signals"]
|
|
frame_id = int(str(signals["frame_id"]), 0)
|
|
payload = bytes.fromhex(str(signals["data_hex"]))
|
|
repeat = command.get("repeat", {"count": 1, "period_ms": 0})
|
|
sent: list[CanFrame] = []
|
|
for index in range(int(repeat["count"])):
|
|
sent.append(
|
|
bus.send(
|
|
frame_id,
|
|
payload,
|
|
is_fd=bool(signals["is_fd"]),
|
|
bitrate_switch=bool(signals["bitrate_switch"]),
|
|
)
|
|
)
|
|
if index + 1 < int(repeat["count"]):
|
|
time.sleep(int(repeat["period_ms"]) / 1000)
|
|
return sent
|
|
|
|
def _execute_driver_seat_axis(
|
|
self,
|
|
bus: CanBus,
|
|
command: dict[str, Any],
|
|
*,
|
|
wait_for_feedback: bool = True,
|
|
) -> tuple[list[CanFrame], CanFrame | None]:
|
|
"""Send bounded motion and attempt all three stop frames in a finally path."""
|
|
|
|
signals = command["signals"]
|
|
special = json.loads(str(signals["special_parameters"]))
|
|
frame_id = int(str(signals["frame_id"]), 0)
|
|
action_payload = bytes.fromhex(str(signals["data_hex"]))
|
|
stop_payload = bytes.fromhex(str(special["stop_data_hex"]))
|
|
repeat = command.get("repeat", {"count": 1, "period_ms": 0})
|
|
sent: list[CanFrame] = []
|
|
action_error: Exception | None = None
|
|
stop_errors: list[Exception] = []
|
|
try:
|
|
for index in range(int(repeat["count"])):
|
|
sent.append(
|
|
bus.send(
|
|
frame_id,
|
|
action_payload,
|
|
is_fd=True,
|
|
bitrate_switch=True,
|
|
)
|
|
)
|
|
if index + 1 < int(repeat["count"]):
|
|
time.sleep(int(repeat["period_ms"]) / 1000)
|
|
time.sleep(int(special["final_action_hold_ms"]) / 1000)
|
|
except Exception as exc: # noqa: BLE001 - stop is mandatory after adapter faults
|
|
action_error = exc
|
|
finally:
|
|
for index in range(int(special["stop_repeat_count"])):
|
|
try:
|
|
sent.append(
|
|
bus.send(
|
|
frame_id,
|
|
stop_payload,
|
|
is_fd=True,
|
|
bitrate_switch=True,
|
|
)
|
|
)
|
|
except Exception as exc: # noqa: BLE001 - attempt remaining stop frames
|
|
stop_errors.append(exc)
|
|
if index + 1 < int(special["stop_repeat_count"]):
|
|
time.sleep(int(special["stop_period_ms"]) / 1000)
|
|
|
|
if action_error is not None or stop_errors:
|
|
details = []
|
|
if action_error is not None:
|
|
details.append(f"motion send failed: {action_error}")
|
|
if stop_errors:
|
|
details.append(
|
|
f"{len(stop_errors)}/3 forced-stop frame(s) failed"
|
|
)
|
|
raise RuntimeError("; ".join(details))
|
|
return sent, self._wait_feedback(bus, command) if wait_for_feedback else None
|
|
|
|
def _execute_driver_seat_memory(
|
|
self,
|
|
bus: CanBus,
|
|
command: dict[str, Any],
|
|
*,
|
|
wait_for_completion: bool = True,
|
|
) -> tuple[list[CanFrame], CanFrame | None]:
|
|
"""Require fresh initialization, then replay the learned 16/15 sequence."""
|
|
|
|
signals = command["signals"]
|
|
special = json.loads(str(signals["special_parameters"]))
|
|
before = self._wait_for_id(
|
|
bus, 0x494, int(special["fresh_status_timeout_ms"])
|
|
)
|
|
initialized = (
|
|
decode_feedback(before)
|
|
.get("actuator_state", {})
|
|
.get("driver", {})
|
|
.get("position_feedback", {})
|
|
.get("initialized", {})
|
|
if before is not None
|
|
else {}
|
|
)
|
|
axes = ("horizontal", "backrest", "height", "basin")
|
|
if not all(initialized.get(axis) is True for axis in axes):
|
|
raise RuntimeError(
|
|
"no fresh 0x494 proving all four driver-seat axes initialized; "
|
|
"refusing memory recall"
|
|
)
|
|
|
|
frame_id = int(str(signals["frame_id"]), 0)
|
|
sequence = [
|
|
bytes.fromhex(str(value)) for value in special["frame_sequence"]
|
|
]
|
|
if (
|
|
len(sequence) != 20
|
|
or any(len(payload) != 8 for payload in sequence)
|
|
or any(
|
|
payload[0] != (0x16 if index % 2 == 0 else 0x15)
|
|
for index, payload in enumerate(sequence)
|
|
)
|
|
):
|
|
raise RuntimeError("compiled driver-seat memory sequence is invalid")
|
|
sent: list[CanFrame] = []
|
|
for index, payload in enumerate(sequence):
|
|
sent.append(
|
|
bus.send(
|
|
frame_id,
|
|
payload,
|
|
is_fd=True,
|
|
bitrate_switch=True,
|
|
)
|
|
)
|
|
if index + 1 < len(sequence):
|
|
time.sleep(int(special["period_ms"]) / 1_000)
|
|
feedback = (
|
|
self._wait_for_id(
|
|
bus, 0x494, int(special["post_status_timeout_ms"])
|
|
)
|
|
if wait_for_completion
|
|
else None
|
|
)
|
|
return sent, feedback
|
|
|
|
def _execute_shade_preset(
|
|
self, bus: CanBus, command: dict[str, Any]
|
|
) -> tuple[list[CanFrame], CanFrame | None]:
|
|
"""Submit a preset and prove the shade is stopped at its target."""
|
|
|
|
special = json.loads(str(command["signals"]["special_parameters"]))
|
|
sent = self._send_command(bus, command)
|
|
feedback = self._wait_matching_feedback(
|
|
bus,
|
|
command,
|
|
timeout_ms=int(special["motion_timeout_ms"]),
|
|
)
|
|
if feedback is None:
|
|
return sent, None
|
|
roof = decode_feedback(feedback).get("domain_state", {}).get("roof", {})
|
|
target = special.get("target_position_pct")
|
|
if roof.get("motion_state") == "stopped" or target is None:
|
|
return sent, feedback
|
|
|
|
sent.append(bus.send(0x431, NativeCanRuntime._shade_payload(4)))
|
|
final = self._wait_for_shade_stopped(
|
|
bus,
|
|
target=int(target),
|
|
tolerance=int(special["tolerance_pct"]),
|
|
timeout_ms=self.feedback_timeout_ms,
|
|
)
|
|
if final is None:
|
|
raise RuntimeError(
|
|
"shade reached the preset position, but STOP state was not confirmed"
|
|
)
|
|
return sent, final
|
|
|
|
def _execute_shade_position(
|
|
self, bus: CanBus, command: dict[str, Any]
|
|
) -> tuple[list[CanFrame], CanFrame | None]:
|
|
"""Drive toward a non-preset position and always issue a STOP frame."""
|
|
|
|
special = json.loads(str(command["signals"]["special_parameters"]))
|
|
target = int(special["position_pct"])
|
|
tolerance = int(special["tolerance_pct"])
|
|
before = self._wait_for_id(
|
|
bus, 0x41A, int(special["fresh_feedback_timeout_ms"])
|
|
)
|
|
before_state = decode_feedback(before) if before is not None else {}
|
|
roof = before_state.get("domain_state", {}).get("roof", {})
|
|
current = roof.get("shade_position_pct")
|
|
if not isinstance(current, int) or not 0 <= current <= 100:
|
|
raise RuntimeError(
|
|
"no fresh valid 0x41A shade position; refusing blind motion"
|
|
)
|
|
if roof.get("thermal_protection") is True:
|
|
raise RuntimeError(
|
|
"0x41A reports shade thermal protection; refusing motion"
|
|
)
|
|
if abs(current - target) <= tolerance:
|
|
if roof.get("motion_state") == "stopped":
|
|
return [], before
|
|
stopped = bus.send(
|
|
0x431, bytes.fromhex(str(special["stop_data_hex"]))
|
|
)
|
|
final = self._wait_for_shade_stopped(
|
|
bus,
|
|
target=target,
|
|
tolerance=tolerance,
|
|
timeout_ms=self.feedback_timeout_ms,
|
|
)
|
|
if final is None:
|
|
raise RuntimeError(
|
|
"shade is at the target but STOP state was not confirmed"
|
|
)
|
|
return [stopped], final
|
|
|
|
moving_open = target > current
|
|
start_payload = bytes.fromhex(
|
|
str(
|
|
special[
|
|
"open_data_hex" if moving_open else "close_data_hex"
|
|
]
|
|
)
|
|
)
|
|
stop_payload = bytes.fromhex(str(special["stop_data_hex"]))
|
|
sent: list[CanFrame] = []
|
|
reached: CanFrame | None = None
|
|
action_error: Exception | None = None
|
|
stop_error: Exception | None = None
|
|
try:
|
|
sent.append(bus.send(0x431, start_payload))
|
|
deadline = time.monotonic() + int(special["motion_timeout_ms"]) / 1_000
|
|
while time.monotonic() < deadline:
|
|
remaining_ms = max(1, int((deadline - time.monotonic()) * 1_000))
|
|
frame = self._wait_for_id(bus, 0x41A, min(remaining_ms, 200))
|
|
if frame is None:
|
|
continue
|
|
state = decode_feedback(frame).get("domain_state", {}).get("roof", {})
|
|
position = state.get("shade_position_pct")
|
|
if not isinstance(position, int):
|
|
continue
|
|
if state.get("thermal_protection") is True:
|
|
raise RuntimeError("shade entered thermal protection during motion")
|
|
if (moving_open and position >= target) or (
|
|
not moving_open and position <= target
|
|
):
|
|
reached = frame
|
|
break
|
|
except Exception as exc: # noqa: BLE001 - STOP remains mandatory
|
|
action_error = exc
|
|
finally:
|
|
try:
|
|
sent.append(bus.send(0x431, stop_payload))
|
|
except Exception as exc: # noqa: BLE001 - normalize after action error
|
|
stop_error = exc
|
|
|
|
if action_error is not None or stop_error is not None:
|
|
details = []
|
|
if action_error is not None:
|
|
details.append(f"shade motion failed: {action_error}")
|
|
if stop_error is not None:
|
|
details.append(f"mandatory shade STOP failed: {stop_error}")
|
|
raise RuntimeError("; ".join(details))
|
|
if reached is None:
|
|
raise RuntimeError(
|
|
"shade did not cross the requested position before timeout; STOP sent"
|
|
)
|
|
|
|
# Confirm both the final position and that the mechanism reports STOP.
|
|
final = self._wait_for_shade_stopped(
|
|
bus,
|
|
target=target,
|
|
tolerance=tolerance,
|
|
timeout_ms=self.feedback_timeout_ms,
|
|
)
|
|
if final is None:
|
|
raise RuntimeError(
|
|
"shade crossed the target and STOP was sent, but no stopped 0x41A "
|
|
"within the target tolerance was observed"
|
|
)
|
|
return sent, final
|
|
|
|
def _wait_feedback(
|
|
self, bus: CanBus, command: dict[str, Any]
|
|
) -> CanFrame | None:
|
|
raw_ids = str(command["signals"].get("feedback_ids", ""))
|
|
feedback_ids = {
|
|
int(value, 0) for value in raw_ids.split(",") if value.strip()
|
|
}
|
|
if not feedback_ids:
|
|
return None
|
|
expected = str(command["signals"].get("expected_feedback", ""))
|
|
last_candidate: CanFrame | None = None
|
|
deadline = time.monotonic() + self.feedback_timeout_ms / 1000
|
|
while time.monotonic() < deadline:
|
|
remaining_ms = max(1, int((deadline - time.monotonic()) * 1000))
|
|
frame = bus.receive(min(remaining_ms, 100), include_fd=True)
|
|
if frame is None or frame.arbitration_id not in feedback_ids:
|
|
continue
|
|
last_candidate = frame
|
|
if feedback_matches(expected, frame) is True:
|
|
return frame
|
|
return last_candidate
|
|
|
|
@staticmethod
|
|
def _wait_matching_feedback(
|
|
bus: CanBus,
|
|
command: dict[str, Any],
|
|
*,
|
|
timeout_ms: int,
|
|
) -> CanFrame | None:
|
|
raw_ids = str(command["signals"].get("feedback_ids", ""))
|
|
feedback_ids = {
|
|
int(value, 0) for value in raw_ids.split(",") if value.strip()
|
|
}
|
|
expected = str(command["signals"].get("expected_feedback", ""))
|
|
deadline = time.monotonic() + max(1, timeout_ms) / 1_000
|
|
last_candidate: CanFrame | None = None
|
|
while time.monotonic() < deadline:
|
|
remaining_ms = max(1, int((deadline - time.monotonic()) * 1_000))
|
|
frame = bus.receive(min(remaining_ms, 100), include_fd=True)
|
|
if frame is None or frame.arbitration_id not in feedback_ids:
|
|
continue
|
|
last_candidate = frame
|
|
if feedback_matches(expected, frame) is True:
|
|
return frame
|
|
return last_candidate
|
|
|
|
@staticmethod
|
|
def _wait_for_shade_stopped(
|
|
bus: CanBus,
|
|
*,
|
|
target: int,
|
|
tolerance: int,
|
|
timeout_ms: int,
|
|
) -> CanFrame | None:
|
|
deadline = time.monotonic() + max(1, timeout_ms) / 1_000
|
|
while time.monotonic() < deadline:
|
|
remaining_ms = max(1, int((deadline - time.monotonic()) * 1_000))
|
|
frame = bus.receive(min(remaining_ms, 100), include_fd=True)
|
|
if frame is None or frame.arbitration_id != 0x41A:
|
|
continue
|
|
roof = decode_feedback(frame).get("domain_state", {}).get("roof", {})
|
|
position = roof.get("shade_position_pct")
|
|
if (
|
|
isinstance(position, int)
|
|
and abs(position - target) <= tolerance
|
|
and roof.get("motion_state") == "stopped"
|
|
):
|
|
return frame
|
|
return None
|
|
|
|
@staticmethod
|
|
def _feedback_state(frame: CanFrame | None) -> dict[str, Any] | None:
|
|
if frame is None:
|
|
return None
|
|
decoded = decode_feedback(frame)
|
|
source = f"0x{frame.arbitration_id:03X}"
|
|
if frame.arbitration_id == 0x4FA:
|
|
if len(frame.data) < 8:
|
|
return {"source": source, "valid": False}
|
|
thermal = {1: "off", 2: "low", 3: "high"}
|
|
massage_modes = {
|
|
1: "off",
|
|
2: "wave",
|
|
3: "pulse",
|
|
4: "stretch",
|
|
5: "slack",
|
|
6: "relax",
|
|
}
|
|
massage_strengths = {8: "off", 9: "weak", 10: "medium", 11: "strong"}
|
|
ventilation_raw = frame.data[0] & 0x07
|
|
heating_raw = (frame.data[0] >> 3) & 0x07
|
|
massage_mode_raw = frame.data[5] & 0x0F
|
|
massage_strength_raw = (frame.data[5] >> 4) & 0x0F
|
|
return {
|
|
**decoded,
|
|
"feedback_frame_id": source,
|
|
"valid": True,
|
|
"target": "driver",
|
|
"ventilation": thermal.get(
|
|
ventilation_raw, f"unknown({ventilation_raw})"
|
|
),
|
|
"heating": thermal.get(heating_raw, f"unknown({heating_raw})"),
|
|
"massage_mode": massage_modes.get(
|
|
massage_mode_raw, f"unknown({massage_mode_raw})"
|
|
),
|
|
"massage_strength": massage_strengths.get(
|
|
massage_strength_raw, f"unknown({massage_strength_raw})"
|
|
),
|
|
}
|
|
if frame.arbitration_id == 0x494:
|
|
if len(frame.data) < 8:
|
|
return {"source": source, "valid": False}
|
|
initialized = {
|
|
"horizontal": bool((frame.data[7] >> 2) & 1),
|
|
"backrest": bool((frame.data[7] >> 3) & 1),
|
|
"height": bool((frame.data[7] >> 4) & 1),
|
|
"basin": bool((frame.data[7] >> 5) & 1),
|
|
}
|
|
return {
|
|
**decoded,
|
|
"feedback_frame_id": source,
|
|
"valid": True,
|
|
"target": "driver",
|
|
"initialized": initialized,
|
|
"execution_confirmation": "status_frame_only",
|
|
"limitation": "0x494 has no absolute motor position",
|
|
}
|
|
return {**decoded, "feedback_frame_id": source, "valid": True}
|
|
|
|
def _execute_window_position(
|
|
self, bus: CanBus, command: dict[str, Any]
|
|
) -> tuple[list[CanFrame], CanFrame | None]:
|
|
parameters = json.loads(command["signals"]["special_parameters"])
|
|
target = str(parameters["target"])
|
|
goal = int(parameters["position_pct"])
|
|
indexes = {"front_left": 0, "rear_left": 1, "front_right": 2, "rear_right": 3}
|
|
index = indexes[target]
|
|
before = self._wait_for_id(bus, 0x388, 2_500)
|
|
if before is None or len(before.data) < 6 or before.data[2 + index] > 100:
|
|
raise RuntimeError("未收到有效且新鲜的 0x388 车窗位置,拒绝启动机构")
|
|
current = int(before.data[2 + index])
|
|
if abs(current - goal) <= 3:
|
|
return [], before
|
|
motion = 1 if goal > current else 2
|
|
start_payload = NativeCanRuntime._window_payload(target, motion)
|
|
stop_payload = NativeCanRuntime._window_payload(target, 3)
|
|
sent: list[CanFrame] = []
|
|
for attempt in range(3):
|
|
sent.append(bus.send(0x431, start_payload, is_fd=True, bitrate_switch=True))
|
|
if attempt < 2:
|
|
time.sleep(0.1)
|
|
deadline = time.monotonic() + 12
|
|
reached: CanFrame | None = None
|
|
try:
|
|
while time.monotonic() < deadline:
|
|
frame = self._wait_for_id(bus, 0x388, 200)
|
|
if frame is None or len(frame.data) < 6 or frame.data[2 + index] > 100:
|
|
continue
|
|
if abs(int(frame.data[2 + index]) - goal) <= 3:
|
|
reached = frame
|
|
break
|
|
finally:
|
|
for attempt in range(3):
|
|
sent.append(bus.send(0x431, stop_payload, is_fd=True, bitrate_switch=True))
|
|
if attempt < 2:
|
|
time.sleep(0.1)
|
|
if reached is None:
|
|
raise RuntimeError("车窗在 12 秒内未到达目标,已发送停止序列")
|
|
return sent, reached
|
|
|
|
@staticmethod
|
|
def _wait_for_id(bus: CanBus, frame_id: int, timeout_ms: int) -> CanFrame | None:
|
|
deadline = time.monotonic() + timeout_ms / 1000
|
|
while time.monotonic() < deadline:
|
|
remaining = max(1, int((deadline - time.monotonic()) * 1000))
|
|
frame = bus.receive(min(remaining, 100), include_fd=True)
|
|
if frame is not None and frame.arbitration_id == frame_id:
|
|
return frame
|
|
return None
|
|
|
|
@staticmethod
|
|
def _feedback_matches(
|
|
command: dict[str, Any], frame: CanFrame | None
|
|
) -> bool | None:
|
|
expected = str(command["signals"].get("expected_feedback", ""))
|
|
return feedback_matches(expected, frame)
|