init
This commit is contained in:
Executable
+8
@@ -0,0 +1,8 @@
|
||||
domain: seat
|
||||
primary_owner: seat_owner
|
||||
reviewers:
|
||||
- integration_owner
|
||||
- can_owner
|
||||
paths:
|
||||
- domains/seat/**
|
||||
|
||||
Executable
+25
@@ -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
@@ -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
@@ -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
@@ -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}
|
||||
Executable
+189
@@ -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
@@ -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
|
||||
Executable
+132
@@ -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
|
||||
Executable
+211
@@ -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
|
||||
Executable
+7
@@ -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
@@ -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
@@ -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
|
||||
|
||||
Executable
+16
@@ -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
@@ -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}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Executable
+3
@@ -0,0 +1,3 @@
|
||||
# Seat Plugin Source
|
||||
|
||||
当前实现包含本地意图解析、配置生成、领域校验、确定性 Compiler、矩阵位编码和虚拟回读 Executor。Compiler 只返回 `CommandPlan`,虚拟 Executor 不打开 CAN socket。获得正式 DBC、发送属性、安全输入和审批前不得增加硬件发送路径。
|
||||
Executable
+25
@@ -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.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Executable
+133
@@ -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,
|
||||
},
|
||||
}
|
||||
Executable
+199
@@ -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,
|
||||
},
|
||||
}
|
||||
Executable
+277
@@ -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
@@ -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
@@ -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
|
||||
]
|
||||
Executable
+433
@@ -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
|
||||
Executable
+115
@@ -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
@@ -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
|
||||
|
||||
Executable
+256
@@ -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
|
||||
Executable
+4
@@ -0,0 +1,4 @@
|
||||
# Seat Tests
|
||||
|
||||
`unit/` 覆盖姿态参数和运动学转换,`contract/` 覆盖配置与插件契约,`integration/` 使用模拟 CAN Gateway 覆盖命令、回读、互锁和超时。实车范围需与平台安全标定分别测试,不能只依赖 JSON Schema 的宽范围。
|
||||
|
||||
Executable
+1
@@ -0,0 +1 @@
|
||||
|
||||
Executable
+1
@@ -0,0 +1 @@
|
||||
|
||||
Binary file not shown.
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
Binary file not shown.
BIN
Binary file not shown.
Binary file not shown.
+104
@@ -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()
|
||||
Executable
+1
@@ -0,0 +1 @@
|
||||
|
||||
Executable
+1
@@ -0,0 +1 @@
|
||||
|
||||
Binary file not shown.
Binary file not shown.
Executable
BIN
Binary file not shown.
Executable
BIN
Binary file not shown.
BIN
Binary file not shown.
Executable
BIN
Binary file not shown.
BIN
Binary file not shown.
+610
@@ -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()
|
||||
Executable
+1
@@ -0,0 +1 @@
|
||||
|
||||
Executable
+1
@@ -0,0 +1 @@
|
||||
|
||||
Binary file not shown.
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
Binary file not shown.
BIN
Binary file not shown.
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
Binary file not shown.
BIN
Binary file not shown.
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
Binary file not shown.
Executable
+140
@@ -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
@@ -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()
|
||||
Executable
+835
@@ -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()
|
||||
Reference in New Issue
Block a user