2233 lines
105 KiB
Python
2233 lines
105 KiB
Python
"""ZCANPRO bridge for the standalone selectable seat-function GUI.
|
||
|
||
Run this file in ZCANPRO's extension-script window and leave it running. The
|
||
normal Windows GUI writes armed JSON requests into ``seat_function_runtime``;
|
||
this bridge is the only process that imports and calls ``zcanpro``.
|
||
"""
|
||
|
||
from __future__ import unicode_literals
|
||
|
||
import csv
|
||
import datetime
|
||
import importlib
|
||
import json
|
||
import math
|
||
import os
|
||
import sys
|
||
import time
|
||
|
||
import zcanpro
|
||
|
||
if "hil_common" in sys.modules:
|
||
_hil_common = importlib.reload(sys.modules["hil_common"])
|
||
else:
|
||
import hil_common as _hil_common
|
||
command_payload = _hil_common.command_payload
|
||
frame_id = _hil_common.frame_id
|
||
hex_data = _hil_common.hex_data
|
||
parse_301 = _hil_common.parse_301
|
||
parse_302 = _hil_common.parse_302
|
||
parse_303 = _hil_common.parse_303
|
||
parse_304 = _hil_common.parse_304
|
||
parse_305 = _hil_common.parse_305
|
||
|
||
|
||
BASE_DIR = os.path.dirname(os.path.abspath(__file__))
|
||
RUNTIME_DIR = os.path.join(BASE_DIR, "seat_function_runtime")
|
||
PENDING_DIR = os.path.join(RUNTIME_DIR, "requests", "pending")
|
||
PROCESSING_DIR = os.path.join(RUNTIME_DIR, "requests", "processing")
|
||
COMPLETED_DIR = os.path.join(RUNTIME_DIR, "requests", "completed")
|
||
STATUS_PATH = os.path.join(RUNTIME_DIR, "bridge_status.json")
|
||
EMERGENCY_PATH = os.path.join(RUNTIME_DIR, "emergency_stop.json")
|
||
LOG_ROOT = os.path.join(BASE_DIR, "logs", "05_seat_function_gui")
|
||
CONFIRMED_MAX_HALL = (480, 374, 917, 647)
|
||
POSITION_TIMEOUT_MS = 30000
|
||
NORMAL_TASK_MIN_OBSERVE_S = 5.0
|
||
|
||
# Bench-only software fault injection service. The user-authorized bench
|
||
# assumption is that the ECU/test-agent implements 0x2F0 request and 0x2F1
|
||
# acknowledgement frames. The production application path never emits these
|
||
# frames; only an armed self-healing request can reach this service.
|
||
SELF_HEAL_HOOK_REQUEST_ID = 0x2F0
|
||
SELF_HEAL_HOOK_RESPONSE_ID = 0x2F1
|
||
SELF_HEAL_HOOK_RESULT_NAMES = {
|
||
0: "OK", 1: "PRECONDITION", 2: "EXECUTION", 3: "TIMEOUT",
|
||
4: "UNAUTHORIZED", 5: "UNSUPPORTED", 6: "BUSY",
|
||
}
|
||
SELF_HEAL_HOOK_EVIDENCE_NAMES = {
|
||
0xE0: "位置有效状态在注入前已不满足",
|
||
0xE1: "被中断电机未在时限内停止",
|
||
0xE2: "位置失效状态写入失败",
|
||
0xE3: "滑轨未在过载探测前停止",
|
||
0xE4: "受控过载探测启动失败",
|
||
}
|
||
SELF_HEAL_HOOK_CASES = {
|
||
"FI-FIELD-01": {
|
||
"index": 1, "fault_code": "POSITION_CALIBRATION_LOST",
|
||
"normal_task": "靠背正在移动到驾驶记忆角度",
|
||
"fault_reaction": "靠背移动中途停止,绝对位置命令被拒绝",
|
||
"resume_reaction": "靠背单轴回最近端重建位置后,继续完成原驾驶记忆角度",
|
||
"demo_motor": 2, "demo_endpoints": (1000, 9000),
|
||
"normal_observe_s": NORMAL_TASK_MIN_OBSERVE_S,
|
||
"inject": "中断SMV8 dirty→clean提交并使PositionValid失效",
|
||
"repair": "靠背单轴HOME_KNOWN_RANGE + SMV8双槽提交 + 位置状态重建",
|
||
"fault": {"position_valid": False, "store_slot_valid": False,
|
||
"request_feedback_match": False,
|
||
"ai_hint": "实物PositionValid=0,Hall/电流链路保持健康"},
|
||
"repaired": {"position_valid": True, "store_slot_valid": True},
|
||
},
|
||
"FI-FIELD-02": {
|
||
"index": 2, "fault_code": "MEMORY_PROFILE_NOT_RETAINED",
|
||
"normal_task": "用户调整腿托后调用刚保存的舒适位置",
|
||
"fault_reaction": "CRC校验失败,腿托调用目标被挂起",
|
||
"resume_reaction": "重建双槽后继续执行原腿托舒适位置",
|
||
"demo_motor": 3, "demo_delta": 2200, "prelude_delta": 2800,
|
||
"normal_observe_s": NORMAL_TASK_MIN_OBSERVE_S,
|
||
"inject": "向真实SMV8备用槽写入更新序号但CRC损坏的记录",
|
||
"repair": "保留旧有效槽并以更高序号重新原子提交SMV8冗余记录",
|
||
"fault": {"profile_crc_valid": False, "profile_version": 8,
|
||
"expected_profile_version": 8,
|
||
"request_feedback_match": False,
|
||
"ai_hint": "实物Work Flash最新槽CRC失败,旧槽回退仍有效"},
|
||
"repaired": {"profile_crc_valid": True, "profile_version": 8},
|
||
},
|
||
"FI-FIELD-03": {
|
||
"index": 3, "fault_code": "FALSE_OVERLOAD_CLASSIFICATION",
|
||
"normal_task": "水平滑轨正在移动到预设驾驶位置",
|
||
"fault_reaction": "瞬态电流被误判,滑轨在目标前停止并保持静止",
|
||
"resume_reaction": "短距健康复测通过后,继续移动到原驾驶位置目标",
|
||
"demo_motor": 0, "demo_endpoints": (1000, 9000),
|
||
"normal_observe_s": NORMAL_TASK_MIN_OBSERVE_S,
|
||
"inject": "向电流判定入口写入80 ms瞬态峰值并保留Hall增长",
|
||
"repair": "保持停止并清除瞬态误判状态;随后只进行一次短距健康复测",
|
||
"fault": {"current_peak_ms": 80, "hall_resumed": True,
|
||
"request_feedback_match": False,
|
||
"ai_hint": "实物瞬态电流峰值后Hall继续增长"},
|
||
"repaired": {"current_peak_ms": 0, "retry_count": 1},
|
||
},
|
||
"FI-FIELD-05": {
|
||
"index": 5, "fault_code": "FAN_STOP_SEQUENCE_REENTRY",
|
||
"normal_task": "通风运行中,用户发出关闭指令",
|
||
"fault_reaction": "风扇先停止后异常自行升速重启",
|
||
"resume_reaction": "三次启停复测后恢复用户要求的OFF",
|
||
"normal_observe_s": 5.5,
|
||
"inject": "保留pending-start事件并在ON→OFF后触发一次再启动",
|
||
"repair": "PWM安全关闭 + 取消全部延迟事件 + 三次启停复测",
|
||
"fault": {"thermal_request": "风扇OFF",
|
||
"thermal_feedback": "异常升速", "pending_start": True,
|
||
"request_feedback_match": False,
|
||
"ai_hint": "实物OFF已确认但pending-start仍执行"},
|
||
"repaired": {"thermal_feedback": "关闭", "pending_start": False},
|
||
},
|
||
"FI-FIELD-06": {
|
||
"index": 6, "fault_code": "POST_WATCHDOG_STATE_NOT_REBUILT",
|
||
"normal_task": "通风2挡与加热1挡正在执行",
|
||
"fault_reaction": "WDT后负载停止,CAN短暂离线且请求未恢复",
|
||
"resume_reaction": "重建子系统并恢复原舒适请求",
|
||
"normal_observe_s": 6.0,
|
||
"inject": "触发一次受控WDT并保留部分子系统未初始化状态",
|
||
"repair": "重建CAN/ADC/PWM和位置状态,仅恢复已校验请求",
|
||
"fault": {"reset_reason": "WDT", "subsystem_ready_mask": "0x03",
|
||
"position_valid": False, "config_ready": False,
|
||
"request_feedback_match": False,
|
||
"ai_hint": "实物WDT后多个子系统未就绪"},
|
||
"repaired": {"reset_reason": "WDT_RECOVERED",
|
||
"subsystem_ready_mask": "0x1F",
|
||
"position_valid": True, "config_ready": True},
|
||
},
|
||
}
|
||
POSITION_HALL_TOLERANCE = 2
|
||
HOME_KNOWN_RANGE_KEY = 0x4B52
|
||
HOME_LEG_TIMEOUT_S = 120
|
||
SIGNAL_WINDOW_S = 2.5
|
||
HEATER_CURRENT_ACTIVE_DELTA_RAW = 500
|
||
FAN_CURRENT_ACTIVE_DELTA_RAW = 40
|
||
BRIDGE_VERSION = "2026-08-07-reset-home-far-target-v15"
|
||
HARDWARE_MOTOR_PINS = {
|
||
0: ("JA1-4", "JA1-5", "JC1-9"),
|
||
1: ("JB1-6", "JB1-8", "JC1-15"),
|
||
2: ("JB1-12", "JB1-15", "JC1-17"),
|
||
3: ("JB1-1", "JB1-3", "JC1-12"),
|
||
}
|
||
HARDWARE_ENABLED_PINS = (
|
||
"JA1-1", "JA1-4", "JA1-5", "JA1-10", "JA1-11", "JA1-13",
|
||
"JB1-1", "JB1-3", "JB1-6", "JB1-8", "JB1-12", "JB1-15",
|
||
"JB1-18", "JC1-1", "JC1-6", "JC1-9", "JC1-12", "JC1-15",
|
||
"JC1-17", "JC1-18", "JC1-21")
|
||
|
||
stop_task = False
|
||
|
||
|
||
def z_notify(event_type, obj):
|
||
del obj
|
||
global stop_task
|
||
if event_type == "stop":
|
||
stop_task = True
|
||
|
||
|
||
def _atomic_json(path, value):
|
||
temporary = "%s.bridge.%d.%d.tmp" % (
|
||
path, os.getpid(), int(time.time() * 1000000))
|
||
try:
|
||
with open(temporary, "w", encoding="utf-8") as stream:
|
||
json.dump(value, stream, ensure_ascii=False, indent=2,
|
||
sort_keys=True, default=str)
|
||
stream.flush()
|
||
os.fsync(stream.fileno())
|
||
for attempt in range(8):
|
||
try:
|
||
os.replace(temporary, path)
|
||
return
|
||
except PermissionError:
|
||
if attempt >= 7:
|
||
raise
|
||
# The web console polls this file frequently. Windows can
|
||
# briefly deny replacement while a reader is closing it.
|
||
time.sleep(0.01 * (attempt + 1))
|
||
finally:
|
||
try:
|
||
os.remove(temporary)
|
||
except OSError:
|
||
pass
|
||
|
||
|
||
def _read_json(path):
|
||
try:
|
||
with open(path, "r", encoding="utf-8") as stream:
|
||
return json.load(stream)
|
||
except (OSError, ValueError):
|
||
return None
|
||
|
||
|
||
class RunLogger(object):
|
||
def __init__(self, request):
|
||
timestamp = time.strftime("%Y%m%d_%H%M%S")
|
||
suffix = request["request_id"].split("_")[-1]
|
||
self.run_dir = os.path.join(LOG_ROOT, "%s_%s" % (timestamp, suffix))
|
||
os.makedirs(self.run_dir, exist_ok=True)
|
||
self.started = time.monotonic()
|
||
self.raw_stream = open(os.path.join(self.run_dir, "raw_can.csv"),
|
||
"w", newline="", encoding="utf-8-sig")
|
||
self.raw_writer = csv.DictWriter(
|
||
self.raw_stream,
|
||
fieldnames=["local_time", "elapsed_s", "direction", "can_id",
|
||
"dlc", "data", "adapter_timestamp_us", "note"])
|
||
self.raw_writer.writeheader()
|
||
self.events = []
|
||
self.request = request
|
||
|
||
def frame(self, direction, can_id, data, timestamp_us=0, note=""):
|
||
self.raw_writer.writerow({
|
||
"local_time": datetime.datetime.now().isoformat(
|
||
timespec="milliseconds"),
|
||
"elapsed_s": "%.6f" % (time.monotonic() - self.started),
|
||
"direction": direction,
|
||
"can_id": "0x%03X" % int(can_id),
|
||
"dlc": len(data),
|
||
"data": hex_data(data),
|
||
"adapter_timestamp_us": int(timestamp_us or 0),
|
||
"note": note,
|
||
})
|
||
self.raw_stream.flush()
|
||
|
||
def event(self, event_type, value):
|
||
self.events.append({
|
||
"elapsed_s": round(time.monotonic() - self.started, 6),
|
||
"type": event_type,
|
||
"value": value,
|
||
})
|
||
|
||
def finish(self, result, details):
|
||
summary_row = {
|
||
"TestID": self.request["request_id"],
|
||
"Result": result,
|
||
"Actual": details.get("summary", ""),
|
||
"Notes": "See report.json and operator_observation.json",
|
||
}
|
||
with open(os.path.join(self.run_dir, "summary.csv"), "w",
|
||
newline="", encoding="utf-8-sig") as stream:
|
||
writer = csv.DictWriter(
|
||
stream, fieldnames=["TestID", "Result", "Actual", "Notes"])
|
||
writer.writeheader()
|
||
writer.writerow(summary_row)
|
||
report = {
|
||
"schema": "seat-function-report-v1",
|
||
"generated_local": datetime.datetime.now().isoformat(
|
||
timespec="seconds"),
|
||
"request": self.request,
|
||
"result": result,
|
||
"details": details,
|
||
"events": self.events,
|
||
"operator_observation": "See operator_observation.json after GUI review",
|
||
}
|
||
_atomic_json(os.path.join(self.run_dir, "report.json"), report)
|
||
with open(os.path.join(self.run_dir,
|
||
"operator_observation_template.txt"), "w",
|
||
encoding="utf-8-sig") as stream:
|
||
stream.write("实际动作 PASS/FAIL/BLOCKED:\n")
|
||
stream.write("观察说明:\n")
|
||
self.raw_stream.flush()
|
||
self.raw_stream.close()
|
||
|
||
|
||
class SeatFunctionBridge(object):
|
||
def __init__(self):
|
||
for path in (RUNTIME_DIR, PENDING_DIR, PROCESSING_DIR, COMPLETED_DIR,
|
||
LOG_ROOT):
|
||
os.makedirs(path, exist_ok=True)
|
||
buses = zcanpro.get_buses()
|
||
if not buses:
|
||
raise RuntimeError("ZCANPRO中没有已启动的CAN通道")
|
||
self.bus = buses[0]
|
||
self.bus_id = self.bus["busID"]
|
||
self.sequence = int(time.time() * 1000.0) & 0xFFFF
|
||
self.latest_system = None
|
||
self.latest_motors = [None, None, None, None]
|
||
self.latest_diagnostics = [None, None, None, None]
|
||
self.latest_currents = [None, None, None, None]
|
||
self.latest_thermal_current = None
|
||
self.latest_thermal = None
|
||
self.frame_epochs = {}
|
||
self.motor_current_history = [[], [], [], []]
|
||
self.motor_hall_history = [[], [], [], []]
|
||
self.fan_current_history = []
|
||
self.heater_current_history = []
|
||
self.bridge_state = "IDLE"
|
||
self.current_request_id = None
|
||
self.last_result = None
|
||
self.messages = []
|
||
self.logger = None
|
||
self.last_emergency_id = None
|
||
self.last_status_write = 0.0
|
||
self.self_healing_progress = None
|
||
self.hardware_diagnostic_progress = None
|
||
self.message("桥接已启动:%s" % str(self.bus))
|
||
|
||
def message(self, value):
|
||
text = str(value)
|
||
zcanpro.write_log(text)
|
||
stamp = datetime.datetime.now().strftime("%H:%M:%S")
|
||
self.messages.append("[%s] %s" % (stamp, text))
|
||
self.messages = self.messages[-40:]
|
||
if self.logger is not None:
|
||
self.logger.event("message", text)
|
||
|
||
@staticmethod
|
||
def _append_history(history, sample, now):
|
||
history.append((now, sample))
|
||
cutoff = now - SIGNAL_WINDOW_S
|
||
while history and history[0][0] < cutoff:
|
||
history.pop(0)
|
||
|
||
@staticmethod
|
||
def _numeric_summary(history, fields):
|
||
summary = {"sample_count": len(history)}
|
||
for field in fields:
|
||
values = [sample.get(field) for _, sample in history
|
||
if sample.get(field) is not None]
|
||
summary[field + "_min"] = min(values) if values else None
|
||
summary[field + "_max"] = max(values) if values else None
|
||
return summary
|
||
|
||
def signal_window(self):
|
||
now = time.time()
|
||
cutoff = now - SIGNAL_WINDOW_S
|
||
histories = (self.motor_current_history + self.motor_hall_history +
|
||
[self.fan_current_history, self.heater_current_history])
|
||
for history in histories:
|
||
while history and history[0][0] < cutoff:
|
||
history.pop(0)
|
||
|
||
motors = []
|
||
for motor in range(4):
|
||
current = self._numeric_summary(
|
||
self.motor_current_history[motor], ("raw", "delta"))
|
||
current["valid_count"] = sum(
|
||
1 for _, sample in self.motor_current_history[motor]
|
||
if sample.get("sample_valid"))
|
||
current["baseline_valid_count"] = sum(
|
||
1 for _, sample in self.motor_current_history[motor]
|
||
if sample.get("baseline_valid"))
|
||
hall = self._numeric_summary(
|
||
self.motor_hall_history[motor], ("hall", "speed_pps_x100"))
|
||
motors.append({"current": current, "hall": hall})
|
||
|
||
fan = self._numeric_summary(self.fan_current_history, ("raw",))
|
||
fan["valid_count"] = sum(
|
||
1 for _, sample in self.fan_current_history
|
||
if sample.get("valid"))
|
||
heater = self._numeric_summary(self.heater_current_history, ("raw",))
|
||
heater["valid_count"] = sum(
|
||
1 for _, sample in self.heater_current_history
|
||
if sample.get("valid"))
|
||
return {
|
||
"window_s": SIGNAL_WINDOW_S,
|
||
"generated_epoch": now,
|
||
"motors": motors,
|
||
"fan": fan,
|
||
"heater": heater,
|
||
}
|
||
|
||
def next_sequence(self):
|
||
value = self.sequence
|
||
self.sequence = (self.sequence + 1) & 0xFFFF
|
||
return value
|
||
|
||
def publish(self, force=False):
|
||
now = time.monotonic()
|
||
if not force and (now - self.last_status_write) < 0.2:
|
||
return
|
||
value = {
|
||
"schema": "seat-function-bridge-status-v1",
|
||
"bridge_version": BRIDGE_VERSION,
|
||
"heartbeat_epoch": time.time(),
|
||
"bridge_state": self.bridge_state,
|
||
"current_request_id": self.current_request_id,
|
||
"bus": self.bus,
|
||
"system": self.latest_system,
|
||
"motors": self.latest_motors,
|
||
"diagnostics": self.latest_diagnostics,
|
||
"currents": self.latest_currents,
|
||
"thermal_current": self.latest_thermal_current,
|
||
"thermal": self.latest_thermal,
|
||
"frame_epochs": self.frame_epochs,
|
||
"signal_window": self.signal_window(),
|
||
"last_result": self.last_result,
|
||
"self_healing_progress": self.self_healing_progress,
|
||
"hardware_diagnostic": self.hardware_diagnostic_progress,
|
||
"messages": self.messages[-12:],
|
||
}
|
||
_atomic_json(STATUS_PATH, value)
|
||
self.last_status_write = now
|
||
|
||
def transmit(self, can_id, data, note=""):
|
||
values = [int(item) & 0xFF for item in data]
|
||
if self.logger is not None:
|
||
self.logger.frame("TX", can_id, values, note=note)
|
||
frame = {"can_id": int(can_id), "is_canfd": 0, "canfd_brs": 0,
|
||
"data": values}
|
||
if not zcanpro.transmit(self.bus_id, [frame]):
|
||
raise RuntimeError("发送失败 0x%03X %s" %
|
||
(can_id, hex_data(values)))
|
||
|
||
def receive(self):
|
||
ok, frames = zcanpro.receive(self.bus_id)
|
||
if not ok or not frames:
|
||
return []
|
||
output = []
|
||
for frame in frames:
|
||
received_epoch = time.time()
|
||
can_id = frame_id(frame)
|
||
data = list(frame.get("data", []))
|
||
self.frame_epochs["0x%03X" % can_id] = received_epoch
|
||
if self.logger is not None:
|
||
self.logger.frame("RX", can_id, data,
|
||
frame.get("timestamp_us", 0))
|
||
if can_id == 0x301:
|
||
self.latest_system = parse_301(data)
|
||
elif can_id == 0x302:
|
||
value = parse_302(data)
|
||
if value is not None and 0 <= value["motor"] < 4:
|
||
self.latest_motors[value["motor"]] = value
|
||
self.frame_epochs["0x302:M%d" % value["motor"]] = \
|
||
received_epoch
|
||
elif can_id == 0x303:
|
||
value = parse_303(data)
|
||
if value is not None and 0 <= value["motor"] < 4:
|
||
self.latest_diagnostics[value["motor"]] = value
|
||
motor = value["motor"]
|
||
self.frame_epochs["0x303:M%d" % motor] = received_epoch
|
||
self._append_history(self.motor_hall_history[motor],
|
||
value, received_epoch)
|
||
elif can_id == 0x304:
|
||
value = parse_304(data)
|
||
if value is not None and 0 <= value["motor"] < 4:
|
||
self.latest_currents[value["motor"]] = value
|
||
motor = value["motor"]
|
||
self.frame_epochs["0x304:M%d" % motor] = received_epoch
|
||
self._append_history(self.motor_current_history[motor],
|
||
value, received_epoch)
|
||
elif can_id == 0x305:
|
||
value = parse_305(data)
|
||
if value is not None:
|
||
self.latest_thermal_current = value
|
||
self._append_history(self.fan_current_history, {
|
||
"raw": value["fan_raw"],
|
||
"valid": value["fan_sample_valid"],
|
||
}, received_epoch)
|
||
self._append_history(self.heater_current_history, {
|
||
"raw": value["heater_raw"],
|
||
"valid": value["heater_sample_valid"],
|
||
}, received_epoch)
|
||
elif can_id == 0x4FA and len(data) >= 1:
|
||
self.latest_thermal = data
|
||
output.append((can_id, data))
|
||
return output
|
||
|
||
def drain(self):
|
||
for _ in range(12):
|
||
if not self.receive():
|
||
break
|
||
|
||
def check_emergency(self):
|
||
request = _read_json(EMERGENCY_PATH)
|
||
if request is None:
|
||
return False
|
||
request_id = request.get("request_id")
|
||
if request_id == self.last_emergency_id:
|
||
return False
|
||
self.last_emergency_id = request_id
|
||
valid = (request.get("schema") == "seat-function-emergency-v1" and
|
||
float(request.get("armed_until_epoch", 0)) >= time.time())
|
||
if not valid:
|
||
self.message("忽略过期或无效的STOP请求 %s" % request_id)
|
||
return False
|
||
self.message("执行GUI紧急STOP_ALL %s" % request_id)
|
||
sequence = self.next_sequence()
|
||
self.transmit(0x300, command_payload(0x09, 0, 0, sequence),
|
||
"GUI EMERGENCY STOP_ALL")
|
||
if request.get("thermal_off", True):
|
||
self.transmit(0x42A, [0x0A, 0, 0, 0, 0, 0, 0, 0],
|
||
"GUI emergency thermal off")
|
||
return True
|
||
|
||
def wait(self, predicate, timeout_s, allow_emergency=True):
|
||
deadline = time.monotonic() + timeout_s
|
||
while time.monotonic() < deadline and not stop_task:
|
||
if allow_emergency and self.check_emergency():
|
||
return "EMERGENCY", None
|
||
for can_id, data in self.receive():
|
||
value = predicate(can_id, data)
|
||
if value is not None:
|
||
return "OK", value
|
||
self.publish()
|
||
time.sleep(0.005)
|
||
return "TIMEOUT", None
|
||
|
||
def send_command(self, payload, expected_ack, timeout_s=2.0, note=""):
|
||
sequence = payload[2] | (payload[3] << 8)
|
||
self.drain()
|
||
self.transmit(0x300, payload, note)
|
||
|
||
def predicate(can_id, data):
|
||
if can_id != 0x301:
|
||
return None
|
||
value = parse_301(data)
|
||
if value is not None and value["sequence"] == sequence and \
|
||
value["ack"] == expected_ack:
|
||
return value
|
||
return None
|
||
|
||
state, value = self.wait(predicate, timeout_s)
|
||
if self.logger is not None:
|
||
self.logger.event("command", {
|
||
"payload": hex_data(payload), "wait_state": state,
|
||
"response": value, "note": note,
|
||
})
|
||
return state, value
|
||
|
||
def stop_all_sync(self):
|
||
sequence = self.next_sequence()
|
||
state, response = self.send_command(
|
||
command_payload(0x09, 0, 0, sequence), 4, 2.0,
|
||
"request baseline STOP_ALL")
|
||
return state == "OK" and response is not None
|
||
|
||
def capture(self, duration_s=1.2):
|
||
deadline = time.monotonic() + duration_s
|
||
while time.monotonic() < deadline and not stop_task:
|
||
if self.check_emergency():
|
||
return False
|
||
self.receive()
|
||
self.publish()
|
||
time.sleep(0.005)
|
||
return True
|
||
|
||
def _demo_axis_task(self, motor, delta):
|
||
"""Build a visible, bounded target from the real current position."""
|
||
self.capture(0.35)
|
||
status = self.latest_motors[motor] or {}
|
||
current = int(status.get("position", 0))
|
||
if not status.get("position_valid"):
|
||
return None
|
||
magnitude = max(600, min(2800, int(delta)))
|
||
target = current + magnitude if current <= 5000 else current - magnitude
|
||
target = max(800, min(9200, target))
|
||
if abs(target - current) < 500:
|
||
target = 8000 if current < 5000 else 2000
|
||
return {"motor": int(motor), "start": current, "target": target,
|
||
"sequence": None, "ack": None}
|
||
|
||
def _demo_axis_far_endpoint_task(self, motor, endpoints):
|
||
"""Build the reset-zero to safe-far target used by motion faults."""
|
||
self.capture(0.35)
|
||
status = self.latest_motors[motor] or {}
|
||
current = int(status.get("position", 0))
|
||
if not status.get("position_valid"):
|
||
return None
|
||
if current > 300:
|
||
return None
|
||
_low, high = [max(0, min(9200, int(value)))
|
||
for value in endpoints]
|
||
target = high
|
||
return {"motor": int(motor), "start": current, "target": target,
|
||
"sequence": None, "ack": None,
|
||
"already_at_target": abs(target - current) <= 250}
|
||
|
||
def _start_demo_axis_task(self, task, note):
|
||
if task is None:
|
||
return False
|
||
if task.get("already_at_target"):
|
||
task["started_epoch"] = time.time()
|
||
return True
|
||
sequence = self.next_sequence()
|
||
state, response = self.send_command(
|
||
command_payload(0x01, task["motor"], 0, sequence,
|
||
task["target"], POSITION_TIMEOUT_MS),
|
||
1, 2.0, note)
|
||
task["sequence"] = sequence
|
||
task["ack"] = response
|
||
accepted = (state == "OK" and response is not None and
|
||
int(response.get("reject", 1)) == 0)
|
||
if accepted:
|
||
task["started_epoch"] = time.time()
|
||
return accepted
|
||
|
||
def _wait_demo_axis_interruption_point(
|
||
self, task, timeout_s=12.0,
|
||
min_observe_s=NORMAL_TASK_MIN_OBSERVE_S):
|
||
"""Wait for both minimum run time and clearly visible real travel."""
|
||
if task is None or task.get("sequence") is None:
|
||
return False
|
||
travel = abs(int(task["target"]) - int(task["start"]))
|
||
visible_delta = max(900, min(2800, int(travel * 0.42)))
|
||
minimum_remaining = max(900, min(1800, int(travel * 0.25)))
|
||
started = time.monotonic()
|
||
deadline = time.monotonic() + float(timeout_s)
|
||
while time.monotonic() < deadline and not stop_task:
|
||
if self.check_emergency():
|
||
return False
|
||
self.receive()
|
||
status = self.latest_motors[task["motor"]]
|
||
if (status is not None and
|
||
int(status.get("sequence", -1)) == task["sequence"]):
|
||
position = int(status.get("position", task["start"]))
|
||
moved = abs(position - int(task["start"]))
|
||
remaining = abs(int(task["target"]) - position)
|
||
observed_s = time.monotonic() - started
|
||
if status.get("active") and \
|
||
observed_s >= float(min_observe_s) and \
|
||
moved >= visible_delta and \
|
||
remaining >= minimum_remaining:
|
||
task["interruption_position"] = position
|
||
task["visible_delta"] = moved
|
||
task["normal_observed_s"] = round(observed_s, 3)
|
||
self.capture(0.35)
|
||
return True
|
||
if not status.get("active") and \
|
||
int(status.get("task", -1)) in (6, 7):
|
||
return False
|
||
self.publish()
|
||
time.sleep(0.005)
|
||
return False
|
||
|
||
def _wait_demo_axis_task(self, task, timeout_s=35.0):
|
||
if task is None:
|
||
return False
|
||
if task.get("already_at_target"):
|
||
return self.capture(0.35)
|
||
if task.get("sequence") is None:
|
||
return False
|
||
deadline = time.monotonic() + timeout_s
|
||
terminal = None
|
||
while time.monotonic() < deadline and not stop_task:
|
||
if self.check_emergency():
|
||
return False
|
||
self.receive()
|
||
status = self.latest_motors[task["motor"]]
|
||
if (status is not None and
|
||
int(status.get("sequence", -1)) == task["sequence"] and
|
||
not status.get("active") and
|
||
int(status.get("task", -1)) in (6, 7)):
|
||
terminal = status
|
||
break
|
||
self.publish()
|
||
time.sleep(0.005)
|
||
self.capture(0.35)
|
||
status = self.latest_motors[task["motor"]] or terminal or {}
|
||
task["terminal"] = status
|
||
return bool(terminal) and int(status.get("task", -1)) == 6 and \
|
||
abs(int(status.get("position", -10000)) - task["target"]) <= 250
|
||
|
||
def _resume_demo_axis_task(self, task, note):
|
||
if not self.stop_all_sync():
|
||
return False
|
||
if not self._start_demo_axis_task(task, note):
|
||
return False
|
||
return self._wait_demo_axis_task(task)
|
||
|
||
def _start_demo_comfort(self, hold_s=1.2):
|
||
"""Start audible fan-2 plus measurable heater-1 for a bounded demo."""
|
||
self.latest_thermal = None
|
||
self.transmit(0x3CE, [0x10, 0, 0, 0, 0, 0, 0, 0],
|
||
"DEMO TASK valid thermal mode")
|
||
self.transmit(0x2AA, [0x01, 0, 0x1B, 0, 0, 0, 0, 0],
|
||
"DEMO TASK thermal config")
|
||
self.transmit(0x29E, [0x15, 0, 0x12, 0, 0, 0, 0, 0],
|
||
"DEMO TASK fan2 + heater1")
|
||
feedback = self._wait_thermal_feedback(0x13, 3.0)
|
||
self.capture(max(0.0, min(float(hold_s), 8.0)))
|
||
return feedback is not None
|
||
|
||
@staticmethod
|
||
def _task_target_positions(task):
|
||
if task is None:
|
||
return None
|
||
values = [None, None, None, None]
|
||
values[task["motor"]] = round(task["target"] / 100.0, 1)
|
||
return values
|
||
|
||
def _publish_hardware_diagnostic(self, request_id, phase, title,
|
||
detail, pin_results, steps):
|
||
self.hardware_diagnostic_progress = {
|
||
"request_id": request_id,
|
||
"phase": phase,
|
||
"title": title,
|
||
"detail": detail,
|
||
"pin_results": pin_results,
|
||
"steps": list(steps),
|
||
"tested_count": sum(
|
||
1 for value in pin_results.values()
|
||
if value.get("state") in ("OK", "WARN", "FAULT")),
|
||
"enabled_count": len(HARDWARE_ENABLED_PINS),
|
||
"epoch": time.time(),
|
||
}
|
||
self.publish(force=True)
|
||
|
||
@staticmethod
|
||
def _set_pin_result(pin_results, pin_ids, state, evidence,
|
||
evidence_kind="direct"):
|
||
for pin_id in pin_ids:
|
||
pin_results[pin_id] = {
|
||
"state": state,
|
||
"evidence": evidence,
|
||
"evidence_kind": evidence_kind,
|
||
}
|
||
|
||
def _probe_motor_pins(self, motor, request_id, pin_results, steps):
|
||
duration_ms = 900 if motor == 3 else 700
|
||
initial_status = self.latest_motors[motor] or {}
|
||
first_direction = 2 if int(initial_status.get("position", 0)) > 5000 \
|
||
else 1
|
||
second_direction = 1 if first_direction == 2 else 2
|
||
runs = []
|
||
for cycle in range(2):
|
||
for direction in (first_direction, second_direction):
|
||
self.motor_current_history[motor] = []
|
||
self.motor_hall_history[motor] = []
|
||
before = self.latest_diagnostics[motor]
|
||
before_hall = None if before is None else int(before["hall"])
|
||
sequence = self.next_sequence()
|
||
state, response = self.send_command(
|
||
command_payload(0x03, motor, direction, sequence,
|
||
duration_ms, 600),
|
||
1, 3.0,
|
||
"HW PIN M%d cycle=%d direction=%d" %
|
||
(motor, cycle + 1, direction))
|
||
if state == "EMERGENCY":
|
||
raise RuntimeError("硬件诊断期间收到紧急停止")
|
||
self.capture(duration_ms / 1000.0 + 0.55)
|
||
stop_sequence = self.next_sequence()
|
||
self.send_command(
|
||
command_payload(0x08, motor, 0, stop_sequence),
|
||
1, 2.0, "HW PIN M%d bounded stop" % motor)
|
||
self.capture(0.35)
|
||
after = self.latest_diagnostics[motor]
|
||
after_hall = None if after is None else int(after["hall"])
|
||
hall_delta = None if before_hall is None or \
|
||
after_hall is None else abs(after_hall - before_hall)
|
||
current_samples = [
|
||
sample for _, sample in self.motor_current_history[motor]
|
||
if sample.get("sample_valid") and
|
||
sample.get("baseline_valid")]
|
||
current_peak = max(
|
||
[int(sample.get("delta", 0))
|
||
for sample in current_samples] or [0])
|
||
ack_ok = (state == "OK" and response is not None and
|
||
response.get("reject") == 0)
|
||
runs.append({
|
||
"cycle": cycle + 1,
|
||
"direction": direction,
|
||
"ack_ok": ack_ok,
|
||
"hall_before": before_hall,
|
||
"hall_after": after_hall,
|
||
"hall_delta": hall_delta,
|
||
"current_peak_delta_raw": current_peak,
|
||
"current_sample_count": len(current_samples),
|
||
})
|
||
steps.append({
|
||
"key": "M%d-C%d-D%d" % (motor, cycle + 1, direction),
|
||
"state": "PASS" if ack_ok and current_peak >= 20 and
|
||
hall_delta is not None and hall_delta >= 1 else "FAIL",
|
||
"detail": "ACK=%s, ΔI=%d, ΔHall=%s" %
|
||
(ack_ok, current_peak,
|
||
"--" if hall_delta is None else hall_delta),
|
||
})
|
||
self._publish_hardware_diagnostic(
|
||
request_id, "RUNNING", "检测四路电机功率线与Hall线",
|
||
"M%d完成第%d轮%s向短时带载" %
|
||
(motor, cycle + 1,
|
||
"远端" if direction == 1 else "近端"),
|
||
pin_results, steps)
|
||
|
||
power_ok = all(item["ack_ok"] and
|
||
item["current_peak_delta_raw"] >= 20
|
||
for item in runs)
|
||
# A motor can start at a mechanical end stop. Requiring every short
|
||
# pulse to produce Hall edges misclassifies that normal endpoint as an
|
||
# open Hall pin. Two cycles still exercise both directions; accept the
|
||
# signal only when each direction produces Hall movement at least once.
|
||
# This remains sensitive to a disconnected/intermittent pin, for which
|
||
# one direction (or both) never produces a valid edge in either cycle.
|
||
hall_direction_ok = {
|
||
direction: any(
|
||
item["direction"] == direction and
|
||
item["hall_delta"] is not None and
|
||
item["hall_delta"] >= 1
|
||
for item in runs)
|
||
for direction in (first_direction, second_direction)
|
||
}
|
||
hall_ok = all(hall_direction_ok.values())
|
||
power_pins = HARDWARE_MOTOR_PINS[motor][:2]
|
||
hall_pin = (HARDWARE_MOTOR_PINS[motor][2],)
|
||
self._set_pin_result(
|
||
pin_results, power_pins, "OK" if power_ok else "FAULT",
|
||
"M%d双向×2:%s" %
|
||
(motor, "; ".join("D%d ΔI=%d" %
|
||
(item["direction"],
|
||
item["current_peak_delta_raw"])
|
||
for item in runs)), "direct")
|
||
self._set_pin_result(
|
||
pin_results, hall_pin, "OK" if hall_ok else "FAULT",
|
||
"M%d双向×2 Hall变化:%s(端点容错,每方向至少一次有效)" %
|
||
(motor, ", ".join("%s" % item["hall_delta"]
|
||
for item in runs)), "direct")
|
||
return {"motor": motor, "power_ok": power_ok,
|
||
"hall_ok": hall_ok,
|
||
"hall_direction_ok": hall_direction_ok, "runs": runs}
|
||
|
||
def execute_hardware_diagnostic(self, request):
|
||
request_id = request.get("request_id")
|
||
pin_results = {
|
||
pin_id: {"state": "UNKNOWN", "evidence": "尚未执行主动检测",
|
||
"evidence_kind": "pending"}
|
||
for pin_id in HARDWARE_ENABLED_PINS}
|
||
steps = []
|
||
motor_results = []
|
||
self._set_pin_result(
|
||
pin_results, ("JC1-1", "JC1-21"), "OK",
|
||
"已接收本工程0x301~0x305且STOP_ALL得到ACK",
|
||
"direct")
|
||
self._publish_hardware_diagnostic(
|
||
request_id, "BASELINE", "建立安全基线",
|
||
"关闭全部电机、风扇和加热后开始21针主动检测",
|
||
pin_results, steps)
|
||
if not self.stop_all_sync():
|
||
self.transmit(0x42A, [0x0A, 0, 0, 0, 0, 0, 0, 0],
|
||
"hardware diagnostic baseline failure thermal off")
|
||
self._set_pin_result(
|
||
pin_results, ("JC1-1", "JC1-21"), "FAULT",
|
||
"发送STOP_ALL后未获得0x301有效ACK,CAN针脚闭环失败",
|
||
"direct")
|
||
self._publish_hardware_diagnostic(
|
||
request_id, "FAILED", "21针主动检测已安全终止",
|
||
"基线STOP_ALL无有效ACK,未施加任何带载检测",
|
||
pin_results, steps)
|
||
return "FAIL", {
|
||
"summary": "基线STOP_ALL无有效ACK,已关闭热负载且未执行带载检测",
|
||
"pin_results": pin_results,
|
||
"steps": steps,
|
||
}
|
||
self.transmit(0x42A, [0x0A, 0, 0, 0, 0, 0, 0, 0],
|
||
"hardware diagnostic baseline thermal off")
|
||
self.capture(1.0)
|
||
try:
|
||
for motor in range(4):
|
||
motor_results.append(self._probe_motor_pins(
|
||
motor, request_id, pin_results, steps))
|
||
|
||
# Fan branch: use the normal vehicle CAN sequence and real 0x305
|
||
# ADC feedback, then force it off on every path.
|
||
self.transmit(0x42A, [0x0A, 0, 0, 0, 0, 0, 0, 0],
|
||
"HW fan baseline off")
|
||
self.capture(0.5)
|
||
fan_baseline = int((self.latest_thermal_current or {}).get(
|
||
"fan_raw", 0))
|
||
self.fan_current_history = []
|
||
self.transmit(0x2AA, [0x01, 0, 0x1B, 0, 0, 0, 0, 0],
|
||
"HW fan configuration")
|
||
self.transmit(0x29E, [0x05, 0, 0x01, 0, 0, 0, 0, 0],
|
||
"HW fan gear 1")
|
||
self.capture(3.0)
|
||
fan_samples = [int(sample.get("raw", 0))
|
||
for _, sample in self.fan_current_history
|
||
if sample.get("valid")]
|
||
fan_peak = max(fan_samples or [fan_baseline])
|
||
fan_ok = fan_peak - fan_baseline >= 20
|
||
self._set_pin_result(
|
||
pin_results, ("JA1-13",), "OK" if fan_ok else "FAULT",
|
||
"风扇0x305 raw %d→%d, Δ=%d" %
|
||
(fan_baseline, fan_peak, fan_peak - fan_baseline), "direct")
|
||
steps.append({"key": "FAN", "state": "PASS" if fan_ok else "FAIL",
|
||
"detail": "0x305 ΔI=%d" %
|
||
(fan_peak - fan_baseline)})
|
||
self.transmit(0x42A, [0x0A, 0, 0, 0, 0, 0, 0, 0],
|
||
"HW fan safe off")
|
||
|
||
# Heater branch: low level only, bounded to three seconds.
|
||
self.capture(0.5)
|
||
heater_baseline = int((self.latest_thermal_current or {}).get(
|
||
"heater_raw", 0))
|
||
self.heater_current_history = []
|
||
self.transmit(0x3CE, [0x10, 0, 0, 0, 0, 0, 0, 0],
|
||
"HW heater mode")
|
||
self.transmit(0x2AA, [0x01, 0, 0x1B, 0, 0, 0, 0, 0],
|
||
"HW heater config")
|
||
self.transmit(0x29E, [0x12, 0, 0x10, 0, 0, 0, 0, 0],
|
||
"HW heater level 1")
|
||
self.capture(3.0)
|
||
heater_samples = [int(sample.get("raw", 0))
|
||
for _, sample in self.heater_current_history
|
||
if sample.get("valid")]
|
||
heater_peak = max(heater_samples or [heater_baseline])
|
||
heater_ok = (heater_peak - heater_baseline >=
|
||
HEATER_CURRENT_ACTIVE_DELTA_RAW)
|
||
self._set_pin_result(
|
||
pin_results, ("JB1-18",), "OK" if heater_ok else "FAULT",
|
||
"加热0x305 raw %d→%d, Δ=%d" %
|
||
(heater_baseline, heater_peak,
|
||
heater_peak - heater_baseline), "direct")
|
||
steps.append({"key": "HEATER",
|
||
"state": "PASS" if heater_ok else "FAIL",
|
||
"detail": "0x305 ΔI=%d" %
|
||
(heater_peak - heater_baseline)})
|
||
|
||
all_motor_power = all(item["power_ok"] for item in motor_results)
|
||
main_hall = all(item["hall_ok"] for item in motor_results[:3])
|
||
leg_hall = motor_results[3]["hall_ok"]
|
||
self._set_pin_result(
|
||
pin_results, ("JA1-1", "JA1-10"),
|
||
"OK" if all_motor_power else "WARN",
|
||
"四路电机双向重复带载均建立电流" if all_motor_power else
|
||
"公共负载电源/地关联支路存在异常",
|
||
"correlated")
|
||
thermal_ok = fan_ok and heater_ok
|
||
self._set_pin_result(
|
||
pin_results, ("JA1-11",),
|
||
"OK" if thermal_ok else "WARN",
|
||
"风扇与加热两条独立支路均建立电流" if thermal_ok else
|
||
"热管理公共地关联支路存在异常",
|
||
"correlated")
|
||
self._set_pin_result(
|
||
pin_results, ("JC1-6",), "OK" if main_hall else "WARN",
|
||
"M0/M1/M2双向Hall均连续变化" if main_hall else
|
||
"主座椅三路Hall公共信号地关联异常", "correlated")
|
||
self._set_pin_result(
|
||
pin_results, ("JC1-18",), "OK" if leg_hall else "WARN",
|
||
"M3双向Hall连续变化" if leg_hall else
|
||
"腿托Hall信号地关联异常", "correlated")
|
||
passed = all(value["state"] == "OK"
|
||
for value in pin_results.values())
|
||
self._publish_hardware_diagnostic(
|
||
request_id, "PASSED" if passed else "FAILED",
|
||
"21针主动检测完成",
|
||
"直接验证16针,跨支路关联验证5针;%s" %
|
||
("全部稳定" if passed else "发现异常或不稳定针脚"),
|
||
pin_results, steps)
|
||
return ("PASS" if passed else "FAIL"), {
|
||
"summary": "21个启用针脚已全部执行检测:%d正常,%d异常" %
|
||
(sum(1 for value in pin_results.values()
|
||
if value["state"] == "OK"),
|
||
sum(1 for value in pin_results.values()
|
||
if value["state"] != "OK")),
|
||
"pin_results": pin_results,
|
||
"motor_results": motor_results,
|
||
"steps": steps,
|
||
}
|
||
finally:
|
||
self.stop_all_sync()
|
||
self.transmit(0x42A, [0x0A, 0, 0, 0, 0, 0, 0, 0],
|
||
"hardware diagnostic final safe off")
|
||
|
||
def position_consistency_issues(self):
|
||
"""Check only ECU-internal Hall/normalized consistency.
|
||
|
||
This cannot detect a mechanism that was moved while the ECU was
|
||
unpowered. Such an externally unknown position must be resolved by a
|
||
real known-range home, regardless of PositionValidMask.
|
||
"""
|
||
issues = []
|
||
system_mask = 0 if self.latest_system is None else \
|
||
self.latest_system["position_valid_mask"]
|
||
for motor, expected_max in enumerate(CONFIRMED_MAX_HALL):
|
||
status = self.latest_motors[motor]
|
||
diag = self.latest_diagnostics[motor]
|
||
if status is None or diag is None:
|
||
issues.append("M%d缺少0x302/0x303" % motor)
|
||
continue
|
||
mask_valid = (system_mask & (1 << motor)) != 0
|
||
if bool(status["position_valid"]) != mask_valid:
|
||
issues.append("M%d PositionValid与0x301不一致" % motor)
|
||
continue
|
||
if not mask_valid:
|
||
continue
|
||
max_hall = int(diag["max_hall"])
|
||
hall = int(diag["hall"])
|
||
if abs(max_hall - expected_max) > max(4, int(expected_max * 0.05)):
|
||
issues.append("M%d MaxHall=%d(预期约%d)" %
|
||
(motor, max_hall, expected_max))
|
||
continue
|
||
if max_hall <= 0 or hall < 0 or hall > max_hall:
|
||
issues.append("M%d Hall=%d超出0..%d" %
|
||
(motor, hall, max_hall))
|
||
continue
|
||
expected_position = int(
|
||
(hall * 10000.0 / max_hall) + 0.5)
|
||
normalized_tolerance = max(
|
||
100,
|
||
int(math.ceil(10000.0 * POSITION_HALL_TOLERANCE /
|
||
max_hall)))
|
||
if abs(int(status["position"]) - expected_position) > \
|
||
normalized_tolerance:
|
||
issues.append(
|
||
"M%d Position=%d与Hall=%d/%d(应约%d)不一致" %
|
||
(motor, status["position"], hall, max_hall,
|
||
expected_position))
|
||
return issues
|
||
|
||
def execute_mode(self, request):
|
||
payload = request.get("payload") or {}
|
||
axes = payload.get("axes") or []
|
||
if len(axes) != 4 or sorted(int(axis.get("motor", -1))
|
||
for axis in axes) != [0, 1, 2, 3]:
|
||
return "BLOCKED", {"summary": "请求没有完整且唯一的M0..M3目标"}
|
||
if not self.stop_all_sync():
|
||
return "FAIL", {"summary": "基线STOP_ALL无有效ACK"}
|
||
if not self.capture(1.2):
|
||
return "STOPPED", {"summary": "执行前收到紧急停止"}
|
||
system = self.latest_system
|
||
if system is None:
|
||
return "BLOCKED", {"summary": "未收到0x301系统反馈"}
|
||
if system["position_valid_mask"] != 0x0F:
|
||
if not payload.get("allow_auto_home", False):
|
||
return "BLOCKED", {
|
||
"summary": "PositionValidMask=0x%X,且请求未授权自动回最近端" %
|
||
system["position_valid_mask"],
|
||
"system": system,
|
||
}
|
||
self.message(
|
||
"绝对位置未知:先将失效轴回最近端,再执行模式目标")
|
||
home_result, home_details = self.execute_initialize_known_ranges({
|
||
"payload": {"force_all_axes": False},
|
||
})
|
||
if home_result != "PASS":
|
||
return home_result, {
|
||
"summary": "模式执行前自动回最近端失败:%s" %
|
||
home_details.get("summary", ""),
|
||
"auto_home": home_details,
|
||
}
|
||
self.capture(0.5)
|
||
system = self.latest_system
|
||
if system is None or system["position_valid_mask"] != 0x0F:
|
||
return "FAIL", {
|
||
"summary": "自动回最近端完成后PositionValidMask仍不是0xF",
|
||
"auto_home": home_details,
|
||
"system": system,
|
||
}
|
||
else:
|
||
home_details = None
|
||
if system["faulted_mask"] != 0:
|
||
return "BLOCKED", {
|
||
"summary": "FaultedMask=0x%X,先排除故障" %
|
||
system["faulted_mask"],
|
||
"system": system,
|
||
}
|
||
range_issues = []
|
||
for motor, expected in enumerate(CONFIRMED_MAX_HALL):
|
||
diag = self.latest_diagnostics[motor]
|
||
if diag is None:
|
||
range_issues.append("M%d无0x303" % motor)
|
||
elif abs(diag["max_hall"] - expected) > max(4, int(expected * 0.05)):
|
||
range_issues.append("M%d MaxHall=%d(预期约%d)" %
|
||
(motor, diag["max_hall"], expected))
|
||
if range_issues:
|
||
return "BLOCKED", {
|
||
"summary": "量程反馈不符:" + ";".join(range_issues),
|
||
"diagnostics": self.latest_diagnostics,
|
||
}
|
||
consistency_issues = self.position_consistency_issues()
|
||
if consistency_issues:
|
||
return "BLOCKED", {
|
||
"summary": "位置记录内部不一致,请强制四轴归零:" +
|
||
";".join(consistency_issues),
|
||
"system": self.latest_system,
|
||
"motors": self.latest_motors,
|
||
"diagnostics": self.latest_diagnostics,
|
||
}
|
||
sequences = {}
|
||
acknowledgements = {}
|
||
for axis in sorted(axes, key=lambda value: int(value["motor"])):
|
||
if self.check_emergency():
|
||
return "STOPPED", {"summary": "下发过程中收到紧急停止"}
|
||
motor = int(axis["motor"])
|
||
target = int(axis["normalized"])
|
||
if target < 0 or target > 10000:
|
||
return "BLOCKED", {"summary": "M%d目标超出0..10000" % motor}
|
||
sequence = self.next_sequence()
|
||
sequences[motor] = sequence
|
||
state, response = self.send_command(
|
||
command_payload(0x01, motor, 0, sequence, target,
|
||
POSITION_TIMEOUT_MS),
|
||
1, 2.0, "%s M%d absolute=%d" %
|
||
(payload.get("name", "mode"), motor, target))
|
||
acknowledgements[motor] = response
|
||
if state == "EMERGENCY":
|
||
return "STOPPED", {"summary": "下发过程中收到紧急停止"}
|
||
if state != "OK" or response is None or response["reject"] != 0:
|
||
self.stop_all_sync()
|
||
return "FAIL", {
|
||
"summary": "M%d绝对位置命令未被接受" % motor,
|
||
"acknowledgements": acknowledgements,
|
||
}
|
||
# Firmware also enforces a 100 ms motor-start stagger. This
|
||
# smaller transmit gap keeps command ACKs distinct.
|
||
end_gap = time.monotonic() + 0.05
|
||
while time.monotonic() < end_gap:
|
||
self.receive()
|
||
time.sleep(0.005)
|
||
deadline = time.monotonic() + POSITION_TIMEOUT_MS / 1000.0 + 5.0
|
||
terminal = {}
|
||
while time.monotonic() < deadline and not stop_task:
|
||
if self.check_emergency():
|
||
return "STOPPED", {
|
||
"summary": "运动过程中收到紧急停止",
|
||
"acknowledgements": acknowledgements,
|
||
}
|
||
self.receive()
|
||
for motor, sequence in sequences.items():
|
||
status = self.latest_motors[motor]
|
||
if status is not None and status["sequence"] == sequence and \
|
||
not status["active"] and status["task"] in (6, 7):
|
||
terminal[motor] = status
|
||
self.publish()
|
||
if len(terminal) == 4:
|
||
break
|
||
time.sleep(0.005)
|
||
self.capture(1.0)
|
||
axis_results = []
|
||
passed = len(terminal) == 4
|
||
for axis in sorted(axes, key=lambda value: int(value["motor"])):
|
||
motor = int(axis["motor"])
|
||
target = int(axis["normalized"])
|
||
status = self.latest_motors[motor]
|
||
diag = self.latest_diagnostics[motor]
|
||
max_hall = int(axis.get("max_hall", CONFIRMED_MAX_HALL[motor]))
|
||
if diag is not None and diag["max_hall"] > 0:
|
||
max_hall = diag["max_hall"]
|
||
norm_tolerance = max(100, int(math.ceil(20000.0 / max_hall)))
|
||
expected_hall = int(target * max_hall / 10000.0 + 0.5)
|
||
hall_tolerance = max(2, int(math.ceil(
|
||
max_hall * norm_tolerance / 10000.0)))
|
||
item_pass = (
|
||
motor in terminal and status is not None and
|
||
status["task"] == 6 and status["result"] in (2, 4) and
|
||
status["position_valid"] and
|
||
abs(status["position"] - target) <= norm_tolerance and
|
||
diag is not None and
|
||
abs(diag["hall"] - expected_hall) <= hall_tolerance)
|
||
passed = passed and item_pass
|
||
axis_results.append({
|
||
"motor": motor,
|
||
"passed": item_pass,
|
||
"target_normalized": target,
|
||
"target_physical": axis.get("physical_value"),
|
||
"physical_unit": axis.get("unit"),
|
||
"expected_hall_from_reported_range": expected_hall,
|
||
"normalized_tolerance": norm_tolerance,
|
||
"hall_tolerance": hall_tolerance,
|
||
"status": status,
|
||
"diagnostic": diag,
|
||
})
|
||
return ("PASS" if passed else "FAIL"), {
|
||
"summary": "%s;四轴CAN位置%s,仍需GUI人工确认实物动作" %
|
||
(payload.get("name", "模式"),
|
||
"均正确" if passed else "存在不符"),
|
||
"acknowledgements": acknowledgements,
|
||
"axis_results": axis_results,
|
||
"system": self.latest_system,
|
||
"warnings": payload.get("warnings", []),
|
||
"workbook": payload.get("workbook"),
|
||
"workbook_sha256": payload.get("workbook_sha256"),
|
||
"auto_home": home_details,
|
||
}
|
||
|
||
def execute_initialize_known_ranges(self, request):
|
||
payload = request.get("payload") or request
|
||
force_all_axes = bool(payload.get("force_all_axes", False))
|
||
if not self.stop_all_sync():
|
||
return "FAIL", {"summary": "基线STOP_ALL无有效ACK"}
|
||
if not self.capture(1.2):
|
||
return "STOPPED", {"summary": "初始化前收到紧急停止"}
|
||
initial_mask = 0 if self.latest_system is None else \
|
||
self.latest_system["position_valid_mask"]
|
||
homed = []
|
||
for motor, expected_max in enumerate(CONFIRMED_MAX_HALL):
|
||
if not force_all_axes and (initial_mask & (1 << motor)) != 0:
|
||
homed.append({
|
||
"motor": motor,
|
||
"result": "ALREADY_VALID_NOT_HOMED",
|
||
"status": self.latest_motors[motor],
|
||
"diagnostic": self.latest_diagnostics[motor],
|
||
"note": "位置记录有效,未执行机械归零",
|
||
})
|
||
continue
|
||
if self.check_emergency():
|
||
return "STOPPED", {
|
||
"summary": "回最近端过程中收到紧急停止",
|
||
"axes": homed,
|
||
}
|
||
initial_status = self.latest_motors[motor]
|
||
initial_diag = self.latest_diagnostics[motor]
|
||
initial_current = self.latest_currents[motor]
|
||
initial_status = dict(initial_status) \
|
||
if initial_status is not None else None
|
||
initial_diag = dict(initial_diag) \
|
||
if initial_diag is not None else None
|
||
initial_current = dict(initial_current) \
|
||
if initial_current is not None else None
|
||
observed_halls = []
|
||
observed_directions = []
|
||
if initial_diag is not None:
|
||
observed_halls.append(int(initial_diag["hall"]))
|
||
current_peak = initial_current
|
||
home_started = time.monotonic()
|
||
sequence = self.next_sequence()
|
||
state, response = self.send_command(
|
||
command_payload(0x0B, motor, 0, sequence,
|
||
HOME_KNOWN_RANGE_KEY,
|
||
HOME_LEG_TIMEOUT_S),
|
||
1, 3.0, "M%d home near with known MaxHall=%d" %
|
||
(motor, expected_max))
|
||
if state == "EMERGENCY":
|
||
return "STOPPED", {
|
||
"summary": "回最近端过程中收到紧急停止",
|
||
"axes": homed,
|
||
}
|
||
if state != "OK" or response is None or response["reject"] != 0:
|
||
self.stop_all_sync()
|
||
return "FAIL", {
|
||
"summary": "M%d已知量程回零命令未被固件接受;若Reject=BAD_OPCODE,需烧录新HEX" %
|
||
motor,
|
||
"motor": motor,
|
||
"response": response,
|
||
"latest_system": self.latest_system,
|
||
"axes": homed,
|
||
}
|
||
|
||
def terminal_predicate(can_id, data):
|
||
nonlocal current_peak
|
||
if can_id == 0x303:
|
||
diagnostic = parse_303(data)
|
||
if diagnostic is not None and \
|
||
diagnostic["motor"] == motor:
|
||
observed_halls.append(int(diagnostic["hall"]))
|
||
return None
|
||
if can_id == 0x304:
|
||
current = parse_304(data)
|
||
if current is not None and current["motor"] == motor and \
|
||
(current_peak is None or
|
||
current["delta"] > current_peak["delta"]):
|
||
current_peak = current
|
||
return None
|
||
if can_id != 0x302:
|
||
return None
|
||
value = parse_302(data)
|
||
if value is not None and value["motor"] == motor and \
|
||
value["sequence"] == sequence:
|
||
direction = int(value["direction"])
|
||
if value["active"] and direction in (1, 2) and \
|
||
(not observed_directions or
|
||
observed_directions[-1] != direction):
|
||
observed_directions.append(direction)
|
||
if not value["active"] and value["task"] in (6, 7):
|
||
return value
|
||
return None
|
||
|
||
wait_state, terminal = self.wait(
|
||
terminal_predicate, HOME_LEG_TIMEOUT_S + 15.0)
|
||
if wait_state == "EMERGENCY":
|
||
return "STOPPED", {
|
||
"summary": "M%d回最近端时收到紧急停止" % motor,
|
||
"axes": homed,
|
||
}
|
||
self.capture(1.0)
|
||
status = self.latest_motors[motor]
|
||
diag = self.latest_diagnostics[motor]
|
||
if diag is not None:
|
||
observed_halls.append(int(diag["hall"]))
|
||
distinct_halls = sorted(set(observed_halls))
|
||
initial_hall = None if initial_diag is None else \
|
||
int(initial_diag["hall"])
|
||
can_motion_evidence = True
|
||
if initial_hall is not None and initial_hall > 2:
|
||
can_motion_evidence = any(
|
||
0 < value < initial_hall for value in distinct_halls)
|
||
passed = (terminal is not None and terminal["task"] == 6 and
|
||
terminal["result"] == 2 and
|
||
status is not None and status["position_valid"] and
|
||
status["near"] and status["position"] <= 100 and
|
||
diag is not None and diag["hall"] <= 2 and
|
||
can_motion_evidence and
|
||
abs(diag["max_hall"] - expected_max) <=
|
||
max(4, int(expected_max * 0.05)))
|
||
homed.append({
|
||
"motor": motor,
|
||
"result": "PASS" if passed else "FAIL",
|
||
"terminal": terminal,
|
||
"status": status,
|
||
"diagnostic": diag,
|
||
"initial_status": initial_status,
|
||
"initial_diagnostic": initial_diag,
|
||
"initial_current": initial_current,
|
||
"peak_current": current_peak,
|
||
"home_elapsed_s": round(time.monotonic() - home_started, 3),
|
||
"observed_hall_values": distinct_halls,
|
||
"observed_direction_sequence": observed_directions,
|
||
"can_motion_evidence": can_motion_evidence,
|
||
"firmware_minimum_raw_hall_verification_required": True,
|
||
"expected_max_hall": expected_max,
|
||
})
|
||
if not passed:
|
||
self.stop_all_sync()
|
||
if not can_motion_evidence:
|
||
failure_summary = (
|
||
"M%d从Hall=%d直接跳到0,未观察到真实归零运动;"
|
||
"拒绝把PositionValid置位结果判为PASS,请确认烧录了"
|
||
"带启动验证的新HEX" % (motor, initial_hall))
|
||
elif terminal is None:
|
||
failure_summary = \
|
||
"M%d回最近端命令超时,未收到固件终态" % motor
|
||
elif terminal.get("result") == 11 and \
|
||
1 in observed_directions and \
|
||
(not observed_directions or
|
||
observed_directions[-1] != 2):
|
||
failure_summary = (
|
||
"M%d离开最近端的Hall启动验证失败;"
|
||
"固件未进入回程,请检查该方向Hall边沿/机械启动,"
|
||
"并确认烧录最新HEX" % motor)
|
||
elif terminal.get("result") == 11 and \
|
||
observed_directions and \
|
||
observed_directions[-1] == 2:
|
||
failure_summary = (
|
||
"M%d已完成离端动作,但返回最近端的Hall/端点验证失败;"
|
||
"请检查回程Hall反馈并确认烧录最新HEX" % motor)
|
||
else:
|
||
failure_summary = \
|
||
"M%d回最近端后的位置/Hall反馈不符合要求" % motor
|
||
return "FAIL", {
|
||
"summary": failure_summary,
|
||
"axes": homed,
|
||
}
|
||
self.capture(1.5)
|
||
final_mask = 0 if self.latest_system is None else \
|
||
self.latest_system["position_valid_mask"]
|
||
range_issues = []
|
||
for motor, expected_max in enumerate(CONFIRMED_MAX_HALL):
|
||
diag = self.latest_diagnostics[motor]
|
||
if diag is None:
|
||
range_issues.append("M%d无0x303" % motor)
|
||
elif abs(diag["max_hall"] - expected_max) > \
|
||
max(4, int(expected_max * 0.05)):
|
||
range_issues.append("M%d MaxHall=%d(预期约%d)" %
|
||
(motor, diag["max_hall"], expected_max))
|
||
passed = final_mask == 0x0F and not range_issues
|
||
actually_homed = [item["motor"] for item in homed
|
||
if item["result"] == "PASS"]
|
||
if force_all_axes:
|
||
passed = passed and actually_homed == [0, 1, 2, 3]
|
||
summary_prefix = "四轴已实际回最近端并重建Hall=0"
|
||
elif actually_homed:
|
||
summary_prefix = "PositionValid失效轴已实际回最近端"
|
||
else:
|
||
summary_prefix = (
|
||
"四轴位置记录已有效,未执行机械归零;"
|
||
"PositionValid不等于位于最近端")
|
||
return ("PASS" if passed else "FAIL"), {
|
||
"summary": "%s;PositionValidMask=0x%X%s" %
|
||
(summary_prefix,
|
||
final_mask,
|
||
";" + ";".join(range_issues)
|
||
if range_issues else ""),
|
||
"initial_position_valid_mask": initial_mask,
|
||
"final_position_valid_mask": final_mask,
|
||
"force_all_axes": force_all_axes,
|
||
"actually_homed_axes": actually_homed,
|
||
"axes": homed,
|
||
"range_issues": range_issues,
|
||
"note": (
|
||
"HOME_KNOWN_RANGE只寻找最近端,不重新测量最远端或"
|
||
"25/50/75%位置;完成后的Hall/绝对位置由固件写入SMV8"),
|
||
}
|
||
|
||
def execute_thermal(self, request):
|
||
payload = request.get("payload") or {}
|
||
frames = payload.get("frames") or []
|
||
expected = int(payload.get("expected_byte0", -1))
|
||
if not frames or not (0 <= expected <= 255):
|
||
return "BLOCKED", {"summary": "热管理请求格式无效"}
|
||
if not self.stop_all_sync():
|
||
return "FAIL", {"summary": "基线STOP_ALL无有效ACK"}
|
||
self.latest_thermal = None
|
||
for item in frames:
|
||
if self.check_emergency():
|
||
return "STOPPED", {"summary": "下发过程中收到紧急停止"}
|
||
can_id, data = item
|
||
if len(data) != 8:
|
||
return "BLOCKED", {"summary": "热管理CAN帧DLC不是8"}
|
||
self.transmit(int(can_id), data, payload.get("key", "thermal"))
|
||
end_gap = time.monotonic() + 0.1
|
||
while time.monotonic() < end_gap:
|
||
self.receive()
|
||
time.sleep(0.005)
|
||
|
||
def active_predicate(can_id, data):
|
||
if can_id == 0x4FA and len(data) >= 1 and data[0] == expected:
|
||
return list(data)
|
||
return None
|
||
|
||
if (self.latest_thermal is not None and
|
||
len(self.latest_thermal) >= 1 and
|
||
self.latest_thermal[0] == expected):
|
||
state, active_feedback = "OK", list(self.latest_thermal)
|
||
else:
|
||
state, active_feedback = self.wait(active_predicate, 3.0)
|
||
active_ok = state == "OK" and active_feedback is not None
|
||
hold_s = float(payload.get("hold_s", 8.0))
|
||
if payload.get("key") == "全部关闭":
|
||
hold_s = 0.0
|
||
deadline = time.monotonic() + max(0.0, min(hold_s, 30.0))
|
||
while time.monotonic() < deadline and not stop_task:
|
||
if self.check_emergency():
|
||
return "STOPPED", {
|
||
"summary": "保持窗口内收到紧急停止",
|
||
"active_feedback": active_feedback,
|
||
}
|
||
self.receive()
|
||
self.publish()
|
||
time.sleep(0.01)
|
||
self.transmit(0x42A, [0x0A, 0, 0, 0, 0, 0, 0, 0],
|
||
"automatic thermal safe off")
|
||
|
||
def off_predicate(can_id, data):
|
||
if can_id == 0x4FA and len(data) >= 1 and data[0] == 0x09:
|
||
return list(data)
|
||
return None
|
||
|
||
off_state, off_feedback = self.wait(off_predicate, 3.0)
|
||
off_ok = off_state == "OK" and off_feedback is not None
|
||
passed = active_ok and off_ok
|
||
return ("PASS" if passed else "FAIL"), {
|
||
"summary": "%s:目标反馈%s,自动安全关闭%s;仍需GUI人工确认风量/温升" %
|
||
(payload.get("key", "热管理"),
|
||
"正确" if active_ok else "不符",
|
||
"正确" if off_ok else "不符"),
|
||
"active_feedback": active_feedback,
|
||
"expected_byte0": expected,
|
||
"hold_s": hold_s,
|
||
"off_feedback": off_feedback,
|
||
}
|
||
|
||
def _publish_healing_progress(self, request_id, case_id, phase, title,
|
||
detail, telemetry=None,
|
||
diagnosed_code=None):
|
||
self.self_healing_progress = {
|
||
"request_id": request_id,
|
||
"case_id": case_id,
|
||
"phase": phase,
|
||
"title": title,
|
||
"detail": detail,
|
||
"telemetry": telemetry or {},
|
||
"diagnosed_code": diagnosed_code,
|
||
"epoch": time.time(),
|
||
}
|
||
self.publish(force=True)
|
||
|
||
def _wait_thermal_feedback(self, expected, timeout_s=3.0):
|
||
if (self.latest_thermal is not None and
|
||
len(self.latest_thermal) >= 1 and
|
||
self.latest_thermal[0] == expected):
|
||
return list(self.latest_thermal)
|
||
|
||
def predicate(can_id, data):
|
||
if can_id == 0x4FA and len(data) >= 1 and data[0] == expected:
|
||
return list(data)
|
||
return None
|
||
|
||
state, value = self.wait(predicate, timeout_s)
|
||
return value if state == "OK" else None
|
||
|
||
@staticmethod
|
||
def _self_heal_hook_payload(case_index, operation, token,
|
||
arm_token=None):
|
||
if int(operation) == 0:
|
||
byte5, byte6 = 0x53, 0x46
|
||
else:
|
||
byte5 = int(arm_token) & 0xFF
|
||
byte6 = (int(arm_token) >> 8) & 0xFF
|
||
values = [0xA5, int(case_index) & 0xFF, int(operation) & 0xFF,
|
||
int(token) & 0xFF, (int(token) >> 8) & 0xFF,
|
||
byte5, byte6]
|
||
values.append(sum(values) & 0xFF)
|
||
return values
|
||
|
||
def _wait_self_heal_hook_response(self, case_index, operation, token,
|
||
timeout_s):
|
||
def predicate(can_id, data):
|
||
if can_id != SELF_HEAL_HOOK_RESPONSE_ID or len(data) != 8:
|
||
return None
|
||
if (data[0] != 0x5A or data[1] != (case_index & 0xFF) or
|
||
data[2] != (operation & 0xFF) or
|
||
data[3] != (token & 0xFF) or
|
||
data[4] != ((token >> 8) & 0xFF)):
|
||
return None
|
||
if (sum(data[:7]) & 0xFF) != data[7]:
|
||
return None
|
||
return {
|
||
"case_index": data[1], "operation": data[2],
|
||
"token": token, "result": data[5],
|
||
"evidence_code": data[6], "raw": list(data),
|
||
}
|
||
|
||
state, response = self.wait(predicate, timeout_s)
|
||
return response if state == "OK" else None
|
||
|
||
def _run_self_heal_hook(self, case_index, operation, timeout_s, note,
|
||
after_arm=None):
|
||
arm_token = self.next_sequence()
|
||
arm_payload = self._self_heal_hook_payload(
|
||
case_index, 0, arm_token)
|
||
self.drain()
|
||
self.transmit(SELF_HEAL_HOOK_REQUEST_ID, arm_payload,
|
||
"ARM " + note)
|
||
arm_ack = self._wait_self_heal_hook_response(
|
||
case_index, 0, arm_token, 3.0)
|
||
if arm_ack is None or arm_ack["result"] != 0:
|
||
return None
|
||
if after_arm is not None and not after_arm():
|
||
return None
|
||
|
||
token = self.next_sequence()
|
||
payload = self._self_heal_hook_payload(
|
||
case_index, operation, token, arm_token)
|
||
self.transmit(SELF_HEAL_HOOK_REQUEST_ID, payload, note)
|
||
response = self._wait_self_heal_hook_response(
|
||
case_index, operation, token, timeout_s)
|
||
if response is not None:
|
||
response["arm_ack"] = arm_ack
|
||
return response
|
||
|
||
def _execute_hook_self_healing_case(self, request, profile):
|
||
request_id = request.get("request_id")
|
||
case_id = (request.get("payload") or {}).get("case_id")
|
||
base = {
|
||
"source": "REAL_BENCH_CAN",
|
||
"bench_request_id": request_id,
|
||
"request_feedback_match": True,
|
||
"safety_protection": True,
|
||
"normal_task": profile["normal_task"],
|
||
"normal_task_min_observe_s": profile.get(
|
||
"normal_observe_s", NORMAL_TASK_MIN_OBSERVE_S),
|
||
"operation_stage": "NORMAL_TASK",
|
||
"bench_reaction": profile["normal_task"],
|
||
}
|
||
self._publish_healing_progress(
|
||
request_id, case_id, "BASELINE", "准备实物正常任务",
|
||
profile["normal_task"], base)
|
||
if not self.stop_all_sync():
|
||
self._publish_healing_progress(
|
||
request_id, case_id, "FAILED", "基线确认失败",
|
||
"实物ECU没有返回STOP_ALL ACK,未注入故障",
|
||
dict(base, request_feedback_match=False))
|
||
return "FAIL", {"summary": "基线STOP_ALL无有效ACK,未注入故障"}
|
||
self.transmit(0x42A, [0x0A, 0, 0, 0, 0, 0, 0, 0],
|
||
"self-healing hook baseline thermal off")
|
||
self.capture(0.5)
|
||
|
||
demo_task = None
|
||
if "demo_motor" in profile:
|
||
if case_id == "FI-FIELD-02":
|
||
prelude_started_epoch = time.time()
|
||
base["normal_task_started_epoch"] = prelude_started_epoch
|
||
prelude_telemetry = dict(base)
|
||
prelude_telemetry.update({
|
||
"normal_task_started_epoch": prelude_started_epoch,
|
||
"normal_task_min_observe_s": profile.get(
|
||
"normal_observe_s", NORMAL_TASK_MIN_OBSERVE_S),
|
||
"bench_reaction":
|
||
"正常任务持续运行:用户正在调整腿托并保存舒适位置",
|
||
})
|
||
self._publish_healing_progress(
|
||
request_id, case_id, "BASELINE", "正常任务持续运行",
|
||
prelude_telemetry["bench_reaction"], prelude_telemetry)
|
||
prelude = self._demo_axis_task(
|
||
profile["demo_motor"], profile.get("prelude_delta", 900))
|
||
if (prelude is None or
|
||
not self._start_demo_axis_task(
|
||
prelude, "NORMAL TASK user legrest adjustment") or
|
||
not self._wait_demo_axis_task(prelude)):
|
||
self._publish_healing_progress(
|
||
request_id, case_id, "FAILED", "正常任务未建立",
|
||
"CRC用例的故障前腿托调整未完成,未注入故障",
|
||
dict(base, request_feedback_match=False))
|
||
return "FAIL", {"summary": "CRC用例的正常腿托调整未完成"}
|
||
self.capture(1.0)
|
||
if "demo_endpoints" in profile:
|
||
demo_task = self._demo_axis_far_endpoint_task(
|
||
profile["demo_motor"], profile["demo_endpoints"])
|
||
else:
|
||
demo_task = self._demo_axis_task(
|
||
profile["demo_motor"], profile.get("demo_delta", 2000))
|
||
if demo_task is None:
|
||
self._publish_healing_progress(
|
||
request_id, case_id, "FAILED", "正常任务未建立",
|
||
"位置尚未有效,无法生成安全的实物运行目标",
|
||
dict(base, request_feedback_match=False))
|
||
return "FAIL", {"summary": "正常任务目标生成失败:位置尚未有效"}
|
||
base["task_target_positions"] = \
|
||
self._task_target_positions(demo_task)
|
||
self._publish_healing_progress(
|
||
request_id, case_id, "BASELINE", "实物正常任务运行",
|
||
profile["normal_task"], base)
|
||
|
||
def start_normal_task_after_arm():
|
||
if case_id in ("FI-FIELD-01", "FI-FIELD-03"):
|
||
if not self._start_demo_axis_task(
|
||
demo_task, "NORMAL TASK before injected interruption"):
|
||
return False
|
||
normal_running = dict(base)
|
||
normal_running.update({
|
||
"normal_task_started_epoch": demo_task.get(
|
||
"started_epoch", time.time()),
|
||
"normal_task_min_observe_s": profile.get(
|
||
"normal_observe_s", NORMAL_TASK_MIN_OBSERVE_S),
|
||
"task_target_positions":
|
||
self._task_target_positions(demo_task),
|
||
"operation_stage": "NORMAL_TASK",
|
||
"bench_reaction": profile["normal_task"] +
|
||
";故障将在持续运行至少%.0f秒后注入" %
|
||
profile.get("normal_observe_s",
|
||
NORMAL_TASK_MIN_OBSERVE_S),
|
||
})
|
||
self._publish_healing_progress(
|
||
request_id, case_id, "BASELINE", "正常任务持续运行",
|
||
normal_running["bench_reaction"], normal_running)
|
||
return self._wait_demo_axis_interruption_point(
|
||
demo_task,
|
||
min_observe_s=profile.get(
|
||
"normal_observe_s", NORMAL_TASK_MIN_OBSERVE_S))
|
||
if case_id == "FI-FIELD-06":
|
||
normal_task_started_epoch = time.time()
|
||
base["normal_task_started_epoch"] = normal_task_started_epoch
|
||
comfort_running = dict(base)
|
||
comfort_running.update({
|
||
"normal_task_started_epoch": normal_task_started_epoch,
|
||
"bench_reaction": profile["normal_task"] +
|
||
";保持运行%.1f秒后注入WDT故障" %
|
||
profile.get("normal_observe_s", 6.0),
|
||
})
|
||
self._publish_healing_progress(
|
||
request_id, case_id, "BASELINE", "正常任务持续运行",
|
||
comfort_running["bench_reaction"], comfort_running)
|
||
return self._start_demo_comfort(
|
||
profile.get("normal_observe_s", 6.0))
|
||
if case_id == "FI-FIELD-05":
|
||
normal_task_started_epoch = time.time()
|
||
base["normal_task_started_epoch"] = normal_task_started_epoch
|
||
fan_running = dict(base)
|
||
fan_running.update({
|
||
"normal_task_started_epoch": normal_task_started_epoch,
|
||
"bench_reaction":
|
||
"通风先稳定运行5.5秒,再执行用户关闭指令并注入状态机故障",
|
||
})
|
||
self._publish_healing_progress(
|
||
request_id, case_id, "BASELINE", "正常任务持续运行",
|
||
fan_running["bench_reaction"], fan_running)
|
||
return True
|
||
try:
|
||
injection_ack = self._run_self_heal_hook(
|
||
profile["index"], 1, 15.0,
|
||
"FAULT HOOK INJECT %s" % case_id,
|
||
after_arm=start_normal_task_after_arm)
|
||
fault = dict(base)
|
||
fault.update(profile["fault"])
|
||
fault["operation_stage"] = "FAULT_REACTION"
|
||
fault["bench_reaction"] = profile["fault_reaction"]
|
||
hook_result = None if injection_ack is None else \
|
||
injection_ack["result"]
|
||
hook_evidence = None if injection_ack is None else \
|
||
injection_ack["evidence_code"]
|
||
fault["hook_result"] = hook_result
|
||
fault["hook_result_name"] = (
|
||
"NO_RESPONSE" if hook_result is None else
|
||
SELF_HEAL_HOOK_RESULT_NAMES.get(
|
||
hook_result, "UNKNOWN_%s" % hook_result))
|
||
fault["hook_evidence_code"] = hook_evidence
|
||
fault["hook_evidence_name"] = (
|
||
"未收到应答" if hook_evidence is None else
|
||
SELF_HEAL_HOOK_EVIDENCE_NAMES.get(
|
||
hook_evidence, "0x%02X" % hook_evidence))
|
||
fault["bench_evidence"] = [
|
||
"TX 0x2F0 case=%d operation=INJECT" % profile["index"],
|
||
("RX 0x2F1 result=%s evidence=%s" %
|
||
("--" if injection_ack is None else injection_ack["result"],
|
||
"--" if injection_ack is None else
|
||
injection_ack["evidence_code"])),
|
||
profile["inject"],
|
||
]
|
||
if demo_task is not None:
|
||
fault["interruption_position"] = \
|
||
demo_task.get("interruption_position")
|
||
fault["original_task_target"] = demo_task.get("target")
|
||
fault["original_task_start"] = demo_task.get("start")
|
||
fault["normal_task_observed_s"] = demo_task.get(
|
||
"normal_observed_s")
|
||
fault["normal_task_visible_delta"] = demo_task.get(
|
||
"visible_delta")
|
||
if demo_task.get("interruption_position") is not None:
|
||
fault["normal_task_remaining_at_injection"] = abs(
|
||
int(demo_task["target"]) -
|
||
int(demo_task["interruption_position"]))
|
||
elif base.get("normal_task_started_epoch") is not None:
|
||
fault["normal_task_observed_s"] = round(max(
|
||
0.0, time.time() -
|
||
float(base["normal_task_started_epoch"])), 3)
|
||
if injection_ack is None or injection_ack["result"] != 0:
|
||
fault["injection_failure_detail"] = (
|
||
"故障注入未成立:0x2F1 %s,证据=%s;未进入AI诊断阶段,台架已安全停止" %
|
||
(fault["hook_result_name"],
|
||
fault["hook_evidence_name"]))
|
||
self._publish_healing_progress(
|
||
request_id, case_id, "FAILED", "实物故障注入未确认",
|
||
"未收到台架故障钩子成功应答,AI拒绝执行修复", fault)
|
||
return "FAIL", {
|
||
"summary": "0x2F0故障注入没有收到有效0x2F1成功应答"}
|
||
self._publish_healing_progress(
|
||
request_id, case_id, "INJECTED", "实物故障已写入",
|
||
profile["fault_reaction"], fault)
|
||
self.message(
|
||
"VISIBLE FAULT STOP: original task interrupted at %s; target %s retained" %
|
||
(fault.get("interruption_position", "--"),
|
||
fault.get("original_task_target", "--")))
|
||
# Keep the mechanism still long enough for an operator to see
|
||
# that the original task was interrupted by the fault.
|
||
if not self.capture(1.8):
|
||
return "STOPPED", {"summary": "故障观察阶段收到紧急停止"}
|
||
self._publish_healing_progress(
|
||
request_id, case_id, "DIAGNOSED", "AI完成实物故障定位",
|
||
fault.get("ai_hint", profile["inject"]), fault,
|
||
profile["fault_code"])
|
||
if not self.capture(1.2):
|
||
return "STOPPED", {"summary": "诊断展示阶段收到紧急停止"}
|
||
|
||
repairing = dict(fault)
|
||
repairing["repair_action"] = profile["repair"]
|
||
repairing["operation_stage"] = "REPAIR"
|
||
repairing["bench_reaction"] = profile["repair"]
|
||
self._publish_healing_progress(
|
||
request_id, case_id, "REPAIRING", "执行实物受限恢复",
|
||
profile["repair"], repairing, profile["fault_code"])
|
||
physical_repair = None
|
||
if case_id == "FI-FIELD-01":
|
||
# The firmware hook invalidates the real runtime position and
|
||
# commits a newer dirty SMV8 transaction. Recovery therefore
|
||
# uses the production HOME_KNOWN_RANGE path for the affected
|
||
# backrest axis; no test-only shortcut sets PositionValid.
|
||
repair_state, physical_repair = \
|
||
self.execute_initialize_known_ranges({
|
||
"payload": {"force_all_axes": False}})
|
||
repairing["home_known_range"] = physical_repair
|
||
if repair_state != "PASS":
|
||
self._publish_healing_progress(
|
||
request_id, case_id, "FAILED",
|
||
"实物位置重建失败",
|
||
physical_repair.get("summary", "HOME_KNOWN_RANGE失败"),
|
||
repairing, profile["fault_code"])
|
||
return "FAIL", {
|
||
"summary": "PositionValid失效轴未完成真实回零",
|
||
"home_known_range": physical_repair}
|
||
self.capture(1.0)
|
||
repair_ack = self._run_self_heal_hook(
|
||
profile["index"], 2, 180.0,
|
||
"AI REPAIR HOOK %s" % case_id)
|
||
if repair_ack is None or repair_ack["result"] != 0:
|
||
self._publish_healing_progress(
|
||
request_id, case_id, "FAILED", "实物修复执行失败",
|
||
"台架修复钩子未返回成功,已提交安全关闭", repairing,
|
||
profile["fault_code"])
|
||
return "FAIL", {"summary": "实物修复钩子没有成功完成"}
|
||
|
||
if case_id == "FI-FIELD-03":
|
||
repairing["bench_reaction"] = \
|
||
"误判状态已清除,水平滑轨保持停止,准备一次短距健康复测"
|
||
self._publish_healing_progress(
|
||
request_id, case_id, "REPAIRING", "故障已修复,座椅保持停止",
|
||
repairing["bench_reaction"], repairing,
|
||
profile["fault_code"])
|
||
if not self.capture(1.0):
|
||
return "STOPPED", {"summary": "修复后停顿阶段收到紧急停止"}
|
||
self.message(
|
||
"REPAIR COMPLETE: false overload cleared; slide remains stopped")
|
||
|
||
verifying = dict(repairing)
|
||
verifying.update(profile["repaired"])
|
||
verifying["operation_stage"] = "VERIFYING"
|
||
verifying["bench_reaction"] = (
|
||
"水平滑轨执行一次短距健康复测,确认Hall反馈正常" if
|
||
case_id == "FI-FIELD-03" else
|
||
"修复已完成,正在确认位置记录和安全保护均已恢复")
|
||
verifying["task_restored"] = False
|
||
verifying["bench_evidence"] = [
|
||
"RX 0x2F1 REPAIR result=0",
|
||
"先复测保护,再恢复故障前原任务",
|
||
profile["repair"],
|
||
]
|
||
self._publish_healing_progress(
|
||
request_id, case_id, "VERIFYING", "修复后安全复测",
|
||
verifying["bench_reaction"], verifying,
|
||
profile["fault_code"])
|
||
verify_ack = self._run_self_heal_hook(
|
||
profile["index"], 3, 120.0,
|
||
"SELF HEAL VERIFY HOOK %s" % case_id)
|
||
protection_ok = verify_ack is not None and verify_ack["result"] == 0
|
||
self.message(
|
||
"SAFETY VERIFY %s: %s" %
|
||
("PASS" if protection_ok else "FAIL",
|
||
verifying["bench_reaction"]))
|
||
task_restored = protection_ok
|
||
if protection_ok and demo_task is not None:
|
||
resume_telemetry = dict(verifying)
|
||
resume_telemetry["operation_stage"] = "RESUMING"
|
||
resume_telemetry["bench_reaction"] = profile["resume_reaction"]
|
||
self._publish_healing_progress(
|
||
request_id, case_id, "VERIFYING", "继续执行故障前原任务",
|
||
profile["resume_reaction"], resume_telemetry,
|
||
profile["fault_code"])
|
||
if not self.capture(0.8):
|
||
return "STOPPED", {"summary": "恢复原任务前收到紧急停止"}
|
||
self.message(
|
||
"RESUME ORIGINAL TASK: motor M%d continues to retained target %s" %
|
||
(demo_task["motor"], demo_task["target"]))
|
||
task_restored = self._resume_demo_axis_task(
|
||
demo_task, "RESUME ORIGINAL TASK %s" % case_id)
|
||
elif protection_ok and case_id == "FI-FIELD-06":
|
||
task_restored = self._start_demo_comfort(3.0)
|
||
passed = protection_ok and task_restored
|
||
|
||
self.latest_thermal = None
|
||
self.transmit(0x42A, [0x0A, 0, 0, 0, 0, 0, 0, 0],
|
||
"self-healing task restored; final safe off")
|
||
final_off = self._wait_thermal_feedback(0x09, 3.0)
|
||
passed = passed and final_off is not None
|
||
final = dict(verifying)
|
||
final["request_feedback_match"] = passed
|
||
final["safety_protection"] = passed
|
||
final["task_restored"] = task_restored
|
||
final["operation_stage"] = "COMPLETE" if passed else "SAFE_STOP"
|
||
final["bench_reaction"] = (
|
||
profile["resume_reaction"] if passed else
|
||
"恢复证据不完整,台架已安全停止")
|
||
final["thermal_safe_off"] = final_off is not None
|
||
final["bench_evidence"] = [
|
||
("0x2F1 INJECT/REPAIR/VERIFY三阶段均成功" if protection_ok else
|
||
"0x2F1 VERIFY未返回成功"),
|
||
"原任务恢复%s" % ("完成" if task_restored else "失败"),
|
||
"实物目标反馈与保护复测%s" % ("通过" if passed else "失败"),
|
||
]
|
||
if passed:
|
||
self._publish_healing_progress(
|
||
request_id, case_id, "PASSED", "实物自修复闭环通过",
|
||
"原任务已恢复执行;验证结束后热负载自动安全关闭", final,
|
||
profile["fault_code"])
|
||
return "PASS", {
|
||
"summary": "%s真实运行、故障、修复、原任务恢复全部通过" % case_id,
|
||
"injection_ack": injection_ack,
|
||
"repair_ack": repair_ack, "verify_ack": verify_ack,
|
||
"physical_repair": physical_repair,
|
||
"restored_task": demo_task,
|
||
"thermal_safe_off": final_off,
|
||
}
|
||
self._publish_healing_progress(
|
||
request_id, case_id, "FAILED", "实物闭环复测失败",
|
||
"修复后实物复测没有返回完整成功证据", final,
|
||
profile["fault_code"])
|
||
return "FAIL", {
|
||
"summary": "实物保护复测、原任务恢复或安全关闭未全部通过"
|
||
}
|
||
finally:
|
||
self.transmit(0x42A, [0x0A, 0, 0, 0, 0, 0, 0, 0],
|
||
"self-healing hook final safety off")
|
||
|
||
def execute_self_healing_case(self, request):
|
||
"""Run a real-bench software fault injection and recovery.
|
||
|
||
FI-FIELD-04 first runs the real fan/heater comfort request, simulates a
|
||
wake transition, then puts the ECU thermal state machine into an
|
||
invalid ordering state. Recovery replays mode -> configuration ->
|
||
control, restores the original request, verifies 0x4FA plus both real
|
||
0x305 currents, then always switches the outputs off.
|
||
"""
|
||
payload = request.get("payload") or {}
|
||
case_id = payload.get("case_id")
|
||
request_id = request.get("request_id")
|
||
if case_id in SELF_HEAL_HOOK_CASES:
|
||
return self._execute_hook_self_healing_case(
|
||
request, SELF_HEAL_HOOK_CASES[case_id])
|
||
if case_id != "FI-FIELD-04":
|
||
return "BLOCKED", {
|
||
"summary": "未知自修复台架用例"}
|
||
|
||
base_telemetry = {
|
||
"source": "REAL_BENCH_CAN",
|
||
"bench_request_id": request_id,
|
||
"thermal_request": "通风2挡 / 加热1挡",
|
||
"request_feedback_match": True,
|
||
"safety_protection": True,
|
||
"normal_task": "远程舒适功能请求通风2挡和靠背加热1挡",
|
||
"normal_task_min_observe_s": 6.0,
|
||
"operation_stage": "NORMAL_TASK",
|
||
"bench_reaction": "风扇可听见运行,加热电流已建立",
|
||
}
|
||
self._publish_healing_progress(
|
||
request_id, case_id, "BASELINE", "实物正常任务运行",
|
||
"先运行原远程舒适任务,再模拟唤醒时序异常",
|
||
base_telemetry)
|
||
if not self.stop_all_sync():
|
||
self._publish_healing_progress(
|
||
request_id, case_id, "FAILED", "基线确认失败",
|
||
"实物ECU没有返回STOP_ALL ACK,未注入故障",
|
||
dict(base_telemetry, request_feedback_match=False))
|
||
return "FAIL", {"summary": "基线STOP_ALL无有效ACK,未注入故障"}
|
||
|
||
self.transmit(0x42A, [0x0A, 0, 0, 0, 0, 0, 0, 0],
|
||
"self-healing baseline thermal off")
|
||
self.latest_thermal = None
|
||
off_feedback = self._wait_thermal_feedback(0x09, 3.0)
|
||
self.capture(0.8)
|
||
baseline_thermal = self.latest_thermal_current or {}
|
||
baseline_raw = int(baseline_thermal.get("heater_raw", 0))
|
||
baseline_fan_raw = int(baseline_thermal.get("fan_raw", 0))
|
||
if off_feedback is None:
|
||
self._publish_healing_progress(
|
||
request_id, case_id, "FAILED", "基线确认失败",
|
||
"没有收到热管理关闭反馈0x4FA Byte0=0x09,未注入故障",
|
||
dict(base_telemetry, request_feedback_match=False))
|
||
return "FAIL", {"summary": "热管理关闭基线无反馈,未注入故障"}
|
||
|
||
base_telemetry["normal_task_started_epoch"] = time.time()
|
||
base_telemetry["bench_reaction"] = (
|
||
"风扇可听见运行、加热电流建立;正常任务保持6.0秒后再模拟唤醒")
|
||
self._publish_healing_progress(
|
||
request_id, case_id, "BASELINE", "正常任务持续运行",
|
||
base_telemetry["bench_reaction"], base_telemetry)
|
||
if not self._start_demo_comfort(6.0):
|
||
self._publish_healing_progress(
|
||
request_id, case_id, "FAILED", "正常任务未建立",
|
||
"故障前通风/加热舒适任务没有形成实物反馈",
|
||
dict(base_telemetry, request_feedback_match=False))
|
||
return "FAIL", {"summary": "故障前的实物远程舒适任务未建立"}
|
||
self.latest_thermal = None
|
||
self.transmit(0x42A, [0x0A, 0, 0, 0, 0, 0, 0, 0],
|
||
"simulate ECU sleep before wake ordering fault")
|
||
if self._wait_thermal_feedback(0x09, 3.0) is None:
|
||
self._publish_healing_progress(
|
||
request_id, case_id, "FAILED", "唤醒前安全关闭失败",
|
||
"无法建立可重复的热管理唤醒基线,未注入故障",
|
||
dict(base_telemetry, request_feedback_match=False))
|
||
return "FAIL", {"summary": "模拟唤醒前无法安全关闭热负载"}
|
||
|
||
try:
|
||
# Real exception write: explicitly invalidate the heater mode and
|
||
# issue the control frame before the required mode/config frames.
|
||
self.latest_thermal = None
|
||
self.transmit(0x3CE, [0, 0, 0, 0, 0, 0, 0, 0],
|
||
"FAULT INJECTION invalid thermal mode")
|
||
self.transmit(0x29E, [0x11, 0, 0x12, 0, 0, 0, 0, 0],
|
||
"FAULT INJECTION control before mode/config")
|
||
# The wake-race control uses invalid default fan mode plus an
|
||
# invalid heater mode. Both requested outputs are rejected, so
|
||
# the packed physical feedback is 0x00 instead of target 0x13.
|
||
injected_feedback = self._wait_thermal_feedback(0x00, 2.0)
|
||
self.capture(0.8)
|
||
injected_raw = int((self.latest_thermal_current or {}).get(
|
||
"heater_raw", 0))
|
||
injection_observed = injected_feedback is not None
|
||
injected_telemetry = dict(
|
||
base_telemetry,
|
||
normal_task_observed_s=round(max(
|
||
0.0, time.time() - float(base_telemetry[
|
||
"normal_task_started_epoch"])), 3),
|
||
thermal_feedback="请求无效 / 输出关闭",
|
||
config_ready=False,
|
||
request_feedback_match=False,
|
||
operation_stage="FAULT_REACTION",
|
||
bench_reaction="风扇无声、加热电流为零,原舒适任务中断",
|
||
fan_running=False, heater_level=0,
|
||
heater_current_raw_before=baseline_raw,
|
||
heater_current_raw_injected=injected_raw,
|
||
ai_hint="实物0x29E请求存在,但0x4FA返回无效且0x305电流未建立",
|
||
bench_evidence=[
|
||
"TX 0x3CE无效模式 + 提前TX 0x29E",
|
||
"RX 0x4FA Byte0=0x00(通风/加热请求均无效)",
|
||
"0x305 heater raw=%d→%d" %
|
||
(baseline_raw, injected_raw),
|
||
])
|
||
self._publish_healing_progress(
|
||
request_id, case_id, "INJECTED", "实物故障已写入",
|
||
"风扇和加热均未启动,异常由实物反馈确认",
|
||
injected_telemetry)
|
||
if not injection_observed:
|
||
self._publish_healing_progress(
|
||
request_id, case_id, "FAILED", "故障证据不成立",
|
||
"没有收到预期的关闭反馈,AI拒绝执行修复",
|
||
injected_telemetry)
|
||
return "FAIL", {"summary": "台架故障注入证据不成立"}
|
||
if not self.capture(0.9):
|
||
return "STOPPED", {"summary": "故障观察阶段收到紧急停止"}
|
||
|
||
self._publish_healing_progress(
|
||
request_id, case_id, "DIAGNOSED", "AI完成实物故障定位",
|
||
"请求存在、ADC在线而热管理模式无效,定位为初始化报文顺序错误",
|
||
injected_telemetry,
|
||
"THERMAL_WAKE_SEQUENCE_MISMATCH")
|
||
if not self.capture(0.7):
|
||
return "STOPPED", {"summary": "诊断展示阶段收到紧急停止"}
|
||
|
||
repairing = dict(injected_telemetry)
|
||
repairing["repair_action"] = (
|
||
"实物重放0x3CE模式→0x2AA配置→0x29E控制")
|
||
repairing["operation_stage"] = "REPAIR"
|
||
repairing["bench_reaction"] = "按依赖顺序重建热管理会话"
|
||
self._publish_healing_progress(
|
||
request_id, case_id, "REPAIRING", "执行实物受限恢复",
|
||
"保持原保护阈值,按依赖顺序重新发送三帧",
|
||
repairing, "THERMAL_WAKE_SEQUENCE_MISMATCH")
|
||
self.heater_current_history = []
|
||
self.fan_current_history = []
|
||
self.latest_thermal = None
|
||
self.transmit(0x3CE, [0x10, 0, 0, 0, 0, 0, 0, 0],
|
||
"AI REPAIR thermal mode")
|
||
self.transmit(0x2AA, [0x01, 0, 0x1B, 0, 0, 0, 0, 0],
|
||
"AI REPAIR thermal config")
|
||
self.transmit(0x29E, [0x15, 0, 0x12, 0, 0, 0, 0, 0],
|
||
"AI REPAIR fan2 + heater1")
|
||
active_feedback = self._wait_thermal_feedback(0x13, 3.0)
|
||
self.capture(4.0)
|
||
active_values = [sample.get("raw", 0)
|
||
for _, sample in self.heater_current_history]
|
||
active_peak = max(active_values) if active_values else int(
|
||
(self.latest_thermal_current or {}).get("heater_raw", 0))
|
||
current_delta = active_peak - baseline_raw
|
||
fan_values = [sample.get("raw", 0)
|
||
for _, sample in self.fan_current_history]
|
||
fan_peak = max(fan_values) if fan_values else int(
|
||
(self.latest_thermal_current or {}).get("fan_raw", 0))
|
||
fan_delta = fan_peak - baseline_fan_raw
|
||
verifying = dict(
|
||
repairing,
|
||
thermal_feedback=("通风2挡 / 加热1挡" if active_feedback is not None
|
||
else "反馈未恢复"),
|
||
config_ready=True,
|
||
operation_stage="RESUMING",
|
||
bench_reaction="风扇重新转动,加热电流建立,原舒适任务恢复",
|
||
fan_running=active_feedback is not None,
|
||
heater_level=1 if active_feedback is not None else 0,
|
||
task_restored=active_feedback is not None,
|
||
fan_current_raw_peak=fan_peak,
|
||
fan_current_delta_raw=fan_delta,
|
||
heater_current_raw_peak=active_peak,
|
||
heater_current_delta_raw=current_delta,
|
||
request_feedback_match=active_feedback is not None,
|
||
bench_evidence=[
|
||
"RX 0x4FA Byte0=%s" %
|
||
("0x13" if active_feedback is not None else "未恢复"),
|
||
"0x305 fan peak=%d,delta=%d" %
|
||
(fan_peak, fan_delta),
|
||
"0x305 heater peak=%d,delta=%d" %
|
||
(active_peak, current_delta),
|
||
"修复后自动发送0x42A安全关闭",
|
||
])
|
||
self._publish_healing_progress(
|
||
request_id, case_id, "VERIFYING", "恢复原任务并闭环复测",
|
||
"通风2挡与加热1挡已恢复,验证双负载反馈和电流",
|
||
verifying, "THERMAL_WAKE_SEQUENCE_MISMATCH")
|
||
|
||
self.latest_thermal = None
|
||
self.transmit(0x42A, [0x0A, 0, 0, 0, 0, 0, 0, 0],
|
||
"self-healing verification safe off")
|
||
final_off = self._wait_thermal_feedback(0x09, 3.0)
|
||
passed = (active_feedback is not None and
|
||
fan_delta >= FAN_CURRENT_ACTIVE_DELTA_RAW and
|
||
current_delta >= HEATER_CURRENT_ACTIVE_DELTA_RAW and
|
||
final_off is not None)
|
||
final = dict(
|
||
verifying,
|
||
request_feedback_match=passed,
|
||
safety_protection=final_off is not None,
|
||
operation_stage="COMPLETE" if passed else "SAFE_STOP",
|
||
bench_reaction=("原舒适任务已恢复,验证结束后安全关闭" if
|
||
passed else "恢复证据不完整,台架已安全停止"),
|
||
fan_running=False, heater_level=0,
|
||
thermal_feedback="关闭(验证完成)" if final_off else "关闭未确认")
|
||
if passed:
|
||
self._publish_healing_progress(
|
||
request_id, case_id, "PASSED", "实物自修复闭环通过",
|
||
"通风/加热原任务与0x305双电流均恢复,输出已自动安全关闭",
|
||
final, "THERMAL_WAKE_SEQUENCE_MISMATCH")
|
||
return "PASS", {
|
||
"summary": "实物热管理任务、故障、AI修复和原任务恢复全部通过",
|
||
"baseline_fan_raw": baseline_fan_raw,
|
||
"active_fan_peak": fan_peak,
|
||
"active_fan_delta": fan_delta,
|
||
"baseline_heater_raw": baseline_raw,
|
||
"active_heater_peak": active_peak,
|
||
"active_heater_delta": current_delta,
|
||
"active_feedback": active_feedback,
|
||
"off_feedback": final_off,
|
||
}
|
||
self._publish_healing_progress(
|
||
request_id, case_id, "FAILED", "实物闭环复测失败",
|
||
"反馈、电流或安全关闭证据不完整,输出已进入安全关闭",
|
||
final, "THERMAL_WAKE_SEQUENCE_MISMATCH")
|
||
return "FAIL", {
|
||
"summary": "实物修复证据不完整:feedback=%s fan=%d heater=%d off=%s" %
|
||
(active_feedback is not None, fan_delta,
|
||
current_delta, final_off is not None)}
|
||
finally:
|
||
# This redundant off is intentional: every exit path leaves the
|
||
# heating/fan outputs in a known safe state.
|
||
self.transmit(0x42A, [0x0A, 0, 0, 0, 0, 0, 0, 0],
|
||
"self-healing final safety off")
|
||
|
||
def process_request(self, path):
|
||
name = os.path.basename(path)
|
||
processing = os.path.join(PROCESSING_DIR, name)
|
||
try:
|
||
os.replace(path, processing)
|
||
except OSError:
|
||
return
|
||
request = _read_json(processing)
|
||
if request is None:
|
||
self.message("无法读取请求:%s" % name)
|
||
os.replace(processing, os.path.join(COMPLETED_DIR, name))
|
||
return
|
||
request_id = request.get("request_id", name)
|
||
self.current_request_id = request_id
|
||
self.bridge_state = "RUNNING"
|
||
self.logger = RunLogger(request)
|
||
self.message("开始请求 %s action=%s" %
|
||
(request_id, request.get("action")))
|
||
self.publish(force=True)
|
||
result = "FAIL"
|
||
details = {"summary": "未执行"}
|
||
try:
|
||
valid = (request.get("schema") == "seat-function-request-v1" and
|
||
float(request.get("armed_until_epoch", 0)) >= time.time())
|
||
if not valid:
|
||
result = "BLOCKED"
|
||
details = {"summary": "请求已过期或schema无效,未驱动任何输出"}
|
||
elif request.get("action") == "execute_mode":
|
||
result, details = self.execute_mode(request)
|
||
elif request.get("action") == "execute_thermal":
|
||
result, details = self.execute_thermal(request)
|
||
elif request.get("action") == "initialize_known_ranges":
|
||
result, details = self.execute_initialize_known_ranges(request)
|
||
elif request.get("action") == "execute_self_healing_case":
|
||
result, details = self.execute_self_healing_case(request)
|
||
elif request.get("action") == "execute_hardware_diagnostic":
|
||
result, details = self.execute_hardware_diagnostic(request)
|
||
else:
|
||
result = "BLOCKED"
|
||
details = {"summary": "未知action,未驱动任何输出"}
|
||
except Exception as error:
|
||
result = "FAIL"
|
||
details = {"summary": "桥接异常:%r" % error}
|
||
self.message(details["summary"])
|
||
try:
|
||
sequence = self.next_sequence()
|
||
self.transmit(0x300, command_payload(0x09, 0, 0, sequence),
|
||
"exception STOP_ALL")
|
||
self.transmit(0x42A, [0x0A, 0, 0, 0, 0, 0, 0, 0],
|
||
"exception thermal off")
|
||
except Exception as stop_error:
|
||
self.message("异常后的安全关闭失败:%r" % stop_error)
|
||
finally:
|
||
log_dir = self.logger.run_dir
|
||
self.logger.finish(result, details)
|
||
self.logger = None
|
||
self.last_result = {
|
||
"request_id": request_id,
|
||
"result": result,
|
||
"summary": details.get("summary", ""),
|
||
"log_dir": log_dir,
|
||
"report_path": os.path.join(log_dir, "report.json"),
|
||
"details": details,
|
||
}
|
||
self.message("完成 %s result=%s logs=%s" %
|
||
(request_id, result, log_dir))
|
||
self.current_request_id = None
|
||
self.bridge_state = "IDLE"
|
||
self.publish(force=True)
|
||
destination = os.path.join(COMPLETED_DIR, name)
|
||
if os.path.exists(destination):
|
||
destination += ".%d" % int(time.time())
|
||
os.replace(processing, destination)
|
||
|
||
def run(self):
|
||
self.publish(force=True)
|
||
while not stop_task:
|
||
self.check_emergency()
|
||
self.receive()
|
||
pending = sorted(
|
||
os.path.join(PENDING_DIR, name)
|
||
for name in os.listdir(PENDING_DIR)
|
||
if name.lower().endswith(".json"))
|
||
if pending:
|
||
self.process_request(pending[0])
|
||
self.publish()
|
||
time.sleep(0.01)
|
||
|
||
|
||
def z_main():
|
||
global stop_task
|
||
stop_task = False
|
||
bridge = None
|
||
try:
|
||
bridge = SeatFunctionBridge()
|
||
bridge.run()
|
||
except Exception as error:
|
||
zcanpro.write_log("SEAT_FUNCTION_BRIDGE_ERROR %r" % error)
|
||
finally:
|
||
if bridge is not None:
|
||
bridge.bridge_state = "STOPPED"
|
||
try:
|
||
sequence = bridge.next_sequence()
|
||
bridge.transmit(0x300,
|
||
command_payload(0x09, 0, 0, sequence),
|
||
"bridge exit STOP_ALL")
|
||
bridge.transmit(0x42A,
|
||
[0x0A, 0, 0, 0, 0, 0, 0, 0],
|
||
"bridge exit thermal off")
|
||
except Exception as error:
|
||
bridge.message("退出安全关闭失败:%r" % error)
|
||
bridge.publish(force=True)
|