feat: integrate calibration extension and self-repair modules

This commit is contained in:
vee
2026-08-07 23:39:45 +08:00
parent 156b10fda1
commit 3bceb0d5a1
41 changed files with 21310 additions and 314 deletions
@@ -0,0 +1,5 @@
"""Seat self-repair and hardware diagnostic application module."""
from .service import SelfRepairService
__all__ = ["SelfRepairService"]
@@ -0,0 +1,690 @@
"""Software-fault injection and closed-loop self-healing demo model.
The model deliberately injects faults into a shadow copy of Seat Controller
telemetry. It never writes ECU memory, changes protection thresholds or sends
CAN frames. The real bridge status remains available beside the simulation so
the UI can clearly distinguish simulated evidence from physical HIL evidence.
"""
from __future__ import unicode_literals
import copy
import datetime
import time
PHASE_IDLE = "IDLE"
PHASE_BASELINE = "BASELINE"
PHASE_INJECTED = "INJECTED"
PHASE_DIAGNOSED = "DIAGNOSED"
PHASE_REPAIRING = "REPAIRING"
PHASE_VERIFYING = "VERIFYING"
PHASE_PASSED = "PASSED"
PHASE_FAILED = "FAILED"
PHASE_ORDER = (
PHASE_BASELINE, PHASE_INJECTED, PHASE_DIAGNOSED,
PHASE_REPAIRING, PHASE_VERIFYING, PHASE_PASSED,
)
PHASE_AT_S = {
PHASE_BASELINE: 0.0,
PHASE_INJECTED: 6.0,
PHASE_DIAGNOSED: 8.5,
PHASE_REPAIRING: 11.0,
PHASE_VERIFYING: 14.0,
PHASE_PASSED: 18.0,
}
PHASE_TEXT = {
PHASE_IDLE: "等待验证",
PHASE_BASELINE: "基线确认",
PHASE_INJECTED: "故障已注入",
PHASE_DIAGNOSED: "AI已定位",
PHASE_REPAIRING: "安全修复中",
PHASE_VERIFYING: "闭环复测中",
PHASE_PASSED: "已自修复",
PHASE_FAILED: "安全停止",
}
SCENARIOS = (
{
"id": "FI-FIELD-01",
"title": "多次移动后位置标定丢失",
"short_title": "位置标定丢失",
"system": "座椅移动 / 位置记忆",
"normal_task": "靠背正在移动到驾驶记忆角度",
"fault_reaction": "靠背移动中途停止,后续绝对位置命令被拒绝",
"recovery_reaction": "靠背停止后单轴回最近端重建位置,再继续完成原驾驶记忆角度",
"field_basis": "量产案例:FCA/Jeep记忆座椅在大量移动后丢失端点标定,迎宾和记忆位置失效。",
"symptom": "位置命令被拒绝,但电机电流、Hall采样和驱动器均正常。",
"injection": "在影子SMV8提交的dirty→clean阶段模拟软件复位,使最新位置记录无效。",
"diagnosis": "PositionValid丢失,且Hall/电流链路健康,归因为位置持久化事务未完成。",
"repair": "停止移动,执行HOME_KNOWN_RANGE,重建双槽位置记录和可信位置基线。",
"pass": "PositionValid恢复、可信端点到位、再次加载记录有效、保护未被绕过。",
"fault_code": "POSITION_CALIBRATION_LOST",
"baseline_updates": {
"operation_stage": "NORMAL_TASK", "bench_reaction": "靠背向驾驶记忆位移动",
"axis_positions": [25, 40, 48, 20], "active_axes": [2],
"task_target_positions": [25, 40, 68, 20],
},
"fault_updates": {
"position_valid": False, "position_pct": None,
"request_feedback_match": False, "store_slot_valid": False,
"ai_hint": "电机/Hall健康,位置有效位丢失",
"operation_stage": "FAULT_REACTION", "bench_reaction": "靠背在途中停止,记忆位请求被拒绝",
"axis_positions": [25, 40, 55, 20], "active_axes": [],
},
"repair_updates": {
"position_valid": True, "position_pct": 0,
"request_feedback_match": False, "store_slot_valid": True,
"repair_action": "HOME_KNOWN_RANGE + SMV8双槽提交",
"operation_stage": "REPAIR", "bench_reaction": "仅靠背向最近端回零,其他座椅机构保持不动",
"axis_positions": [25, 40, 0, 20], "active_axes": [2],
},
"verify_updates": {
"operation_stage": "RESUMING", "bench_reaction": "重新执行被中断的驾驶记忆位任务",
"axis_positions": [25, 40, 68, 20], "active_axes": [2],
"task_restored": True,
},
"passed_updates": {
"operation_stage": "COMPLETE", "bench_reaction": "原驾驶记忆位已到位",
"axis_positions": [25, 40, 68, 20], "active_axes": [],
},
"bench": {
"supported": True, "target": "REAL_ECU_FAULT_HOOK",
"risk": "CONTROLLED_MOTION",
"injection": "由台架故障钩子中断SMV8 dirty→clean提交,使实物ECU PositionValid失效。",
"repair": "AI发送HOME_KNOWN_RANGE,验证真实Hall归零、PositionValid恢复和SMV8干净提交。",
},
},
{
"id": "FI-FIELD-02",
"title": "断电重启后座椅位置记录回退",
"short_title": "位置记录CRC回退",
"system": "四轴位置 / SMV8持久化",
"normal_task": "用户调用刚保存的腿托舒适位置",
"fault_reaction": "CRC校验失败后调用被挂起,腿托停在当前位且目标未执行",
"recovery_reaction": "回退有效槽、重建冗余记录后,继续执行原腿托舒适位置",
"field_basis": "量产常见现象:座椅模块偶发掉电或写入中断后丢失最近位置,重启后回退到旧的有效记录。",
"symptom": "四轴Hall与标定有效,但Work Flash最新槽CRC失败,只能回退旧槽。",
"injection": "向真实SMV8备用槽写入更高序号但CRC损坏的记录,保留旧有效槽。",
"diagnosis": "旧槽CRC有效、最新槽CRC失败,归因为冗余位置事务未完整提交。",
"repair": "选择旧有效槽并以更高序号重新原子提交,恢复双槽冗余。",
"pass": "两个SMV8槽均通过CRC/语义校验,重启后仍选择最新完整记录。",
"fault_code": "MEMORY_PROFILE_NOT_RETAINED",
"baseline_updates": {
"operation_stage": "NORMAL_TASK", "bench_reaction": "腿托舒适位置调用已发起",
"axis_positions": [32, 38, 46, 22], "active_axes": [],
"task_target_positions": [32, 38, 46, 62],
},
"fault_updates": {
"profile_crc_valid": False, "profile_version": 8,
"expected_profile_version": 8, "position_pct": 42,
"request_feedback_match": False,
"ai_hint": "轴标定有效,场景CRC/版本不一致",
"operation_stage": "FAULT_REACTION", "bench_reaction": "腿托目标挂起,实物保持在22%",
"axis_positions": [32, 38, 46, 22], "active_axes": [],
},
"repair_updates": {
"profile_crc_valid": True, "profile_version": 8,
"position_pct": 61, "request_feedback_match": True,
"repair_action": "回退旧有效SMV8槽并重新原子提交",
"operation_stage": "REPAIR", "bench_reaction": "内部双槽记录正在重建,执行器保持不动",
"axis_positions": [32, 38, 46, 22], "active_axes": [],
},
"verify_updates": {
"operation_stage": "RESUMING", "bench_reaction": "继续执行原腿托舒适位置",
"axis_positions": [32, 38, 46, 62], "active_axes": [3],
"task_restored": True,
},
"passed_updates": {
"operation_stage": "COMPLETE", "bench_reaction": "腿托舒适位置到位",
"axis_positions": [32, 38, 46, 62], "active_axes": [],
},
"bench": {
"supported": True, "target": "REAL_ECU_FAULT_HOOK",
"risk": "WORK_FLASH_REDUNDANT_SLOT",
"injection": "由台架故障钩子只损坏SMV8备用槽CRC,旧有效槽始终保留。",
"repair": "AI回退旧有效槽并以更高序号重写备用槽,验证双槽均有效。",
},
},
{
"id": "FI-FIELD-03",
"title": "瞬态电流峰值被误判为过载",
"short_title": "瞬态过载误判",
"system": "电机移动 / 防夹保护",
"normal_task": "水平滑轨正在移动到预设驾驶位置",
"fault_reaction": "短时电流尖峰触发误停,滑轨在目标前停止并保持静止",
"recovery_reaction": "保持保护阈值,短距健康复测通过后继续到原驾驶位置目标",
"field_basis": "量产案例:Audi迎宾回位过程中短时负载峰值触发软件过载限制,座椅错误停止或反向。",
"symptom": "电流短时超过阈值后立即恢复,Hall随后继续增长,不符合持续堵转。",
"injection": "向影子电流采样入口回放80 ms量产工况尖峰,保留后续真实Hall增长。",
"diagnosis": "峰值持续时间短、ADC有效且Hall恢复,归因为瞬态负载的错误分类。",
"repair": "保持原保护阈值,安全停机观察后沿同一安全方向只重试一次。",
"pass": "原目标到位、没有错误反转、持续堵转保护自检仍然有效。",
"fault_code": "FALSE_OVERLOAD_CLASSIFICATION",
"baseline_updates": {
"operation_stage": "NORMAL_TASK", "bench_reaction": "水平滑轨向驾驶位前移",
"axis_positions": [30, 38, 46, 22], "active_axes": [0],
"task_target_positions": [72, 38, 46, 22],
},
"fault_updates": {
"current_a": 8.7, "current_peak_ms": 80,
"hall_resumed": True, "position_pct": 58,
"request_feedback_match": False,
"ai_hint": "短时尖峰后Hall恢复,不是持续堵转",
"operation_stage": "FAULT_REACTION", "bench_reaction": "滑轨在42%位置突然停止,并保持静止供AI诊断",
"axis_positions": [42, 38, 46, 22], "active_axes": [],
},
"repair_updates": {
"current_a": 2.2, "current_peak_ms": 0,
"position_pct": 61, "request_feedback_match": True,
"retry_count": 1,
"repair_action": "保持停止并清除误判状态,随后执行一次短距健康复测",
"operation_stage": "REPAIR", "bench_reaction": "误判状态已清除,水平滑轨继续保持停止",
"axis_positions": [42, 38, 46, 22], "active_axes": [],
},
"verify_updates": {
"operation_stage": "RESUMING", "bench_reaction": "短距健康复测通过,继续执行原驾驶位置目标",
"axis_positions": [72, 38, 46, 22], "active_axes": [0],
"task_restored": True,
},
"passed_updates": {
"operation_stage": "COMPLETE", "bench_reaction": "水平滑轨已到原驾驶位",
"axis_positions": [72, 38, 46, 22], "active_axes": [],
},
"bench": {
"supported": True, "target": "REAL_ECU_FAULT_HOOK",
"risk": "CONTROLLED_MOTION",
"injection": "由台架故障钩子向实物电流判定入口写入80 ms瞬态峰值,同时保留真实Hall增长。",
"repair": "AI保持保护阈值不变,安全停机观察后仅重试一次并验证真实位置反馈。",
},
},
{
"id": "FI-FIELD-04",
"title": "唤醒后加热/通风请求不生效",
"short_title": "热管理唤醒失败",
"system": "通风 / 加热",
"normal_task": "远程舒适功能请求通风2挡和靠背加热1挡",
"fault_reaction": "唤醒时序错误使风扇和加热均未启动",
"recovery_reaction": "按依赖顺序重放报文,风扇重新转动且加热电流建立",
"field_basis": "量产案例:Ford/Subaru/Land Rover在远程启动或模块唤醒后座椅通风、加热不工作。",
"symptom": "控制请求为ON,但功能反馈和负载电流均为OFF,ADC本身正常。",
"injection": "模拟控制帧先于模式和配置帧到达,形成真实唤醒时序竞争。",
"diagnosis": "请求存在、硬件观测健康但配置未就绪,归因为软件初始化/报文顺序错误。",
"repair": "清理过期请求,按模式→配置→控制请求的依赖顺序完整重放。",
"pass": "风扇/加热电流建立、反馈匹配目标、温度和电流保护保持有效。",
"fault_code": "THERMAL_WAKE_SEQUENCE_MISMATCH",
"baseline_updates": {
"operation_stage": "NORMAL_TASK", "bench_reaction": "远程舒适请求已发起",
"axis_positions": [35, 42, 48, 20], "active_axes": [],
"fan_running": False, "heater_level": 0,
},
"fault_updates": {
"thermal_request": "通风2挡 / 加热1挡", "thermal_feedback": "关闭",
"fan_current_a": 0.0, "heater_current_a": 0.0,
"config_ready": False, "request_feedback_match": False,
"ai_hint": "请求有效、ADC健康,但模式/配置尚未就绪",
"operation_stage": "FAULT_REACTION", "bench_reaction": "风扇无声、加热电流为零",
"fan_running": False, "heater_level": 0,
},
"repair_updates": {
"thermal_feedback": "通风2挡 / 加热1挡",
"fan_current_a": 1.3, "heater_current_a": 3.2,
"config_ready": True, "request_feedback_match": True,
"repair_action": "按依赖顺序重放模式、配置和请求",
"operation_stage": "REPAIR", "bench_reaction": "通风2挡启动,加热1挡电流建立",
"fan_running": True, "heater_level": 1,
},
"verify_updates": {
"operation_stage": "RESUMING", "bench_reaction": "原远程舒适任务保持运行并复测",
"fan_running": True, "heater_level": 1, "task_restored": True,
},
"passed_updates": {
"operation_stage": "COMPLETE", "bench_reaction": "舒适任务已恢复,验证结束后安全关闭",
"fan_running": False, "heater_level": 0,
},
"bench": {
"supported": True,
"target": "REAL_ECU_CAN",
"risk": "LOW",
"injection": (
"向实物ECU写入无效热管理模式,再提前发送加热控制帧,"
"用0x4FA反馈和0x305实测电流证明请求未生效"),
"repair": (
"由AI按模式0x3CE→配置0x2AA→控制0x29E重放,"
"验证反馈和电流建立后自动关闭输出"),
},
},
{
"id": "FI-FIELD-05",
"title": "关闭通风后风扇再次升速",
"short_title": "风扇关闭异常",
"system": "座椅通风",
"normal_task": "通风2挡运行中,用户发出关闭指令",
"fault_reaction": "风扇先停止又自行升速重启,OFF命令与实物动作矛盾",
"recovery_reaction": "清除残留事件并进行三次启停复测,最终恢复用户要求的OFF",
"field_basis": "量产案例:BMW通风座椅关闭时风扇异常升速或再次启动,原因是座椅模块软件状态机。",
"symptom": "OFF已确认,但遗留启动延时事件再次拉起PWM并产生风扇电流。",
"injection": "模拟启动增强计时器尚未结束时快速执行ON→OFF命令序列。",
"diagnosis": "命令状态为OFF而pending-start仍存在,归因为风扇状态机遗留事件。",
"repair": "立即安全关闭PWM,取消全部待执行启动/换挡事件并清除旧请求。",
"pass": "电流归零且连续三次启停不再出现二次升速或自动重启。",
"fault_code": "FAN_STOP_SEQUENCE_REENTRY",
"baseline_updates": {
"operation_stage": "NORMAL_TASK", "bench_reaction": "通风2挡运行后收到OFF指令",
"axis_positions": [35, 42, 48, 20], "active_axes": [],
"fan_running": True, "heater_level": 0,
},
"fault_updates": {
"thermal_request": "风扇OFF", "thermal_feedback": "异常升速",
"fan_current_a": 2.6, "pending_start": True,
"request_feedback_match": False,
"ai_hint": "OFF已确认,遗留启动事件仍在执行",
"operation_stage": "FAULT_REACTION", "bench_reaction": "风扇关闭后异常重新升速",
"fan_running": True, "heater_level": 0,
},
"repair_updates": {
"thermal_feedback": "关闭", "fan_current_a": 0.0,
"pending_start": False, "request_feedback_match": True,
"repair_action": "PWM安全关闭 + 取消延迟事件",
"operation_stage": "REPAIR", "bench_reaction": "PWM关闭并取消所有延迟启动事件",
"fan_running": False, "heater_level": 0,
},
"verify_updates": {
"operation_stage": "RESUMING", "bench_reaction": "执行三次短时启停复测",
"fan_running": True, "heater_level": 0, "verification_cycle": 3,
"task_restored": True,
},
"passed_updates": {
"operation_stage": "COMPLETE", "bench_reaction": "三次复测通过,风扇保持关闭",
"fan_running": False, "heater_level": 0,
},
"bench": {
"supported": True, "target": "REAL_ECU_FAULT_HOOK",
"risk": "LOW_THERMAL",
"injection": "由台架故障钩子保留风扇pending-start事件,实物执行ON→OFF后触发一次异常再启动。",
"repair": "AI关闭真实PWM、取消延迟事件,连续三次实物启停复测后保持关闭。",
},
},
{
"id": "FI-FIELD-06",
"title": "软件复位后座椅功能持续失能",
"short_title": "WDT复位后失能",
"system": "座椅控制器 / 多功能协同",
"normal_task": "通风2挡与加热1挡正在执行,控制器发生受控WDT复位",
"fault_reaction": "风扇和加热立即停止,CAN短暂离线,重新上线后请求未恢复",
"recovery_reaction": "重建子系统并恢复经校验的舒适请求,验证后自动安全关闭",
"field_basis": "量产案例:座椅模块在低压恢复、远程启动或软件复位后状态未重建,功能需再次上电才恢复。",
"symptom": "WDT复位后固件重新在线,但运行时位置有效位和热管理状态未完整重建。",
"injection": "在影子任务调度中模拟多功能并发超时,触发软件看门狗复位原因。",
"diagnosis": "复位原因为WDT且多个子系统同时未就绪,归因为启动状态重建不完整。",
"repair": "重新初始化热管理/ADC,并从有效SMV8记录恢复四轴运行时位置;只恢复已校验请求。",
"pass": "无需整车重新上电,多功能复测通过且无未经授权的自动动作。",
"fault_code": "POST_WATCHDOG_STATE_NOT_REBUILT",
"baseline_updates": {
"operation_stage": "NORMAL_TASK", "bench_reaction": "通风2挡与加热1挡正常运行",
"axis_positions": [35, 42, 48, 20], "active_axes": [],
"fan_running": True, "heater_level": 1, "ecu_online": True,
},
"fault_updates": {
"reset_reason": "WDT", "subsystem_ready_mask": "0x03",
"position_valid": False, "config_ready": False,
"thermal_feedback": "关闭", "request_feedback_match": False,
"ai_hint": "WDT后位置、热管理和CAN会话同时未就绪",
"operation_stage": "FAULT_REACTION", "bench_reaction": "所有负载停止,CAN短暂离线后空载上线",
"fan_running": False, "heater_level": 0, "ecu_online": False,
},
"repair_updates": {
"reset_reason": "WDT_RECOVERED", "subsystem_ready_mask": "0x1F",
"position_valid": True, "config_ready": True,
"thermal_feedback": "通风2挡 / 加热1挡",
"request_feedback_match": True,
"repair_action": "启动自检 + 子系统重建 + 受控状态恢复",
"operation_stage": "REPAIR", "bench_reaction": "CAN/ADC/PWM/位置状态正在重建",
"fan_running": False, "heater_level": 0, "ecu_online": True,
},
"verify_updates": {
"operation_stage": "RESUMING", "bench_reaction": "通风2挡与加热1挡恢复执行",
"fan_running": True, "heater_level": 1, "ecu_online": True,
"task_restored": True,
},
"passed_updates": {
"operation_stage": "COMPLETE", "bench_reaction": "原舒适任务恢复,验证结束后安全关闭",
"fan_running": False, "heater_level": 0, "ecu_online": True,
},
"bench": {
"supported": True, "target": "REAL_ECU_FAULT_HOOK",
"risk": "CONTROLLED_RESET",
"injection": "由台架故障钩子触发一次受控WDT复位,并保留部分初始化未完成状态。",
"repair": "AI验证CAN已恢复,重新初始化热管理/ADC并从SMV8恢复位置状态。",
},
},
)
SCENARIO_BY_ID = {item["id"]: item for item in SCENARIOS}
def _base_telemetry():
return {
"source": "FAULT_INJECTION_SHADOW_MODEL",
"position_pct": 61,
"target_position_pct": 61,
"position_valid": True,
"store_slot_valid": True,
"profile_crc_valid": True,
"profile_version": 8,
"expected_profile_version": 8,
"current_a": 2.1,
"current_peak_ms": 0,
"hall_resumed": True,
"adc_valid": True,
"retry_count": 0,
"thermal_request": "通风2挡 / 加热1挡",
"thermal_feedback": "通风2挡 / 加热1挡",
"fan_current_a": 1.3,
"heater_current_a": 3.2,
"config_ready": True,
"pending_start": False,
"reset_reason": "POWER_ON",
"subsystem_ready_mask": "0x1F",
"request_feedback_match": True,
"safety_protection": True,
"repair_action": "--",
"ai_hint": "请求、反馈和安全保护一致",
}
def classify_fault(telemetry):
"""Classify one of the supported field software faults from evidence."""
if (telemetry.get("reset_reason") == "WDT" and
telemetry.get("subsystem_ready_mask") != "0x1F"):
return "POST_WATCHDOG_STATE_NOT_REBUILT"
if (not telemetry.get("position_valid", True) and
telemetry.get("store_slot_valid") is False and
telemetry.get("adc_valid", False)):
return "POSITION_CALIBRATION_LOST"
if (telemetry.get("profile_crc_valid") is False and
telemetry.get("position_valid", False)):
return "MEMORY_PROFILE_NOT_RETAINED"
if (float(telemetry.get("current_a", 0)) >= 8.0 and
int(telemetry.get("current_peak_ms", 9999)) <= 120 and
telemetry.get("hall_resumed") is True):
return "FALSE_OVERLOAD_CLASSIFICATION"
if (telemetry.get("thermal_request") != "关闭" and
telemetry.get("thermal_feedback") == "关闭" and
telemetry.get("config_ready") is False and
telemetry.get("adc_valid") is True):
return "THERMAL_WAKE_SEQUENCE_MISMATCH"
if (telemetry.get("thermal_request") == "风扇OFF" and
telemetry.get("pending_start") is True and
float(telemetry.get("fan_current_a", 0)) > 0.5):
return "FAN_STOP_SEQUENCE_REENTRY"
return None
def _trend_for(case_id, phase):
count = 18
request = [70] * count
feedback = [68 + (index % 3) for index in range(count)]
current = [28 + ((index * 3) % 5) for index in range(count)]
if phase in (PHASE_INJECTED, PHASE_DIAGNOSED):
if case_id == "FI-FIELD-03":
current[7:10] = [42, 96, 40]
feedback[7:12] = [54, 52, 53, 56, 60]
elif case_id in ("FI-FIELD-04", "FI-FIELD-06"):
feedback[7:] = [8] * (count - 7)
current[7:] = [6] * (count - 7)
elif case_id == "FI-FIELD-05":
request[7:] = [8] * (count - 7)
feedback[7:11] = [52, 82, 70, 45]
feedback[11:] = [60] * (count - 11)
current[7:11] = [38, 78, 62, 34]
else:
feedback[7:] = [42] * (count - 7)
current[7:] = [18] * (count - 7)
elif phase == PHASE_REPAIRING:
feedback[7:] = [40, 35, 30, 34, 42, 50, 57, 63, 68, 70, 70]
current[7:] = [24, 20, 18, 22, 31, 34, 32, 30, 29, 28, 28]
elif phase in (PHASE_VERIFYING, PHASE_PASSED):
feedback[7:] = [40, 46, 52, 58, 63, 67, 69, 70, 70, 69, 70]
current[7:] = [25, 29, 33, 34, 32, 30, 29, 28, 28, 29, 28]
return {"request": request, "feedback": feedback, "current": current}
class SelfHealingEngine(object):
"""Time-driven demonstration engine with evidence-based classification."""
def __init__(self, clock=None):
self.clock = clock or time.time
self.reset()
def reset(self):
self.run_id = None
self.case_id = SCENARIOS[0]["id"]
self.started_epoch = None
self.phase = PHASE_IDLE
self.events = []
self.telemetry = _base_telemetry()
self.diagnosed_code = None
self.report_saved = False
self.execution_mode = "shadow"
def start(self, case_id, now=None, execution_mode="shadow"):
if case_id not in SCENARIO_BY_ID:
raise ValueError("unknown case_id: %s" % case_id)
if execution_mode not in ("shadow", "bench"):
raise ValueError("unknown execution_mode: %s" % execution_mode)
if (execution_mode == "bench" and
not (SCENARIO_BY_ID[case_id].get("bench") or {}).get(
"supported", False)):
raise ValueError("该用例没有安全、可重复的实物台架注入手段")
now = self.clock() if now is None else float(now)
self.run_id = "RUN-%s-%03d" % (
datetime.datetime.fromtimestamp(now).strftime("%Y%m%d-%H%M%S"),
int((now * 1000) % 1000),
)
self.case_id = case_id
self.started_epoch = now
self.phase = PHASE_BASELINE
self.events = []
self.telemetry = _base_telemetry()
self.telemetry.update(copy.deepcopy(
SCENARIO_BY_ID[case_id].get("baseline_updates") or {}))
self.telemetry["normal_task"] = SCENARIO_BY_ID[case_id]["normal_task"]
self.telemetry["normal_task_started_epoch"] = now
self.telemetry["normal_task_min_observe_s"] = PHASE_AT_S[
PHASE_INJECTED]
self.diagnosed_code = None
self.report_saved = False
self.execution_mode = execution_mode
if execution_mode == "bench":
self.telemetry["source"] = "REAL_BENCH_CAN"
self._append_event(PHASE_BASELINE, "基线确认",
("等待实物CAN桥接确认安全基线" if
execution_mode == "bench" else
"请求、反馈、ADC与安全保护处于一致状态"), now)
return self.snapshot(now)
def apply_external(self, phase, telemetry=None, diagnosed_code=None,
title=None, detail=None, now=None):
"""Advance a real-bench run from auditable bridge evidence."""
if self.execution_mode != "bench" or self.started_epoch is None:
return self.snapshot(now)
if phase not in PHASE_ORDER and phase != PHASE_FAILED:
raise ValueError("invalid external phase: %s" % phase)
now = self.clock() if now is None else float(now)
if telemetry:
self.telemetry.update(copy.deepcopy(telemetry))
if diagnosed_code:
self.diagnosed_code = diagnosed_code
changed = phase != self.phase
if phase == PHASE_FAILED:
self.phase = phase
elif self.phase != PHASE_FAILED:
current_index = PHASE_ORDER.index(self.phase)
target_index = PHASE_ORDER.index(phase)
if target_index >= current_index:
self.phase = phase
if changed and self.phase == phase:
self._append_event(
phase, title or PHASE_TEXT[phase],
detail or "实物台架证据已更新", now)
return self.snapshot(now)
def _append_event(self, phase, title, detail, now):
self.events.append({
"phase": phase,
"title": title,
"detail": detail,
"epoch": now,
"time": datetime.datetime.fromtimestamp(now).strftime("%H:%M:%S.%f")[:-3],
})
def _advance_to(self, new_phase, now):
if new_phase == self.phase:
return
scenario = SCENARIO_BY_ID[self.case_id]
self.phase = new_phase
if new_phase == PHASE_INJECTED:
self.telemetry.update(copy.deepcopy(scenario["fault_updates"]))
self.telemetry["normal_task_observed_s"] = PHASE_AT_S[
PHASE_INJECTED]
self._append_event(new_phase, "故障注入完成", scenario["injection"], now)
elif new_phase == PHASE_DIAGNOSED:
self.diagnosed_code = classify_fault(self.telemetry)
if self.diagnosed_code != scenario["fault_code"]:
self.phase = PHASE_FAILED
self._append_event(PHASE_FAILED, "安全停止",
"证据不足或分类不一致,未执行任何恢复动作", now)
else:
self._append_event(new_phase, "AI完成故障归因", scenario["diagnosis"], now)
elif new_phase == PHASE_REPAIRING:
self.telemetry.update(copy.deepcopy(scenario["repair_updates"]))
self._append_event(new_phase, "执行受限恢复", scenario["repair"], now)
elif new_phase == PHASE_VERIFYING:
self.telemetry.update(copy.deepcopy(
scenario.get("verify_updates") or {}))
if self.telemetry.get("target_position_pct") is not None:
self.telemetry["position_pct"] = self.telemetry[
"target_position_pct"]
self.telemetry["request_feedback_match"] = True
self.telemetry["safety_protection"] = True
self._append_event(new_phase, "修复结果闭环复测",
"同时验证功能恢复与安全保护未被绕过", now)
elif new_phase == PHASE_PASSED:
self.telemetry.update(copy.deepcopy(
scenario.get("passed_updates") or {}))
self._append_event(new_phase, "闭环验证通过", scenario["pass"], now)
def _update(self, now):
if (self.execution_mode == "bench" or self.started_epoch is None or
self.phase in (PHASE_IDLE, PHASE_FAILED)):
return
elapsed = max(0.0, now - self.started_epoch)
target = PHASE_BASELINE
for phase in PHASE_ORDER:
if elapsed >= PHASE_AT_S[phase]:
target = phase
current_index = PHASE_ORDER.index(self.phase)
target_index = PHASE_ORDER.index(target)
for index in range(current_index + 1, target_index + 1):
self._advance_to(PHASE_ORDER[index],
self.started_epoch + PHASE_AT_S[PHASE_ORDER[index]])
if self.phase == PHASE_FAILED:
break
def snapshot(self, now=None):
now = self.clock() if now is None else float(now)
self._update(now)
scenario = SCENARIO_BY_ID[self.case_id]
elapsed = 0.0 if self.started_epoch is None else max(0.0, now - self.started_epoch)
# Freeze completed run duration at the terminal event. Real-bench
# runs used to keep counting after PASS, so an already repaired case
# misleadingly appeared to have taken several minutes.
if self.phase in (PHASE_PASSED, PHASE_FAILED) and self.events:
elapsed = max(0.0, self.events[-1]["epoch"] - self.started_epoch)
return {
"run_id": self.run_id,
"case_id": self.case_id,
"phase": self.phase,
"phase_text": PHASE_TEXT[self.phase],
"elapsed_s": round(elapsed, 3),
"scenario": copy.deepcopy(scenario),
"telemetry": copy.deepcopy(self.telemetry),
"diagnosed_code": self.diagnosed_code,
"events": copy.deepcopy(self.events),
"trend": _trend_for(self.case_id, self.phase),
"automatic": True,
"execution_mode": self.execution_mode,
"injection_scope": ("REAL_BENCH_CAN_WRITE" if
self.execution_mode == "bench" else
"SHADOW_TELEMETRY_ONLY"),
"pass": self.phase == PHASE_PASSED,
}
def public_scenarios():
keys = ("id", "title", "short_title", "system", "normal_task",
"fault_reaction", "recovery_reaction", "field_basis", "symptom",
"injection", "diagnosis", "repair", "pass", "fault_code")
values = []
for item in SCENARIOS:
value = {key: item[key] for key in keys}
value["bench"] = copy.deepcopy(item.get("bench") or {
"supported": False,
"reason": "当前量产固件没有该故障的受控注入钩子;只能影子注入,不能伪装成实物故障",
})
values.append(value)
return values
def run_self_test():
for scenario in SCENARIOS:
fault = _base_telemetry()
fault.update(copy.deepcopy(scenario["fault_updates"]))
assert classify_fault(fault) == scenario["fault_code"], scenario["id"]
engine = SelfHealingEngine(clock=lambda: 1000.0)
engine.start(scenario["id"], 1000.0)
injected = engine.snapshot(1006.2)
assert injected["phase"] == PHASE_INJECTED
diagnosed = engine.snapshot(1008.7)
assert diagnosed["phase"] == PHASE_DIAGNOSED
passed = engine.snapshot(1018.2)
assert passed["phase"] == PHASE_PASSED
assert passed["pass"] is True
assert passed["telemetry"]["request_feedback_match"] is True
assert passed["telemetry"]["safety_protection"] is True
assert passed["telemetry"]["task_restored"] is True
assert passed["telemetry"]["operation_stage"] == "COMPLETE"
bench = SelfHealingEngine(clock=lambda: 2000.0)
bench.start("FI-FIELD-04", 2000.0, execution_mode="bench")
bench.apply_external(
PHASE_INJECTED, {"request_feedback_match": False},
title="实物故障已写入", now=2001.0)
bench.apply_external(
PHASE_DIAGNOSED, diagnosed_code="THERMAL_WAKE_SEQUENCE_MISMATCH",
now=2002.0)
bench.apply_external(PHASE_REPAIRING, now=2003.0)
bench.apply_external(PHASE_VERIFYING, now=2004.0)
bench.apply_external(
PHASE_PASSED,
{"request_feedback_match": True, "safety_protection": True},
now=2005.0)
bench_passed = bench.snapshot(2005.0)
assert bench_passed["phase"] == PHASE_PASSED
assert bench.snapshot(2999.0)["elapsed_s"] == 5.0
assert bench_passed["execution_mode"] == "bench"
assert bench_passed["injection_scope"] == "REAL_BENCH_CAN_WRITE"
assert public_scenarios()[3]["bench"]["supported"] is True
assert sum(1 for value in public_scenarios()
if value["bench"]["supported"]) == len(SCENARIOS)
return True
if __name__ == "__main__":
run_self_test()
print("seat_self_healing_model self-test PASS")
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,331 @@
"""Keep the ZCANPRO seat-function bridge online while the web console runs.
``zcanpro`` is injected by ZCANPRO and cannot be imported by a normal Python
process. The manager therefore treats the bridge heartbeat as the source of
truth. When the heartbeat is stale it starts ZCANPRO if necessary and uses a
small, deterministic Win32 UI sequence to run the configured extension
script. It never opens/closes the CAN device itself and never sends CAN data.
"""
from __future__ import unicode_literals
import configparser
import ctypes
from ctypes import wintypes
import os
from pathlib import Path
import subprocess
import threading
import time
class ZcanBridgeManager(object):
"""Heartbeat monitor and best-effort ZCANPRO extension-script starter."""
def __init__(self, status_path, script_path, heartbeat_stale_s=3.0,
poll_s=1.0, retry_cooldown_s=15.0,
required_version=None, zcanpro_exe=None,
script_config_path=None, auto_start=False):
self.status_path = Path(status_path)
self.script_path = Path(script_path).resolve()
self.zcanpro_exe = Path(zcanpro_exe or "__unconfigured_zcanpro__")
self.script_config_path = Path(
script_config_path or "__unconfigured_zcanpro_script_config__")
self.heartbeat_stale_s = float(heartbeat_stale_s)
self.poll_s = float(poll_s)
self.retry_cooldown_s = float(retry_cooldown_s)
self.required_version = required_version
self.lock = threading.RLock()
self.stop_event = threading.Event()
self.wake_event = threading.Event()
self.thread = None
self.state = "IDLE"
self.message = "等待前端服务启动"
self.attempts = 0
self.auto_started = False
self.last_attempt_epoch = None
self.last_online_epoch = None
self.zcan_window_found = False
# UI automation is necessarily visible. Arm one startup attempt, but
# never keep foregrounding ZCANPRO on a timer after a failed attempt.
self.retry_requested = bool(auto_start)
def start(self):
with self.lock:
if self.thread and self.thread.is_alive():
return
self.stop_event.clear()
self.thread = threading.Thread(
target=self._monitor, name="zcan-bridge-manager", daemon=True)
self.thread.start()
def ensure_async(self):
with self.lock:
self.retry_requested = True
self.wake_event.set()
def stop(self):
self.stop_event.set()
self.wake_event.set()
def snapshot(self):
age = self._heartbeat_age()
with self.lock:
return {
"state": self.state,
"message": self.message,
"attempts": self.attempts,
"auto_started": self.auto_started,
"last_attempt_epoch": self.last_attempt_epoch,
"last_online_epoch": self.last_online_epoch,
"heartbeat_age_s": None if age is None else round(age, 2),
"zcan_window_found": self.zcan_window_found,
"retry_armed": self.retry_requested,
"zcan_exe": str(self.zcanpro_exe),
"script_config_path": str(self.script_config_path),
"script_path": str(self.script_path),
"required_version": self.required_version,
}
def _set_state(self, state, message, **updates):
with self.lock:
self.state = state
self.message = message
for key, value in updates.items():
setattr(self, key, value)
def _heartbeat_age(self):
try:
import json
with self.status_path.open("r", encoding="utf-8") as stream:
status = json.load(stream)
heartbeat = float(status.get("heartbeat_epoch", 0) or 0)
if heartbeat <= 0:
return None
return max(0.0, time.time() - heartbeat)
except (OSError, ValueError, TypeError):
return None
def _heartbeat_online(self):
age = self._heartbeat_age()
if age is None or age > self.heartbeat_stale_s:
return False
if self.required_version:
try:
import json
with self.status_path.open("r", encoding="utf-8") as stream:
status = json.load(stream)
return status.get("bridge_version") == self.required_version
except (OSError, ValueError, TypeError):
return False
return True
def _take_retry_request(self):
with self.lock:
if not self.retry_requested:
return False
self.retry_requested = False
return True
def _monitor(self):
while not self.stop_event.is_set():
if self._heartbeat_online():
self._set_state(
"ONLINE", "CAN桥接在线,由ZCANPRO扩展脚本持续提供数据",
last_online_epoch=time.time())
else:
with self.lock:
# A bridge that was genuinely online gets one automatic
# recovery attempt. A failed recovery is not repeated
# until an explicit Start action calls ``ensure_async``.
if self.state == "ONLINE":
self.retry_requested = True
if self._take_retry_request():
self._attempt_start()
elif self.state not in ("STARTING", "ERROR"):
self._set_state(
"WAITING",
"桥接离线;后台弹窗重试已暂停,启动诊断时仅重试一次")
self.wake_event.wait(self.poll_s)
self.wake_event.clear()
def _attempt_start(self):
started = time.time()
with self.lock:
self.attempts += 1
self.last_attempt_epoch = started
self._set_state("STARTING", "正在自动启动ZCANPRO座椅CAN桥接")
try:
self._ensure_extension_config()
hwnd, launched = self._find_or_launch_zcanpro()
self._set_state(
"STARTING", "已找到ZCANPRO,正在运行05座椅功能桥接脚本",
zcan_window_found=bool(hwnd))
if not hwnd:
raise RuntimeError("未找到ZCANPRO主窗口")
self._run_extension_script(hwnd, minimize_after=launched)
deadline = time.time() + 8.0
while time.time() < deadline and not self.stop_event.is_set():
if self._heartbeat_online():
self._set_state(
"ONLINE", "CAN桥接已由前端自动拉起并保持在线",
auto_started=True, last_online_epoch=time.time())
return
self.stop_event.wait(0.25)
raise RuntimeError(
"扩展脚本未产生匹配版本心跳;请确认CAN设备已打开且旧脚本已停止")
except Exception as error:
self._set_state(
"ERROR",
"CAN桥接自动启动失败:%s;已停止后台弹窗重试" % error)
def _ensure_extension_config(self):
if not self.script_path.is_file():
raise RuntimeError("桥接脚本不存在:%s" % self.script_path)
if not self.zcanpro_exe.is_file():
raise RuntimeError("未配置或未安装ZCANPRO:%s" % self.zcanpro_exe)
expected = str(self.script_path).replace("\\", "/")
parser = configparser.RawConfigParser()
if self.script_config_path.is_file():
parser.read(str(self.script_config_path), encoding="utf-8")
if not parser.has_section("Script"):
parser.add_section("Script")
if parser.get("Script", "last_file", fallback="") == expected:
return
parser.set("Script", "last_file", expected)
self.script_config_path.parent.mkdir(parents=True, exist_ok=True)
with self.script_config_path.open("w", encoding="utf-8") as stream:
parser.write(stream, space_around_delimiters=False)
@staticmethod
def _user32():
if os.name != "nt":
raise RuntimeError("自动桥接仅支持Windows/ZCANPRO")
return ctypes.windll.user32
def _find_zcanpro_window(self):
user32 = self._user32()
matches = []
callback_type = ctypes.WINFUNCTYPE(wintypes.BOOL, wintypes.HWND,
wintypes.LPARAM)
@callback_type
def collect(hwnd, _):
if not user32.IsWindowVisible(hwnd):
return True
length = user32.GetWindowTextLengthW(hwnd)
if length <= 0:
return True
value = ctypes.create_unicode_buffer(length + 1)
user32.GetWindowTextW(hwnd, value, length + 1)
if value.value.strip().upper() == "ZCANPRO":
matches.append(hwnd)
return True
user32.EnumWindows(collect, 0)
return matches[0] if matches else None
def _find_or_launch_zcanpro(self):
hwnd = self._find_zcanpro_window()
if hwnd:
return hwnd, False
startup = subprocess.STARTUPINFO()
startup.dwFlags |= subprocess.STARTF_USESHOWWINDOW
startup.wShowWindow = 7 # SW_SHOWMINNOACTIVE
subprocess.Popen([str(self.zcanpro_exe)], cwd=str(self.zcanpro_exe.parent),
startupinfo=startup)
deadline = time.time() + 20.0
while time.time() < deadline and not self.stop_event.is_set():
hwnd = self._find_zcanpro_window()
if hwnd:
return hwnd, True
self.stop_event.wait(0.25)
return None, True
def _client_point(self, hwnd, x_ratio, y_ratio):
user32 = self._user32()
rect = wintypes.RECT()
if not user32.GetClientRect(hwnd, ctypes.byref(rect)):
raise RuntimeError("无法读取ZCANPRO窗口尺寸")
point = wintypes.POINT(
int(max(1, rect.right - rect.left) * x_ratio),
int(max(1, rect.bottom - rect.top) * y_ratio))
if not user32.ClientToScreen(hwnd, ctypes.byref(point)):
raise RuntimeError("无法换算ZCANPRO窗口坐标")
return point.x, point.y
def _click_client(self, hwnd, x_ratio, y_ratio):
user32 = self._user32()
previous = wintypes.POINT()
user32.GetCursorPos(ctypes.byref(previous))
x, y = self._client_point(hwnd, x_ratio, y_ratio)
user32.SetCursorPos(x, y)
user32.mouse_event(0x0002, 0, 0, 0, 0) # MOUSEEVENTF_LEFTDOWN
time.sleep(0.06)
user32.mouse_event(0x0004, 0, 0, 0, 0) # MOUSEEVENTF_LEFTUP
user32.SetCursorPos(previous.x, previous.y)
def _press_escape(self):
user32 = self._user32()
user32.keybd_event(0x1B, 0, 0, 0)
time.sleep(0.04)
user32.keybd_event(0x1B, 0, 0x0002, 0)
def _run_extension_script(self, hwnd, minimize_after=False):
user32 = self._user32()
previous_foreground = user32.GetForegroundWindow()
was_minimized = bool(user32.IsIconic(hwnd))
try:
user32.ShowWindow(hwnd, 9) # SW_RESTORE
user32.SetForegroundWindow(hwnd)
time.sleep(0.25)
# Normalize to the main window, then use the stable ZCANPRO menu
# path: 高级功能 -> 扩展脚本 -> 运行.
# Opening the extension panel is followed by a stop/start cycle.
# A version-mismatched bridge can still have a fresh heartbeat,
# but ZCANPRO disables Run while that old script is active. Stop
# is harmless when the script is already idle and makes upgrades
# deterministic without requiring operator interaction.
self._press_escape()
time.sleep(0.15)
self._click_client(hwnd, 0.568, 0.057)
time.sleep(0.45)
# “扩展脚本” is the penultimate item in the Advanced menu. On the
# validated 1100x700 layout its centre is y≈365 (0.522h).
self._click_client(hwnd, 0.564, 0.522)
time.sleep(0.8)
self._click_client(hwnd, 0.751, 0.183) # Stop old/stale script.
time.sleep(0.7)
self._click_client(hwnd, 0.678, 0.183)
finally:
# Return focus immediately after the single launch sequence. The
# heartbeat is awaited in the background without holding ZCANPRO
# in front of the self-healing console.
if minimize_after or was_minimized:
user32.ShowWindow(hwnd, 6) # SW_MINIMIZE
if (previous_foreground and previous_foreground != hwnd and
user32.IsWindow(previous_foreground)):
user32.SetForegroundWindow(previous_foreground)
def _close_extension_panel(self, hwnd):
try:
time.sleep(0.25)
self._click_client(hwnd, 0.777, 0.124)
except Exception:
# The bridge is already online; failing to hide its panel must not
# downgrade the actual connection state.
pass
def run_self_test(status_path, script_path, zcanpro_exe=None,
script_config_path=None):
manager = ZcanBridgeManager(
status_path, script_path, zcanpro_exe=zcanpro_exe,
script_config_path=script_config_path)
assert manager.script_path.is_file()
assert manager.heartbeat_stale_s > 0
assert manager.snapshot()["state"] == "IDLE"
assert manager._take_retry_request() is False
manager.ensure_async()
assert manager._take_retry_request() is True
return True