This commit is contained in:
orin
2026-07-30 08:45:38 +08:00
commit 25b24ac4cd
1333 changed files with 170092 additions and 0 deletions
+8
View File
@@ -0,0 +1,8 @@
domain: seat
primary_owner: seat_owner
reviewers:
- integration_owner
- can_owner
paths:
- domains/seat/**
+25
View File
@@ -0,0 +1,25 @@
# Seat Domain
负责座椅单领域能力。通风和加热基于根目录 `座椅部分功能CAN矩阵表.xlsx` 建立可审计的归一化矩阵;位置电机使用单独的虚构矩阵,仅验证模型理解、配置生成、CAN 编译和反馈闭环。
## 首个交付目标
- 支持显式指令以及“主驾有点热,有点闷”等舒适反馈。
- GPT 在同一 `conversation_id` 下获取当前虚拟执行状态和前序成功动作,自行解析跟进、因果和作用域。
- GPT 返回领域无关的通用能力请求;座椅适配器只校验并转换已注册能力,不用本地舒适或位置词条覆盖模型结果。
- 主副驾分别支持水平、高度、坐垫俯仰、靠背、腿托升降和腿托伸缩六个虚构电机轴,会话状态保留每轴的 `0-100%` 绝对位置。
- 混合请求中可执行的座椅动作独立 dry-run,未接入的其他域保留为 `deferred_requests`,不会阻断座椅子请求。
- 生成只含 `domain: seat` 的 `FunctionConfig`。
- 1–3 档目标进入 `FunctionConfig` 和会话状态;`0x42A` 仍只编译开关,档位由 `0x4FA` dry-run 模拟。
- 使用模拟 CAN Gateway 测试正常执行、冲突、缺失能力和回读不一致。
## 待负责人补齐
- 0x42A 的正式报文名称、发送节点、周期、DLC、字节序、counter/checksum 和总线所有权。
- 档位控制信号;当前“设置三档”只是认知目标和虚拟回读,不得视为已产生实车档位命令。
- 行车状态、乘员状态和防夹等安全互锁。
- DBC message/signal 以及命令保持、释放和真实回读策略。
`can/seat-thermal-matrix.yaml` 和 `can/seat-motion-virtual-matrix.yaml` 中 `hardware_execution_allowed: false` 是强制边界。后者的帧号、信号布局、时序和反馈行为全部为虚构,当前闭环只能 dry-run。
真实 T2G 台架使用独立的 `signal-mapping.hardware.yaml`、`seat-thermal-hardware-matrix.yaml` 和 `seat-motion-hardware-matrix.yaml`,不会解除上述虚拟矩阵的边界。仓库内 `firmware/t2g/Seat_Controller_demo` 是唯一硬件固件接口,真实路径只接受它的 `combined` profile。当前主驾与副驾都是 `t2g_primary_seat` 的逻辑别名:两种指令使用同一组实体电机、加热和通风,成功反馈会同步到两侧会话状态。完整接线、自动烧录、授权和反馈要求见 [T2G 座椅真实硬件台架接入](../../docs/workflows/t2g-seat-hardware-bench.md)。
+71
View File
@@ -0,0 +1,71 @@
version: 3.0.0
source:
project: Seat_Move_demo
workbook: Seat_Move_demo/Docs/outputs/seat_move_four_motor_20260724/座椅四电机移动功能CAN矩阵表.xlsx
test_report: Seat_Move_demo/Docs/TEST_REPORT_2026-07-24.md
verified_on: 2026-07-24
scope: t2g_driver_seat_four_motor_protocol
hardware_execution_allowed: true
bit_numbering: lsb0
byte_order: intel_little_endian
limitations:
- Motor 0 through 2 were hardware verified; Motor 3 is disabled until it is wired and calibrated.
- Cushion tilt has no actuator in Seat_Move_demo and is never eligible for hardware transmission.
- Mechanical direction labels still require a final visual sign-off on the assembled seat.
messages:
- name: T2G_Seat_Move_Command_300
frame_id: 0x300
role: command
minimum_payload_bytes: 8
hardware_send_allowed: true
signals:
- {name: Opcode, start_bit: 0, bit_length: 8}
- {name: Motor_Selector, start_bit: 8, bit_length: 2}
- {name: Direction, start_bit: 10, bit_length: 2}
- {name: Protocol_Version, start_bit: 12, bit_length: 2}
- {name: Sequence, start_bit: 16, bit_length: 16}
- {name: Param_A, start_bit: 32, bit_length: 16}
- {name: Param_B, start_bit: 48, bit_length: 16}
- name: T2G_Seat_Move_System_Status_301
frame_id: 0x301
role: feedback
minimum_payload_bytes: 8
signals:
- {name: Ack, start_bit: 0, bit_length: 3}
- {name: Reject_Reason, start_bit: 3, bit_length: 3}
- {name: Shutdown_Inhibit, start_bit: 6, bit_length: 1}
- {name: Safety_Inhibit, start_bit: 7, bit_length: 1}
- {name: Last_Opcode, start_bit: 8, bit_length: 4}
- {name: Active_Mask, start_bit: 12, bit_length: 4}
- {name: Last_Sequence, start_bit: 16, bit_length: 16}
- {name: Retry_Mask, start_bit: 32, bit_length: 4}
- {name: Record_Clean, start_bit: 36, bit_length: 1}
- {name: Watchdog_Reset, start_bit: 37, bit_length: 1}
- {name: Record_Valid, start_bit: 38, bit_length: 1}
- {name: Safety_Fault, start_bit: 40, bit_length: 8}
- {name: Config_Sequence, start_bit: 48, bit_length: 16}
- name: T2G_Seat_Move_Motor_Status_302
frame_id: 0x302
role: feedback
minimum_payload_bytes: 8
signals:
- {name: Motor_Sub_ID, start_bit: 0, bit_length: 8}
- {name: Task_State, start_bit: 8, bit_length: 4}
- {name: Result, start_bit: 12, bit_length: 4}
- {name: Direction, start_bit: 16, bit_length: 2}
- {name: Active, start_bit: 18, bit_length: 1}
- {name: Retried, start_bit: 19, bit_length: 1}
- {name: Position_Valid, start_bit: 20, bit_length: 1}
- {name: Motor_Sequence, start_bit: 24, bit_length: 16}
- {name: Value_X100, start_bit: 40, bit_length: 16}
- {name: Duty_Percent, start_bit: 56, bit_length: 8}
- name: T2G_Seat_Move_Diagnostic_303
frame_id: 0x303
role: feedback
minimum_payload_bytes: 8
signals:
- {name: Motor_Sub_ID, start_bit: 0, bit_length: 8}
- {name: Last_Motor_Event, start_bit: 8, bit_length: 4}
- {name: Hall_Count, start_bit: 16, bit_length: 16}
- {name: Max_Hall_Count, start_bit: 32, bit_length: 16}
- {name: Max_Speed_Pps_X100, start_bit: 48, bit_length: 16}
+55
View File
@@ -0,0 +1,55 @@
version: 0.1.0
source:
kind: synthetic_demo
file: seat-motion-virtual-matrix.yaml
designed_on: 2026-07-23
scope: driver_and_front_passenger_position_motors
hardware_execution_allowed: false
synthetic: true
bit_numbering: lsb0
byte_order: intel_little_endian
limitations:
- All frame IDs, signal layouts, timing and feedback behavior in this file are fictional.
- Values are normalized positions, not physical millimeters or degrees.
- No vehicle-state, occupant, anti-pinch, current-sensing or collision interlock is implemented.
- These frames must never be sent to a vehicle, bench ECU or HIL network.
messages:
- name: Driver_Seat_Motion_Command_550
frame_id: 0x550
role: synthetic_command
minimum_payload_bytes: 8
hardware_send_allowed: false
signals: &motion_command_signals
- {name: Longitudinal_Target_Pct, description: "Fore-aft absolute target", start_bit: 0, bit_length: 7}
- {name: Cushion_Tilt_Target_Pct, description: "Cushion pitch absolute target", start_bit: 7, bit_length: 7}
- {name: Backrest_Recline_Target_Pct, description: "Backrest recline absolute target", start_bit: 14, bit_length: 7}
- {name: Legrest_Height_Target_Pct, description: "Leg-rest lift absolute target", start_bit: 21, bit_length: 7}
- {name: Legrest_Extension_Target_Pct, description: "Leg-rest extension absolute target", start_bit: 28, bit_length: 7}
- {name: Motion_Enable_Mask, description: "One enable bit per position axis", start_bit: 35, bit_length: 5}
- {name: Command_Counter, description: "Synthetic per-plan command sequence", start_bit: 40, bit_length: 4}
- {name: Command_Mode, description: "0=no request, 1=absolute position", start_bit: 44, bit_length: 2}
- {name: Command_Checksum, description: "Synthetic additive checksum", start_bit: 48, bit_length: 8}
- name: Passenger_Seat_Motion_Command_551
frame_id: 0x551
role: synthetic_command
minimum_payload_bytes: 8
hardware_send_allowed: false
signals: *motion_command_signals
- name: Driver_Seat_Motion_Status_558
frame_id: 0x558
role: synthetic_feedback
minimum_payload_bytes: 8
signals: &motion_status_signals
- {name: Longitudinal_Actual_Pct, description: "Fore-aft measured position", start_bit: 0, bit_length: 7}
- {name: Cushion_Tilt_Actual_Pct, description: "Cushion pitch measured position", start_bit: 7, bit_length: 7}
- {name: Backrest_Recline_Actual_Pct, description: "Backrest recline measured position", start_bit: 14, bit_length: 7}
- {name: Legrest_Height_Actual_Pct, description: "Leg-rest lift measured position", start_bit: 21, bit_length: 7}
- {name: Legrest_Extension_Actual_Pct, description: "Leg-rest extension measured position", start_bit: 28, bit_length: 7}
- {name: Moving_Mask, description: "One moving-state bit per position axis", start_bit: 35, bit_length: 5}
- {name: Fault_Mask, description: "One synthetic motor-fault bit per position axis", start_bit: 40, bit_length: 5}
- {name: Position_Valid_Mask, description: "One valid-feedback bit per position axis", start_bit: 45, bit_length: 5}
- name: Passenger_Seat_Motion_Status_559
frame_id: 0x559
role: synthetic_feedback
minimum_payload_bytes: 8
signals: *motion_status_signals
+43
View File
@@ -0,0 +1,43 @@
version: 1.0.0
source:
projects:
- Seat_Backrest_Heater_demo
- Seat_Fan_demo
workbooks:
- 座椅加热功能CAN矩阵表.xlsx
- 座椅通风功能CAN矩阵表.xlsx
verified_on: 2026-07-23
scope: t2g_driver_seat_heating_and_ventilation
hardware_execution_allowed: true
bit_numbering: lsb0
byte_order: intel_little_endian
limitations:
- Only the driver-seat backrest heater and ventilation fan are implemented by the verified demos.
- The two demos are standalone firmware images; use one board per profile or a reviewed combined image.
- Product deployment still requires command timeout, E2E protection and calibrated thermal/current limits.
messages:
- name: T2G_Seat_Thermal_Control_29E
frame_id: 0x29E
role: command
minimum_payload_bytes: 8
hardware_send_allowed: true
signals:
- {name: Driver_Seat_Ventilation_On, start_bit: 0, bit_length: 2}
- {name: Driver_Seat_Ventilation_Mode, start_bit: 2, bit_length: 2}
- {name: Driver_Seat_Heating_On, start_bit: 4, bit_length: 2}
- {name: Driver_Seat_Ventilation_Gear, start_bit: 16, bit_length: 4}
- {name: Driver_Seat_Heating_Gear, start_bit: 20, bit_length: 4}
- name: T2G_Seat_Thermal_Mode_3CE
frame_id: 0x3CE
role: command
minimum_payload_bytes: 8
hardware_send_allowed: true
signals:
- {name: Driver_Seat_Thermal_Mode, start_bit: 4, bit_length: 2}
- name: T2G_Seat_Thermal_Status_4FA
frame_id: 0x4FA
role: feedback
minimum_payload_bytes: 8
signals:
- {name: Driver_Seat_Ventilation_Status, start_bit: 0, bit_length: 3}
- {name: Driver_Seat_Heating_Status, start_bit: 3, bit_length: 3}
+189
View File
@@ -0,0 +1,189 @@
version: 1.0.0
source:
file: ../../../座椅部分功能CAN矩阵表.xlsx
sheet: Sheet1
normalized_on: 2026-07-22
scope: driver_and_front_passenger_heating_ventilation
hardware_execution_allowed: false
bit_numbering: lsb0_inferred_from_start_bit_and_bit_columns
byte_order: intel_little_endian_inferred_not_confirmed
limitations:
- Message names are local aliases because the spreadsheet only provides frame IDs.
- Transmitter, receiver, cycle time, DLC, byte order, counter and checksum are absent.
- Payload lengths below are minimum lengths inferred from the highest occupied bit.
- Frame 0x42A is treated as a control candidate only for virtual execution.
- Gear signals are observable states; no gear command signal is identified.
messages:
- name: Seat_Thermal_Status_4FA
frame_id: 0x4FA
role: feedback
minimum_payload_bytes: 2
signals:
- name: Pilot_Seat_Vtilat_Stat_S
description: 主驾座椅通风工作状态
start_bit: 0
bit_length: 3
values:
0: invalid
1: "off"
2: low
3: high
4: level_3
- name: Pilot_Seat_Heat_Stat_S
description: 主驾座椅加热工作状态
start_bit: 3
bit_length: 3
values:
0: invalid
1: "off"
2: low
3: high
4: level_3
- name: Copilot_Seat_Vtilat_Stat_4FA_S
description: 副驾座椅通风工作状态
start_bit: 8
bit_length: 3
values:
0: invalid
1: "off"
2: low
3: high
4: level_3
- name: Copilot_Seat_Heat_Stat_4FA_S
description: 副驾座椅加热工作状态
start_bit: 11
bit_length: 3
values:
0: invalid
1: "off"
2: low
3: high
4: level_3
- name: Driver_Seat_Thermal_Status_29E
frame_id: 0x29E
role: feedback
minimum_payload_bytes: 3
signals:
- name: Driver_Seat_Ventilation_On_S
description: 主驾座椅通风开启状态
start_bit: 0
bit_length: 2
values: {0: invalid, 1: "on", 2: "off", 3: reserved}
- name: Driver_Seat_Ventilation_Control_Mode_S
description: 主驾座椅通风控制模式
start_bit: 2
bit_length: 2
values: {0: invalid, 1: manual, 2: automatic, 3: reserved}
- name: The_Driver_Seat_Heating_Is_On_S
description: 主驾座椅加热开启状态
start_bit: 4
bit_length: 2
values: {0: invalid, 1: "on", 2: "off", 3: reserved}
- name: Driver_Seat_Ventilation_Gear_S
description: 主驾座椅通风档位
start_bit: 16
bit_length: 4
values: {0: invalid, 1: level_1, 2: level_2, 3: level_3, 4: level_4, 5: level_5}
- name: Heated_Driver_Seat_S
description: 主驾座椅加热档位
start_bit: 20
bit_length: 4
values: {0: invalid, 1: level_1, 2: level_2, 3: level_3, 4: level_4, 5: level_5}
- name: Driver_Seat_Thermal_Mode_3CE
frame_id: 0x3CE
role: feedback
minimum_payload_bytes: 1
signals:
- name: Main_Driver_Seat_Ven_And_Hea_Control_Mode_S
description: 主驾座椅通风加热控制模式
start_bit: 4
bit_length: 2
values: {0: invalid, 1: manual, 2: automatic, 3: reserved}
- name: Passenger_Seat_Thermal_Status_3D3
frame_id: 0x3D3
role: feedback
minimum_payload_bytes: 3
signals:
- name: Front_Pass_Seat_Ventilation_On_S
description: 副驾座椅通风开启状态
start_bit: 0
bit_length: 2
values: {0: invalid, 1: "on", 2: "off", 3: reserved}
- name: Pas_Seat_Heating_On_S
description: 副驾座椅加热开启状态
start_bit: 2
bit_length: 2
values: {0: invalid, 1: "on", 2: "off", 3: reserved}
- name: Front_Pass_Seat_Ven_And_Hea_Control_Mode_S
description: 副驾座椅通风加热控制模式
start_bit: 4
bit_length: 2
values: {0: invalid, 1: manual, 2: automatic, 3: reserved}
- name: Passenger_Seat_Ventilation_Gear_3D3_S
description: 副驾座椅通风档位
start_bit: 12
bit_length: 4
values: {0: invalid, 1: level_1, 2: level_2, 3: level_3, 4: level_4, 5: level_5}
- name: Heated_Passenger_Seat_3D3_S
description: 副驾座椅加热档位
start_bit: 16
bit_length: 4
values: {0: invalid, 1: level_1, 2: level_2, 3: level_3, 4: level_4, 5: level_5}
- name: Seat_Thermal_Configuration_2AA_01
frame_id: 0x2AA
sub_id: 0x01
role: configuration_status
minimum_payload_bytes: 3
signals:
- name: Copilot_Seat_Three_Speed_Ventilat_Config_S
description: 副驾座椅通风配置
start_bit: 8
bit_length: 3
values: {0: invalid, 1: level_1_config, 2: level_2_config, 3: level_3_config, 4: level_5_split_config}
- name: Copilot_Seat_Three_Speed_Heat_Config_S
description: 副驾座椅加热配置
start_bit: 11
bit_length: 3
values: {0: invalid, 1: level_1_config, 2: level_2_config, 3: level_3_config, 4: level_5_split_config}
- name: Driver_Seat_Three_Speed_Ventilat_Config_S
description: 主驾座椅通风配置
start_bit: 16
bit_length: 3
values: {0: invalid, 1: level_1_config, 2: level_2_config, 3: level_3_config, 4: level_5_split_config}
- name: Driver_Seat_Three_Speed_Heat_Config_S
description: 主驾座椅加热配置
start_bit: 19
bit_length: 3
values: {0: invalid, 1: level_1_config, 2: level_2_config, 3: level_3_config, 4: level_5_split_config}
- name: Seat_Thermal_Control_42A
frame_id: 0x42A
role: command_candidate
minimum_payload_bytes: 2
hardware_send_allowed: false
signals:
- name: Driver_Seat_Ventilation_S
description: 主驾通风开启/关闭
start_bit: 0
bit_length: 2
values: {0: invalid, 1: "on", 2: "off", 3: reserved}
- name: Driver_Seat_Heating_S
description: 主驾加热开启/关闭
start_bit: 2
bit_length: 2
values: {0: invalid, 1: "on", 2: "off", 3: reserved}
- name: Front_Passenger_Seat_Ventilation_S
description: 副驾通风开启/关闭
start_bit: 12
bit_length: 2
values: {0: invalid, 1: "on", 2: "off", 3: reserved}
- name: Front_Passenger_Seat_Heating_S
description: 副驾加热开启/关闭
start_bit: 14
bit_length: 2
values: {0: invalid, 1: "on", 2: "off", 3: reserved}
+150
View File
@@ -0,0 +1,150 @@
apiVersion: cognitive-agent/v1alpha1
kind: SignalMapping
metadata:
id: seat-t2g-bench-hardware-mapping
name: Verified T2G seat bench mapping
version: 1.1.0
description: Mapping implemented by the unified Seat_Controller_demo IAR firmware.
spec:
domain: seat
platform: t2g_seat_bench
dbc_ref: ../../../platforms/t2g_seat_bench/can/dbc/seat.dbc
matrix_ref: seat-thermal-hardware-matrix.yaml
status: hil_verified
mappings:
- capability: seat.set_ventilation
parameter: state
target_zone: front_left
physical_target_alias: t2g_primary_seat
compiler_handler: compile_t2g_thermal
bus: seat_can
message: T2G_Seat_Thermal_Control_29E
signals: [Driver_Seat_Ventilation_On, Driver_Seat_Ventilation_Mode, Driver_Seat_Ventilation_Gear]
feedback_message: T2G_Seat_Thermal_Status_4FA
feedback_signal: Driver_Seat_Ventilation_Status
- capability: seat.set_heating
parameter: state
target_zone: front_left
physical_target_alias: t2g_primary_seat
compiler_handler: compile_t2g_thermal
bus: seat_can
message: T2G_Seat_Thermal_Control_29E
signals: [Driver_Seat_Heating_On, Driver_Seat_Heating_Gear]
feedback_message: T2G_Seat_Thermal_Status_4FA
feedback_signal: Driver_Seat_Heating_Status
- capability: seat.set_longitudinal_position
parameter: position_pct
target_zone: front_left
physical_target_alias: t2g_primary_seat
compiler_handler: compile_t2g_absolute_position
bus: seat_can
message: T2G_Seat_Move_Command_300
signal: Param_A
feedback_message: T2G_Seat_Move_Motor_Status_302
feedback_signal: Value_X100
calibration_ref: ../../../platforms/t2g_seat_bench/calibration/seat.yaml
unit: percent
- capability: seat.set_backrest_recline
parameter: position_pct
target_zone: front_left
physical_target_alias: t2g_primary_seat
compiler_handler: compile_t2g_absolute_position
bus: seat_can
message: T2G_Seat_Move_Command_300
signal: Param_A
feedback_message: T2G_Seat_Move_Motor_Status_302
feedback_signal: Value_X100
calibration_ref: ../../../platforms/t2g_seat_bench/calibration/seat.yaml
unit: percent
- capability: seat.set_legrest_height
parameter: position_pct
target_zone: front_left
physical_target_alias: t2g_primary_seat
compiler_handler: compile_t2g_absolute_position
bus: seat_can
message: T2G_Seat_Move_Command_300
signal: Param_A
feedback_message: T2G_Seat_Move_Motor_Status_302
feedback_signal: Value_X100
calibration_ref: ../../../platforms/t2g_seat_bench/calibration/seat.yaml
unit: percent
- capability: seat.set_legrest_extension
parameter: position_pct
target_zone: front_left
physical_target_alias: t2g_primary_seat
compiler_handler: compile_t2g_absolute_position
bus: seat_can
message: T2G_Seat_Move_Command_300
signal: Param_A
feedback_message: T2G_Seat_Move_Motor_Status_302
feedback_signal: Value_X100
calibration_ref: ../../../platforms/t2g_seat_bench/calibration/seat.yaml
unit: percent
- capability: seat.set_ventilation
parameter: state
target_zone: front_right
physical_target_alias: t2g_primary_seat
compiler_handler: compile_t2g_thermal
bus: seat_can
message: T2G_Seat_Thermal_Control_29E
signals: [Driver_Seat_Ventilation_On, Driver_Seat_Ventilation_Mode, Driver_Seat_Ventilation_Gear]
feedback_message: T2G_Seat_Thermal_Status_4FA
feedback_signal: Driver_Seat_Ventilation_Status
- capability: seat.set_heating
parameter: state
target_zone: front_right
physical_target_alias: t2g_primary_seat
compiler_handler: compile_t2g_thermal
bus: seat_can
message: T2G_Seat_Thermal_Control_29E
signals: [Driver_Seat_Heating_On, Driver_Seat_Heating_Gear]
feedback_message: T2G_Seat_Thermal_Status_4FA
feedback_signal: Driver_Seat_Heating_Status
- capability: seat.set_longitudinal_position
parameter: position_pct
target_zone: front_right
physical_target_alias: t2g_primary_seat
compiler_handler: compile_t2g_absolute_position
bus: seat_can
message: T2G_Seat_Move_Command_300
signal: Param_A
feedback_message: T2G_Seat_Move_Motor_Status_302
feedback_signal: Value_X100
calibration_ref: ../../../platforms/t2g_seat_bench/calibration/seat.yaml
unit: percent
- capability: seat.set_backrest_recline
parameter: position_pct
target_zone: front_right
physical_target_alias: t2g_primary_seat
compiler_handler: compile_t2g_absolute_position
bus: seat_can
message: T2G_Seat_Move_Command_300
signal: Param_A
feedback_message: T2G_Seat_Move_Motor_Status_302
feedback_signal: Value_X100
calibration_ref: ../../../platforms/t2g_seat_bench/calibration/seat.yaml
unit: percent
- capability: seat.set_legrest_height
parameter: position_pct
target_zone: front_right
physical_target_alias: t2g_primary_seat
compiler_handler: compile_t2g_absolute_position
bus: seat_can
message: T2G_Seat_Move_Command_300
signal: Param_A
feedback_message: T2G_Seat_Move_Motor_Status_302
feedback_signal: Value_X100
calibration_ref: ../../../platforms/t2g_seat_bench/calibration/seat.yaml
unit: percent
- capability: seat.set_legrest_extension
parameter: position_pct
target_zone: front_right
physical_target_alias: t2g_primary_seat
compiler_handler: compile_t2g_absolute_position
bus: seat_can
message: T2G_Seat_Move_Command_300
signal: Param_A
feedback_message: T2G_Seat_Move_Motor_Status_302
feedback_signal: Value_X100
calibration_ref: ../../../platforms/t2g_seat_bench/calibration/seat.yaml
unit: percent
+132
View File
@@ -0,0 +1,132 @@
apiVersion: cognitive-agent/v1alpha1
kind: SignalMapping
metadata:
id: seat-reference-vehicle-mapping
name: Seat mapping for reference vehicle
version: 1.1.0
description: Supplied thermal matrix plus explicitly synthetic motion mappings; virtual execution only.
spec:
domain: seat
platform: reference_vehicle
dbc_ref: ../../../platforms/reference_vehicle/can/dbc/vehicle.dbc
matrix_ref: seat-thermal-matrix.yaml
status: draft
mappings:
- capability: seat.set_ventilation
parameter: state
target_zone: front_left
compiler_handler: compile_binary_control
bus: body_can
message: Seat_Thermal_Control_42A
signal: Driver_Seat_Ventilation_S
- capability: seat.set_heating
parameter: state
target_zone: front_left
compiler_handler: compile_binary_control
bus: body_can
message: Seat_Thermal_Control_42A
signal: Driver_Seat_Heating_S
- capability: seat.set_ventilation
parameter: state
target_zone: front_right
compiler_handler: compile_binary_control
bus: body_can
message: Seat_Thermal_Control_42A
signal: Front_Passenger_Seat_Ventilation_S
- capability: seat.set_heating
parameter: state
target_zone: front_right
compiler_handler: compile_binary_control
bus: body_can
message: Seat_Thermal_Control_42A
signal: Front_Passenger_Seat_Heating_S
- capability: seat.set_longitudinal_position
parameter: position_pct
target_zone: front_left
compiler_handler: compile_position_control
bus: body_can
message: Driver_Seat_Motion_Command_550
signal: Longitudinal_Target_Pct
calibration_ref: seat-motion-virtual-matrix.yaml
unit: percent
- capability: seat.set_cushion_tilt
parameter: position_pct
target_zone: front_left
compiler_handler: compile_position_control
bus: body_can
message: Driver_Seat_Motion_Command_550
signal: Cushion_Tilt_Target_Pct
calibration_ref: seat-motion-virtual-matrix.yaml
unit: percent
- capability: seat.set_backrest_recline
parameter: position_pct
target_zone: front_left
compiler_handler: compile_position_control
bus: body_can
message: Driver_Seat_Motion_Command_550
signal: Backrest_Recline_Target_Pct
calibration_ref: seat-motion-virtual-matrix.yaml
unit: percent
- capability: seat.set_legrest_height
parameter: position_pct
target_zone: front_left
compiler_handler: compile_position_control
bus: body_can
message: Driver_Seat_Motion_Command_550
signal: Legrest_Height_Target_Pct
calibration_ref: seat-motion-virtual-matrix.yaml
unit: percent
- capability: seat.set_legrest_extension
parameter: position_pct
target_zone: front_left
compiler_handler: compile_position_control
bus: body_can
message: Driver_Seat_Motion_Command_550
signal: Legrest_Extension_Target_Pct
calibration_ref: seat-motion-virtual-matrix.yaml
unit: percent
- capability: seat.set_longitudinal_position
parameter: position_pct
target_zone: front_right
compiler_handler: compile_position_control
bus: body_can
message: Passenger_Seat_Motion_Command_551
signal: Longitudinal_Target_Pct
calibration_ref: seat-motion-virtual-matrix.yaml
unit: percent
- capability: seat.set_cushion_tilt
parameter: position_pct
target_zone: front_right
compiler_handler: compile_position_control
bus: body_can
message: Passenger_Seat_Motion_Command_551
signal: Cushion_Tilt_Target_Pct
calibration_ref: seat-motion-virtual-matrix.yaml
unit: percent
- capability: seat.set_backrest_recline
parameter: position_pct
target_zone: front_right
compiler_handler: compile_position_control
bus: body_can
message: Passenger_Seat_Motion_Command_551
signal: Backrest_Recline_Target_Pct
calibration_ref: seat-motion-virtual-matrix.yaml
unit: percent
- capability: seat.set_legrest_height
parameter: position_pct
target_zone: front_right
compiler_handler: compile_position_control
bus: body_can
message: Passenger_Seat_Motion_Command_551
signal: Legrest_Height_Target_Pct
calibration_ref: seat-motion-virtual-matrix.yaml
unit: percent
- capability: seat.set_legrest_extension
parameter: position_pct
target_zone: front_right
compiler_handler: compile_position_control
bus: body_can
message: Passenger_Seat_Motion_Command_551
signal: Legrest_Extension_Target_Pct
calibration_ref: seat-motion-virtual-matrix.yaml
unit: percent
+211
View File
@@ -0,0 +1,211 @@
apiVersion: cognitive-agent/v1alpha1
kind: CapabilityCatalog
metadata:
id: seat-capabilities
name: Seat capabilities
version: 1.2.0
spec:
domain: seat
capabilities:
- name: seat.set_ventilation
description: >-
Move air through one seat surface to reduce local occupant heat and moisture; this does not
control general cabin airflow. Turn it on or off for the driver or front passenger seat.
target_devices:
- seat
target_scopes:
- driver
- passenger
parameters:
- name: state
type: string
required: true
description: Requested binary ventilation state.
allowed_values:
- "on"
- "off"
- name: level
type: integer
required: false
description: Desired 1-3 comfort level; simulated until a level command signal is supplied.
allowed_values:
- 1
- 2
- 3
risk_level: medium
confirmation_policy: on_ambiguity
- name: seat.set_heating
description: >-
Warm one seat surface for local occupant thermal comfort. Turn it on or off for the driver
or front passenger seat.
target_devices:
- seat
target_scopes:
- driver
- passenger
parameters:
- name: state
type: string
required: true
description: Requested binary heating state.
allowed_values:
- "on"
- "off"
- name: level
type: integer
required: false
description: Desired 1-3 comfort level; simulated until a level command signal is supplied.
allowed_values:
- 1
- 2
- 3
risk_level: medium
confirmation_policy: on_ambiguity
- name: seat.set_longitudinal_position
description: >-
Set one front seat's fore-aft rail position. position_pct is an absolute target: 0 is the
rearmost mechanical limit and 100 is the forwardmost limit. Resolve relative requests such
as "move back a little" against the current position supplied in runtime state.
target_devices:
- seat_longitudinal_motor
target_scopes:
- driver
- passenger
parameters:
- name: position_pct
type: integer
unit: percent
required: true
minimum: 0
maximum: 100
description: Absolute normalized fore-aft target.
risk_level: high
confirmation_policy: on_ambiguity
- name: seat.set_cushion_tilt
description: >-
Set the seat-cushion pitch for one front seat. position_pct is absolute: 0 places the front
edge at its lowest limit and 100 raises the front edge to its highest tilt limit.
target_devices:
- seat_cushion_tilt_motor
target_scopes:
- driver
- passenger
parameters:
- name: position_pct
type: integer
unit: percent
required: true
minimum: 0
maximum: 100
description: Absolute normalized cushion front-edge height target.
risk_level: high
confirmation_policy: on_ambiguity
- name: seat.set_backrest_recline
description: >-
Set the backrest recline of one front seat. Use either position_pct or angle_deg.
position_pct is absolute: 0 is the most upright limit and 100 is the most reclined limit.
angle_deg is the included angle between the seat-cushion surface and backrest centreline,
from 95 to 140 degrees. "Backrest backward" means a larger value.
target_devices:
- seat_backrest_motor
target_scopes:
- driver
- passenger
parameters:
- name: position_pct
type: integer
unit: percent
required: false
minimum: 0
maximum: 100
description: Absolute normalized backrest-recline target.
- name: angle_deg
type: number
unit: degree
required: false
minimum: 95
maximum: 140
description: Exact included angle between the cushion surface and backrest centreline.
risk_level: high
confirmation_policy: on_ambiguity
- name: seat.set_legrest_height
description: >-
Raise or lower one front seat's powered leg rest. Use either position_pct or angle_deg.
position_pct is absolute: 0 is fully lowered and 100 is fully raised. angle_deg is the
included angle between the cushion surface and leg-rest surface, from 95 to 160 degrees.
This axis does not change leg-rest extension.
target_devices:
- seat_legrest_lift_motor
target_scopes:
- driver
- passenger
parameters:
- name: position_pct
type: integer
unit: percent
required: false
minimum: 0
maximum: 100
description: Absolute normalized leg-rest lift target.
- name: angle_deg
type: number
unit: degree
required: false
minimum: 95
maximum: 160
description: Exact included angle between the cushion surface and leg-rest surface.
risk_level: high
confirmation_policy: on_ambiguity
- name: seat.set_legrest_extension
description: >-
Extend or retract one front seat's powered leg rest. position_pct is absolute: 0 is fully
retracted and 100 is fully extended. This axis does not change leg-rest height.
target_devices:
- seat_legrest_extension_motor
target_scopes:
- driver
- passenger
parameters:
- name: position_pct
type: integer
unit: percent
required: true
minimum: 0
maximum: 100
description: Absolute normalized leg-rest extension target.
risk_level: high
confirmation_policy: on_ambiguity
- name: seat.save_posture_preset
description: >-
Save the current five-axis posture of one front seat as a conversation-owned named preset.
This is a control-plane operation and does not emit a CAN command. Use only when the user
explicitly asks to save, remember or overwrite the current posture.
target_devices:
- seat_preset_store
target_scopes:
- driver
- passenger
parameters:
- name: preset_name
type: string
required: true
description: User-visible name of the posture preset to create or overwrite.
risk_level: low
confirmation_policy: on_ambiguity
- name: seat.apply_posture_preset
description: >-
Apply a previously saved five-axis posture preset to one front seat. The runtime expands
the stored FunctionPreset into the registered motion capabilities before CAN compilation.
Use this capability only when the named preset exists in runtime conversation context.
target_devices:
- seat_preset_store
target_scopes:
- driver
- passenger
parameters:
- name: preset_name
type: string
required: true
description: Exact user-visible name of an existing posture preset.
risk_level: high
confirmation_policy: on_ambiguity
+7
View File
@@ -0,0 +1,7 @@
# Planned Seat Capabilities
零重力姿态和车型级自定义角度仍不属于实车可执行交付。现有 Excel 只覆盖主/副驾通风和加热相关信号,没有滑轨、靠背、坐垫或腿托控制信号,也没有运动学标定。
当前已使用 `seat-motion-virtual-matrix.yaml` 提供五轴 `0-100%` 虚构位置能力,用于模型理解和端到端 dry-run 验证。靠背/坐垫 `95–140°` 和坐垫/腿托 `95–160°` 采用显式 synthetic 线性标定,仅用于验证精确夹角语义、配置生成和孪生展示。该文件不是 DBC,不提供真实物理毫米/角度转换,也不具备防夹、电流、占位、车速或挡位互锁。
当前支持把某一会话中指定座位的五轴 dry-run 状态保存为命名 `FunctionPreset` 并再次调用;这是用户态控制面数据,不是车型出厂零重力标定。获得完整姿态 CAN 矩阵、坐标系、安全互锁和回读定义后,再交付硬件可执行的 `seat.set_posture` 及官方零重力 preset。
+24
View File
@@ -0,0 +1,24 @@
apiVersion: cognitive-agent/v1alpha1
kind: FunctionPreset
metadata:
id: seat-driver-ventilation
name: Driver seat ventilation
version: 1.0.0
description: Binary ventilation command supported by the partial CAN matrix.
spec:
domain: seat
operations:
- id: driver_ventilation_on
capability: seat.set_ventilation
target:
device: seat
zone: front_left
instance: driver
parameters:
state: "on"
timeout_ms: 3000
failure_policy: abort
locked_parameters: []
applicability:
platform: reference_vehicle
+24
View File
@@ -0,0 +1,24 @@
apiVersion: cognitive-agent/v1alpha1
kind: FunctionPreset
metadata:
id: seat-passenger-heating
name: Front passenger seat heating
version: 1.0.0
description: Binary heating command supported by the partial CAN matrix.
spec:
domain: seat
operations:
- id: passenger_heating_on
capability: seat.set_heating
target:
device: seat
zone: front_right
instance: passenger
parameters:
state: "on"
timeout_ms: 3000
failure_policy: abort
locked_parameters: []
applicability:
platform: reference_vehicle
+16
View File
@@ -0,0 +1,16 @@
apiVersion: cognitive-agent/v1alpha1
kind: DomainManifest
metadata:
id: seat-domain-plugin
name: Seat domain plugin
version: 1.1.0
spec:
domain: seat
entrypoint: seat_domain.plugin:create_plugin
function_schema: schemas/function-parameters.schema.json
capabilities: config/capabilities.yaml
signal_mapping: can/signal-mapping.yaml
supported_platforms:
- reference_vehicle
- t2g_seat_bench
telemetry_required: true
+75
View File
@@ -0,0 +1,75 @@
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "https://cognitive-agent.local/domains/seat/function-parameters.schema.json",
"title": "SeatThermalFunctionParameters",
"type": "object",
"properties": {
"spec": {
"type": "object",
"properties": {
"domain": {"const": "seat"},
"operations": {
"type": "array",
"items": {
"oneOf": [
{
"type": "object",
"properties": {
"capability": {"const": "seat.set_ventilation"},
"parameters": {
"type": "object",
"additionalProperties": false,
"required": ["state"],
"properties": {
"state": {"type": "string", "enum": ["on", "off"]},
"level": {"type": "integer", "minimum": 1, "maximum": 3}
}
}
}
},
{
"type": "object",
"properties": {
"capability": {"const": "seat.set_heating"},
"parameters": {
"type": "object",
"additionalProperties": false,
"required": ["state"],
"properties": {
"state": {"type": "string", "enum": ["on", "off"]},
"level": {"type": "integer", "minimum": 1, "maximum": 3}
}
}
}
},
{
"type": "object",
"properties": {
"capability": {
"type": "string",
"enum": [
"seat.set_longitudinal_position",
"seat.set_cushion_tilt",
"seat.set_backrest_recline",
"seat.set_legrest_height",
"seat.set_legrest_extension"
]
},
"parameters": {
"type": "object",
"additionalProperties": false,
"required": ["position_pct"],
"properties": {
"position_pct": {"type": "integer", "minimum": 0, "maximum": 100},
"angle_deg": {"type": "number", "minimum": 95, "maximum": 160}
}
}
}
}
]
}
}
}
}
}
}
+3
View File
@@ -0,0 +1,3 @@
# Seat Plugin Source
当前实现包含本地意图解析、配置生成、领域校验、确定性 Compiler、矩阵位编码和虚拟回读 Executor。Compiler 只返回 `CommandPlan`,虚拟 Executor 不打开 CAN socket。获得正式 DBC、发送属性、安全输入和审批前不得增加硬件发送路径。
+25
View File
@@ -0,0 +1,25 @@
"""Seat thermal domain plugin."""
from .compiler import SeatCompiler
from .executor import VirtualSeatExecutor
from .generator import build_function_config, build_function_preset, build_intent_ir
from .hardware_compiler import T2GSeatCompiler
from .hardware_executor import HardwareExecutionError, HardwareSeatExecutor
from .intent import parse_local_intent, resolve_contextual_intent
from .matrix import SeatCanMatrix
from .validator import validate_semantics
__all__ = [
"SeatCanMatrix",
"SeatCompiler",
"T2GSeatCompiler",
"HardwareExecutionError",
"HardwareSeatExecutor",
"VirtualSeatExecutor",
"build_function_config",
"build_function_preset",
"build_intent_ir",
"parse_local_intent",
"resolve_contextual_intent",
"validate_semantics",
]
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
+133
View File
@@ -0,0 +1,133 @@
"""Deterministically compile seat FunctionConfig into a CommandPlan."""
from __future__ import annotations
import hashlib
import json
from pathlib import Path
from typing import Any
import yaml
STATE_VALUES = {"on": 1, "off": 2}
MOTION_AXIS_BITS = {
"seat.set_longitudinal_position": 0,
"seat.set_cushion_tilt": 1,
"seat.set_backrest_recline": 2,
"seat.set_legrest_height": 3,
"seat.set_legrest_extension": 4,
}
MOTION_STATUS_MESSAGES = {
"front_left": "Driver_Seat_Motion_Status_558",
"front_right": "Passenger_Seat_Motion_Status_559",
}
class SeatCompiler:
version = "1.1.0"
def __init__(self, mapping_path: Path):
self.mapping = yaml.safe_load(mapping_path.read_text(encoding="utf-8"))
self.mapping_version = self.mapping["metadata"]["version"]
def _mapping_for(self, capability: str, zone: str) -> dict[str, Any]:
for mapping in self.mapping["spec"]["mappings"]:
if mapping["capability"] == capability and mapping.get("target_zone") == zone:
return mapping
raise ValueError(f"No signal mapping for capability={capability}, zone={zone}")
def compile(self, function_config: dict[str, Any]) -> dict[str, Any]:
thermal_groups: dict[tuple[str, str], dict[str, Any]] = {}
motion_commands: list[dict[str, Any]] = []
motion_index = 0
for operation in function_config["spec"]["operations"]:
mapping = self._mapping_for(operation["capability"], operation["target"]["zone"])
handler = mapping["compiler_handler"]
if handler == "compile_binary_control":
key = (mapping["bus"], mapping["message"])
group = thermal_groups.setdefault(key, {"signals": {}, "operation_ids": []})
group["signals"][mapping["signal"]] = STATE_VALUES[
operation["parameters"]["state"]
]
group["operation_ids"].append(operation["id"])
continue
if handler != "compile_position_control":
raise ValueError(f"Unsupported compiler handler: {handler}")
target = int(operation["parameters"]["position_pct"])
axis_bit = MOTION_AXIS_BITS[operation["capability"]]
enable_mask = 1 << axis_bit
counter = motion_index % 16
checksum = (target + enable_mask + counter + 1) & 0xFF
feedback_signal = mapping["signal"].replace("_Target_", "_Actual_")
motion_commands.append(
{
"id": f"move_{operation['id']}",
"operation_ref": operation["id"],
"bus": mapping["bus"],
"message": mapping["message"],
"signals": dict(
sorted(
{
mapping["signal"]: target,
"Motion_Enable_Mask": enable_mask,
"Command_Counter": counter,
"Command_Mode": 1,
"Command_Checksum": checksum,
}.items()
)
),
"send_offset_ms": motion_index * 50,
"repeat": {"count": 3, "period_ms": 20},
"completion": {
"message": MOTION_STATUS_MESSAGES[operation["target"]["zone"]],
"signal": feedback_signal,
"operator": "within",
"value": target,
"tolerance": 1,
"timeout_ms": int(operation.get("timeout_ms", 8000)),
},
}
)
motion_index += 1
canonical = json.dumps(function_config, ensure_ascii=False, sort_keys=True).encode("utf-8")
config_hash = hashlib.sha256(canonical).hexdigest()
metadata = function_config["metadata"]
commands = [
{
"id": "seat_thermal_control" if index == 0 else f"seat_thermal_control_{index}",
"operation_ref": ",".join(group["operation_ids"]),
"bus": bus,
"message": message,
"signals": dict(sorted(group["signals"].items())),
"send_offset_ms": 0,
}
for index, ((bus, message), group) in enumerate(thermal_groups.items())
]
commands.extend(motion_commands)
return {
"apiVersion": "cognitive-agent/v1alpha1",
"kind": "CommandPlan",
"metadata": {
"id": f"plan-{metadata['request_id']}",
"name": "Seat CAN command plan",
"version": "1.0.0",
"request_id": metadata["request_id"],
"labels": {"execution_scope": "virtual_only"},
},
"spec": {
"domain": "seat",
"platform": "reference_vehicle",
"source": {
"config_id": metadata["id"],
"config_version": metadata["version"],
"config_sha256": config_hash,
"compiler_version": self.version,
"mapping_version": self.mapping_version,
},
"commands": commands,
},
}
+199
View File
@@ -0,0 +1,199 @@
"""Virtual CAN executor and feedback simulator for the partial matrix."""
from __future__ import annotations
from datetime import UTC, datetime
from typing import Any
from .matrix import SeatCanMatrix
COMMAND_TO_FEEDBACK = {
"Driver_Seat_Ventilation_S": "Pilot_Seat_Vtilat_Stat_S",
"Driver_Seat_Heating_S": "Pilot_Seat_Heat_Stat_S",
"Front_Passenger_Seat_Ventilation_S": "Copilot_Seat_Vtilat_Stat_4FA_S",
"Front_Passenger_Seat_Heating_S": "Copilot_Seat_Heat_Stat_4FA_S",
}
OPERATION_TO_COMMAND = {
("front_left", "seat.set_ventilation"): "Driver_Seat_Ventilation_S",
("front_left", "seat.set_heating"): "Driver_Seat_Heating_S",
("front_right", "seat.set_ventilation"): "Front_Passenger_Seat_Ventilation_S",
("front_right", "seat.set_heating"): "Front_Passenger_Seat_Heating_S",
}
MOTION_CAPABILITY_TO_AXIS = {
"seat.set_longitudinal_position": "longitudinal",
"seat.set_cushion_tilt": "cushion_tilt",
"seat.set_backrest_recline": "backrest_recline",
"seat.set_legrest_height": "legrest_height",
"seat.set_legrest_extension": "legrest_extension",
}
MOTION_AXIS_BITS = {
"longitudinal": 0,
"cushion_tilt": 1,
"backrest_recline": 2,
"legrest_height": 3,
"legrest_extension": 4,
}
def _now() -> str:
return datetime.now(UTC).isoformat().replace("+00:00", "Z")
class VirtualSeatExecutor:
def __init__(self, matrix: SeatCanMatrix):
self.matrix = matrix
def execute(
self,
command_plan: dict[str, Any],
function_config: dict[str, Any],
actuator_state: dict[str, Any] | None = None,
) -> dict[str, Any]:
started_at = _now()
operation_by_id = {
operation["id"]: operation
for operation in function_config["spec"]["operations"]
}
results = []
for command in command_plan["spec"]["commands"]:
operation_ids = command["operation_ref"].split(",")
operations = [operation_by_id[item] for item in operation_ids]
if command["message"] == "Seat_Thermal_Control_42A":
results.extend(self._execute_thermal(command, operations))
else:
if len(operations) != 1:
raise ValueError("A motion command must reference exactly one operation")
results.append(
self._execute_motion(command, operations[0], actuator_state or {})
)
request_id = function_config["metadata"]["request_id"]
return {
"apiVersion": "cognitive-agent/v1alpha1",
"kind": "ExecutionReport",
"metadata": {
"id": f"report-{request_id}",
"name": "Seat virtual execution report",
"version": "1.0.0",
"request_id": request_id,
"labels": {"simulation": "true"},
},
"spec": {
"mode": "dry_run",
"status": "succeeded",
"started_at": started_at,
"finished_at": _now(),
"results": results,
},
}
def _execute_thermal(
self, command: dict[str, Any], operations: list[dict[str, Any]]
) -> list[dict[str, Any]]:
command_frame = self.matrix.encode(command["message"], command["signals"])
operation_by_signal = {
OPERATION_TO_COMMAND[(operation["target"]["zone"], operation["capability"])]: operation
for operation in operations
}
feedback_values = {}
for signal, value in command["signals"].items():
operation = operation_by_signal[signal]
level = int(operation["parameters"].get("level", 1))
feedback_values[COMMAND_TO_FEEDBACK[signal]] = level + 1 if value == 1 else 1
feedback_frame = self.matrix.encode("Seat_Thermal_Status_4FA", feedback_values)
results = []
for operation in operations:
command_signal = OPERATION_TO_COMMAND[
(operation["target"]["zone"], operation["capability"])
]
feedback_signal = COMMAND_TO_FEEDBACK[command_signal]
state = operation["parameters"]["state"]
requested_level = operation["parameters"].get("level")
effective_level = (requested_level or 1) if state == "on" else 0
results.append(
{
"ref": operation["id"],
"domain": "seat",
"status": "succeeded",
"message": (
f"虚拟回读确认 {operation['target']['instance']} "
f"{operation['capability']}={state}, level={effective_level}"
),
"telemetry": {
"requested_state": state,
"requested_level": requested_level,
"effective_level": effective_level,
"level_command_simulated": requested_level is not None,
"command_signal": command_signal,
"command_value": command["signals"][command_signal],
"feedback_signal": feedback_signal,
"feedback_value": feedback_values[feedback_signal],
"command_frame": command_frame,
"feedback_frame": feedback_frame,
"simulated": True,
},
}
)
return results
def _execute_motion(
self,
command: dict[str, Any],
operation: dict[str, Any],
actuator_state: dict[str, Any],
) -> dict[str, Any]:
capability = operation["capability"]
axis = MOTION_CAPABILITY_TO_AXIS[capability]
instance = operation["target"]["instance"]
target = int(operation["parameters"]["position_pct"])
target_angle = operation["parameters"].get("angle_deg")
start = int(
actuator_state.get(instance, {})
.get("position", {})
.get(axis, {})
.get("position_pct", target)
)
command_frame = self.matrix.encode(command["message"], command["signals"])
axis_mask = 1 << MOTION_AXIS_BITS[axis]
completion = command["completion"]
feedback_values = {
completion["signal"]: target,
"Moving_Mask": 0,
"Fault_Mask": 0,
"Position_Valid_Mask": axis_mask,
}
feedback_frame = self.matrix.encode(completion["message"], feedback_values)
midpoint = round((start + target) / 2)
samples = list(dict.fromkeys([start, midpoint, target]))
return {
"ref": operation["id"],
"domain": "seat",
"status": "succeeded",
"message": (
f"虚构电机反馈确认 {instance} {axis} 从 {start}% 到达 {target}%"
),
"telemetry": {
"motion_axis": axis,
"start_position_pct": start,
"requested_position_pct": target,
"effective_position_pct": target,
"requested_angle_deg": target_angle,
"effective_angle_deg": target_angle,
"position_tolerance_pct": completion.get("tolerance", 0),
"motion_samples_pct": samples,
"motion_state": "stopped",
"moving_mask": 0,
"fault_mask": 0,
"position_valid_mask": axis_mask,
"command_signal": next(
name for name in command["signals"] if name.endswith("_Target_Pct")
),
"feedback_signal": completion["signal"],
"command_frame": command_frame,
"feedback_frame": feedback_frame,
"matrix_source": "synthetic_demo",
"simulated": True,
},
}
+277
View File
@@ -0,0 +1,277 @@
"""Generate public intent and function configuration documents."""
from __future__ import annotations
from datetime import UTC, datetime
from typing import Any
TARGETS = {
"driver": {"device": "seat", "zone": "front_left", "instance": "driver"},
"passenger": {"device": "seat", "zone": "front_right", "instance": "passenger"},
}
CAPABILITIES = {
"ventilation": "seat.set_ventilation",
"heating": "seat.set_heating",
}
MOTION_CAPABILITIES = {
"longitudinal": "seat.set_longitudinal_position",
"cushion_tilt": "seat.set_cushion_tilt",
"backrest_recline": "seat.set_backrest_recline",
"legrest_height": "seat.set_legrest_height",
"legrest_extension": "seat.set_legrest_extension",
}
def _now() -> str:
return datetime.now(UTC).isoformat().replace("+00:00", "Z")
def build_intent_ir(
parsed: dict[str, Any], utterance: str, request_id: str, provider: str
) -> dict[str, Any]:
requests = []
for action in parsed["actions"]:
if action.get("action_type") == "position":
parameters = {
"axis": action["axis"],
"position_pct": action["position_pct"],
}
if action.get("angle_deg") is not None:
parameters["angle_deg"] = action["angle_deg"]
requests.append(
{
"domain": "seat",
"goal": (
f"Set {action['target']} {action['axis']} position "
f"to {action['position_pct']} percent"
),
"target": TARGETS[action["target"]],
"parameters": parameters,
"source_span": utterance,
}
)
continue
parameters: dict[str, Any] = {
"function": action["function"],
"state": action["state"],
}
if action.get("requested_level") is not None:
parameters["requested_level"] = action["requested_level"]
requests.append(
{
"domain": "seat",
"goal": f"Set {action['target']} {action['function']} {action['state']}",
"target": TARGETS[action["target"]],
"parameters": parameters,
"source_span": utterance,
}
)
for preset_action in parsed.get("preset_actions", []):
requests.append(
{
"domain": "seat",
"goal": (
f"Save the current {preset_action['target']} posture as "
f"{preset_action['preset_name']}"
),
"target": TARGETS[preset_action["target"]],
"parameters": {
"capability": "seat.save_posture_preset",
"preset_name": preset_action["preset_name"],
},
"source_span": utterance,
}
)
for preset_event in parsed.get("preset_events", []):
requests.append(
{
"domain": "seat",
"goal": (
f"Apply saved posture {preset_event['preset_name']} to "
f"{preset_event['target']}"
),
"target": TARGETS[preset_event["target"]],
"parameters": {
"capability": "seat.apply_posture_preset",
"preset_name": preset_event["preset_name"],
},
"source_span": utterance,
}
)
for deferred in parsed.get("deferred_requests", []):
requests.append(
{
"domain": deferred["domain"],
"goal": f"{deferred['desired_change']} {deferred['capability']}",
"target": {
"device": deferred["domain"],
"zone": deferred["target_scope"],
},
"parameters": {
"capability": deferred["capability"],
"desired_change": deferred["desired_change"],
"requested_value": deferred.get("requested_value"),
"requested_parameters": deferred.get("parameters", {}),
"availability": "catalog_only",
},
"source_span": utterance,
}
)
detected_domains = parsed.get("detected_domains", ["seat"])
return {
"apiVersion": "cognitive-agent/v1alpha1",
"kind": "IntentIR",
"metadata": {
"id": f"intent-{request_id}",
"name": "Seat function intent",
"version": "1.0.0",
"request_id": request_id,
"generated_at": _now(),
"labels": {"provider": provider},
},
"spec": {
"utterance": utterance,
"language": "zh-CN",
"mode": "cross_domain" if len(detected_domains) > 1 else "single_domain",
"detected_domains": detected_domains,
"requests": requests,
"ambiguities": parsed["ambiguities"],
"confidence": parsed["confidence"],
"requires_confirmation": bool(parsed["ambiguities"]),
},
}
def build_function_config(
parsed: dict[str, Any], request_id: str, provider: str
) -> dict[str, Any] | None:
if not parsed["actions"]:
return None
operations = []
assumptions = [
"The supplied matrix is partial; this configuration is eligible for virtual execution only."
]
for index, action in enumerate(parsed["actions"], start=1):
if action.get("action_type") == "position":
operation_id = (
f"{action['target']}_{action['axis']}_{action['position_pct']}pct"
)
if any(item["id"] == operation_id for item in operations):
operation_id = f"{operation_id}_{index}"
parameters: dict[str, Any] = {"position_pct": action["position_pct"]}
if action.get("angle_deg") is not None:
parameters["angle_deg"] = action["angle_deg"]
operation = {
"id": operation_id,
"capability": MOTION_CAPABILITIES[action["axis"]],
"target": TARGETS[action["target"]],
"parameters": parameters,
"timeout_ms": 8000,
"failure_policy": "abort",
}
if action.get("motion_intent") is not None:
operation["motion"] = dict(action["motion_intent"])
operations.append(operation)
assumptions.append(
f"Operation {operation_id} uses an explicitly synthetic position CAN mapping; "
"it is never eligible for hardware transmission."
)
continue
operation_id = f"{action['target']}_{action['function']}_{action['state']}"
if any(item["id"] == operation_id for item in operations):
operation_id = f"{operation_id}_{index}"
parameters: dict[str, Any] = {"state": action["state"]}
if action.get("requested_level") is not None:
parameters["level"] = action["requested_level"]
operations.append(
{
"id": operation_id,
"capability": CAPABILITIES[action["function"]],
"target": TARGETS[action["target"]],
"parameters": parameters,
"timeout_ms": 3000,
"failure_policy": "abort",
}
)
if action.get("requested_level") is not None:
assumptions.append(
f"Operation {operation_id} targets level {action['requested_level']}; "
"the level is simulated because the supplied matrix has no level command signal."
)
return {
"apiVersion": "cognitive-agent/v1alpha1",
"kind": "FunctionConfig",
"metadata": {
"id": f"seat-{request_id}",
"name": "Seat function",
"version": "1.0.0",
"request_id": request_id,
"generated_at": _now(),
"labels": {"provider": provider, "execution_scope": "virtual_only"},
},
"spec": {"domain": "seat", "operations": operations, "assumptions": assumptions},
}
def build_function_preset(
target: str,
preset_name: str,
device_state: dict[str, Any],
request_id: str,
provider: str,
) -> dict[str, Any]:
operations = []
for axis, capability in MOTION_CAPABILITIES.items():
motion_state = device_state["position"][axis]
parameters: dict[str, Any] = {
"position_pct": int(motion_state["position_pct"])
}
if motion_state.get("angle_deg") is not None:
parameters["angle_deg"] = motion_state["angle_deg"]
operations.append(
{
"id": f"{target}_{axis}_preset",
"capability": capability,
"target": TARGETS[target],
"parameters": parameters,
"timeout_ms": 8000,
"failure_policy": "abort",
}
)
return {
"apiVersion": "cognitive-agent/v1alpha1",
"kind": "FunctionPreset",
"metadata": {
"id": f"seat-preset-{request_id}",
"name": preset_name,
"version": "1.0.0",
"request_id": request_id,
"generated_at": _now(),
"description": f"User-saved five-axis posture for {target}.",
"labels": {
"provider": provider,
"target": target,
"storage_scope": "conversation_durable",
},
},
"spec": {
"domain": "seat",
"operations": operations,
"locked_parameters": [
f"operations.{index}.parameters.position_pct"
for index in range(len(operations))
],
"applicability": {
"target": target,
"execution_mode": "dry_run",
},
},
}
+303
View File
@@ -0,0 +1,303 @@
"""Compile seat functions to the CAN protocol implemented by the T2G IAR demos."""
from __future__ import annotations
import hashlib
import json
from pathlib import Path
from typing import Any
import yaml
STATE_VALUES = {"on": 1, "off": 2}
class T2GSeatCompiler:
"""Deterministic compiler for the HIL-verified T2G seat bench protocol."""
version = "1.3.0"
def __init__(self, mapping_path: Path, calibration_path: Path):
self.mapping = yaml.safe_load(mapping_path.read_text(encoding="utf-8"))
self.calibration = yaml.safe_load(calibration_path.read_text(encoding="utf-8"))
self.mapping_version = self.mapping["metadata"]["version"]
self.axes = self.calibration["axes"]
self.protocol = self.calibration["protocol"]
self.supported = {
(item["capability"], item.get("target_zone"))
for item in self.mapping["spec"]["mappings"]
}
def compile(
self,
function_config: dict[str, Any],
actuator_state: dict[str, Any] | None = None,
) -> dict[str, Any]:
operations = function_config["spec"]["operations"]
self._validate_shared_physical_targets(operations)
thermal_operations = [
item
for item in operations
if item["capability"] in {"seat.set_ventilation", "seat.set_heating"}
]
motion_operations = [item for item in operations if item not in thermal_operations]
commands: list[dict[str, Any]] = []
if thermal_operations:
commands.extend(
self._compile_thermal(thermal_operations, actuator_state or {})
)
commands.extend(self._compile_motion(motion_operations, len(commands)))
canonical = json.dumps(
function_config, ensure_ascii=False, sort_keys=True
).encode("utf-8")
metadata = function_config["metadata"]
return {
"apiVersion": "cognitive-agent/v1alpha1",
"kind": "CommandPlan",
"metadata": {
"id": f"plan-t2g-{metadata['request_id']}",
"name": "T2G seat hardware CAN command plan",
"version": "1.0.0",
"request_id": metadata["request_id"],
"labels": {
"execution_scope": "hardware_candidate",
"protocol": "t2g_seat_v1",
},
},
"spec": {
"domain": "seat",
"platform": "t2g_seat_bench",
"source": {
"config_id": metadata["id"],
"config_version": metadata["version"],
"config_sha256": hashlib.sha256(canonical).hexdigest(),
"compiler_version": self.version,
"mapping_version": self.mapping_version,
},
"commands": commands,
},
}
def _compile_thermal(
self,
operations: list[dict[str, Any]],
actuator_state: dict[str, Any],
) -> list[dict[str, Any]]:
state = actuator_state.get("driver") or actuator_state.get("passenger", {})
desired = {
"ventilation": dict(state.get("ventilation", {"state": "off", "level": 0})),
"heating": dict(state.get("heating", {"state": "off", "level": 0})),
}
for operation in operations:
self._require_supported(operation)
feature = operation["capability"].removeprefix("seat.set_")
requested_state = str(operation["parameters"]["state"])
previous_level = int(desired[feature].get("level", 0))
level = (
int(operation["parameters"].get("level", previous_level or 1))
if requested_state == "on"
else 0
)
desired[feature] = {"state": requested_state, "level": level}
operation_ref = ",".join(item["id"] for item in operations)
commands: list[dict[str, Any]] = []
if desired["heating"]["state"] == "on":
commands.append(
{
"id": "t2g_seat_thermal_mode",
"operation_ref": operation_ref,
"bus": "seat_can",
"message": "T2G_Seat_Thermal_Mode_3CE",
"signals": {"Driver_Seat_Thermal_Mode": 1},
"send_offset_ms": 0,
}
)
commands.append(
{
"id": "t2g_seat_thermal_control",
"operation_ref": operation_ref,
"bus": "seat_can",
"message": "T2G_Seat_Thermal_Control_29E",
"signals": {
"Driver_Seat_Heating_Gear": int(desired["heating"]["level"]),
"Driver_Seat_Heating_On": STATE_VALUES[desired["heating"]["state"]],
"Driver_Seat_Ventilation_Gear": int(desired["ventilation"]["level"]),
"Driver_Seat_Ventilation_Mode": 1,
"Driver_Seat_Ventilation_On": STATE_VALUES[
desired["ventilation"]["state"]
],
},
"send_offset_ms": 20 if commands else 0,
}
)
return commands
def _compile_motion(
self, operations: list[dict[str, Any]], command_offset: int
) -> list[dict[str, Any]]:
grouped: list[list[dict[str, Any]]] = []
by_capability: dict[str, list[dict[str, Any]]] = {}
for operation in operations:
self._require_supported(operation)
capability = operation["capability"]
if capability not in by_capability:
by_capability[capability] = []
grouped.append(by_capability[capability])
by_capability[capability].append(operation)
commands = []
for index, physical_operations in enumerate(grouped):
operation = physical_operations[0]
capability = operation["capability"]
try:
axis = self.axes[capability]
except KeyError as exc:
raise ValueError(
f"T2G IAR firmware has no calibrated actuator for {capability}"
) from exc
maximum = int(axis["maximum_value_x100"])
position_pct = int(operation["parameters"]["position_pct"])
target = round(maximum * position_pct / 100)
tolerance = max(1, round(maximum / 100))
motion = operation.get("motion")
if motion is not None and motion.get("mode") == "relative":
delta_pct = int(motion["delta_position_pct"])
maximum_delta = int(axis["max_uncalibrated_relative_pct"])
if delta_pct <= maximum_delta:
commands.append(
self._compile_uncalibrated_relative(
operation,
physical_operations,
axis,
command_offset + index,
)
)
continue
# A large relative request cannot safely be approximated by a
# long open-loop duty command or by several chained commands.
# Compile its already-resolved target as an absolute move. The
# executor will check the board's live Position_Valid bit before
# putting Opcode 1 on the bus.
commands.append(
{
"id": f"t2g_move_{operation['id']}",
"operation_ref": ",".join(
item["id"] for item in physical_operations
),
"bus": "seat_can",
"message": "T2G_Seat_Move_Command_300",
"signals": {
"Direction": 0,
"Motor_Selector": int(axis["motor_selector"]),
"Opcode": int(self.protocol["absolute_position_opcode"]),
"Param_A": target,
"Param_B": int(self.protocol["default_duty_percent"]),
"Protocol_Version": int(self.protocol["version"]),
# Allocated immediately before hardware transmission. Zero keeps
# the compiled plan deterministic and is never put on the bus.
"Sequence": 0,
},
"send_offset_ms": (command_offset + index) * 100,
"completion": {
"message": "T2G_Seat_Move_Motor_Status_302",
"signal": "Value_X100",
"operator": "within",
"value": target,
"tolerance": tolerance,
"timeout_ms": int(operation.get("timeout_ms", 8000)),
},
}
)
return commands
def _compile_uncalibrated_relative(
self,
operation: dict[str, Any],
physical_operations: list[dict[str, Any]],
axis: dict[str, Any],
command_index: int,
) -> dict[str, Any]:
motion = dict(operation["motion"])
delta_pct = int(motion["delta_position_pct"])
maximum_delta = int(axis["max_uncalibrated_relative_pct"])
if delta_pct <= 0:
raise ValueError(
"relative seat movement resolved to zero travel; no motor command was generated"
)
if delta_pct > maximum_delta:
raise ValueError(
"internal compiler error: an oversized relative movement must be "
"compiled as a calibrated absolute target"
)
duration_ms = delta_pct * int(axis["timed_duty_ms_per_pct"])
duration_ms = max(
int(self.protocol["timed_duty_min_duration_ms"]), duration_ms
)
maximum_duration = int(self.protocol["timed_duty_max_duration_ms"])
if duration_ms > maximum_duration:
raise ValueError(
"uncalibrated relative seat movement would exceed the bounded "
f"{maximum_duration} ms timed-motion limit"
)
direction_name = str(motion["direction"])
direction = int(self.protocol[f"{direction_name}_direction"])
timeout_ms = max(
int(operation.get("timeout_ms", 8000)), duration_ms + 3000
)
return {
"id": f"t2g_move_{operation['id']}",
"operation_ref": ",".join(
item["id"] for item in physical_operations
),
"bus": "seat_can",
"message": "T2G_Seat_Move_Command_300",
"signals": {
"Direction": direction,
"Motor_Selector": int(axis["motor_selector"]),
"Opcode": int(self.protocol["timed_duty_opcode"]),
"Param_A": duration_ms,
"Param_B": int(self.protocol["default_duty_percent"]),
"Protocol_Version": int(self.protocol["version"]),
"Sequence": 0,
},
"send_offset_ms": command_index * 100,
"completion": {
"message": "T2G_Seat_Move_Motor_Status_302",
"signal": "Task_State",
"operator": "eq",
"value": 8,
"timeout_ms": timeout_ms,
},
}
@staticmethod
def _validate_shared_physical_targets(
operations: list[dict[str, Any]],
) -> None:
requested: dict[str, dict[str, Any]] = {}
for operation in operations:
capability = operation["capability"]
request = {
"parameters": dict(operation.get("parameters", {})),
"motion": dict(operation["motion"]) if operation.get("motion") else None,
}
previous = requested.get(capability)
if previous is not None and previous != request:
raise ValueError(
"driver and passenger aliases request conflicting values for "
f"the same physical capability {capability}"
)
requested[capability] = request
def _require_supported(self, operation: dict[str, Any]) -> None:
key = (operation["capability"], operation["target"]["zone"])
if key not in self.supported:
raise ValueError(
"T2G hardware mapping does not support "
f"capability={key[0]}, zone={key[1]}"
)
+713
View File
@@ -0,0 +1,713 @@
"""Hardware-in-the-loop executor for the verified T2G seat CAN protocol."""
from __future__ import annotations
import threading
import time
from datetime import UTC, datetime
from typing import Any, Callable
from cognitive_agent.infrastructure.can.gateway import CanBus, CanFrame
from cognitive_agent.infrastructure.firmware import T2GFirmwareGuard
from .executor import MOTION_CAPABILITY_TO_AXIS
from .matrix import SeatCanMatrix
MOTOR_RESULT_NAMES = {
0: "NONE",
1: "RUNNING",
2: "OK",
3: "STOPPED",
4: "STALL",
5: "ENDPOINT",
6: "TIMEOUT",
7: "DRIVER_FAULT",
8: "INVALID",
9: "CALIBRATION_FAILED",
}
MOTOR_EVENT_NAMES = {
0: "NONE",
1: "TARGET_REACHED",
2: "NEAR_ENDPOINT",
3: "FAR_ENDPOINT",
4: "STALL",
5: "TIMEOUT",
6: "ABORTED",
7: "DRIVER_FAULT",
8: "CALIBRATION_FAILED",
}
class HardwareExecutionError(RuntimeError):
"""Raised for rejected, faulted or timed-out physical operations."""
def __init__(
self, message: str, *, telemetry: dict[str, Any] | None = None
) -> None:
super().__init__(message)
self.telemetry = dict(telemetry or {})
def _now() -> str:
return datetime.now(UTC).isoformat().replace("+00:00", "Z")
class HardwareSeatExecutor:
"""Serialize access to one adapter and require real firmware feedback."""
_adapter_lock = threading.Lock()
_sequence = 0
def __init__(
self,
matrix: SeatCanMatrix,
bus_factory: Callable[[], CanBus],
*,
thermal_timeout_ms: int = 2_000,
ack_timeout_ms: int = 1_500,
axis_maximum_x100: dict[str, int] | None = None,
firmware_guard: T2GFirmwareGuard | None = None,
):
self.matrix = matrix
self.bus_factory = bus_factory
self.thermal_timeout_ms = thermal_timeout_ms
self.ack_timeout_ms = ack_timeout_ms
self.axis_maximum_x100 = dict(axis_maximum_x100 or {})
self.firmware_guard = firmware_guard
@classmethod
def _next_sequence(cls) -> int:
cls._sequence = (cls._sequence + 1) & 0xFFFF
if cls._sequence == 0:
cls._sequence = 1
return cls._sequence
def execute(
self,
command_plan: dict[str, Any],
function_config: dict[str, Any],
actuator_state: dict[str, Any] | None = None,
) -> dict[str, Any]:
del actuator_state # physical feedback, not virtual state, is authoritative here
started_at = _now()
operations = {
item["id"]: item for item in function_config["spec"]["operations"]
}
results: list[dict[str, Any]] = []
commands = command_plan["spec"]["commands"]
has_motion = any(item["message"] == "T2G_Seat_Move_Command_300" for item in commands)
bus: CanBus | None = None
motion_armed = False
with self._adapter_lock:
try:
if self.firmware_guard is not None:
bus, firmware_telemetry = self.firmware_guard.prepare_bus()
results.append(
{
"ref": "seat-controller-firmware",
"domain": "seat",
"status": "succeeded",
"message": (
"repository Seat_Controller_demo was programmed and verified"
if firmware_telemetry.get("programmed_this_request")
else "repository Seat_Controller_demo identity was verified"
),
"telemetry": {
**firmware_telemetry,
"simulated": False,
},
}
)
else:
bus = self.bus_factory()
if has_motion:
self._stop_all(bus, require_ack=True)
motion_armed = True
previous_offset = 0
for command in commands:
offset = int(command.get("send_offset_ms", 0))
if offset > previous_offset:
time.sleep((offset - previous_offset) / 1000)
previous_offset = offset
operation_ids = command["operation_ref"].split(",")
referenced = [operations[item] for item in operation_ids]
if command["message"] == "T2G_Seat_Thermal_Mode_3CE":
frame = self._send_encoded(bus, command["message"], command["signals"])
results.append(
{
"ref": command["id"],
"domain": "seat",
"status": "succeeded",
"message": "T2G heater manual control mode sent to the physical CAN bus",
"telemetry": {
"control_step": "thermal_mode",
"command_frame": frame.public(),
"simulated": False,
},
}
)
elif command["message"] == "T2G_Seat_Thermal_Control_29E":
results.extend(self._execute_thermal(bus, command, referenced))
elif command["message"] == "T2G_Seat_Move_Command_300":
results.extend(self._execute_motion(bus, command, referenced))
else:
raise HardwareExecutionError(
f"hardware executor refuses unknown message {command['message']}"
)
except Exception as exc: # noqa: BLE001 - normalize transport and protocol failures
if bus is not None and motion_armed:
self._best_effort_stop(bus)
operation_ref = self._first_unreported_operation(operations, results)
results.append(
{
"ref": operation_ref,
"domain": "seat",
"status": "failed",
"message": str(exc),
"telemetry": {
**getattr(exc, "telemetry", {}),
"error_type": type(exc).__name__,
"stop_all_attempted": bool(bus is not None and motion_armed),
"simulated": False,
},
}
)
finally:
if bus is not None:
bus.close()
succeeded = all(item["status"] == "succeeded" for item in results)
request_id = function_config["metadata"]["request_id"]
return {
"apiVersion": "cognitive-agent/v1alpha1",
"kind": "ExecutionReport",
"metadata": {
"id": f"report-{request_id}",
"name": "T2G seat hardware execution report",
"version": "1.0.0",
"request_id": request_id,
"labels": {"simulation": "false", "feedback": "physical_can"},
},
"spec": {
"mode": "hil",
"status": "succeeded" if succeeded else "failed",
"started_at": started_at,
"finished_at": _now(),
"results": results,
},
}
@staticmethod
def _first_unreported_operation(
operations: dict[str, dict[str, Any]], results: list[dict[str, Any]]
) -> str:
reported = {item["ref"] for item in results}
return next((item for item in operations if item not in reported), next(iter(operations)))
def _send_encoded(
self, bus: CanBus, message: str, signals: dict[str, int]
) -> CanFrame:
encoded = self.matrix.encode(message, signals)
return bus.send(int(encoded["frame_id"], 16), bytes.fromhex(encoded["data_hex"]))
def _receive_until(
self,
bus: CanBus,
arbitration_id: int,
timeout_ms: int,
predicate: Callable[[dict[str, int], CanFrame], bool],
) -> tuple[dict[str, int], CanFrame]:
deadline = time.monotonic() + timeout_ms / 1000
while True:
remaining_ms = round((deadline - time.monotonic()) * 1000)
if remaining_ms <= 0:
raise HardwareExecutionError(
f"timed out after {timeout_ms} ms waiting for CAN 0x{arbitration_id:03X}"
)
frame = bus.receive(remaining_ms)
if frame is None:
raise HardwareExecutionError(
f"timed out after {timeout_ms} ms waiting for CAN 0x{arbitration_id:03X}"
)
if frame.arbitration_id != arbitration_id or len(frame.data) < 8:
continue
values = self.matrix.decode_by_frame_id(arbitration_id, frame.data)
if predicate(values, frame):
return values, frame
def _wait_ack(
self, bus: CanBus, sequence: int, opcode: int
) -> tuple[dict[str, int], CanFrame]:
def matches(values: dict[str, int], _: CanFrame) -> bool:
if values["Last_Sequence"] != sequence or values["Last_Opcode"] != opcode:
return False
if values["Ack"] != 1:
raise HardwareExecutionError(
"T2G movement command rejected: "
f"ack={values['Ack']}, reason={values['Reject_Reason']}, "
f"safety_fault={values['Safety_Fault']}"
)
if values["Safety_Inhibit"] or values["Safety_Fault"]:
raise HardwareExecutionError(
f"T2G movement safety inhibit/fault: {values['Safety_Fault']}"
)
return True
return self._receive_until(bus, 0x301, self.ack_timeout_ms, matches)
def _stop_all(self, bus: CanBus, *, require_ack: bool) -> tuple[CanFrame, CanFrame | None]:
sequence = self._next_sequence()
signals = {
"Opcode": 9,
"Motor_Selector": 3,
"Direction": 0,
"Protocol_Version": 1,
"Sequence": sequence,
"Param_A": 0,
"Param_B": 0,
}
sent = self._send_encoded(bus, "T2G_Seat_Move_Command_300", signals)
if not require_ack:
return sent, None
_, ack = self._wait_ack(bus, sequence, 9)
return sent, ack
def _best_effort_stop(self, bus: CanBus) -> None:
try:
self._stop_all(bus, require_ack=False)
except Exception: # noqa: BLE001 - already handling the primary failure
pass
def _execute_thermal(
self,
bus: CanBus,
command: dict[str, Any],
operations: list[dict[str, Any]],
) -> list[dict[str, Any]]:
command_frame = self._send_encoded(bus, command["message"], command["signals"])
expected: dict[str, int] = {}
for operation in operations:
feature = operation["capability"].removeprefix("seat.set_")
signal = (
"Driver_Seat_Heating_Status"
if feature == "heating"
else "Driver_Seat_Ventilation_Status"
)
state = operation["parameters"]["state"]
level = int(operation["parameters"].get("level", 1))
expected[signal] = 1 if state == "off" else min(level, 3) + 1
def matches(values: dict[str, int], _: CanFrame) -> bool:
return all(values[name] == value for name, value in expected.items())
values, feedback = self._receive_until(
bus, 0x4FA, self.thermal_timeout_ms, matches
)
output = []
for operation in operations:
feature = operation["capability"].removeprefix("seat.set_")
signal = (
"Driver_Seat_Heating_Status"
if feature == "heating"
else "Driver_Seat_Ventilation_Status"
)
raw = values[signal]
output.append(
{
"ref": operation["id"],
"domain": "seat",
"status": "succeeded",
"message": (
f"physical 0x4FA confirmed shared front-seat {feature} "
f"state={operation['parameters']['state']}, level={max(0, raw - 1)}"
),
"telemetry": {
"requested_target": operation["target"]["instance"],
"physical_target": "t2g_primary_seat",
"requested_state": operation["parameters"]["state"],
"requested_level": operation["parameters"].get("level"),
"feedback_signal": signal,
"feedback_value": raw,
"command_frame": command_frame.public(),
"feedback_frame": feedback.public(),
"hardware_feedback": True,
"simulated": False,
},
}
)
return output
def _execute_motion(
self,
bus: CanBus,
command: dict[str, Any],
operations: list[dict[str, Any]],
) -> list[dict[str, Any]]:
if not operations:
raise HardwareExecutionError(
"a T2G motion frame must reference at least one operation"
)
operation = operations[0]
if any(
item["capability"] != operation["capability"]
or item["parameters"] != operation["parameters"]
or item.get("motion") != operation.get("motion")
for item in operations[1:]
):
raise HardwareExecutionError(
"one shared T2G motion frame cannot represent conflicting operations"
)
signals = dict(command["signals"])
motor = int(signals["Motor_Selector"])
opcode = int(signals["Opcode"])
if opcode == 3:
return self._execute_timed_motion(
bus, command, operations, signals, motor
)
preflight_values, preflight_frame = self._motor_status_snapshot(bus, motor)
if not preflight_values["Position_Valid"]:
raise HardwareExecutionError(
f"T2G motor {motor} absolute-position command blocked before send: "
"controller Position_Valid=0. Run the explicit endpoint-calibration "
"maintenance procedure with the bench clear and emergency stop ready, "
"or use a small relative movement request.",
telemetry={
"motor_selector": motor,
"position_valid": False,
"calibration_required": True,
"preflight_frame": preflight_frame.public(),
},
)
sequence = self._next_sequence()
signals["Sequence"] = sequence
command_frame = self._send_encoded(bus, command["message"], signals)
ack_values, ack_frame = self._wait_ack(bus, sequence, opcode)
target = int(command["completion"]["value"])
tolerance = int(command["completion"].get("tolerance", 0))
def complete(values: dict[str, int], _: CanFrame) -> bool:
if values["Motor_Sub_ID"] != motor or values["Motor_Sequence"] != sequence:
return False
task_state = values["Task_State"]
result = values["Result"]
if task_state == 9 or result not in {1, 2}:
raise HardwareExecutionError(
f"T2G motor {motor} failed: task_state={task_state}, result={result}"
)
if task_state != 8 or result != 2:
return False
if not values["Position_Valid"]:
raise HardwareExecutionError(
f"T2G motor {motor} completed without a valid calibrated position"
)
if abs(values["Value_X100"] - target) > tolerance:
raise HardwareExecutionError(
f"T2G motor {motor} stopped outside tolerance: "
f"actual={values['Value_X100']}, target={target}, tolerance={tolerance}"
)
return True
values, feedback = self._receive_until(
bus,
0x302,
int(command["completion"]["timeout_ms"]),
complete,
)
axis = MOTION_CAPABILITY_TO_AXIS[operation["capability"]]
maximum = self.axis_maximum_x100.get(operation["capability"])
if maximum is None:
requested_pct = int(operation["parameters"]["position_pct"])
maximum = max(1, target * 100 // max(1, requested_pct))
actual_pct = round(values["Value_X100"] * 100 / maximum)
common_telemetry = {
"motion_axis": axis,
"motor_selector": motor,
"physical_target": "t2g_primary_seat",
"sequence": sequence,
"requested_position_pct": int(operation["parameters"]["position_pct"]),
"effective_position_pct": actual_pct,
"requested_value_x100": target,
"effective_value_x100": values["Value_X100"],
"position_tolerance_x100": tolerance,
"task_state": values["Task_State"],
"motor_result": values["Result"],
"position_valid": bool(values["Position_Valid"]),
"system_ack": ack_values["Ack"],
"preflight_frame": preflight_frame.public(),
"command_frame": command_frame.public(),
"ack_frame": ack_frame.public(),
"feedback_frame": feedback.public(),
"hardware_feedback": True,
"simulated": False,
}
return [
{
"ref": item["id"],
"domain": "seat",
"status": "succeeded",
"message": (
f"physical motor {motor} feedback confirmed shared front-seat "
f"{axis} at {actual_pct}%"
),
"telemetry": {
**common_telemetry,
"requested_target": item["target"]["instance"],
},
}
for item in operations
]
def _motor_status_snapshot(
self, bus: CanBus, motor: int
) -> tuple[dict[str, int], CanFrame]:
return self._receive_until(
bus,
0x302,
max(self.ack_timeout_ms, 1_000),
lambda values, _: values["Motor_Sub_ID"] == motor,
)
def _motor_diagnostic_snapshot(
self, bus: CanBus, motor: int
) -> tuple[dict[str, int], CanFrame]:
return self._receive_until(
bus,
0x303,
max(self.ack_timeout_ms, 1_000),
lambda values, _: values["Motor_Sub_ID"] == motor,
)
def _execute_timed_motion(
self,
bus: CanBus,
command: dict[str, Any],
operations: list[dict[str, Any]],
signals: dict[str, int],
motor: int,
) -> list[dict[str, Any]]:
baseline, baseline_frame = self._motor_diagnostic_snapshot(bus, motor)
sequence = self._next_sequence()
signals["Sequence"] = sequence
command_frame = self._send_encoded(bus, command["message"], signals)
ack_values, ack_frame = self._wait_ack(bus, sequence, 3)
def terminal(values: dict[str, int], _: CanFrame) -> bool:
if values["Motor_Sub_ID"] != motor or values["Motor_Sequence"] != sequence:
return False
task_state = values["Task_State"]
result = values["Result"]
return task_state in {8, 9} or result not in {1, 2}
values, feedback = self._receive_until(
bus,
0x302,
int(command["completion"]["timeout_ms"]),
terminal,
)
diagnostic: dict[str, int] | None = None
diagnostic_frame: CanFrame | None = None
diagnostic_error: str | None = None
try:
diagnostic, diagnostic_frame = self._motor_diagnostic_snapshot(bus, motor)
except HardwareExecutionError as exc:
diagnostic_error = str(exc)
hall_before = int(baseline["Hall_Count"])
hall_after = (
int(diagnostic["Hall_Count"]) if diagnostic is not None else hall_before
)
direction = int(signals["Direction"])
task_state = int(values["Task_State"])
result = int(values["Result"])
result_name = MOTOR_RESULT_NAMES.get(result, f"UNKNOWN_{result}")
last_event = (
int(diagnostic["Last_Motor_Event"])
if diagnostic is not None
else None
)
last_event_name = (
MOTOR_EVENT_NAMES.get(last_event, f"UNKNOWN_{last_event}")
if last_event is not None
else "UNAVAILABLE"
)
hall_delta = abs(hall_after - hall_before)
max_hall = max(
1,
int(diagnostic["Max_Hall_Count"])
if diagnostic is not None
else int(baseline["Max_Hall_Count"]),
)
if task_state != 8 or result != 2:
if result == 4 and hall_delta <= 2:
failure_category = "stall_no_hall_activity"
diagnostic_hint = (
"No sustained Hall activity was observed. The commanded direction may "
"already be at a mechanical endpoint or obstructed, or the Motor Hall "
"power/signal path may be disconnected. With the bench clear, use a "
"small command in the opposite direction to distinguish endpoint from "
"a Hall wiring fault; do not run automatic full-range calibration."
)
elif result == 4:
failure_category = "stall_after_hall_activity"
diagnostic_hint = (
"Hall activity was observed before the stall; inspect the mechanism for "
"an endpoint or obstruction in the commanded direction."
)
elif result == 5 or last_event in {2, 3}:
failure_category = "endpoint"
diagnostic_hint = (
"The controller reported an endpoint. Verify the requested direction "
"and establish endpoint calibration before absolute positioning."
)
else:
failure_category = result_name.lower()
diagnostic_hint = (
"Inspect the controller result, last motor event, driver supply and Hall "
"feedback before retrying the movement."
)
raise HardwareExecutionError(
f"T2G timed motor {motor} {result_name}: task_state={task_state}, "
f"Hall={hall_before}->{hall_after}, last_event={last_event_name}. "
f"{diagnostic_hint}",
telemetry={
"motor_selector": motor,
"motion_axis": MOTION_CAPABILITY_TO_AXIS[
operations[0]["capability"]
],
"motion_mode": "relative_timed_duty",
"position_valid": bool(values["Position_Valid"]),
"task_state": task_state,
"motor_result": result,
"motor_result_name": result_name,
"last_motor_event": last_event,
"last_motor_event_name": last_event_name,
"failure_category": failure_category,
"diagnostic_hint": diagnostic_hint,
"hall_before": hall_before,
"hall_after": hall_after,
"hall_delta": hall_delta,
"max_hall_count": max_hall,
"direction": direction,
"duration_ms": int(signals["Param_A"]),
"duty_percent": int(signals["Param_B"]),
"command_frame": command_frame.public(),
"ack_frame": ack_frame.public(),
"feedback_frame": feedback.public(),
"diagnostic_before_frame": baseline_frame.public(),
"diagnostic_frame": (
diagnostic_frame.public()
if diagnostic_frame is not None
else None
),
"diagnostic_read_error": diagnostic_error,
},
)
if diagnostic is None or diagnostic_frame is None:
raise HardwareExecutionError(
f"T2G timed motor {motor} completed but diagnostic Hall feedback "
f"was unavailable: {diagnostic_error}",
telemetry={
"motor_selector": motor,
"position_valid": bool(values["Position_Valid"]),
"task_state": task_state,
"motor_result": result,
"motor_result_name": result_name,
"hall_before": hall_before,
"command_frame": command_frame.public(),
"ack_frame": ack_frame.public(),
"feedback_frame": feedback.public(),
"diagnostic_before_frame": baseline_frame.public(),
"diagnostic_read_error": diagnostic_error,
},
)
direction_verified: bool | None
if direction == 1:
direction_verified = hall_after > hall_before
elif hall_before > 0:
direction_verified = hall_after < hall_before
else:
# At an uncalibrated zero estimate, NEAR movement cannot decrement
# the public Hall position. COMPLETE/OK still proves raw Hall edges
# because the firmware refuses timed success without one.
direction_verified = None
if direction_verified is False:
raise HardwareExecutionError(
f"T2G timed motor {motor} completed but Hall direction did not match "
f"the command: before={hall_before}, after={hall_after}, direction={direction}",
telemetry={
"motor_selector": motor,
"position_valid": bool(values["Position_Valid"]),
"hall_before": hall_before,
"hall_after": hall_after,
"hall_direction_verified": False,
"feedback_frame": feedback.public(),
"diagnostic_frame": diagnostic_frame.public(),
},
)
axis = MOTION_CAPABILITY_TO_AXIS[operations[0]["capability"]]
common_telemetry = {
"motion_axis": axis,
"motion_mode": "relative_timed_duty",
"motor_selector": motor,
"physical_target": "t2g_primary_seat",
"sequence": sequence,
"requested_position_pct": int(
operations[0]["parameters"]["position_pct"]
),
"requested_delta_position_pct": int(
operations[0].get("motion", {}).get("delta_position_pct", 0)
),
"requested_delta_angle_deg": operations[0]
.get("motion", {})
.get("delta_angle_deg"),
"duration_ms": int(signals["Param_A"]),
"duty_percent": int(signals["Param_B"]),
"direction": direction,
"task_state": task_state,
"motor_result": result,
"motor_result_name": result_name,
"last_motor_event": last_event,
"last_motor_event_name": last_event_name,
"position_valid": bool(values["Position_Valid"]),
"absolute_position_available": bool(values["Position_Valid"]),
"hall_before": hall_before,
"hall_after": hall_after,
"hall_delta": hall_delta,
"max_hall_count": max_hall,
"observed_delta_position_pct": round(hall_delta * 100 / max_hall),
"hall_direction_verified": direction_verified,
"system_ack": ack_values["Ack"],
"command_frame": command_frame.public(),
"ack_frame": ack_frame.public(),
"feedback_frame": feedback.public(),
"diagnostic_before_frame": baseline_frame.public(),
"diagnostic_frame": diagnostic_frame.public(),
"hardware_feedback": True,
"simulated": False,
}
return [
{
"ref": item["id"],
"domain": "seat",
"status": "succeeded",
"message": (
f"physical motor {motor} COMPLETE/OK confirmed relative {axis} "
f"movement with Hall {hall_before}->{hall_after}; absolute position "
"remains unavailable until endpoint calibration"
),
"telemetry": {
**common_telemetry,
"requested_target": item["target"]["instance"],
},
}
for item in operations
]
+433
View File
@@ -0,0 +1,433 @@
"""Deterministic fallback parser for the supported seat thermal vocabulary."""
from __future__ import annotations
from copy import deepcopy
import re
from typing import Any
TARGET_PATTERN = re.compile(r"主副驾|前排|主驾|驾驶位|司机位|副驾|乘客位")
FEATURE_PATTERN = re.compile(r"通风|加热")
STATE_PATTERN = re.compile(r"关闭|关掉|停止|取消|打开|开启|启动|开")
LEVEL_PATTERN = re.compile(r"([1-5一二三四五])\s*档")
VENTILATION_FEEDBACK_PATTERN = re.compile(r"(?<!不)(?<!加)热|(?<!不)闷|出汗")
HEATING_FEEDBACK_PATTERN = re.compile(r"(?<!不)冷|(?<!不)凉")
MILD_PATTERN = re.compile(r"有点|一点|稍微|微微")
STRONG_PATTERN = re.compile(r"很|太|非常|特别|难受")
AIRFLOW_CAUSE_PATTERN = re.compile(r"吹|风太大|风吹")
FIRST_PERSON_PATTERN = re.compile(r"我|自己")
VEHICLE_SCOPE_PATTERN = re.compile(r"车上|车内|车里|全车|整车|整个车")
TARGETS = {
"主驾": ["driver"],
"驾驶位": ["driver"],
"司机位": ["driver"],
"副驾": ["passenger"],
"乘客位": ["passenger"],
"主副驾": ["driver", "passenger"],
"前排": ["driver", "passenger"],
}
FEATURES = {"通风": "ventilation", "加热": "heating"}
STATES = {
"打开": "on",
"开启": "on",
"启动": "on",
"开": "on",
"关闭": "off",
"关掉": "off",
"停止": "off",
"取消": "off",
}
LEVELS = {"一": 1, "二": 2, "三": 3, "四": 4, "五": 5}
def _nearest_preceding_target(clause: str, position: int) -> str | None:
candidates = [match for match in TARGET_PATTERN.finditer(clause) if match.start() <= position]
if candidates:
return candidates[-1].group(0)
matches = list(TARGET_PATTERN.finditer(clause))
return matches[0].group(0) if len(matches) == 1 else None
def _state_for_feature(clause: str, position: int) -> str | None:
states = list(STATE_PATTERN.finditer(clause))
if not states:
return None
following = [match for match in states if match.start() >= position]
if following and following[0].start() - position <= 8:
return STATES[following[0].group(0)]
if len(states) == 1:
return STATES[states[0].group(0)]
preceding = [match for match in states if match.start() < position]
return STATES[preceding[-1].group(0)] if preceding else None
def _level_for_clause(clause: str) -> int | None:
match = LEVEL_PATTERN.search(clause)
if not match:
return None
token = match.group(1)
return int(token) if token.isdigit() else LEVELS[token]
def _context_device(
context: dict[str, Any] | None, target: str, function: str
) -> dict[str, Any]:
if not context:
return {"state": "off", "level": 0}
return (
context.get("devices", {})
.get(target, {})
.get(function, {"state": "off", "level": 0})
)
def _feedback_targets(
text: str, context: dict[str, Any] | None, parsed: dict[str, Any]
) -> tuple[list[str], str]:
matches = list(TARGET_PATTERN.finditer(text))
if matches:
targets: list[str] = []
for match in matches:
for target in TARGETS[match.group(0)]:
if target not in targets:
targets.append(target)
return targets, "explicit"
parsed_targets = list(
dict.fromkeys(
action.get("target")
for action in parsed.get("actions", [])
if action.get("target") in {"driver", "passenger"}
)
)
if parsed_targets:
return parsed_targets, "model"
last_target = context.get("last_target") if context else None
if last_target in {"driver", "passenger"}:
return [last_target], "history"
if FIRST_PERSON_PATTERN.search(text):
return ["driver"], "speaker_default"
return [], "missing"
def _inferred_level(text: str) -> tuple[int, str]:
explicit_level = _level_for_clause(text)
if explicit_level is not None:
return explicit_level, "explicit"
if STRONG_PATTERN.search(text):
return 3, "strong"
if MILD_PATTERN.search(text):
return 1, "mild"
return 2, "normal"
def _decrease_action(
target: str, function: str, current: dict[str, Any]
) -> tuple[dict[str, Any], int, int]:
previous_level = int(current.get("level") or 0)
if current.get("state") == "on" and previous_level > 1:
next_level = previous_level - 1
return (
{
"target": target,
"function": function,
"state": "on",
"requested_level": next_level,
},
previous_level,
next_level,
)
return (
{
"target": target,
"function": function,
"state": "off",
"requested_level": None,
},
previous_level,
0,
)
def resolve_contextual_intent(
parsed: dict[str, Any], utterance: str, context: dict[str, Any] | None = None
) -> dict[str, Any]:
"""Resolve implicit comfort feedback against the current virtual seat state."""
result = deepcopy(parsed)
text = re.sub(r"\s+", "", utterance.strip())
ventilation_cues = list(dict.fromkeys(VENTILATION_FEEDBACK_PATTERN.findall(text)))
cold_cues = list(dict.fromkeys(HEATING_FEEDBACK_PATTERN.findall(text)))
if not ventilation_cues and not cold_cues:
return result
if VEHICLE_SCOPE_PATTERN.search(text) and not TARGET_PATTERN.search(text):
result["actions"] = []
result["ambiguities"] = [
{
"field": "domain",
"question": "该请求作用于整车舱空气,需要由空调/新风域执行;当前座椅单域不能代替执行。",
}
]
result["decision_summary"] = [
"“车上/车内”表示整车舱范围,不是主驾或副驾单座位。",
"“太闷”属于舱内空气质量/新风需求,应路由到 climate 域。",
"当前未接入空调执行器,未将整车请求错误下发到任一座椅。",
]
result["detected_domains"] = ["climate"]
result["scope"] = "vehicle"
result["confidence"] = max(float(result.get("confidence", 0)), 0.96)
return result
targets, target_source = _feedback_targets(text, context, result)
if not targets:
if not any(item.get("field") == "target" for item in result["ambiguities"]):
result["ambiguities"].append(
{"field": "target", "question": "该舒适反馈需要作用于主驾还是副驾?"}
)
return result
labels = {
"driver": "主驾",
"passenger": "副驾",
"ventilation": "通风",
"heating": "加热",
}
summaries: list[str] = []
if target_source == "history":
summaries.append(f"本轮省略了座位,沿用上一轮的{labels[targets[0]]}目标。")
elif target_source == "speaker_default":
summaries.append("当前为座椅单域驾驶员交互,将第一人称“我”解析为主驾。")
planned_actions: list[dict[str, Any]] = []
desired_level, intensity = _inferred_level(text)
airflow_is_cause = bool(AIRFLOW_CAUSE_PATTERN.search(text))
is_cold = bool(cold_cues)
is_hot = any(cue in {"热", "出汗"} for cue in ventilation_cues)
for target in targets:
ventilation = _context_device(context, target, "ventilation")
heating = _context_device(context, target, "heating")
cue_text = "、".join(cold_cues if is_cold else ventilation_cues)
summaries.append(f"检测到{labels[target]}“{cue_text}”舒适反馈,开始核对当前执行器状态。")
if cold_cues and ventilation_cues and not airflow_is_cause:
balanced_level = min(desired_level, 3)
planned_actions.extend(
[
{
"target": target,
"function": "heating",
"state": "on",
"requested_level": balanced_level,
},
{
"target": target,
"function": "ventilation",
"state": "on",
"requested_level": balanced_level,
},
]
)
summaries[-1] = (
f"检测到{labels[target]}“{'、'.join(cold_cues + ventilation_cues)}”两个并存的舒适维度,"
"不将其判定为语义冲突。"
)
summaries.append(
f"冷感由{labels[target]}加热{balanced_level}档补偿,闷感由"
f"{labels[target]}通风{balanced_level}档缓解;生成可审计的组合目标。"
)
continue
if is_cold and (airflow_is_cause or ventilation.get("state") == "on"):
action, previous_level, next_level = _decrease_action(
target, "ventilation", ventilation
)
planned_actions.append(action)
if airflow_is_cause:
summaries.append(
f"“{labels[target]}吹得冷”明确将{labels[target]}通风指定为致冷来源,不映射为加热请求。"
)
if previous_level > 1:
summaries.append(
f"当前{labels[target]}通风为{previous_level}档,先降低到{next_level}档,不同时调整加热。"
)
elif previous_level == 1:
summaries.append(
f"当前{labels[target]}通风为1档,冷感更可能由已开启通风造成;"
"先关闭通风,不立即开启加热。"
)
else:
summaries.append(
f"当前状态显示{labels[target]}通风已关闭,保持关闭;不会因“吹”反向调整加热。"
)
continue
if is_cold:
current_level = int(heating.get("level") or 0)
if heating.get("state") == "on" and current_level > 0:
next_level = min(max(current_level + 1, desired_level), 3)
planned_actions.append(
{
"target": target,
"function": "heating",
"state": "on",
"requested_level": next_level,
}
)
summaries.append(
f"{labels[target]}通风已关闭,加热已开启{current_level}档但仍感到冷,"
f"将加热提升到{next_level}档。"
)
else:
planned_actions.append(
{
"target": target,
"function": "heating",
"state": "on",
"requested_level": desired_level,
}
)
summaries.append(
f"{labels[target]}通风已关闭且加热未开启,冷感仍存在;"
f"按{'轻微' if intensity == 'mild' else '当前'}强度开启加热{desired_level}档。"
)
continue
if is_hot and heating.get("state") == "on":
action, previous_level, next_level = _decrease_action(target, "heating", heating)
planned_actions.append(action)
summaries.append(
f"当前{labels[target]}加热为{previous_level}档,热感优先视为已开启加热的结果;"
f"先{'关闭加热' if next_level == 0 else f'降低到{next_level}档'},不立即开启通风。"
)
continue
current_level = int(ventilation.get("level") or 0)
if ventilation.get("state") == "on" and current_level > 0:
next_level = min(max(current_level + 1, desired_level), 3)
summaries.append(
f"{labels[target]}通风已开启{current_level}档但舒适问题仍存在,提升到{next_level}档。"
)
else:
next_level = desired_level
summaries.append(
f"{labels[target]}没有已开启的加热致因,按当前强度开启通风{next_level}档。"
)
planned_actions.append(
{
"target": target,
"function": "ventilation",
"state": "on",
"requested_level": next_level,
}
)
result["actions"] = planned_actions
result["ambiguities"] = [
item
for item in result["ambiguities"]
if item.get("field") not in {"capability", "state", "target"}
]
result["decision_summary"] = summaries
result["confidence"] = max(float(result.get("confidence", 0)), 0.9)
return result
def parse_local_intent(
utterance: str, context: dict[str, Any] | None = None
) -> dict[str, Any]:
"""Parse common Chinese seat commands without making hardware assumptions."""
text = re.sub(r"\s+", "", utterance.strip())
clauses = [part for part in re.split(r"[,,。;;!!??]", text) if part]
actions: list[dict[str, Any]] = []
ambiguities: list[dict[str, str]] = []
for clause in clauses:
level = _level_for_clause(clause)
for feature_match in FEATURE_PATTERN.finditer(clause):
feature_zh = feature_match.group(0)
target_zh = _nearest_preceding_target(clause, feature_match.start())
state = _state_for_feature(clause, feature_match.end())
if target_zh is None:
ambiguities.append(
{"field": "target", "question": f"“{feature_zh}”需要作用于主驾还是副驾?"}
)
continue
if state is None:
ambiguities.append(
{"field": "state", "question": f"请确认{target_zh}{feature_zh}是打开还是关闭。"}
)
continue
for target in TARGETS[target_zh]:
actions.append(
{
"target": target,
"function": FEATURES[feature_zh],
"state": state,
"requested_level": level,
}
)
merged: dict[tuple[str, str], dict[str, Any]] = {}
for action in actions:
key = (action["target"], action["function"])
previous = merged.get(key)
if previous and previous["state"] != action["state"]:
ambiguities.append(
{
"field": f"{key[0]}.{key[1]}.state",
"question": "同一个座椅功能同时出现打开和关闭指令,请确认最终状态。",
}
)
merged[key] = action
actions = list(merged.values())
parsed = resolve_contextual_intent(
{
"actions": actions,
"ambiguities": ambiguities,
"decision_summary": [],
"confidence": 0.96 if actions and not ambiguities else 0.45,
},
utterance,
context,
)
actions = parsed["actions"]
ambiguities = parsed["ambiguities"]
if not actions and not ambiguities:
ambiguities.append(
{
"field": "capability",
"question": "当前仅支持主/副驾座椅通风或加热的打开与关闭。",
}
)
summary = ["识别为座椅热舒适单域请求。", *parsed["decision_summary"]]
labels = {
"driver": "主驾",
"passenger": "副驾",
"ventilation": "通风",
"heating": "加热",
"on": "打开",
"off": "关闭",
}
summary.extend(
f"目标:{labels[action['target']]}座椅{labels[action['function']]}{labels[action['state']]}。"
for action in actions
)
if any(action["requested_level"] is not None for action in actions):
summary.append("目标档位已写入功能配置;现有 CAN 矩阵仅支持档位虚拟回读。")
response = {
"actions": actions,
"ambiguities": ambiguities,
"decision_summary": summary,
"confidence": max(parsed["confidence"], 0.96) if actions and not ambiguities else 0.45,
}
for field in ("detected_domains", "scope"):
if field in parsed:
response[field] = parsed[field]
return response
+115
View File
@@ -0,0 +1,115 @@
"""Load and encode the normalized partial seat CAN matrix."""
from __future__ import annotations
from pathlib import Path
from collections.abc import Iterable
from typing import Any
import yaml
class MatrixError(ValueError):
"""Raised when a command cannot be represented by the normalized matrix."""
class SeatCanMatrix:
def __init__(self, matrix_path: Path | Iterable[Path]):
paths = [matrix_path] if isinstance(matrix_path, Path) else list(matrix_path)
if not paths:
raise MatrixError("At least one matrix document is required")
self.path = paths[0]
self.paths = paths
documents = [yaml.safe_load(path.read_text(encoding="utf-8")) for path in paths]
if len(documents) == 1:
self.data = documents[0]
else:
self.data = {
"version": "+".join(str(item.get("version", "unknown")) for item in documents),
"source": {"files": [str(path) for path in paths]},
"scope": "combined_virtual_seat_capabilities",
"hardware_execution_allowed": all(
item.get("hardware_execution_allowed") is True for item in documents
),
"limitations": [
limitation
for document in documents
for limitation in document.get("limitations", [])
],
"messages": [
message
for document in documents
for message in document.get("messages", [])
],
}
self.messages = {}
for item in self.data["messages"]:
if item["name"] in self.messages:
raise MatrixError(f"Duplicate matrix message: {item['name']}")
self.messages[item["name"]] = item
@property
def hardware_execution_allowed(self) -> bool:
return self.data.get("hardware_execution_allowed") is True
def message(self, name: str) -> dict[str, Any]:
try:
return self.messages[name]
except KeyError as exc:
raise MatrixError(f"Unknown matrix message: {name}") from exc
def signal(self, message_name: str, signal_name: str) -> dict[str, Any]:
message = self.message(message_name)
for signal in message["signals"]:
if signal["name"] == signal_name:
return signal
raise MatrixError(f"Unknown signal {signal_name} in {message_name}")
def encode(self, message_name: str, values: dict[str, int]) -> dict[str, Any]:
message = self.message(message_name)
raw_value = 0
for signal_name, value in values.items():
signal = self.signal(message_name, signal_name)
width = int(signal["bit_length"])
if not isinstance(value, int) or value < 0 or value >= 1 << width:
raise MatrixError(f"Value {value!r} does not fit {signal_name}[{width}]")
start_bit = int(signal["start_bit"])
mask = ((1 << width) - 1) << start_bit
raw_value = (raw_value & ~mask) | (value << start_bit)
payload_size = int(message["minimum_payload_bytes"])
payload = raw_value.to_bytes(payload_size, byteorder="little", signed=False)
return {
"message": message_name,
"frame_id": f"0x{int(message['frame_id']):03X}",
"payload_size": payload_size,
"data_hex": " ".join(f"{byte:02X}" for byte in payload),
"signals": values,
"encoding": "lsb0/intel inferred for virtual execution",
}
def decode(self, message_name: str, payload: bytes) -> dict[str, int]:
"""Decode every declared little-endian lsb0 signal from a CAN payload."""
message = self.message(message_name)
minimum = int(message["minimum_payload_bytes"])
if len(payload) < minimum:
raise MatrixError(
f"Payload for {message_name} must contain at least {minimum} bytes"
)
raw_value = int.from_bytes(payload[:minimum], byteorder="little", signed=False)
return {
signal["name"]: (
raw_value >> int(signal["start_bit"])
) & ((1 << int(signal["bit_length"])) - 1)
for signal in message["signals"]
}
def decode_by_frame_id(self, frame_id: int, payload: bytes) -> dict[str, int]:
matches = [
item for item in self.data["messages"] if int(item["frame_id"]) == frame_id
]
if len(matches) != 1:
raise MatrixError(
f"Expected exactly one matrix message for frame 0x{frame_id:03X}"
)
return self.decode(matches[0]["name"], payload)
+71
View File
@@ -0,0 +1,71 @@
"""Read the supplied XLSX matrix using the standard OOXML structures."""
from __future__ import annotations
import re
import zipfile
from pathlib import Path
from typing import Any
from xml.etree import ElementTree
MAIN_NS = "http://schemas.openxmlformats.org/spreadsheetml/2006/main"
CELL_REFERENCE = re.compile(r"([A-Z]+)([0-9]+)")
def _column_index(reference: str) -> int:
match = CELL_REFERENCE.fullmatch(reference)
if not match:
raise ValueError(f"Invalid XLSX cell reference: {reference}")
result = 0
for character in match.group(1):
result = result * 26 + ord(character) - ord("A") + 1
return result - 1
def _shared_strings(archive: zipfile.ZipFile) -> list[str]:
root = ElementTree.fromstring(archive.read("xl/sharedStrings.xml"))
return [
"".join(node.text or "" for node in item.iter(f"{{{MAIN_NS}}}t"))
for item in root.findall(f"{{{MAIN_NS}}}si")
]
def read_source_signals(path: Path) -> list[dict[str, Any]]:
"""Return the 23 signal rows needed for source-to-normalized contract checks."""
with zipfile.ZipFile(path) as archive:
strings = _shared_strings(archive)
worksheet = ElementTree.fromstring(archive.read("xl/worksheets/sheet1.xml"))
rows: list[list[str]] = []
for row_node in worksheet.iter(f"{{{MAIN_NS}}}row"):
values: dict[int, str] = {}
for cell in row_node.findall(f"{{{MAIN_NS}}}c"):
value_node = cell.find(f"{{{MAIN_NS}}}v")
if value_node is None or value_node.text is None:
continue
value = value_node.text
if cell.get("t") == "s":
value = strings[int(value)]
values[_column_index(str(cell.get("r")))] = value
if values:
width = max(values) + 1
rows.append([values.get(index, "") for index in range(width)])
records = []
for row in rows[1:]:
if len(row) < 6 or not row[0]:
continue
records.append(
{
"name": row[0],
"description": row[1],
"frame_id": int(row[2], 16),
"sub_id": None if row[3] in {"", "null"} else int(row[3], 16),
"start_bit": int(float(row[4])),
"bit_length": int(float(row[5])),
}
)
return records
+256
View File
@@ -0,0 +1,256 @@
"""Seat-specific semantic validation beyond JSON Schema."""
from __future__ import annotations
from typing import Any
SUPPORTED_TARGETS = {"driver", "passenger"}
SUPPORTED_FUNCTIONS = {"ventilation", "heating"}
SUPPORTED_STATES = {"on", "off"}
SUPPORTED_MOTION_AXES = {
"longitudinal",
"cushion_tilt",
"backrest_recline",
"legrest_height",
"legrest_extension",
}
ANGLE_RANGES = {
"backrest_recline": (95, 140),
"legrest_height": (95, 160),
}
def validate_semantics(
parsed: dict[str, Any], function_config: dict[str, Any] | None
) -> list[dict[str, str]]:
issues: list[dict[str, str]] = []
detected_domains = set(parsed.get("detected_domains", ["seat"]))
if parsed.get("actions") and "seat" not in detected_domains:
issues.append(
{
"severity": "error",
"code": "DOMAIN_ACTION_MISMATCH",
"path": "detected_domains",
"message": "GPT 生成了座椅动作,但 detected_domains 中没有 seat。",
}
)
for index, deferred in enumerate(parsed.get("deferred_requests", [])):
domain = str(deferred.get("domain"))
capability = str(deferred.get("capability"))
if domain not in detected_domains or not capability.startswith(f"{domain}."):
issues.append(
{
"severity": "error",
"code": "DEFERRED_REQUEST_MISMATCH",
"path": f"deferred_requests[{index}]",
"message": "暂缓请求的领域、能力名和 detected_domains 不一致。",
}
)
issues.append(
{
"severity": "warning",
"code": "UNAVAILABLE_DOMAIN",
"path": f"deferred_requests[{index}]",
"message": f"已理解 {capability} 目标,但 {domain} 域当前仅有能力目录,未接入执行器。",
}
)
for ambiguity in parsed.get("ambiguities", []):
issues.append(
{
"severity": "error",
"code": "AMBIGUOUS_INTENT",
"path": ambiguity["field"],
"message": ambiguity["question"],
}
)
seen: dict[tuple[str, str], str] = {}
active_functions: dict[str, set[str]] = {}
has_motion = False
has_driver_motion = False
for index, action in enumerate(parsed.get("actions", [])):
path = f"actions[{index}]"
if action.get("target") not in SUPPORTED_TARGETS:
issues.append(
{
"severity": "error",
"code": "UNSUPPORTED_TARGET",
"path": f"{path}.target",
"message": "当前只支持主驾和前排副驾。",
}
)
if action.get("action_type") == "position":
has_motion = True
has_driver_motion = has_driver_motion or action.get("target") == "driver"
axis = action.get("axis")
position = action.get("position_pct")
if axis not in SUPPORTED_MOTION_AXES:
issues.append(
{
"severity": "error",
"code": "UNSUPPORTED_MOTION_AXIS",
"path": f"{path}.axis",
"message": "请求的座椅位置轴未在当前能力目录中注册。",
}
)
if (
not isinstance(position, int)
or isinstance(position, bool)
or not 0 <= position <= 100
):
issues.append(
{
"severity": "error",
"code": "UNSUPPORTED_POSITION_RANGE",
"path": f"{path}.position_pct",
"message": "座椅电机绝对目标必须是 0-100 的整数百分比。",
}
)
angle = action.get("angle_deg")
if angle is not None:
angle_range = ANGLE_RANGES.get(str(axis))
if (
angle_range is None
or not isinstance(angle, (int, float))
or isinstance(angle, bool)
or not angle_range[0] <= float(angle) <= angle_range[1]
):
issues.append(
{
"severity": "error",
"code": "UNSUPPORTED_ANGLE_RANGE",
"path": f"{path}.angle_deg",
"message": "座椅夹角超出当前虚拟标定范围。",
}
)
key = (str(action.get("target")), str(axis))
previous_position = seen.get(key)
if previous_position is not None and previous_position != str(position):
issues.append(
{
"severity": "error",
"code": "CONFLICTING_OPERATIONS",
"path": path,
"message": "同一个座椅电机轴不能在一份配置中包含不同目标位置。",
}
)
seen[key] = str(position)
continue
if action.get("function") not in SUPPORTED_FUNCTIONS:
issues.append(
{
"severity": "error",
"code": "UNSUPPORTED_CAPABILITY",
"path": f"{path}.function",
"message": "当前只支持座椅通风和加热。",
}
)
if action.get("state") not in SUPPORTED_STATES:
issues.append(
{
"severity": "error",
"code": "UNSUPPORTED_STATE",
"path": f"{path}.state",
"message": "当前控制信号只支持打开或关闭。",
}
)
requested_level = action.get("requested_level")
if requested_level is not None and requested_level not in {1, 2, 3}:
issues.append(
{
"severity": "error",
"code": "UNSUPPORTED_LEVEL_RANGE",
"path": f"{path}.requested_level",
"message": "当前舒适推断和 0x4FA 虚拟回读仅支持 1-3 档。",
}
)
elif requested_level is not None:
issues.append(
{
"severity": "warning",
"code": "LEVEL_COMMAND_SIMULATED",
"path": f"{path}.requested_level",
"message": (
"目标档位已写入 FunctionConfig 和会话状态;"
"0x42A 仅编译开关信号,档位回读仅在 dry-run 中模拟。"
),
}
)
key = (str(action.get("target")), str(action.get("function")))
previous_state = seen.get(key)
if previous_state is not None and previous_state != action.get("state"):
issues.append(
{
"severity": "error",
"code": "CONFLICTING_OPERATIONS",
"path": path,
"message": "同一个座椅功能不能在一份配置中同时打开和关闭。",
}
)
seen[key] = str(action.get("state"))
if action.get("state") == "on":
active_functions.setdefault(str(action.get("target")), set()).add(
str(action.get("function"))
)
if has_motion:
issues.append(
{
"severity": "warning",
"code": "SYNTHETIC_MOTION_CAN",
"path": "platform.reference_vehicle.seat_motion",
"message": "座椅位置控制和反馈 CAN 矩阵完全为演示虚构,仅用于验证模型与配置链路。",
}
)
if has_driver_motion:
issues.append(
{
"severity": "warning",
"code": "DRIVER_MOTION_INTERLOCK_NOT_IMPLEMENTED",
"path": "platform.reference_vehicle.vehicle_state",
"message": "未接入车速、挡位、乘员和防夹状态,主驾移动只能虚拟执行。",
}
)
for target, functions in active_functions.items():
if functions == {"ventilation", "heating"}:
issues.append(
{
"severity": "warning",
"code": "COMBINED_THERMAL_SIMULATED",
"path": f"actions.{target}",
"message": (
"同一座位的加热与通风组合目标仅用于 dry-run;"
"实车前必须确认 ECU 是否允许两功能同时开启。"
),
}
)
if (
function_config is None
and not parsed.get("deferred_requests")
and not parsed.get("ambiguities")
and not parsed.get("preset_actions")
):
issues.append(
{
"severity": "error",
"code": "NO_EXECUTABLE_OPERATION",
"path": "spec.operations",
"message": "没有生成可执行的座椅操作。",
}
)
issues.append(
{
"severity": "warning",
"code": "VIRTUAL_EXECUTION_ONLY",
"path": "platform.reference_vehicle",
"message": "CAN 元数据不完整,当前仅允许虚拟执行,禁止实车下发。",
}
)
return issues
+4
View File
@@ -0,0 +1,4 @@
# Seat Tests
`unit/` 覆盖姿态参数和运动学转换,`contract/` 覆盖配置与插件契约,`integration/` 使用模拟 CAN Gateway 覆盖命令、回读、互锁和超时。实车范围需与平台安全标定分别测试,不能只依赖 JSON Schema 的宽范围。
+1
View File
@@ -0,0 +1 @@
+1
View File
@@ -0,0 +1 @@
Binary file not shown.
Binary file not shown.
+104
View File
@@ -0,0 +1,104 @@
from __future__ import annotations
import unittest
from pathlib import Path
import yaml
from cognitive_agent.runtime.contracts import ContractValidator
from seat_domain.generator import build_function_config
from seat_domain.intent import parse_local_intent
from seat_domain.matrix import SeatCanMatrix
from seat_domain.source_matrix import read_source_signals
ROOT = Path(__file__).resolve().parents[4]
DOMAIN_ROOT = ROOT / "domains" / "seat"
class SeatContractTests(unittest.TestCase):
def setUp(self) -> None:
self.validator = ContractValidator(ROOT)
def test_generated_function_config_matches_both_schemas(self) -> None:
parsed = parse_local_intent("打开副驾座椅加热")
config = build_function_config(parsed, "req-contract-001", "local_rules")
self.assertEqual(self.validator.validate(config), [])
def test_inferred_level_function_config_matches_both_schemas(self) -> None:
parsed = parse_local_intent("主驾有点热,有点闷")
config = build_function_config(parsed, "req-contract-002", "local_rules")
self.assertEqual(config["spec"]["operations"][0]["parameters"]["level"], 1)
self.assertEqual(self.validator.validate(config), [])
def test_motion_function_config_matches_both_schemas(self) -> None:
parsed = {
"detected_domains": ["seat"],
"actions": [
{
"action_type": "position",
"target": "passenger",
"axis": "legrest_extension",
"position_pct": 70,
}
],
"deferred_requests": [],
"ambiguities": [],
"decision_summary": ["Extend passenger leg rest."],
"confidence": 1.0,
}
config = build_function_config(parsed, "req-contract-motion", "test")
self.assertEqual(config["spec"]["operations"][0]["parameters"], {"position_pct": 70})
self.assertEqual(self.validator.validate(config), [])
def test_all_active_presets_match_both_schemas(self) -> None:
for path in (DOMAIN_ROOT / "config" / "presets").glob("*.yaml"):
with self.subTest(path=path.name):
document = yaml.safe_load(path.read_text(encoding="utf-8"))
self.assertEqual(self.validator.validate(document), [])
def test_normalized_matrix_matches_source_scope(self) -> None:
matrix = SeatCanMatrix(DOMAIN_ROOT / "can" / "seat-thermal-matrix.yaml")
self.assertEqual(len(matrix.data["messages"]), 6)
self.assertEqual(sum(len(item["signals"]) for item in matrix.data["messages"]), 23)
self.assertFalse(matrix.hardware_execution_allowed)
def test_synthetic_motion_matrix_is_explicitly_non_hardware(self) -> None:
matrix = SeatCanMatrix(DOMAIN_ROOT / "can" / "seat-motion-virtual-matrix.yaml")
self.assertTrue(matrix.data["synthetic"])
self.assertFalse(matrix.hardware_execution_allowed)
self.assertEqual(len(matrix.data["messages"]), 4)
self.assertEqual(sum(len(item["signals"]) for item in matrix.data["messages"]), 34)
def test_normalized_signal_layout_matches_supplied_xlsx(self) -> None:
source = {
item["name"]: item
for item in read_source_signals(ROOT / "座椅部分功能CAN矩阵表.xlsx")
}
matrix = SeatCanMatrix(DOMAIN_ROOT / "can" / "seat-thermal-matrix.yaml")
normalized = {
signal["name"]: {
"frame_id": int(message["frame_id"]),
"start_bit": int(signal["start_bit"]),
"bit_length": int(signal["bit_length"]),
}
for message in matrix.data["messages"]
for signal in message["signals"]
}
self.assertEqual(set(source), set(normalized))
for name, layout in normalized.items():
with self.subTest(signal=name):
self.assertEqual(layout["frame_id"], source[name]["frame_id"])
self.assertEqual(layout["start_bit"], source[name]["start_bit"])
self.assertEqual(layout["bit_length"], source[name]["bit_length"])
if __name__ == "__main__":
unittest.main()
+1
View File
@@ -0,0 +1 @@
+1
View File
@@ -0,0 +1 @@
Binary file not shown.
Binary file not shown.
+610
View File
@@ -0,0 +1,610 @@
from __future__ import annotations
import tempfile
import unittest
from pathlib import Path
from unittest.mock import patch
from cognitive_agent.application.seat_service import REPOSITORY_ROOT, SeatDemoService
class SeatServiceIntegrationTests(unittest.TestCase):
def setUp(self) -> None:
self.service = SeatDemoService(REPOSITORY_ROOT)
self.temporary_directory = tempfile.TemporaryDirectory()
self.addCleanup(self.temporary_directory.cleanup)
self.service.artifact_root = Path(self.temporary_directory.name)
def test_supported_request_completes_full_dry_run(self) -> None:
result = self.service.process(
"打开主驾座椅通风,同时关闭副驾座椅加热", provider="local"
)
self.assertEqual(result["status"], "succeeded")
self.assertEqual(len(result["function_config"]["spec"]["operations"]), 2)
telemetry = result["execution_report"]["spec"]["results"][0]["telemetry"]
self.assertEqual(telemetry["command_frame"]["data_hex"], "01 80")
self.assertTrue(Path(result["artifact_path"]).is_absolute())
self.assertTrue(
(self.service.artifact_root / result["request_id"] / "result.json").is_file()
)
for event in result["trace_events"]:
self.assertEqual(self.service.contracts.validate(event), [])
def test_gear_request_is_preserved_and_simulated(self) -> None:
result = self.service.process("主驾座椅加热开到三档", provider="local")
self.assertEqual(result["status"], "succeeded")
operation = result["function_config"]["spec"]["operations"][0]
self.assertEqual(operation["parameters"]["level"], 3)
telemetry = result["execution_report"]["spec"]["results"][0]["telemetry"]
self.assertEqual(telemetry["effective_level"], 3)
self.assertTrue(telemetry["level_command_simulated"])
self.assertIn(
"LEVEL_COMMAND_SIMULATED", {issue["code"] for issue in result["validation"]}
)
def test_consecutive_feedback_uses_previous_successful_state(self) -> None:
conversation_id = "conv-test-context"
first = self.service.process(
"主驾有点热,有点闷",
provider="local",
conversation_id=conversation_id,
)
second = self.service.process(
"主驾还有点闷",
provider="local",
conversation_id=conversation_id,
)
self.assertEqual(first["status"], "succeeded")
self.assertEqual(first["actuator_state"]["driver"]["ventilation"]["level"], 1)
self.assertEqual(second["status"], "succeeded")
self.assertEqual(second["conversation_turn"], 2)
self.assertEqual(second["actuator_state"]["driver"]["ventilation"]["level"], 2)
operation = second["function_config"]["spec"]["operations"][0]
self.assertEqual(operation["parameters"], {"state": "on", "level": 2})
self.assertIn("提升到2档", "".join(second["decision_summary"]))
def test_four_turn_causal_feedback_sequence(self) -> None:
conversation_id = "conv-causal-sequence"
utterances = [
"我有点热",
"我有点冷",
"我还有点冷",
"副驾吹的我有点冷",
]
first, second, third, fourth = [
self.service.process(text, "local", conversation_id) for text in utterances
]
self.assertEqual(
first["function_config"]["spec"]["operations"][0]["parameters"],
{"state": "on", "level": 1},
)
second_operation = second["function_config"]["spec"]["operations"][0]
self.assertEqual(second_operation["capability"], "seat.set_ventilation")
self.assertEqual(second_operation["parameters"], {"state": "off"})
third_operation = third["function_config"]["spec"]["operations"][0]
self.assertEqual(third_operation["capability"], "seat.set_heating")
self.assertEqual(third_operation["parameters"], {"state": "on", "level": 1})
fourth_operation = fourth["function_config"]["spec"]["operations"][0]
self.assertEqual(fourth_operation["target"]["instance"], "passenger")
self.assertEqual(fourth_operation["capability"], "seat.set_ventilation")
self.assertEqual(fourth_operation["parameters"], {"state": "off"})
self.assertEqual(fourth["actuator_state"]["driver"]["heating"], {"state": "on", "level": 1})
self.assertEqual(fourth["conversation_turn"], 4)
def test_compound_cold_and_stuffy_feedback_generates_balanced_dry_run(self) -> None:
result = self.service.process(
"主驾有点冷、有点闷",
provider="local",
conversation_id="conv-compound-comfort",
)
self.assertEqual(result["status"], "succeeded")
operations = result["function_config"]["spec"]["operations"]
self.assertEqual(
{operation["capability"] for operation in operations},
{"seat.set_heating", "seat.set_ventilation"},
)
command = result["command_plan"]["spec"]["commands"][0]
self.assertEqual(
command["signals"],
{"Driver_Seat_Heating_S": 1, "Driver_Seat_Ventilation_S": 1},
)
self.assertIn(
"COMBINED_THERMAL_SIMULATED",
{issue["code"] for issue in result["validation"]},
)
def test_vehicle_scope_is_routed_away_from_seat_domain(self) -> None:
result = self.service.process(
"车上太闷了",
provider="local",
conversation_id="conv-vehicle-scope",
)
self.assertEqual(result["status"], "needs_confirmation")
self.assertEqual(result["intent_ir"]["spec"]["detected_domains"], ["climate"])
self.assertIsNone(result["function_config"])
self.assertIsNone(result["command_plan"])
self.assertEqual(result["actuator_state"]["passenger"]["ventilation"]["state"], "off")
route = next(event for event in result["trace_events"] if event["stage"] == "domain_route")
self.assertEqual(route["details"]["domains"], ["climate"])
def test_openai_output_is_not_rewritten_by_local_semantic_planner(self) -> None:
model_output = {
"detected_domains": ["seat"],
"requests": [
{
"domain": "seat",
"capability": "seat.set_ventilation",
"target_scope": "passenger",
"operation": "deactivate",
"parameters": [{"name": "state", "value": "off"}],
"reason": "model-owned-plan",
}
],
"ambiguities": [],
"decision_summary": ["model-owned-plan"],
"confidence": 0.91,
}
context = self.service.conversations.snapshot("conv-model-owned-plan")
self.service.openai.api_key = "test-key"
with patch.object(self.service.openai, "interpret", return_value=model_output) as interpret:
parsed, provider, notice = self.service._interpret(
"车上太闷了", "openai", context
)
self.assertEqual(parsed["decision_summary"], ["model-owned-plan"])
self.assertEqual(parsed["model_requests"], model_output["requests"])
self.assertEqual(
parsed["actions"],
[
{
"target": "passenger",
"function": "ventilation",
"state": "off",
"requested_level": None,
}
],
)
self.assertEqual(provider, f"openai:{self.service.openai.model}")
self.assertIsNone(notice)
interpret.assert_called_once_with(
"车上太闷了", context, self.service.capability_context
)
def test_model_climate_request_completes_domain_loop(self) -> None:
model_output = {
"detected_domains": ["climate"],
"requests": [
{
"domain": "climate",
"capability": "climate.set_fan_level",
"target_scope": "vehicle",
"operation": "increase",
"parameters": [{"name": "level", "value": 3}],
"reason": "Increase cabin airflow.",
}
],
"ambiguities": [],
"decision_summary": ["整车舱风量增加需求。"],
"confidence": 0.94,
}
self.service.openai.api_key = "test-key"
with patch.object(self.service.openai, "interpret", return_value=model_output):
result = self.service.process(
"车上太闷了",
"openai",
"conv-model-deferred",
)
self.assertEqual(result["status"], "succeeded")
self.assertEqual(result["model_requests"], model_output["requests"])
self.assertEqual(result["deferred_requests"], [])
self.assertIn("climate", result["function_configs"])
request = result["intent_ir"]["spec"]["requests"][0]
self.assertEqual(request["domain"], "climate")
self.assertEqual(request["parameters"]["capability"], "climate.set_fan_level")
def test_climate_and_seat_requests_generate_scene(self) -> None:
model_output = {
"detected_domains": ["seat", "climate"],
"requests": [
{
"domain": "seat",
"capability": "seat.set_heating",
"target_scope": "driver",
"operation": "activate",
"parameters": [
{"name": "state", "value": "on"},
{"name": "level", "value": 1},
],
"reason": "Warm the driver seat.",
},
{
"domain": "climate",
"capability": "climate.set_fan_level",
"target_scope": "vehicle",
"operation": "set",
"parameters": [{"name": "level", "value": 2}],
"reason": "Improve cabin airflow.",
},
],
"ambiguities": [],
"decision_summary": ["座椅加热与整车空调风量并行执行。"],
"confidence": 0.9,
}
self.service.openai.api_key = "test-key"
with patch.object(self.service.openai, "interpret", return_value=model_output):
result = self.service.process(
"主驾有点冷、有点闷",
"openai",
"conv-model-partial",
)
self.assertEqual(result["status"], "succeeded")
self.assertIsNotNone(result["execution_report"])
self.assertEqual(result["actuator_state"]["driver"]["heating"], {"state": "on", "level": 1})
self.assertEqual(result["domain_state"]["climate"]["fan_level"], 2)
self.assertEqual(set(result["function_configs"]), {"seat", "climate"})
self.assertEqual(result["scene_config"]["kind"], "SceneConfig")
def test_model_motion_requests_complete_control_and_feedback_loop(self) -> None:
model_output = {
"detected_domains": ["seat"],
"requests": [
{
"domain": "seat",
"capability": "seat.set_longitudinal_position",
"target_scope": "driver",
"operation": "set",
"parameters": [{"name": "position_pct", "value": 35}],
"reason": "Move the driver seat backward from its current midpoint.",
},
{
"domain": "seat",
"capability": "seat.set_backrest_recline",
"target_scope": "driver",
"operation": "set",
"parameters": [{"name": "position_pct", "value": 60}],
"reason": "Recline the driver backrest.",
},
{
"domain": "seat",
"capability": "seat.set_legrest_height",
"target_scope": "passenger",
"operation": "set",
"parameters": [{"name": "position_pct", "value": 65}],
"reason": "Raise the passenger leg rest.",
},
{
"domain": "seat",
"capability": "seat.set_legrest_extension",
"target_scope": "passenger",
"operation": "set",
"parameters": [{"name": "position_pct", "value": 70}],
"reason": "Extend the passenger leg rest.",
},
],
"ambiguities": [],
"decision_summary": ["四个电机轴使用绝对位置目标。"],
"confidence": 0.93,
}
self.service.openai.api_key = "test-key"
with patch.object(self.service.openai, "interpret", return_value=model_output):
result = self.service.process(
"主驾往后一点并把靠背躺下,副驾腿托升起并伸长",
"openai",
"conv-model-motion",
)
self.assertEqual(result["status"], "succeeded")
self.assertEqual(len(result["function_config"]["spec"]["operations"]), 4)
self.assertEqual(len(result["command_plan"]["spec"]["commands"]), 4)
self.assertEqual(len(result["execution_report"]["spec"]["results"]), 4)
driver = result["actuator_state"]["driver"]["position"]
passenger = result["actuator_state"]["passenger"]["position"]
self.assertEqual(driver["longitudinal"]["position_pct"], 35)
self.assertEqual(driver["backrest_recline"]["position_pct"], 60)
self.assertEqual(passenger["legrest_height"]["position_pct"], 65)
self.assertEqual(passenger["legrest_extension"]["position_pct"], 70)
telemetry = result["execution_report"]["spec"]["results"][0]["telemetry"]
self.assertEqual(telemetry["start_position_pct"], 50)
self.assertEqual(telemetry["effective_position_pct"], 35)
self.assertEqual(telemetry["feedback_frame"]["frame_id"], "0x558")
self.assertEqual(telemetry["fault_mask"], 0)
codes = {issue["code"] for issue in result["validation"]}
self.assertIn("SYNTHETIC_MOTION_CAN", codes)
self.assertIn("DRIVER_MOTION_INTERLOCK_NOT_IMPLEMENTED", codes)
def test_motion_follow_up_state_is_supplied_to_model(self) -> None:
def output(position: int) -> dict[str, object]:
return {
"detected_domains": ["seat"],
"requests": [
{
"domain": "seat",
"capability": "seat.set_longitudinal_position",
"target_scope": "driver",
"operation": "decrease",
"parameters": [{"name": "position_pct", "value": position}],
"reason": "Model resolved the relative request.",
}
],
"ambiguities": [],
"decision_summary": [f"Move to {position}%."],
"confidence": 0.9,
}
self.service.openai.api_key = "test-key"
with patch.object(
self.service.openai,
"interpret",
side_effect=[output(40), output(30)],
) as interpret:
first = self.service.process(
"主驾往后一点", "openai", "conv-motion-follow-up"
)
second = self.service.process(
"还是靠前,再往后一点", "openai", "conv-motion-follow-up"
)
self.assertEqual(
first["actuator_state"]["driver"]["position"]["longitudinal"][
"position_pct"
],
40,
)
self.assertEqual(
second["actuator_state"]["driver"]["position"]["longitudinal"][
"position_pct"
],
30,
)
second_context = interpret.call_args_list[1].args[1]
self.assertEqual(
second_context["devices"]["driver"]["position"]["longitudinal"]["position_pct"],
40,
)
def test_motion_adapter_does_not_resolve_missing_absolute_target(self) -> None:
model_output = {
"detected_domains": ["seat"],
"requests": [
{
"domain": "seat",
"capability": "seat.set_longitudinal_position",
"target_scope": "driver",
"operation": "decrease",
"parameters": [],
"reason": "No absolute target was produced.",
}
],
"ambiguities": [],
"decision_summary": ["Unresolved relative request."],
"confidence": 0.6,
}
self.service.openai.api_key = "test-key"
with patch.object(self.service.openai, "interpret", return_value=model_output):
result = self.service.process(
"主驾往后一点", "openai", "conv-motion-missing-target"
)
self.assertEqual(result["status"], "needs_confirmation")
self.assertIsNone(result["function_config"])
self.assertIn(
"AMBIGUOUS_INTENT", {issue["code"] for issue in result["validation"]}
)
self.assertNotIn(
"NO_EXECUTABLE_OPERATION",
{issue["code"] for issue in result["validation"]},
)
self.assertNotIn(
"PUBLIC_SCHEMA", {issue["code"] for issue in result["validation"]}
)
def test_exact_angles_and_named_preset_complete_conversation_loop(self) -> None:
def plan(requests: list[dict[str, object]], summary: str) -> dict[str, object]:
return {
"detected_domains": ["seat"],
"requests": requests,
"ambiguities": [],
"decision_summary": [summary],
"confidence": 0.96,
}
def request(
capability: str,
operation: str,
parameters: list[dict[str, object]],
) -> dict[str, object]:
return {
"domain": "seat",
"capability": capability,
"target_scope": "passenger",
"operation": operation,
"parameters": parameters,
"reason": "Model resolved the user's posture goal against runtime context.",
}
first_plan = plan(
[
request(
"seat.set_backrest_recline",
"set",
[{"name": "angle_deg", "value": 128}],
),
request(
"seat.set_legrest_height",
"set",
[{"name": "angle_deg", "value": 133}],
),
],
"副驾靠背/坐垫夹角设为128度,坐垫/腿托夹角设为133度。",
)
recline_plan = plan(
[
request(
"seat.set_backrest_recline",
"increase",
[{"name": "angle_deg", "value": 133}],
)
],
"根据当前128度姿态,将副驾靠背继续后仰至133度。",
)
save_plan = plan(
[
request(
"seat.save_posture_preset",
"save",
[{"name": "preset_name", "value": "零重力模式"}],
)
],
"将副驾当前五轴姿态保存为零重力模式。",
)
leave_preset_plan = plan(
[
request(
"seat.set_backrest_recline",
"set",
[{"name": "angle_deg", "value": 105}],
)
],
"将副驾靠背恢复到105度用于验证预设回调。",
)
apply_plan = plan(
[
request(
"seat.apply_posture_preset",
"apply",
[{"name": "preset_name", "value": "零重力模式"}],
)
],
"调用已保存的副驾零重力模式。",
)
self.service.openai.api_key = "test-key"
conversation_id = "conv-angle-preset-loop"
with patch.object(
self.service.openai,
"interpret",
side_effect=[
first_plan,
recline_plan,
save_plan,
leave_preset_plan,
apply_plan,
],
) as interpret:
first = self.service.process(
"副驾调整为零重力模式,要求靠背座椅夹角128度,坐垫脚托夹角133度",
"openai",
conversation_id,
)
second = self.service.process(
"靠背再向后一点", "openai", conversation_id
)
saved = self.service.process(
"将当前状态保存为零重力模式", "openai", conversation_id
)
changed = self.service.process(
"副驾靠背恢复直立一些", "openai", conversation_id
)
applied = self.service.process(
"副驾恢复到零重力模式", "openai", conversation_id
)
first_operations = first["function_config"]["spec"]["operations"]
self.assertEqual(
first_operations[0]["parameters"],
{"position_pct": 73, "angle_deg": 128},
)
self.assertEqual(
first_operations[1]["parameters"],
{"position_pct": 58, "angle_deg": 133},
)
self.assertEqual(
first["actuator_state"]["passenger"]["position"]["backrest_recline"][
"angle_deg"
],
128,
)
first_telemetry = first["execution_report"]["spec"]["results"][0]["telemetry"]
self.assertEqual(first_telemetry["requested_angle_deg"], 128)
self.assertEqual(first_telemetry["effective_position_pct"], 73)
self.assertEqual(
second["actuator_state"]["passenger"]["position"]["backrest_recline"][
"angle_deg"
],
133,
)
relative_motion = second["function_config"]["spec"]["operations"][0][
"motion"
]
self.assertEqual(relative_motion["mode"], "relative")
self.assertEqual(relative_motion["direction"], "increase")
self.assertEqual(relative_motion["start_position_pct"], 73)
self.assertEqual(relative_motion["delta_position_pct"], 11)
self.assertEqual(relative_motion["delta_angle_deg"], 5)
second_context = interpret.call_args_list[1].args[1]
self.assertEqual(
second_context["devices"]["passenger"]["position"]["backrest_recline"][
"angle_deg"
],
128,
)
self.assertEqual(saved["status"], "succeeded")
self.assertIsNone(saved["function_config"])
self.assertEqual(saved["preset_config"]["kind"], "FunctionPreset")
self.assertEqual(saved["preset_config"]["metadata"]["name"], "零重力模式")
self.assertFalse(
saved["execution_report"]["spec"]["results"][0]["telemetry"][
"can_command_emitted"
]
)
self.assertEqual(
saved["saved_presets"],
[{"name": "零重力模式", "target": "passenger"}],
)
preset_path = (
self.service.artifact_root
/ "user-presets"
/ f"{conversation_id}.json"
)
self.assertTrue(preset_path.is_file())
self.assertEqual(
changed["actuator_state"]["passenger"]["position"]["backrest_recline"][
"angle_deg"
],
105,
)
self.assertEqual(applied["status"], "succeeded")
restored = applied["actuator_state"]["passenger"]["position"]
self.assertEqual(restored["backrest_recline"]["angle_deg"], 133)
self.assertEqual(restored["legrest_height"]["angle_deg"], 133)
self.assertEqual(len(applied["function_config"]["spec"]["operations"]), 5)
apply_context = interpret.call_args_list[4].args[1]
self.assertEqual(apply_context["presets"][0]["name"], "零重力模式")
def test_missing_target_requests_confirmation(self) -> None:
result = self.service.process("打开座椅通风", provider="local")
self.assertEqual(result["status"], "needs_confirmation")
self.assertIsNone(result["function_config"])
if __name__ == "__main__":
unittest.main()
+1
View File
@@ -0,0 +1 @@
+1
View File
@@ -0,0 +1 @@
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
+140
View File
@@ -0,0 +1,140 @@
from __future__ import annotations
import unittest
from seat_domain.intent import parse_local_intent
class LocalIntentParserTests(unittest.TestCase):
@staticmethod
def context(level: int = 0) -> dict:
return {
"last_target": "driver" if level else None,
"devices": {
"driver": {
"ventilation": {"state": "on" if level else "off", "level": level},
"heating": {"state": "off", "level": 0},
},
"passenger": {
"ventilation": {"state": "off", "level": 0},
"heating": {"state": "off", "level": 0},
},
},
}
def test_parses_driver_ventilation_on(self) -> None:
result = parse_local_intent("打开主驾座椅通风")
self.assertEqual(
result["actions"],
[
{
"target": "driver",
"function": "ventilation",
"state": "on",
"requested_level": None,
}
],
)
self.assertEqual(result["ambiguities"], [])
def test_expands_front_seats(self) -> None:
result = parse_local_intent("主副驾座椅通风都打开")
self.assertEqual({item["target"] for item in result["actions"]}, {"driver", "passenger"})
self.assertTrue(all(item["state"] == "on" for item in result["actions"]))
def test_keeps_requested_level_for_safety_validation(self) -> None:
result = parse_local_intent("主驾座椅加热开到三档")
self.assertEqual(result["actions"][0]["requested_level"], 3)
def test_requires_target(self) -> None:
result = parse_local_intent("打开座椅通风")
self.assertEqual(result["actions"], [])
self.assertEqual(result["ambiguities"][0]["field"], "target")
def test_maps_mild_hot_and_stuffy_feedback_to_level_one(self) -> None:
result = parse_local_intent("主驾有点热,有点闷", self.context())
self.assertEqual(
result["actions"],
[
{
"target": "driver",
"function": "ventilation",
"state": "on",
"requested_level": 1,
}
],
)
self.assertIn("热、闷", "".join(result["decision_summary"]))
def test_increments_active_level_for_continuing_discomfort(self) -> None:
result = parse_local_intent("还有点闷", self.context(level=1))
self.assertEqual(result["actions"][0]["requested_level"], 2)
self.assertEqual(result["actions"][0]["target"], "driver")
self.assertIn("提升到2档", "".join(result["decision_summary"]))
def test_cold_feedback_reduces_active_ventilation_before_heating(self) -> None:
result = parse_local_intent("我有点冷", self.context(level=1))
self.assertEqual(
result["actions"],
[
{
"target": "driver",
"function": "ventilation",
"state": "off",
"requested_level": None,
}
],
)
self.assertIn("不立即开启加热", "".join(result["decision_summary"]))
def test_airflow_cause_targets_ventilation_not_heating(self) -> None:
context = self.context()
context["devices"]["passenger"]["ventilation"] = {"state": "on", "level": 2}
result = parse_local_intent("副驾吹的我有点冷", context)
self.assertEqual(
result["actions"],
[
{
"target": "passenger",
"function": "ventilation",
"state": "on",
"requested_level": 1,
}
],
)
self.assertNotIn("heating", {action["function"] for action in result["actions"]})
self.assertIn("致冷来源", "".join(result["decision_summary"]))
def test_cold_and_stuffy_are_independent_comfort_dimensions(self) -> None:
result = parse_local_intent("主驾有点冷、有点闷", self.context())
self.assertEqual(
{
(action["function"], action["state"], action["requested_level"])
for action in result["actions"]
},
{("heating", "on", 1), ("ventilation", "on", 1)},
)
self.assertEqual(result["ambiguities"], [])
self.assertIn("不将其判定为语义冲突", "".join(result["decision_summary"]))
def test_vehicle_scope_does_not_inherit_a_seat_target(self) -> None:
result = parse_local_intent("车上太闷了", self.context(level=1))
self.assertEqual(result["actions"], [])
self.assertEqual(result["ambiguities"][0]["field"], "domain")
self.assertEqual(result["detected_domains"], ["climate"])
self.assertEqual(result["scope"], "vehicle")
self.assertIn("整车舱范围", "".join(result["decision_summary"]))
if __name__ == "__main__":
unittest.main()
+84
View File
@@ -0,0 +1,84 @@
from __future__ import annotations
import unittest
from pathlib import Path
from seat_domain.compiler import SeatCompiler
from seat_domain.generator import build_function_config
from seat_domain.intent import parse_local_intent
from seat_domain.matrix import SeatCanMatrix
DOMAIN_ROOT = Path(__file__).resolve().parents[2]
class MatrixCompilerTests(unittest.TestCase):
def setUp(self) -> None:
self.matrix = SeatCanMatrix(DOMAIN_ROOT / "can" / "seat-thermal-matrix.yaml")
self.combined_matrix = SeatCanMatrix(
[
DOMAIN_ROOT / "can" / "seat-thermal-matrix.yaml",
DOMAIN_ROOT / "can" / "seat-motion-virtual-matrix.yaml",
]
)
self.compiler = SeatCompiler(DOMAIN_ROOT / "can" / "signal-mapping.yaml")
def test_encodes_combined_control_bits(self) -> None:
parsed = parse_local_intent("打开主驾座椅通风,同时关闭副驾座椅加热")
config = build_function_config(parsed, "req-test-001", "local_rules")
plan = self.compiler.compile(config)
command = plan["spec"]["commands"][0]
frame = self.matrix.encode(command["message"], command["signals"])
self.assertEqual(frame["frame_id"], "0x42A")
self.assertEqual(frame["data_hex"], "01 80")
def test_compilation_is_deterministic(self) -> None:
parsed = parse_local_intent("关闭副驾座椅通风")
config = build_function_config(parsed, "req-test-002", "local_rules")
self.assertEqual(self.compiler.compile(config), self.compiler.compile(config))
def test_compiles_each_motion_axis_with_feedback_completion(self) -> None:
parsed = {
"detected_domains": ["seat"],
"actions": [
{
"action_type": "position",
"target": "driver",
"axis": "longitudinal",
"position_pct": 35,
},
{
"action_type": "position",
"target": "driver",
"axis": "backrest_recline",
"position_pct": 60,
},
],
"deferred_requests": [],
"ambiguities": [],
"decision_summary": ["Move two driver-seat axes."],
"confidence": 1.0,
}
config = build_function_config(parsed, "req-motion-001", "test")
plan = self.compiler.compile(config)
self.assertEqual(len(plan["spec"]["commands"]), 2)
first, second = plan["spec"]["commands"]
self.assertEqual(first["message"], "Driver_Seat_Motion_Command_550")
self.assertEqual(first["signals"]["Longitudinal_Target_Pct"], 35)
self.assertEqual(first["signals"]["Motion_Enable_Mask"], 1)
self.assertEqual(first["completion"]["message"], "Driver_Seat_Motion_Status_558")
self.assertEqual(first["completion"]["signal"], "Longitudinal_Actual_Pct")
self.assertEqual(second["signals"]["Backrest_Recline_Target_Pct"], 60)
self.assertEqual(second["signals"]["Motion_Enable_Mask"], 4)
frame = self.combined_matrix.encode(first["message"], first["signals"])
self.assertEqual(frame["frame_id"], "0x550")
self.assertEqual(frame["payload_size"], 8)
if __name__ == "__main__":
unittest.main()
+835
View File
@@ -0,0 +1,835 @@
from __future__ import annotations
import os
import sys
import unittest
from collections import deque
from dataclasses import replace
from pathlib import Path
from unittest.mock import patch
ROOT = Path(__file__).resolve().parents[4]
SEAT_SOURCE = ROOT / "domains" / "seat" / "src"
if str(SEAT_SOURCE) not in sys.path:
sys.path.insert(0, str(SEAT_SOURCE))
from cognitive_agent.infrastructure.can.gateway import CanFrame # noqa: E402
from cognitive_agent.application.seat_service import SeatDemoService # noqa: E402
from cognitive_agent.infrastructure.can.hardware import ( # noqa: E402
HardwareSafetyError,
SeatHardwareSettings,
)
from cognitive_agent.infrastructure.firmware import ( # noqa: E402
EXPECTED_IDENTITY_PAYLOAD,
FirmwareProgrammingError,
T2GFirmwareGuard,
T2GSeatProgrammer,
)
from seat_domain import HardwareSeatExecutor, SeatCanMatrix, T2GSeatCompiler # noqa: E402
def function_config(*operations: dict[str, object]) -> dict[str, object]:
return {
"apiVersion": "cognitive-agent/v1alpha1",
"kind": "FunctionConfig",
"metadata": {
"id": "function-test-hardware",
"name": "T2G hardware test",
"version": "1.0.0",
"request_id": "req-hardware-test",
},
"spec": {"domain": "seat", "operations": list(operations)},
}
def operation(
capability: str,
parameters: dict[str, object],
*,
operation_id: str = "op-hardware-test",
instance: str = "driver",
zone: str = "front_left",
motion: dict[str, object] | None = None,
) -> dict[str, object]:
result = {
"id": operation_id,
"capability": capability,
"target": {"instance": instance, "zone": zone},
"parameters": parameters,
"timeout_ms": 20,
}
if motion is not None:
result["motion"] = motion
return result
class ReactiveFakeBus:
def __init__(
self,
matrix: SeatCanMatrix,
*,
complete_motion: bool = True,
position_valid: bool = True,
timed_failure_result: int | None = None,
timed_failure_event: int = 4,
):
self.matrix = matrix
self.complete_motion = complete_motion
self.position_valid = position_valid
self.timed_failure_result = timed_failure_result
self.timed_failure_event = timed_failure_event
self.hall = [0, 0, 0, 0]
self.max_hall = [480, 790, 1590, 960]
self.sent: list[CanFrame] = []
self.received: deque[CanFrame] = deque()
self.closed = False
def _frame(self, message: str, values: dict[str, int]) -> CanFrame:
encoded = self.matrix.encode(message, values)
return CanFrame(int(encoded["frame_id"], 16), bytes.fromhex(encoded["data_hex"]))
def send(self, arbitration_id: int, data: bytes) -> CanFrame:
frame = CanFrame(arbitration_id, bytes(data))
self.sent.append(frame)
if arbitration_id == 0x300:
command = self.matrix.decode("T2G_Seat_Move_Command_300", data)
sequence = command["Sequence"]
opcode = command["Opcode"]
self.received.append(
self._frame(
"T2G_Seat_Move_System_Status_301",
{
"Ack": 1,
"Reject_Reason": 0,
"Last_Opcode": opcode,
"Last_Sequence": sequence,
"Safety_Inhibit": 0,
"Safety_Fault": 0,
},
)
)
if opcode == 9:
for motor in range(4):
self.received.append(
self._frame(
"T2G_Seat_Move_Motor_Status_302",
{
"Motor_Sub_ID": motor,
"Task_State": 0,
"Result": 0,
"Position_Valid": int(self.position_valid),
"Motor_Sequence": sequence,
"Value_X100": 0,
"Duty_Percent": 0,
},
)
)
for motor in range(4):
self.received.append(
self._frame(
"T2G_Seat_Move_Diagnostic_303",
{
"Motor_Sub_ID": motor,
"Hall_Count": self.hall[motor],
"Max_Hall_Count": self.max_hall[motor],
"Max_Speed_Pps_X100": 9000,
},
)
)
if opcode == 1 and self.complete_motion:
self.received.append(
self._frame(
"T2G_Seat_Move_Motor_Status_302",
{
"Motor_Sub_ID": command["Motor_Selector"],
"Task_State": 8,
"Result": 2,
"Position_Valid": 1,
"Motor_Sequence": sequence,
"Value_X100": command["Param_A"],
"Duty_Percent": command["Param_B"],
},
)
)
if opcode == 3 and self.timed_failure_result is not None:
motor = command["Motor_Selector"]
self.received.append(
self._frame(
"T2G_Seat_Move_Motor_Status_302",
{
"Motor_Sub_ID": motor,
"Task_State": 9,
"Result": self.timed_failure_result,
"Direction": command["Direction"],
"Position_Valid": int(self.position_valid),
"Motor_Sequence": sequence,
"Value_X100": 0,
"Duty_Percent": command["Param_B"],
},
)
)
self.received.append(
self._frame(
"T2G_Seat_Move_Diagnostic_303",
{
"Motor_Sub_ID": motor,
"Last_Motor_Event": self.timed_failure_event,
"Hall_Count": self.hall[motor],
"Max_Hall_Count": self.max_hall[motor],
"Max_Speed_Pps_X100": 9000,
},
)
)
elif opcode == 3 and self.complete_motion:
motor = command["Motor_Selector"]
delta = max(1, command["Param_A"] // 35)
if command["Direction"] == 1:
self.hall[motor] = min(
self.max_hall[motor], self.hall[motor] + delta
)
else:
self.hall[motor] = max(0, self.hall[motor] - delta)
self.received.append(
self._frame(
"T2G_Seat_Move_Motor_Status_302",
{
"Motor_Sub_ID": motor,
"Task_State": 8,
"Result": 2,
"Direction": command["Direction"],
"Position_Valid": int(self.position_valid),
"Motor_Sequence": sequence,
"Value_X100": 0,
"Duty_Percent": command["Param_B"],
},
)
)
self.received.append(
self._frame(
"T2G_Seat_Move_Diagnostic_303",
{
"Motor_Sub_ID": motor,
"Hall_Count": self.hall[motor],
"Max_Hall_Count": self.max_hall[motor],
"Max_Speed_Pps_X100": 9000,
},
)
)
elif arbitration_id == 0x29E:
control = self.matrix.decode("T2G_Seat_Thermal_Control_29E", data)
heating = control["Driver_Seat_Heating_On"]
ventilation = control["Driver_Seat_Ventilation_On"]
self.received.append(
self._frame(
"T2G_Seat_Thermal_Status_4FA",
{
"Driver_Seat_Heating_Status": (
min(control["Driver_Seat_Heating_Gear"], 3) + 1
if heating == 1
else 1
),
"Driver_Seat_Ventilation_Status": (
min(control["Driver_Seat_Ventilation_Gear"], 3) + 1
if ventilation == 1
else 1
),
},
)
)
return frame
def receive(self, timeout_ms: int) -> CanFrame | None:
del timeout_ms
return self.received.popleft() if self.received else None
def close(self) -> None:
self.closed = True
class IdentityFakeBus:
def __init__(self, identity: bytes | None):
self.identity = identity
self.sent: list[CanFrame] = []
self.closed = False
def send(self, arbitration_id: int, data: bytes) -> CanFrame:
frame = CanFrame(arbitration_id, bytes(data))
self.sent.append(frame)
return frame
def receive(self, timeout_ms: int) -> CanFrame | None:
del timeout_ms
if self.identity is None:
return None
identity = self.identity
self.identity = None
return CanFrame(0x305, identity)
def close(self) -> None:
self.closed = True
class FailingIdentityBus(IdentityFakeBus):
def send(self, arbitration_id: int, data: bytes) -> CanFrame:
del arbitration_id, data
raise RuntimeError("CAN target did not ACK the identity probe")
class FakeProgrammer:
def __init__(self):
self.calls = 0
def program(self) -> dict[str, object]:
self.calls += 1
return {
"backend": "fake",
"programmed": True,
"image": "Seat_Controller_demo.hex",
"image_sha256": "0" * 64,
}
class T2GHardwareTests(unittest.TestCase):
def setUp(self) -> None:
self.matrix = SeatCanMatrix(
[
ROOT / "domains" / "seat" / "can" / "seat-thermal-hardware-matrix.yaml",
ROOT / "domains" / "seat" / "can" / "seat-motion-hardware-matrix.yaml",
]
)
self.compiler = T2GSeatCompiler(
ROOT / "domains" / "seat" / "can" / "signal-mapping.hardware.yaml",
ROOT / "platforms" / "t2g_seat_bench" / "calibration" / "seat.yaml",
)
def test_compiler_emits_verified_heating_payload(self) -> None:
config = function_config(
operation("seat.set_heating", {"state": "on", "level": 3})
)
plan = self.compiler.compile(config, {})
frames = [
self.matrix.encode(item["message"], item["signals"])
for item in plan["spec"]["commands"]
]
self.assertEqual(frames[0]["frame_id"], "0x3CE")
self.assertEqual(frames[0]["data_hex"], "10 00 00 00 00 00 00 00")
self.assertEqual(frames[1]["frame_id"], "0x29E")
self.assertEqual(frames[1]["data_hex"], "16 00 30 00 00 00 00 00")
def test_compiler_emits_verified_backrest_payload(self) -> None:
config = function_config(
operation("seat.set_backrest_recline", {"position_pct": 50})
)
plan = self.compiler.compile(config, {})
command = plan["spec"]["commands"][0]
frame = self.matrix.encode(command["message"], command["signals"])
self.assertEqual(frame["frame_id"], "0x300")
self.assertEqual(frame["data_hex"], "01 12 00 00 94 11 32 00")
def test_compiler_maps_small_relative_backrest_request_to_timed_duty(self) -> None:
config = function_config(
operation(
"seat.set_backrest_recline",
{"position_pct": 31, "angle_deg": 109},
motion={
"mode": "relative",
"direction": "increase",
"start_position_pct": 20,
"delta_position_pct": 11,
"start_angle_deg": 104,
"delta_angle_deg": 5,
},
)
)
command = self.compiler.compile(config, {})["spec"]["commands"][0]
self.assertEqual(command["signals"]["Opcode"], 3)
self.assertEqual(command["signals"]["Direction"], 1)
self.assertEqual(command["signals"]["Param_A"], 5500)
self.assertEqual(command["signals"]["Param_B"], 50)
self.assertEqual(command["completion"]["signal"], "Task_State")
def test_compiler_maps_large_relative_backrest_request_to_absolute_target(self) -> None:
config = function_config(
operation(
"seat.set_backrest_recline",
{"position_pct": 64, "angle_deg": 124},
motion={
"mode": "relative",
"direction": "increase",
"start_position_pct": 20,
"delta_position_pct": 44,
"start_angle_deg": 104,
"delta_angle_deg": 20,
},
)
)
command = self.compiler.compile(config, {})["spec"]["commands"][0]
self.assertEqual(command["signals"]["Opcode"], 1)
self.assertEqual(command["signals"]["Direction"], 0)
self.assertEqual(command["signals"]["Param_A"], 5760)
self.assertEqual(command["completion"]["signal"], "Value_X100")
def test_passenger_alias_emits_the_same_physical_payload(self) -> None:
driver = function_config(
operation("seat.set_heating", {"state": "on", "level": 2})
)
passenger = function_config(
operation(
"seat.set_heating",
{"state": "on", "level": 2},
instance="passenger",
zone="front_right",
)
)
driver_plan = self.compiler.compile(driver, {})
passenger_plan = self.compiler.compile(passenger, {})
self.assertEqual(
driver_plan["spec"]["commands"], passenger_plan["spec"]["commands"]
)
def test_driver_and_passenger_identical_motion_is_sent_once(self) -> None:
config = function_config(
operation(
"seat.set_backrest_recline",
{"position_pct": 60},
operation_id="op-driver",
),
operation(
"seat.set_backrest_recline",
{"position_pct": 60},
operation_id="op-passenger",
instance="passenger",
zone="front_right",
),
)
plan = self.compiler.compile(config, {})
self.assertEqual(len(plan["spec"]["commands"]), 1)
self.assertEqual(
plan["spec"]["commands"][0]["operation_ref"],
"op-driver,op-passenger",
)
bus = ReactiveFakeBus(self.matrix)
report = HardwareSeatExecutor(
self.matrix, lambda: bus, ack_timeout_ms=20
).execute(plan, config)
self.assertEqual([item.data[0] for item in bus.sent], [9, 1])
operation_results = [
item for item in report["spec"]["results"] if item["ref"].startswith("op-")
]
self.assertEqual({item["ref"] for item in operation_results}, {"op-driver", "op-passenger"})
def test_executor_waits_for_ack_and_physical_motion_feedback(self) -> None:
config = function_config(
operation("seat.set_backrest_recline", {"position_pct": 50})
)
plan = self.compiler.compile(config, {})
bus = ReactiveFakeBus(self.matrix)
report = HardwareSeatExecutor(
self.matrix, lambda: bus, ack_timeout_ms=20
).execute(plan, config)
self.assertEqual(report["spec"]["mode"], "hil")
self.assertEqual(report["spec"]["status"], "succeeded")
self.assertFalse(report["spec"]["results"][0]["telemetry"]["simulated"])
self.assertTrue(report["spec"]["results"][0]["telemetry"]["hardware_feedback"])
self.assertEqual([item.data[0] for item in bus.sent], [9, 1])
self.assertTrue(bus.closed)
def test_executor_blocks_absolute_motion_when_board_position_is_invalid(self) -> None:
config = function_config(
operation("seat.set_backrest_recline", {"position_pct": 50})
)
plan = self.compiler.compile(config, {})
bus = ReactiveFakeBus(self.matrix, position_valid=False)
report = HardwareSeatExecutor(
self.matrix, lambda: bus, ack_timeout_ms=20
).execute(plan, config)
self.assertEqual(report["spec"]["status"], "failed")
self.assertEqual([item.data[0] for item in bus.sent], [9, 9])
failure = report["spec"]["results"][-1]
self.assertIn("Position_Valid=0", failure["message"])
self.assertTrue(failure["telemetry"]["calibration_required"])
def test_large_relative_request_is_blocked_without_sending_absolute_opcode(self) -> None:
config = function_config(
operation(
"seat.set_backrest_recline",
{"position_pct": 64, "angle_deg": 124},
motion={
"mode": "relative",
"direction": "increase",
"start_position_pct": 20,
"delta_position_pct": 44,
"start_angle_deg": 104,
"delta_angle_deg": 20,
},
)
)
plan = self.compiler.compile(config, {})
bus = ReactiveFakeBus(self.matrix, position_valid=False)
report = HardwareSeatExecutor(
self.matrix, lambda: bus, ack_timeout_ms=20
).execute(plan, config)
self.assertEqual(report["spec"]["status"], "failed")
self.assertEqual([item.data[0] for item in bus.sent], [9, 9])
self.assertNotIn(1, [item.data[0] for item in bus.sent])
self.assertTrue(
report["spec"]["results"][-1]["telemetry"]["calibration_required"]
)
def test_executor_accepts_hall_confirmed_timed_motion_without_position(self) -> None:
config = function_config(
operation(
"seat.set_backrest_recline",
{"position_pct": 31, "angle_deg": 109},
motion={
"mode": "relative",
"direction": "increase",
"start_position_pct": 20,
"delta_position_pct": 11,
"start_angle_deg": 104,
"delta_angle_deg": 5,
},
)
)
plan = self.compiler.compile(config, {})
bus = ReactiveFakeBus(self.matrix, position_valid=False)
report = HardwareSeatExecutor(
self.matrix, lambda: bus, ack_timeout_ms=20
).execute(plan, config)
self.assertEqual(report["spec"]["status"], "succeeded")
self.assertEqual([item.data[0] for item in bus.sent], [9, 3])
telemetry = report["spec"]["results"][0]["telemetry"]
self.assertEqual(telemetry["motion_mode"], "relative_timed_duty")
self.assertFalse(telemetry["position_valid"])
self.assertGreater(telemetry["hall_delta"], 0)
self.assertTrue(telemetry["hall_direction_verified"])
def test_executor_reports_stall_with_post_failure_hall_diagnostics(self) -> None:
config = function_config(
operation(
"seat.set_backrest_recline",
{"position_pct": 31, "angle_deg": 109},
motion={
"mode": "relative",
"direction": "increase",
"start_position_pct": 20,
"delta_position_pct": 11,
"start_angle_deg": 104,
"delta_angle_deg": 5,
},
)
)
plan = self.compiler.compile(config, {})
bus = ReactiveFakeBus(
self.matrix,
position_valid=False,
timed_failure_result=4,
timed_failure_event=4,
)
report = HardwareSeatExecutor(
self.matrix, lambda: bus, ack_timeout_ms=20
).execute(plan, config)
self.assertEqual(report["spec"]["status"], "failed")
self.assertEqual([item.data[0] for item in bus.sent], [9, 3, 9])
failure = report["spec"]["results"][-1]
self.assertIn("STALL", failure["message"])
self.assertEqual(failure["telemetry"]["motor_result_name"], "STALL")
self.assertEqual(failure["telemetry"]["last_motor_event_name"], "STALL")
self.assertEqual(
failure["telemetry"]["failure_category"], "stall_no_hall_activity"
)
self.assertEqual(failure["telemetry"]["hall_before"], 0)
self.assertEqual(failure["telemetry"]["hall_after"], 0)
self.assertEqual(
failure["telemetry"]["diagnostic_frame"]["frame_id"], "0x303"
)
def test_executor_sends_stop_all_after_motion_feedback_timeout(self) -> None:
config = function_config(
operation("seat.set_legrest_extension", {"position_pct": 50})
)
plan = self.compiler.compile(config, {})
bus = ReactiveFakeBus(self.matrix, complete_motion=False)
report = HardwareSeatExecutor(
self.matrix, lambda: bus, ack_timeout_ms=20
).execute(plan, config)
self.assertEqual(report["spec"]["status"], "failed")
self.assertEqual([item.data[0] for item in bus.sent], [9, 1, 9])
self.assertTrue(
report["spec"]["results"][-1]["telemetry"]["stop_all_attempted"]
)
def test_executor_waits_for_real_thermal_status(self) -> None:
config = function_config(
operation("seat.set_ventilation", {"state": "on", "level": 2})
)
plan = self.compiler.compile(config, {})
bus = ReactiveFakeBus(self.matrix)
report = HardwareSeatExecutor(
self.matrix, lambda: bus, thermal_timeout_ms=20
).execute(plan, config)
self.assertEqual(report["spec"]["status"], "succeeded")
telemetry = report["spec"]["results"][0]["telemetry"]
self.assertEqual(telemetry["feedback_frame"]["frame_id"], "0x4FA")
self.assertEqual(telemetry["feedback_value"], 3)
def test_settings_allow_shared_target_aliases_but_fail_closed_for_other_targets(self) -> None:
environment = {
"COGNITIVE_AGENT_HARDWARE_EXECUTION_ENABLED": "true",
"COGNITIVE_AGENT_HARDWARE_APPROVAL_TOKEN": "bench-secret",
"COGNITIVE_AGENT_SEAT_FIRMWARE_PROFILES": "combined",
"COGNITIVE_AGENT_ENABLE_UNVERIFIED_LONGITUDINAL": "false",
}
with patch.dict(os.environ, environment, clear=False):
settings = SeatHardwareSettings.from_env(ROOT)
settings = replace(
settings, firmware_images={"combined": ROOT / "pyproject.toml"}
)
settings.authorize("bench-secret")
settings.validate_function_config(
function_config(
operation(
"seat.set_heating",
{"state": "on", "level": 1},
instance="passenger",
zone="front_right",
)
)
)
with self.assertRaises(HardwareSafetyError):
settings.validate_function_config(
function_config(
operation(
"seat.set_heating",
{"state": "on", "level": 1},
instance="rear_left",
zone="rear_left",
)
)
)
with self.assertRaises(HardwareSafetyError):
settings.validate_function_config(
function_config(
operation(
"seat.set_longitudinal_position", {"position_pct": 40}
)
)
)
with self.assertRaises(HardwareSafetyError):
settings.authorize("wrong-secret")
def test_settings_reject_conflicting_shared_alias_requests(self) -> None:
environment = {
"COGNITIVE_AGENT_HARDWARE_EXECUTION_ENABLED": "true",
"COGNITIVE_AGENT_HARDWARE_APPROVAL_TOKEN": "bench-secret",
"COGNITIVE_AGENT_SEAT_FIRMWARE_PROFILES": "combined",
}
with patch.dict(os.environ, environment, clear=False):
settings = SeatHardwareSettings.from_env(ROOT)
with self.assertRaises(HardwareSafetyError):
settings.validate_function_config(
function_config(
operation(
"seat.set_heating",
{"state": "on", "level": 1},
operation_id="op-driver",
),
operation(
"seat.set_heating",
{"state": "on", "level": 3},
operation_id="op-passenger",
instance="passenger",
zone="front_right",
),
)
)
def test_firmware_guard_reuses_bus_when_identity_matches(self) -> None:
bus = IdentityFakeBus(EXPECTED_IDENTITY_PAYLOAD)
guard = T2GFirmwareGuard(
lambda: bus,
None,
auto_flash=False,
probe_timeout_ms=20,
)
prepared, telemetry = guard.prepare_bus()
self.assertIs(prepared, bus)
self.assertFalse(telemetry["programmed_this_request"])
self.assertEqual(bus.sent[0].arbitration_id, 0x304)
self.assertFalse(bus.closed)
def test_firmware_guard_programs_then_reverifies(self) -> None:
before = IdentityFakeBus(None)
after = IdentityFakeBus(EXPECTED_IDENTITY_PAYLOAD)
buses = deque([before, after])
programmer = FakeProgrammer()
guard = T2GFirmwareGuard(
buses.popleft,
programmer, # type: ignore[arg-type]
auto_flash=True,
probe_timeout_ms=20,
boot_wait_ms=0,
)
prepared, telemetry = guard.prepare_bus()
self.assertIs(prepared, after)
self.assertEqual(programmer.calls, 1)
self.assertTrue(before.closed)
self.assertTrue(telemetry["programmed_this_request"])
def test_firmware_guard_programs_when_unflashed_target_does_not_ack(self) -> None:
before = FailingIdentityBus(None)
after = IdentityFakeBus(EXPECTED_IDENTITY_PAYLOAD)
buses = deque([before, after])
programmer = FakeProgrammer()
guard = T2GFirmwareGuard(
buses.popleft,
programmer, # type: ignore[arg-type]
auto_flash=True,
probe_timeout_ms=20,
boot_wait_ms=0,
)
prepared, telemetry = guard.prepare_bus()
self.assertIs(prepared, after)
self.assertEqual(programmer.calls, 1)
self.assertTrue(before.closed)
self.assertTrue(telemetry["programmed_this_request"])
def test_firmware_guard_fails_closed_when_auto_flash_is_disabled(self) -> None:
bus = IdentityFakeBus(None)
guard = T2GFirmwareGuard(
lambda: bus,
None,
auto_flash=False,
probe_timeout_ms=20,
)
with self.assertRaises(FirmwareProgrammingError):
guard.prepare_bus()
self.assertTrue(bus.closed)
def test_programmer_command_downloads_and_hands_off_to_running_target(self) -> None:
firmware_root = ROOT / "firmware" / "t2g" / "Seat_Controller_demo"
output_image = (
firmware_root / "IAR" / "rev_d" / "Exe" / "Seat_Controller_demo.out"
)
program_macro = firmware_root / "IAR" / "Seat_Controller_program.mac"
programmer = T2GSeatProgrammer(
cspybat=ROOT / "fake-iar" / "common" / "bin" / "cspybat.exe",
debug_file=output_image,
hex_image=(
firmware_root / "IAR" / "rev_d" / "Exe" / "Seat_Controller_demo.hex"
),
macro_file=program_macro,
)
# Dependency discovery is covered by settings/configuration tests. This
# test only validates the command and never starts C-SPY or a probe.
with patch.object(T2GSeatProgrammer, "required_files", return_value=()):
command = programmer.command()
self.assertEqual(command[3], str(output_image))
self.assertIn(f"--macro={program_macro}", command)
self.assertNotIn("--download_only", command)
self.assertIn("--leave_target_running", command)
timeout_index = command.index("--timeout")
self.assertEqual(command[timeout_index + 1], "3000")
self.assertIn("--jet_probe=cmsisdap", command)
self.assertNotIn("Seat_Controller_demo.mac", " ".join(command))
def test_hardware_validation_replaces_reference_dry_run_warnings(self) -> None:
validation = [
{"severity": "warning", "code": code, "message": "dry-run"}
for code in (
"LEVEL_COMMAND_SIMULATED",
"SYNTHETIC_MOTION_CAN",
"DRIVER_MOTION_INTERLOCK_NOT_IMPLEMENTED",
"COMBINED_THERMAL_SIMULATED",
"VIRTUAL_EXECUTION_ONLY",
)
]
SeatDemoService._adapt_validation_for_hardware(validation)
codes = {issue["code"] for issue in validation}
self.assertEqual(
codes,
{
"LEVEL_COMMAND_T2G_MAPPED",
"T2G_MOTION_PROTOCOL_MAPPED",
"BENCH_ONLY_NO_VEHICLE_INTERLOCKS",
"COMBINED_THERMAL_T2G_MAPPED",
"REFERENCE_MATRIX_OVERRIDDEN_BY_T2G_BENCH",
},
)
messages = " ".join(issue["message"] for issue in validation)
self.assertNotIn("禁止实车下发", messages)
self.assertNotIn("只能虚拟执行", messages)
def test_physical_feedback_is_mirrored_to_both_logical_seats(self) -> None:
actions = [
{
"target": "passenger",
"function": "ventilation",
"state": "on",
"requested_level": 2,
}
]
config = function_config(
operation(
"seat.set_ventilation",
{"state": "on", "level": 2},
instance="passenger",
zone="front_right",
)
)
report = {
"spec": {
"results": [
{
"ref": "op-hardware-test",
"status": "succeeded",
"telemetry": {"feedback_value": 3},
}
]
}
}
projected = SeatDemoService._hardware_feedback_actions(
actions, config, report
)
self.assertEqual({item["target"] for item in projected}, {"driver", "passenger"})
self.assertTrue(
all(item["requested_level"] == 2 for item in projected)
)
if __name__ == "__main__":
unittest.main()