优化前端,优化前端指令,优化自定义控制

This commit is contained in:
minghanchen
2026-08-24 09:40:16 +08:00
parent fff7657fea
commit 8d05815773
130 changed files with 5543 additions and 2206 deletions
@@ -767,7 +767,7 @@ class VehicleCanSettings:
@dataclass(frozen=True)
class VehicleMqttSettings:
"""Wireless CAN0/CAN1 relay settings with the same physical approval gate."""
"""Wireless CAN0/CAN1 relay settings for the local head-unit broker."""
enabled: bool
approval_required: bool
@@ -789,7 +789,11 @@ class VehicleMqttSettings:
return cls(
enabled=_env_bool("COGNITIVE_AGENT_VEHICLE_MQTT_ENABLED", default=True),
approval_required=_env_bool(
"COGNITIVE_AGENT_HARDWARE_APPROVAL_TOKEN_REQUIRED", default=True
"COGNITIVE_AGENT_VEHICLE_MQTT_APPROVAL_TOKEN_REQUIRED",
default=_env_bool(
"COGNITIVE_AGENT_HARDWARE_APPROVAL_TOKEN_REQUIRED",
default=False,
),
),
approval_token=os.getenv("COGNITIVE_AGENT_HARDWARE_APPROVAL_TOKEN", ""),
host=os.getenv("COGNITIVE_AGENT_VEHICLE_MQTT_HOST", "127.0.0.1").strip(),
@@ -800,7 +804,7 @@ class VehicleMqttSettings:
).strip(),
control_topic=os.getenv(
"COGNITIVE_AGENT_VEHICLE_MQTT_CONTROL_TOPIC",
"n7/can_control",
"n7/n7-headunit/can/command",
).strip(),
qos=_env_int("COGNITIVE_AGENT_VEHICLE_MQTT_QOS", 1),
keepalive_s=_env_int("COGNITIVE_AGENT_VEHICLE_MQTT_KEEPALIVE_S", 30),
@@ -871,6 +875,8 @@ class VehicleMqttSettings:
"channels": {"seat": 0, "vehicle": 1},
"target_policy": "dual_channel_mqtt_can0_passenger_can1_vehicle",
"frame_contract": "n7.can.frame.v1",
"state_contract": "n7-can-signals/v1",
"control_contract": "n7-headunit-can-command/v1",
"can0_seat_protocol": "seat-ota-runtime",
"can0_state_contract": "0x6A0-SEAT_INFO-plus-0x6A2-to-0x302",
"can0_control_id": "0x6A0",
@@ -27,7 +27,6 @@ from typing import Any
from cognitive_agent.application.native_can_feedback import decode_feedback
from cognitive_agent.application.vehicle_state_monitor import (
OBSERVABLE_IDS,
REQUIRED_UI_IDS,
VehicleStateSnapshot,
)
from cognitive_agent.infrastructure.can.gateway import (
@@ -38,16 +37,25 @@ from cognitive_agent.infrastructure.can.hardware import VehicleMqttSettings
MQTT_CAN_SCHEMA = "n7.can.frame.v1"
MQTT_POLL_INTERVAL_MS = 500
WIRELESS_MQTT_CH1_SOURCE = "wireless_mqtt_ch1"
MQTT_VALID_FD_PAYLOAD_LENGTHS = frozenset(
{*range(9), 12, 16, 20, 24, 32, 48, 64}
)
MQTT_SAFETY_FEEDBACK_IDS = frozenset({0x342, 0x4EB})
MQTT_VEHICLE_FEEDBACK_IDS = OBSERVABLE_IDS | frozenset({0x2F5, 0x37B})
MQTT_SEAT_FEEDBACK_IDS = frozenset({*range(0x301, 0x308), 0x4FA, 0x6A2})
# Frames accepted from the Android head unit are the seat-ota wire protocol.
# ``0x302`` is an internal compatibility frame synthesized below so the
# existing seat-domain state reader can consume the four firmware axes; it is
# deliberately not accepted as authoritative MQTT input.
MQTT_SEAT_WIRE_FEEDBACK_IDS = frozenset({0x6A0, 0x6A2})
MQTT_SEAT_FEEDBACK_IDS = frozenset({0x302, 0x6A2})
MQTT_ACCEPTED_FEEDBACK_IDS = {
0: MQTT_SEAT_FEEDBACK_IDS,
1: MQTT_VEHICLE_FEEDBACK_IDS,
}
MQTT_APPROVED_CONTROL_IDS = {
0: frozenset({0x6A0}),
1: frozenset({0x1DE, 0x3FC, 0x431, 0x4C1, 0x4EF, 0x4F5}),
1: frozenset({0x1DE, 0x32B, 0x3FC, 0x431, 0x4C1, 0x4EF, 0x4F5}),
}
MQTT_OTA_SEAT_RUNTIME_OPCODES = frozenset({0x63, 0x64, 0x65, 0x66})
_ID_KEYS = ("arbitration_id", "can_id", "canId", "frame_id", "frameId", "id")
@@ -184,8 +192,8 @@ def _ota_seat_status_frames(frames: list[CanFrame]) -> list[CanFrame]:
The seat-ota runtime returns a selector value in a generic response frame:
0x6A0 is ``C7 sequence 60 selector ...`` and 0x6A2 is
``7C sequence 60 status uint32-le``. Only the non-mutating SEAT_INFO
opcode is paired here. Neither OTA identifier is added to an outbound
allow-list or exposed to the ordinary seat executor.
opcode is paired here. The original 0x6A2 response remains available to
command-response waiters; the generated 0x302 frame is internal only.
"""
selectors_by_request: dict[tuple[int, int], int] = {}
@@ -220,19 +228,30 @@ def _ota_seat_status_frames(frames: list[CanFrame]) -> list[CanFrame]:
for axis in range(4):
position_entry = values.get(1 + axis)
flags_entry = values.get(5 + axis)
if position_entry is None or flags_entry is None:
if position_entry is None:
continue
packed_position, position_frame = position_entry
flags, flags_frame = flags_entry
position = packed_position & 0xFFFF
hall_range = (packed_position >> 16) & 0xFFFF
if hall_range <= 0 or position > hall_range:
continue
value_x100 = min(10_000, round(position * 10_000 / hall_range))
moving = bool(flags & (1 << 1))
position_valid = bool(flags & (1 << 0))
driver_fault = bool(flags & (1 << 3))
direction = (flags >> 8) & 0x03
if flags_entry is None:
# The production Android poller intentionally requests selectors
# 1..4 only. A valid position/range pair is sufficient to expose
# position, but it does not prove motion or a driver fault.
flags = 0
flags_frame = position_frame
moving = False
position_valid = True
driver_fault = False
direction = 0
else:
flags, flags_frame = flags_entry
moving = bool(flags & (1 << 1))
position_valid = bool(flags & (1 << 0))
driver_fault = bool(flags & (1 << 3))
direction = (flags >> 8) & 0x03
task_state = 1 if moving else 0
result = 7 if driver_fault else 0
state_flags = direction
@@ -323,29 +342,76 @@ def parse_mqtt_can_payload(payload: bytes | str) -> list[CanFrame]:
item_count = sum(len(items) for items, _ in batches)
if not 1 <= item_count <= 256:
raise ValueError("MQTT CAN frame batch must contain between 1 and 256 frames")
frames = [
parsed_frames = [
_parse_one_frame(item, default_channel=default_channel)
for items, default_channel in batches
for item in items
]
frames = [
frame
for frame in parsed_frames
if frame.arbitration_id
in (
MQTT_SEAT_WIRE_FEEDBACK_IDS
if frame.channel == 0
else MQTT_VEHICLE_FEEDBACK_IDS
)
]
return [*frames, *_ota_seat_status_frames(frames)]
def mqtt_control_payload(frame: CanFrame) -> str:
"""Serialize one compiled outbound frame using the documented wire contract."""
def _validate_headunit_command_frame(frame: CanFrame) -> None:
"""Mirror the Android head unit's strict ``CanCommand`` frame checks."""
if frame.channel not in {0, 1}:
raise CanGatewayError("MQTT CAN command channel must be CAN0 or CAN1")
if frame.is_fd:
if not frame.bitrate_switch:
raise CanGatewayError("MQTT CAN FD command requires BRS")
if len(frame.data) not in MQTT_VALID_FD_PAYLOAD_LENGTHS:
raise CanGatewayError(
"MQTT CAN FD command length must be 0..8/12/16/20/24/32/48/64"
)
elif frame.bitrate_switch:
raise CanGatewayError("MQTT classic CAN command cannot enable BRS")
elif len(frame.data) > 8:
raise CanGatewayError("MQTT classic CAN command cannot exceed 8 bytes")
def mqtt_control_payload(
frame: CanFrame,
*,
response_timeout_ms: int = 1_500,
request_id: str | None = None,
command: str | None = None,
) -> str:
"""Serialize a frame as the Android head unit's strict ``CanCommand`` JSON."""
_validate_headunit_command_frame(frame)
command_request_id = request_id or f"cognitive-agent-{uuid.uuid4().hex}"
if not command_request_id.strip() or len(command_request_id) > 128:
raise CanGatewayError("MQTT CAN command requestId must contain 1..128 characters")
debug_command = "" if command is None else str(command).strip()
if len(debug_command) > 256:
raise CanGatewayError("MQTT CAN debug command must contain at most 256 characters")
bounded_timeout_ms = max(100, min(5_000, int(response_timeout_ms)))
return json.dumps(
{
"schema": MQTT_CAN_SCHEMA,
"arbitration_id": frame.arbitration_id,
"frame_id": f"0x{frame.arbitration_id:03X}",
"channel": frame.channel,
"data_hex": frame.data.hex(" ").upper(),
"data": list(frame.data),
"payload_size": len(frame.data),
"is_fd": frame.is_fd,
"bitrate_switch": frame.bitrate_switch,
"timestamp": time.time(),
"requestId": command_request_id,
# Debug-only semantic metadata. The head unit continues to execute
# only the validated channel/ID/frame/data fields below.
"command": debug_command,
"channel": f"can{frame.channel}",
"canId": f"0x{frame.arbitration_id:03X}",
"frameType": "fd" if frame.is_fd else "classic",
"brs": frame.bitrate_switch,
"extended": False,
"remote": False,
"data": frame.data.hex(" ").upper(),
"repeat": 1,
"intervalMs": 0,
"responseTimeoutMs": bounded_timeout_ms,
},
ensure_ascii=False,
separators=(",", ":"),
@@ -587,6 +653,11 @@ class MqttCanTransport:
for key in ("actuator_state", "domain_state", "vehicle_safety")
):
return
# The matrix decoder is shared with the wired CH1 monitor and therefore
# emits its wired source marker by default. Preserve the common byte
# decoder while making the actual transport explicit before this state
# reaches the conversation store or browser.
decoded["source"] = WIRELESS_MQTT_CH1_SOURCE
frame_id = str(decoded.get("frame_id", ""))
captured_at = (
datetime.fromtimestamp(frame.timestamp, UTC)
@@ -683,7 +754,12 @@ class MqttCanTransport:
)
return MqttCanBus(self, cursor, channel, initial_frames=initial_frames)
def publish(self, frame: CanFrame) -> None:
def publish(
self,
frame: CanFrame,
*,
command: str | None = None,
) -> dict[str, Any]:
if (
frame.channel not in MQTT_APPROVED_CONTROL_IDS
or frame.arbitration_id not in MQTT_APPROVED_CONTROL_IDS[frame.channel]
@@ -691,6 +767,7 @@ class MqttCanTransport:
raise CanGatewayError("MQTT CAN frame is outside the approved channel policy")
if frame.channel == 0 and (frame.is_fd or frame.bitrate_switch):
raise CanGatewayError("CAN0 passenger-seat control requires classic CAN")
_validate_headunit_command_frame(frame)
if frame.channel == 0:
_validate_ota_seat_runtime_control(frame.data)
with self._condition:
@@ -698,9 +775,14 @@ class MqttCanTransport:
state = self._state
if client is None or state != "online":
raise CanGatewayError("MQTT CAN relay is not connected")
raw_payload = mqtt_control_payload(
frame,
response_timeout_ms=self.settings.feedback_timeout_ms,
command=command,
)
result = client.publish(
self.settings.control_topic,
mqtt_control_payload(frame),
raw_payload,
qos=self.settings.qos,
retain=False,
)
@@ -710,6 +792,12 @@ class MqttCanTransport:
published = result.wait_for_publish(timeout=2.0)
if published is False:
raise CanGatewayError("MQTT CAN publish confirmation timed out")
return {
"topic": self.settings.control_topic,
"qos": self.settings.qos,
"retain": False,
"payload": json.loads(raw_payload),
}
def _receive_after(
self,
@@ -782,8 +870,24 @@ class MqttCanBus:
self._cursor = cursor
self.channel = channel
self._initial_frames = deque(initial_frames)
self._debug_command = ""
self._published_messages: list[dict[str, Any]] = []
self._closed = False
@property
def published_messages(self) -> tuple[dict[str, Any], ...]:
"""Return this bus view's confirmed MQTT publications in send order."""
return tuple(deepcopy(self._published_messages))
def set_debug_command(self, command: str | None) -> None:
"""Attach semantic debug metadata to subsequent MQTT publications."""
value = "" if command is None else str(command).strip()
if len(value) > 256:
raise CanGatewayError("MQTT CAN debug command must contain at most 256 characters")
self._debug_command = value
def send(
self,
arbitration_id: int,
@@ -809,7 +913,8 @@ class MqttCanBus:
is_fd=is_fd,
bitrate_switch=bitrate_switch,
)
self._transport.publish(frame)
publication = self._transport.publish(frame, command=self._debug_command)
self._published_messages.append(publication)
return frame
def receive(