367 lines
18 KiB
Python
367 lines
18 KiB
Python
"""Deterministic bridge from CAN_DEV plans to the trusted native CAN API.
|
|
|
|
The mapping in this module is intentionally equivalent to CAN_DEV's
|
|
``apps/web-console/app.js::cxxCanCommandsForOperation``. Natural language is
|
|
never forwarded to the hardware gateway. Only structured capabilities are
|
|
converted to the gateway's bounded command grammar, validated as a complete
|
|
batch, and then (when explicitly enabled) executed in order.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import json
|
|
import os
|
|
from dataclasses import dataclass
|
|
from typing import Any
|
|
from urllib.error import HTTPError, URLError
|
|
from urllib.parse import urlparse
|
|
from urllib.request import Request, urlopen
|
|
|
|
|
|
class NativeCanError(RuntimeError):
|
|
"""Raised when native CAN validation or execution cannot complete safely."""
|
|
|
|
|
|
def _target(operation: dict[str, Any]) -> str:
|
|
target = operation.get("target") or {}
|
|
return str(target.get("instance") or target.get("zone") or "").replace("_", "-")
|
|
|
|
|
|
def _integer(parameters: dict[str, Any], name: str, minimum: int, maximum: int) -> int | None:
|
|
value = parameters.get(name)
|
|
if value is None or value == "" or isinstance(value, bool):
|
|
return None
|
|
try:
|
|
parsed = int(value)
|
|
except (TypeError, ValueError):
|
|
return None
|
|
if str(value).strip() not in {str(parsed), f"+{parsed}"} and not isinstance(value, int):
|
|
return None
|
|
return parsed if minimum <= parsed <= maximum else None
|
|
|
|
|
|
def commands_for_operation(operation: dict[str, Any]) -> list[str]:
|
|
"""Convert one validated CAN_DEV operation to native gateway commands."""
|
|
|
|
capability = str(operation.get("capability") or "")
|
|
parameters = dict(operation.get("parameters") or {})
|
|
target = _target(operation)
|
|
state_value = parameters.get("state", parameters.get("power"))
|
|
on_off = state_value if state_value in {"on", "off"} else None
|
|
|
|
if capability in {"seat.set_driver_heating_native", "seat.set_driver_ventilation_native"}:
|
|
if target != "driver" or not on_off:
|
|
return []
|
|
feature = "heat" if "heating" in capability else "vent"
|
|
if on_off == "off":
|
|
return [f"seat driver {feature} off"]
|
|
level = _integer(parameters, "level", 1, 2)
|
|
state = "high" if level == 1 else "low" if level == 2 else None
|
|
return [f"seat driver {feature} {state}"] if state else []
|
|
|
|
if capability == "seat.set_driver_massage_native":
|
|
if target != "driver":
|
|
return []
|
|
if parameters.get("action") == "off":
|
|
return ["seat driver massage off"]
|
|
if parameters.get("action") == "strength":
|
|
strength = _integer(parameters, "strength", 1, 3)
|
|
return [f"seat driver massage strength {['', 'weak', 'medium', 'strong'][strength]}"] if strength else []
|
|
if parameters.get("action") == "mode" and parameters.get("mode") in {
|
|
"wave", "pulse", "stretch", "slack", "relax",
|
|
}:
|
|
return [f"seat driver massage mode {parameters['mode']}"]
|
|
return []
|
|
|
|
if capability == "seat.set_driver_axis_native":
|
|
directions = {
|
|
"horizontal": {"forward", "backward", "stop"},
|
|
"height": {"up", "down", "stop"},
|
|
"backrest": {"forward", "backward", "stop"},
|
|
"basin": {"up", "down", "stop"},
|
|
}
|
|
axis = parameters.get("axis")
|
|
direction = parameters.get("direction")
|
|
if target != "driver" or direction not in directions.get(axis, set()):
|
|
return []
|
|
if direction == "stop":
|
|
return [f"seat driver {axis} stop"]
|
|
duration = _integer(parameters, "duration_ms", 100, 2000)
|
|
return [f"seat driver {axis} {direction} {duration}"] if duration else []
|
|
|
|
if capability == "seat.set_driver_memory_native":
|
|
action = parameters.get("action")
|
|
position = _integer(parameters, "position", 1, 3)
|
|
return [f"seat driver memory {action} {position}"] if target == "driver" and action in {"recall", "save"} and position else []
|
|
|
|
if capability in {"seat.set_heating", "seat.set_ventilation"}:
|
|
seats = {"driver": "driver", "front-left": "driver"}
|
|
if seats.get(target) != "driver" or not on_off:
|
|
return []
|
|
feature = "heat" if capability.endswith("heating") else "vent"
|
|
if on_off == "off":
|
|
return [f"seat driver {feature} off"]
|
|
level = _integer(parameters, "level", 1, 5)
|
|
return [f"seat driver {feature} {'high' if level and level >= 3 else 'low'}"]
|
|
|
|
if capability in {"climate.set_power", "climate.set_ac"}:
|
|
return [f"ac {on_off}"] if on_off and target in {"vehicle", "cabin"} else []
|
|
if capability == "climate.set_fan_level":
|
|
level = _integer(parameters, "fan_level", 1, 7)
|
|
if level is None:
|
|
level = _integer(parameters, "level", 1, 7)
|
|
return [f"ac fan {level}"] if level is not None else []
|
|
if capability == "climate.set_temperature":
|
|
temperature = _integer(parameters, "temperature_c", 18, 32)
|
|
zones = {
|
|
"driver": ["driver"], "front-left": ["driver"],
|
|
"passenger": ["passenger"], "front-right": ["passenger"],
|
|
"rear": ["rear"], "rear-row": ["rear"], "rear-left": ["rear"],
|
|
"rear-right": ["rear"], "front-row": ["driver", "passenger"],
|
|
"vehicle": ["driver", "passenger"], "cabin": ["driver", "passenger"],
|
|
}.get(target)
|
|
return [f"ac temp {zone} {temperature}" for zone in zones or []] if temperature is not None else []
|
|
if capability == "climate.set_outlet_mode":
|
|
aliases = {
|
|
"face": "face", "face-feet": "face-feet", "face_feet": "face-feet",
|
|
"feet": "feet", "feet-defrost": "feet-defrost", "feet_defrost": "feet-defrost",
|
|
"defrost": "defrost", "face-feet-defrost": "face-feet-defrost",
|
|
"face_feet_defrost": "face-feet-defrost", "face-defrost": "face-defrost",
|
|
"face_defrost": "face-defrost",
|
|
}
|
|
mode = aliases.get(parameters.get("outlet_mode", parameters.get("mode")))
|
|
return [f"ac mode {mode}"] if mode and target in {"vehicle", "cabin"} else []
|
|
if capability == "climate.set_front_defrost" and on_off == "on":
|
|
return ["ac mode defrost"]
|
|
if capability == "climate.set_control_mode" and parameters.get("mode") in {"manual", "auto"}:
|
|
return [f"ac control {parameters['mode']}"]
|
|
|
|
if capability == "door.set_rear_lock":
|
|
state = parameters.get("state")
|
|
return [f"door rear {state}"] if target == "rear" and state in {"lock", "unlock"} else []
|
|
if capability == "window.set_motion":
|
|
motion = parameters.get("motion")
|
|
scopes = {"all", "front", "rear", "front-left", "front-right", "rear-left", "rear-right"}
|
|
return [f"window {target} {motion}"] if target in scopes and motion in {"open", "close", "stop"} else []
|
|
if capability == "window.set_position":
|
|
position = _integer(parameters, "position_pct", 0, 100)
|
|
scopes = {"front-left", "front-right", "rear-left", "rear-right"}
|
|
return [f"window {target} position {position}"] if target in scopes and position is not None else []
|
|
|
|
if capability in {"outlet.set_position_percent", "outlet.set_calibrated_position"}:
|
|
scopes = {"driver-left", "driver-right", "passenger-left", "passenger-right"}
|
|
if capability.endswith("percent"):
|
|
horizontal = _integer(parameters, "horizontal_pct", 0, 100)
|
|
vertical = _integer(parameters, "vertical_pct", 0, 100)
|
|
command_type = "percent"
|
|
else:
|
|
horizontal = _integer(parameters, "horizontal_value", 0, 255)
|
|
vertical = _integer(parameters, "vertical_value", 0, 255)
|
|
command_type = "position"
|
|
return [f"outlet {target} {command_type} {horizontal} {vertical}"] if target in scopes and horizontal is not None and vertical is not None else []
|
|
if capability == "outlet.set_airflow_mode":
|
|
mode, state = parameters.get("mode"), parameters.get("state")
|
|
return [f"outlet mode {target} {mode} {state}"] if target in {"driver", "passenger"} and mode in {"person", "avoid", "free"} and state in {"on", "off"} else []
|
|
if capability == "outlet.set_sweep_mode":
|
|
axis = {"up_down": "up-down", "left_right": "left-right"}.get(parameters.get("axis"))
|
|
action = parameters.get("action")
|
|
return [f"outlet mode {target} sweep {axis} {action}"] if target in {"driver", "passenger"} and axis and action in {"start", "stop"} else []
|
|
if capability == "outlet.set_power":
|
|
state = parameters.get("state")
|
|
return [f"outlet mode {target} {state}"] if target in {"driver", "passenger"} and state in {"open", "close"} else []
|
|
|
|
if capability == "roof.set_shade_position":
|
|
position = _integer(parameters, "shade_position_pct", 0, 100)
|
|
if position is None:
|
|
position = _integer(parameters, "position_pct", 0, 100)
|
|
return [f"shade position {position}"] if position is not None else []
|
|
if capability == "roof.set_shade_motion" and parameters.get("motion") in {"open", "close", "half", "stop"}:
|
|
return [f"shade {parameters['motion']}"]
|
|
|
|
if capability == "interior_light.set_indoor_power":
|
|
return [f"light indoor {on_off}"] if on_off else []
|
|
if capability == "interior_light.set_reading_power":
|
|
return [f"light reading all {on_off}"] if target == "all" and on_off else []
|
|
if capability == "ambient_light.set_power":
|
|
return [f"light ambient {on_off}"] if on_off else []
|
|
if capability == "ambient_light.set_color_index_native":
|
|
color = _integer(parameters, "color_index", 1, 127)
|
|
return [f"light ambient color {color}"] if color is not None else []
|
|
if capability == "ambient_light.set_brightness_level_native":
|
|
level = _integer(parameters, "level", 1, 5)
|
|
return [f"light ambient brightness {level}"] if level is not None else []
|
|
if capability == "ambient_light.set_mode" and parameters.get("mode") in {"on", "off"}:
|
|
return [f"light ambient {parameters['mode']}"]
|
|
return []
|
|
|
|
|
|
def extract_native_commands(result: dict[str, Any]) -> tuple[list[str], list[str]]:
|
|
"""Return unique commands and explicit labels for unsupported operations."""
|
|
|
|
operations: list[dict[str, Any]] = []
|
|
for config in (result.get("function_configs") or {}).values():
|
|
operations.extend((config or {}).get("spec", {}).get("operations", []))
|
|
for request in result.get("deferred_requests") or []:
|
|
parameters = request.get("parameters") or {}
|
|
if isinstance(parameters, list):
|
|
parameters = {item.get("name"): item.get("value") for item in parameters if isinstance(item, dict)}
|
|
operations.append({
|
|
"capability": request.get("capability"),
|
|
"target": {"zone": request.get("target_scope")},
|
|
"parameters": parameters,
|
|
})
|
|
|
|
commands: list[str] = []
|
|
unsupported: list[str] = []
|
|
for operation in operations:
|
|
converted = commands_for_operation(operation)
|
|
if converted:
|
|
commands.extend(converted)
|
|
else:
|
|
unsupported.append(str(operation.get("capability") or "unknown"))
|
|
return list(dict.fromkeys(commands)), list(dict.fromkeys(unsupported))
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class NativeCanSettings:
|
|
endpoint: str
|
|
execution_mode: str
|
|
approval_token: str
|
|
timeout: float
|
|
|
|
@classmethod
|
|
def from_env(cls) -> "NativeCanSettings":
|
|
mode = os.environ.get("COGNITIVE_AGENT_EXECUTION_MODE", "dry_run").strip().lower()
|
|
if mode not in {"dry_run", "hardware"}:
|
|
mode = "dry_run"
|
|
return cls(
|
|
endpoint=os.environ.get("COGNITIVE_AGENT_NATIVE_CAN_URL", "http://127.0.0.1:8765").rstrip("/"),
|
|
execution_mode=mode,
|
|
approval_token=os.environ.get("COGNITIVE_AGENT_NATIVE_CAN_APPROVAL_TOKEN", "").strip(),
|
|
timeout=float(os.environ.get("COGNITIVE_AGENT_NATIVE_CAN_TIMEOUT", "20")),
|
|
)
|
|
|
|
|
|
class NativeCanClient:
|
|
def __init__(self, settings: NativeCanSettings | None = None):
|
|
self.settings = settings or NativeCanSettings.from_env()
|
|
|
|
def _request(self, path: str, *, body: str | None = None, token: bool = False, timeout: float | None = None) -> tuple[int, dict[str, Any]]:
|
|
headers = {"Accept": "application/json"}
|
|
data = None
|
|
if body is not None:
|
|
data = body.encode("utf-8")
|
|
headers["Content-Type"] = "text/plain; charset=utf-8"
|
|
if token:
|
|
headers["X-CAN-Approval-Token"] = self.settings.approval_token
|
|
request = Request(self.settings.endpoint + path, data=data, headers=headers, method="POST" if data is not None else "GET")
|
|
try:
|
|
with urlopen(request, timeout=timeout or self.settings.timeout) as response: # noqa: S310 - operator configured endpoint
|
|
raw, status = response.read(), int(response.status)
|
|
except HTTPError as exc:
|
|
raw, status = exc.read(), int(exc.code)
|
|
except (URLError, TimeoutError, OSError) as exc:
|
|
raise NativeCanError(f"无法连接原生 CAN 网关:{urlparse(self.settings.endpoint).hostname or self.settings.endpoint}") from exc
|
|
try:
|
|
payload = json.loads(raw.decode("utf-8")) if raw else {}
|
|
except (UnicodeDecodeError, json.JSONDecodeError) as exc:
|
|
raise NativeCanError("原生 CAN 网关返回了无法识别的数据。") from exc
|
|
if not isinstance(payload, dict):
|
|
raise NativeCanError("原生 CAN 网关未返回 JSON 对象。")
|
|
return status, payload
|
|
|
|
def status(self) -> dict[str, Any]:
|
|
try:
|
|
status, runtime = self._request("/api/can/runtime", timeout=0.8)
|
|
connected = 200 <= status < 300 and runtime.get("running") is not False
|
|
return {
|
|
"connected": connected,
|
|
"endpoint": self.settings.endpoint,
|
|
"execution_mode": self.settings.execution_mode,
|
|
"gateway_mode": runtime.get("mode", "unknown"),
|
|
"control_ready": bool(connected and runtime.get("mode") == "control" and runtime.get("control_token_configured")),
|
|
"adapter": runtime.get("adapter", {}),
|
|
"latest_state": runtime,
|
|
"message": "原生 CAN 网关在线" if connected else "原生 CAN 网关异常",
|
|
}
|
|
except NativeCanError as exc:
|
|
return {
|
|
"connected": False,
|
|
"endpoint": self.settings.endpoint,
|
|
"execution_mode": self.settings.execution_mode,
|
|
"gateway_mode": "offline",
|
|
"control_ready": False,
|
|
"message": str(exc),
|
|
}
|
|
|
|
def process(self, result: dict[str, Any]) -> dict[str, Any]:
|
|
commands, unsupported = extract_native_commands(result)
|
|
report: dict[str, Any] = {
|
|
"mode": "native_can" if self.settings.execution_mode == "hardware" else "dry_run",
|
|
"status": "blocked",
|
|
"commands": [],
|
|
"skipped": unsupported,
|
|
"target_state_confirmed": False,
|
|
"physical_state_confirmed": False,
|
|
"message": "没有可映射的受信任 CAN 命令。",
|
|
}
|
|
if not commands:
|
|
return report
|
|
|
|
validated: list[str] = []
|
|
for command in commands:
|
|
status, payload = self._request("/api/can/validate", body=command)
|
|
if not 200 <= status < 300 or payload.get("valid") is not True or payload.get("transmitted") is not False:
|
|
report["message"] = f"CAN 只读校验失败,未发送任何控制帧:{payload.get('message') or command}"
|
|
return report
|
|
validated.append(str(payload.get("command") or command))
|
|
|
|
if self.settings.execution_mode != "hardware":
|
|
report.update({
|
|
"status": "validated",
|
|
"commands": [{"command": command, "status": "validated"} for command in validated],
|
|
"message": f"{len(validated)} 条车控命令已通过只读校验;当前为安全演练模式,未发送 CAN。",
|
|
})
|
|
return report
|
|
if len(self.settings.approval_token) < 6:
|
|
report["message"] = "实车模式缺少至少 6 字符的原生 CAN 授权令牌。"
|
|
return report
|
|
|
|
report["status"] = "running"
|
|
target_state_confirmed = True
|
|
for command in validated:
|
|
status, payload = self._request("/api/can/command", body=command, token=True)
|
|
accepted = 200 <= status < 300 and payload.get("accepted") is True
|
|
execution = payload.get("execution") or {}
|
|
report["commands"].append({
|
|
"command": str(payload.get("command") or command),
|
|
"status": "confirmed" if execution.get("feedback_matched") else "accepted" if accepted else "failed",
|
|
"message": payload.get("message") or payload.get("meaning"),
|
|
"execution": execution,
|
|
})
|
|
if not accepted:
|
|
report["status"] = "failed"
|
|
report["message"] = str(payload.get("message") or "CAN 命令失败,后续动作已停止。")
|
|
return report
|
|
target_state_confirmed = (
|
|
target_state_confirmed
|
|
and execution.get("feedback_matched") is True
|
|
)
|
|
|
|
report["status"] = "confirmed" if target_state_confirmed else "accepted"
|
|
report["target_state_confirmed"] = target_state_confirmed
|
|
# CAN feedback proves that a target state appeared on the bus. It does
|
|
# not independently prove that the physical mechanism moved; that
|
|
# requires a dedicated physical sensor or operator/HIL observation.
|
|
report["physical_state_confirmed"] = False
|
|
report["message"] = (
|
|
f"{len(validated)} 条 CAN 命令均收到目标状态反馈(不等同于实体机构确认)。"
|
|
if target_state_confirmed
|
|
else f"{len(validated)} 条 CAN 命令已由适配器提交;部分动作尚无目标状态反馈,请核对实体设备。"
|
|
)
|
|
try:
|
|
_, report["latest_state"] = self._request("/api/can/status", timeout=1.5)
|
|
except NativeCanError:
|
|
pass
|
|
return report
|