优化前端,优化前端指令,优化自定义控制
This commit is contained in:
@@ -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(
|
||||
|
||||
Reference in New Issue
Block a user