feat: integrate calibration extension and self-repair modules
This commit is contained in:
@@ -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
|
||||
Reference in New Issue
Block a user