Files
Cognitive_Agent/tools/zcan/seat_self_repair_bridge.py
T

2233 lines
105 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""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)