Files
Cognitive_Agent/tools/zcan/seat_self_repair_bridge.py
T

2842 lines
135 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.
"""Transport-neutral bridge for the seat/cabin hardware workflow.
The protocol engine can still run in ZCANPRO for compatibility, but the normal
application now injects a direct ZLG channel transport. Both modes consume the
same armed requests and publish the same status/report schema.
"""
from __future__ import unicode_literals
import csv
import datetime
import importlib
import json
import math
import os
import sys
import time
try:
import zcanpro
except ImportError: # normal Python uses an injected direct-CAN transport
zcanpro = None
# ZCANPRO executes extension scripts without adding the script directory or
# the firmware HIL helpers to ``sys.path``. A fresh ZCANPRO process therefore
# could only start this bridge when another script happened to have imported
# ``hil_common`` first. Resolve the repository helper explicitly so bridge
# startup is deterministic after a reboot.
BASE_DIR = os.path.dirname(os.path.abspath(__file__))
REPOSITORY_ROOT = os.path.dirname(os.path.dirname(BASE_DIR))
HIL_COMMON_DIR = os.path.join(
REPOSITORY_ROOT, "firmware", "t2g", "Seat_Controller_demo",
"Tests", "manual_hil")
if HIL_COMMON_DIR not in sys.path:
sys.path.insert(0, HIL_COMMON_DIR)
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
RUNTIME_DIR = os.environ.get(
"SELF_REPAIR_RUNTIME_DIR",
os.path.join(REPOSITORY_ROOT, "artifacts", "tmp", "self-repair", "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.environ.get(
"SELF_REPAIR_BRIDGE_STATUS_PATH", os.path.join(RUNTIME_DIR, "bridge_status.json"))
EMERGENCY_PATH = os.path.join(RUNTIME_DIR, "emergency_stop.json")
LOG_ROOT = os.environ.get(
"SELF_REPAIR_HARDWARE_LOG_DIR",
os.path.join(REPOSITORY_ROOT, "artifacts", "self-repair", "hardware"),
)
CONFIRMED_MAX_HALL = (480, 374, 917, 647)
EXPECTED_FIRMWARE_REVISION = 15
EXPECTED_FIRMWARE_BUILD_ID = 16
FIRMWARE_ID_MAGIC = [0x53, 0x4D, 0x56, 0x38] # "SMV8"
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": "重建双槽后继续执行原腿托舒适位置",
# Keep both physical legs inside the connected seat's repeatedly
# observed M3 travel window. The CRC scenario needs a real saved move
# and a later restored move; it does not require a long-range pose.
# Save one reachable short-travel comfort pose, retreat from it, then
# use the saved pose itself as the post-repair recall target. This is
# both semantically accurate and independent of the connected seat's
# much shorter M3 internal-limit window.
"demo_motor": 3, "demo_delta": 200, "prelude_delta": 200,
"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-10-legrest-runtime-v36"
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
class ZcanProTransport(object):
"""Compatibility adapter for ZCANPRO's injected extension API."""
def __init__(self):
if zcanpro is None:
raise RuntimeError("zcanpro is unavailable; inject a CAN transport")
buses = zcanpro.get_buses()
if not buses:
raise RuntimeError("ZCANPRO中没有已启动的CAN通道")
self.bus = buses[0]
self.bus_id = self.bus["busID"]
def log(self, value):
zcanpro.write_log(str(value))
def send(self, can_id, data):
frame = {
"can_id": int(can_id),
"is_canfd": 0,
"canfd_brs": 0,
"data": [int(item) & 0xFF for item in data],
}
if not zcanpro.transmit(self.bus_id, [frame]):
raise RuntimeError("ZCANPRO transmit returned false")
def receive(self):
ok, frames = zcanpro.receive(self.bus_id)
return list(frames or []) if ok else []
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 True
except PermissionError:
if attempt >= 7:
# The status file is a best-effort telemetry snapshot.
# Windows readers may briefly deny FILE_SHARE_DELETE;
# dropping one snapshot is safe because the 10 ms bridge
# loop republishes the retained state (and last_result).
# Raising here would terminate the live ZCAN bridge and
# lose the in-flight request result.
if os.path.normcase(os.path.abspath(path)) == \
os.path.normcase(os.path.abspath(STATUS_PATH)):
return False
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
return False
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, transport=None, stop_event=None):
for path in (RUNTIME_DIR, PENDING_DIR, PROCESSING_DIR, COMPLETED_DIR,
LOG_ROOT):
os.makedirs(path, exist_ok=True)
self.transport = transport or ZcanProTransport()
self.stop_event = stop_event
self.bus = dict(self.transport.bus)
self.bus_id = self.bus.get("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.latest_dynamic = None
self.latest_firmware_identity = 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)
self.transport.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,
"dynamic": self.latest_dynamic,
"firmware_identity": self.latest_firmware_identity,
"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)
try:
self.transport.send(can_id, values)
except Exception:
raise RuntimeError("发送失败 0x%03X %s" %
(can_id, hex_data(values)))
def receive(self):
frames = self.transport.receive()
if 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 == 0x306 and len(data) >= 8:
self.latest_dynamic = {
"mode_id": data[0],
"state": data[1],
"phase": data[2],
"active_mask": data[3] & 0x0F,
"fault_mask": (data[3] >> 4) & 0x0F,
"cycle_index": data[4] | (data[5] << 8),
"phase_elapsed_ms": data[6] | (data[7] << 8),
}
elif can_id == 0x307 and len(data) >= 8:
self.latest_firmware_identity = {
"magic": "".join(chr(item) for item in data[:4]),
"magic_valid": data[:4] == FIRMWARE_ID_MAGIC,
"firmware_revision": data[4],
"firmware_build_id": data[5],
"motor_count": data[6],
"protocol_version": data[7],
"qualified": (
data[:4] == FIRMWARE_ID_MAGIC
and data[4] == EXPECTED_FIRMWARE_REVISION
and data[5] == EXPECTED_FIRMWARE_BUILD_ID
and data[6] == 4
and data[7] == 1
),
}
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()
# A silent or disconnected CAN bus must not kill the long-running
# bridge. The request still interrupts any active workflow, while
# each shutdown frame is attempted independently so observation can
# resume after a firmware flash or a temporary bus interruption.
try:
self.transmit(0x300, command_payload(0x09, 0, 0, sequence),
"GUI EMERGENCY STOP_ALL")
except Exception as error:
self.message("GUI STOP_ALL发送失败:%r" % error)
if request.get("thermal_off", True):
try:
self.transmit(0x42A, [0x0A, 0, 0, 0, 0, 0, 0, 0],
"GUI emergency thermal off")
except Exception as error:
self.message("GUI thermal off发送失败:%r" % error)
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(100, min(2800, int(delta)))
target = current + magnitude if current <= 5000 else current - magnitude
target = max(100, min(9200, target))
if abs(target - current) < 100:
target = min(9200, current + 100) if current < 5000 else \
max(100, current - 100)
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 _depart_legrest_endpoint_for_home(self):
"""Move M3 off its hard stop with a bounded adaptive timed profile.
Absolute homing cannot prove an endpoint when the loaded legrest is
statically wedged and its first Hall edge never arrives. The active
pin diagnostic normally releases this bench with a bounded near/far
pair at 60% PWM. Loaded repetitions can need additional breakaway
torque, so retry at 75% and finally the ECU's normal 90% duty. Every
leg remains limited to 900 ms and is followed by STOP; multiple real
Hall edges are still required before proceeding.
"""
motor = 3
attempts = []
initial = self.latest_diagnostics[motor]
initial_hall = None if initial is None else int(initial["hall"])
for attempt, duty_x10 in enumerate((600, 750, 900), start=1):
for direction in (2, 1):
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,
900, duty_x10),
1, 3.0,
"M3 HOME breakaway attempt=%d direction=%d" %
(attempt, direction))
if state == "EMERGENCY":
return False, {
"summary": "M3端点脱离期间收到紧急停止",
"attempts": attempts,
}
self.capture(1.45)
stop_sequence = self.next_sequence()
self.send_command(
command_payload(0x08, motor, 0, stop_sequence),
1, 2.0, "M3 HOME breakaway bounded stop")
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)
cumulative_hall_delta = None if initial_hall is None or \
after_hall is None else abs(after_hall - initial_hall)
accepted = (state == "OK" and response is not None and
int(response.get("reject", 1)) == 0)
attempts.append({
"attempt": attempt,
"direction": direction,
"duty_percent": duty_x10 // 10,
"accepted": accepted,
"hall_before": before_hall,
"hall_after": after_hall,
"hall_delta": hall_delta,
"cumulative_hall_delta": cumulative_hall_delta,
})
if(direction == 1 and accepted and
((hall_delta is not None and hall_delta >= 3) or
(cumulative_hall_delta is not None and
cumulative_hall_delta >= 2))):
return True, {
"summary": "M3已用%d%%受控近/远端序列脱离硬端点" %
(duty_x10 // 10),
"attempts": attempts,
}
return False, {
"summary": "M3受控脱离重试后仍无真实Hall边沿",
"attempts": attempts,
}
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~0x307且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: use high level for a short, bounded interval.
# The heater PWM is 100 Hz while 0x305 is published every 100 ms;
# at low duty those periods can stay phase-aligned on the OFF
# portion and hide a healthy load. High duty breaks that sampling
# blind spot while the three-second bound keeps the diagnostic
# thermally safe.
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, 0x30, 0, 0, 0, 0, 0],
"HW heater level 3 diagnostic pulse")
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_command_plan(self, request):
"""Execute a compiler-produced normal seat plan with this CAN owner.
The web server has already validated the FunctionConfig and CommandPlan.
This boundary validates the small physical allow-list again, replaces
placeholder motion sequences, and requires ECU acknowledgement plus
the matching physical feedback before reporting PASS.
"""
payload = request.get("payload") or {}
commands = payload.get("commands") or []
operations = payload.get("operations") or []
operation_by_id = dict((item.get("id"), item) for item in operations)
if not commands or not operations:
return "BLOCKED", {"summary": "座椅命令计划为空"}
allowed_messages = {
"T2G_Seat_Move_Command_300": 0x300,
"T2G_Seat_Thermal_Control_29E": 0x29E,
"T2G_Seat_Thermal_Mode_3CE": 0x3CE,
}
for command in commands:
message = command.get("message")
if message not in allowed_messages or \
int(command.get("frame_id", -1)) != allowed_messages[message]:
return "BLOCKED", {
"summary": "桥接拒绝未授权CAN消息 %s" % message,
}
data = command.get("data") or []
if len(data) != 8 or any(int(value) < 0 or int(value) > 255
for value in data):
return "BLOCKED", {"summary": "%s的CAN数据无效" % message}
refs = str(command.get("operation_ref", "")).split(",")
if not refs or any(ref not in operation_by_id for ref in refs):
return "BLOCKED", {"summary": "%s引用了未知操作" % message}
self.capture(0.8)
if self.latest_system is None:
return "BLOCKED", {"summary": "未收到0x301系统反馈"}
motion_commands = [
command for command in commands
if command.get("message") == "T2G_Seat_Move_Command_300" and
int((command.get("signals") or {}).get("Opcode", -1)) not in (0x09, 0x0F)
]
if motion_commands and not self.stop_all_sync():
return "FAIL", {
"summary": "自然语言座椅控制前的STOP_ALL无有效ACK",
"stop_all_attempted": True,
}
results = []
for command in commands:
if self.check_emergency():
return "STOPPED", {
"summary": "座椅控制过程中收到紧急停止",
"results": results,
"stop_all_attempted": bool(motion_commands),
}
message = command["message"]
refs = str(command["operation_ref"]).split(",")
referenced = [operation_by_id[ref] for ref in refs]
signals = command.get("signals") or {}
if message == "T2G_Seat_Thermal_Mode_3CE":
data = [int(value) & 0xFF for value in command["data"]]
if (data[0] & ~0x30) or any(data[1:]):
return "BLOCKED", {"summary": "3CE包含未授权位"}
self.transmit(0x3CE, data, command.get("id", "thermal mode"))
results.append({
"command_id": command.get("id"), "result": "PASS",
"summary": "热管理手动模式已下发",
})
continue
if message == "T2G_Seat_Thermal_Control_29E":
data = [int(value) & 0xFF for value in command["data"]]
if (data[0] & ~0x3F) or any(data[index] for index in (1, 3, 4, 5, 6, 7)):
return "BLOCKED", {"summary": "29E包含未授权位"}
expected = {}
for operation in referenced:
capability = operation.get("capability")
state = (operation.get("parameters") or {}).get("state")
level = int((operation.get("parameters") or {}).get("level", 1))
raw = 1 if state == "off" else min(level, 3) + 1
if capability == "seat.set_ventilation":
expected["ventilation"] = raw
elif capability == "seat.set_heating":
expected["heating"] = raw
else:
return "BLOCKED", {
"summary": "29E引用了非热管理操作 %s" % capability,
}
self.transmit(0x29E, data, command.get("id", "thermal control"))
def thermal_predicate(can_id, frame_data):
if can_id != 0x4FA or len(frame_data) < 1:
return None
ventilation = frame_data[0] & 0x07
heating = (frame_data[0] >> 3) & 0x07
if ("ventilation" in expected and
ventilation != expected["ventilation"]):
return None
if "heating" in expected and heating != expected["heating"]:
return None
return list(frame_data)
wait_state, feedback = self.wait(
thermal_predicate,
max(1.0, float((command.get("completion") or {}).get(
"timeout_ms", 3000)) / 1000.0))
if wait_state != "OK" or feedback is None:
return "FAIL", {
"summary": "0x4FA未确认热管理目标",
"results": results,
"expected": expected,
}
results.append({
"command_id": command.get("id"), "result": "PASS",
"summary": "0x4FA已确认热管理目标", "feedback": feedback,
})
continue
opcode = int(signals.get("Opcode", -1))
motor = int(signals.get("Motor_Selector", -1))
direction = int(signals.get("Direction", 0))
param_a = int(signals.get("Param_A", 0))
param_b = int(signals.get("Param_B", 0))
if opcode not in (0x01, 0x03, 0x09, 0x0B, 0x0E, 0x0F) or \
motor not in range(4):
return "BLOCKED", {
"summary": "0x300操作码或电机号未授权:opcode=0x%02X motor=%d" %
(opcode, motor),
}
# Absolute positioning needs a trusted Hall origin. A bounded
# timed command is deliberately allowed while PositionValid=0 so
# an operator can move an axis away from a hard stop and recover
# it with HOME_KNOWN_RANGE; forcing home before that recovery move
# can otherwise leave the axis permanently uncalibratable.
if opcode == 0x01 and self.latest_system is not None and \
not (self.latest_system["position_valid_mask"] & (1 << motor)):
if not payload.get("allow_auto_home", False):
return "BLOCKED", {
"summary": "M%d位置无效且请求未授权自动归零" % motor,
}
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,
}
completion = command.get("completion") or {}
target = int(completion.get("value", param_a))
tolerance = max(0, int(completion.get("tolerance", 100)))
current = self.latest_motors[motor] or {}
already_at_target = (
opcode in (0x01, 0x0B) and
bool(current.get("position_valid")) and
not bool(current.get("active")) and
abs(int(current.get("position", -100000)) - target) <= tolerance)
if opcode == 0x0B:
already_at_target = already_at_target and bool(current.get("near"))
if already_at_target:
results.append({
"command_id": command.get("id"), "result": "PASS",
"summary": "M%d已由真实CAN确认处于目标位置,未重复驱动机械端点" % motor,
"terminal": current, "motion_skipped": True,
"skip_reason": "PHYSICAL_TARGET_ALREADY_CONFIRMED",
})
continue
sequence = self.next_sequence()
expected_ack = 4 if opcode == 0x09 else 1
send_state, acknowledgement = self.send_command(
command_payload(opcode, motor, direction, sequence,
param_a, param_b),
expected_ack, 2.5, command.get("id", "orchestration"))
if send_state == "EMERGENCY":
return "STOPPED", {"summary": "下发时收到紧急停止"}
if send_state != "OK" or acknowledgement is None or \
acknowledgement.get("reject") != 0:
self.stop_all_sync()
return "FAIL", {
"summary": "0x300命令未被ECU接受",
"command_id": command.get("id"),
"acknowledgement": acknowledgement,
"stop_all_attempted": True,
}
if opcode == 0x09:
results.append({
"command_id": command.get("id"), "result": "PASS",
"summary": "STOP_ALL已确认",
"acknowledgement": acknowledgement,
})
continue
if opcode in (0x0E, 0x0F):
mode_id = 0 if opcode == 0x0F else param_a
accepted_states = set(
(command.get("completion") or {}).get("accepted_states", [1, 2]))
def dynamic_predicate(can_id, frame_data):
if can_id != 0x306 or len(frame_data) < 8:
return None
if opcode == 0x0F:
matched = frame_data[0] == 0 and frame_data[1] == 0
else:
matched = frame_data[0] == mode_id and \
frame_data[1] in accepted_states
return list(frame_data) if matched else None
dynamic_state, dynamic_feedback = self.wait(
dynamic_predicate,
max(1.0, float((command.get("completion") or {}).get(
"timeout_ms", 5000)) / 1000.0))
if dynamic_state != "OK" or dynamic_feedback is None:
self.stop_all_sync()
return "FAIL", {
"summary": "0x306未确认动态模式状态",
"mode_id": mode_id,
"stop_all_attempted": True,
}
results.append({
"command_id": command.get("id"), "result": "PASS",
"summary": "0x301/0x306已确认动态模式",
"feedback": dynamic_feedback,
})
continue
timeout_s = max(
1.0, float((command.get("completion") or {}).get(
"timeout_ms", POSITION_TIMEOUT_MS)) / 1000.0)
def motor_predicate(can_id, frame_data):
if can_id != 0x302:
return None
value = parse_302(frame_data)
if value is None or value["motor"] != motor or \
value["sequence"] != sequence:
return None
if not value["active"] and value["task"] in (6, 7):
return value
return None
motor_state, terminal = self.wait(motor_predicate, timeout_s + 2.0)
passed = (motor_state == "OK" and terminal is not None and
terminal.get("task") == 6 and
terminal.get("result") in (2, 4))
if opcode in (0x01, 0x0B):
passed = (passed and terminal.get("position_valid") and
abs(int(terminal.get("position", -100000)) - target) <=
tolerance)
if not passed:
self.stop_all_sync()
return "FAIL", {
"summary": "M%d运动反馈未达到目标" % motor,
"command_id": command.get("id"),
"target": target, "tolerance": tolerance,
"terminal": terminal, "stop_all_attempted": True,
}
results.append({
"command_id": command.get("id"), "result": "PASS",
"summary": "M%d物理反馈已达到目标" % motor,
"terminal": terminal,
})
self.capture(0.3)
return "PASS", {
"summary": "共享CAN Runtime已完成%d条座椅命令并验证实物反馈" %
len(commands),
"results": results,
"system": self.latest_system,
"motors": self.latest_motors,
"thermal": self.latest_thermal,
"dynamic": self.latest_dynamic,
"stop_all_attempted": bool(motion_commands),
}
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))
home_nonzero_axes = bool(payload.get("home_nonzero_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):
initial_status = self.latest_motors[motor]
initial_diag = self.latest_diagnostics[motor]
already_zero = bool(
initial_status is not None and
initial_status.get("position_valid") and
initial_status.get("near") and
int(initial_status.get("position", 65535)) <= 100 and
initial_diag is not None and
int(initial_diag.get("hall", 65535)) <= 2)
position_valid = (initial_mask & (1 << motor)) != 0
should_home = (force_all_axes or not position_valid or
(home_nonzero_axes and not already_zero))
if not should_home:
homed.append({
"motor": motor,
"result": "ALREADY_VALID_NOT_HOMED",
"status": initial_status,
"diagnostic": initial_diag,
"note": ("位置已有效且处于零位,未重复机械归零" if
already_zero else
"位置记录有效,未执行机械归零"),
})
continue
if self.check_emergency():
return "STOPPED", {
"summary": "回最近端过程中收到紧急停止",
"axes": homed,
}
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,
"home_nonzero_axes": home_nonzero_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()
arm_ack = None
# A controlled watchdog case deliberately makes CAN disappear between
# INJECT and REPAIR. Repeat the idempotent ARM exchange while the ECU
# reboots instead of classifying that bounded outage as CAN failure.
arm_deadline = time.monotonic() + 12.0
while arm_ack is None and time.monotonic() < arm_deadline:
self.transmit(SELF_HEAL_HOOK_REQUEST_ID, arm_payload,
"ARM " + note)
arm_ack = self._wait_self_heal_hook_response(
case_index, 0, arm_token,
min(2.0, max(0.1, arm_deadline - time.monotonic())))
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)
departure_ok, departure = \
self._depart_legrest_endpoint_for_home()
base["normal_task_endpoint_departure"] = departure
if not departure_ok:
self._publish_healing_progress(
request_id, case_id, "FAILED", "正常任务未建立",
"CRC用例的故障前腿托无法真实离开机械端点,未注入故障",
dict(base, request_feedback_match=False))
return "FAIL", {
"summary": departure.get(
"summary", "CRC用例腿托端点脱离失败")}
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用例的正常腿托调整未完成"}
# The ECU marks SMV8 dirty before motion and only commits the
# settled clean record after a 3000 ms quiet period. Injecting
# CRC corruption earlier is correctly rejected by the firmware
# because there is not yet a saved comfort position to protect.
self.capture(3.5)
saved_target = int(prelude["target"])
self.capture(0.35)
current_status = self.latest_motors[profile["demo_motor"]] or {}
retreat_start = int(current_status.get(
"position", saved_target))
retreat_target = max(100, retreat_start - 200)
retreat = {
"motor": int(profile["demo_motor"]),
"start": retreat_start,
"target": retreat_target,
"sequence": None,
"ack": None,
}
if (not current_status.get("position_valid") or
not self._start_demo_axis_task(
retreat, "NORMAL TASK leave saved comfort pose") or
not self._wait_demo_axis_task(retreat)):
self._publish_healing_progress(
request_id, case_id, "FAILED", "正常任务未建立",
"CRC用例无法从已保存舒适位安全回退,未注入故障",
dict(base, request_feedback_match=False))
return "FAIL", {"summary": "CRC用例舒适位回退未完成"}
# Retreat is another persisted position transaction. Wait
# through its clean SMV8 commit before deliberately writing a
# higher-sequence corrupt inactive slot; otherwise the ECU
# correctly rejects the HIL injection while the store is dirty.
self.capture(3.5)
demo_task = {
"motor": int(profile["demo_motor"]),
"start": int(retreat["target"]),
"target": saved_target,
"sequence": None,
"ack": None,
}
if demo_task is None and "demo_endpoints" in profile:
demo_task = self._demo_axis_far_endpoint_task(
profile["demo_motor"], profile["demo_endpoints"])
elif demo_task is None:
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)
self.latest_thermal = None
self.transmit(0x2AA, [0x01, 0, 0x1B, 0, 0, 0, 0, 0],
"FI-05 normal fan configuration")
self.transmit(0x29E, [0x25, 0, 0x02, 0, 0, 0, 0, 0],
"FI-05 normal fan gear 2")
if self._wait_thermal_feedback(0x0B, 3.0) is None:
return False
if not self.capture(profile.get("normal_observe_s", 5.5)):
return False
self.latest_thermal = None
self.transmit(0x42A, [0x0A, 0, 0, 0, 0, 0, 0, 0],
"FI-05 user requested fan off")
return self._wait_thermal_feedback(0x09, 3.0) is not None
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成功应答"}
if case_id == "FI-FIELD-05":
reentry_feedback = self._wait_thermal_feedback(0x0B, 3.0)
fault["fan_reentry_feedback"] = reentry_feedback
if reentry_feedback is None:
self._publish_healing_progress(
request_id, case_id, "FAILED", "风扇异常重启未建立",
"0x2F1已确认注入,但实物0x4FA未出现风扇2挡重入",
fault)
return "FAIL", {
"summary": "风扇重入故障没有形成实物反馈"}
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": "诊断展示阶段收到紧急停止"}
if case_id == "FI-FIELD-06":
# The real watchdog upper limit is 3200 ticks. The generic
# 1.8 s + 1.2 s display window ends just before expiry, which
# can race the repair request against the pending reset. Keep
# observing through the actual reboot; ARM below already
# retries idempotently while CAN is temporarily absent.
if not self.capture(2.0):
return "STOPPED", {
"summary": "WDT复位观察阶段收到紧急停止"}
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
fan_cycle_evidence = []
if protection_ok and case_id == "FI-FIELD-05":
for cycle in range(1, 4):
self.latest_thermal = None
self.transmit(
0x2AA, [0x01, 0, 0x1B, 0, 0, 0, 0, 0],
"FI-05 verify cycle %d config" % cycle)
self.transmit(
0x29E, [0x25, 0, 0x01, 0, 0, 0, 0, 0],
"FI-05 verify cycle %d fan on" % cycle)
on_feedback = self._wait_thermal_feedback(0x0A, 3.0)
if on_feedback is not None:
self.capture(0.8)
self.latest_thermal = None
self.transmit(
0x42A, [0x0A, 0, 0, 0, 0, 0, 0, 0],
"FI-05 verify cycle %d fan off" % cycle)
off_feedback = self._wait_thermal_feedback(0x09, 3.0)
cycle_ok = on_feedback is not None and \
off_feedback is not None
fan_cycle_evidence.append({
"cycle": cycle, "pass": cycle_ok,
"on_feedback": on_feedback,
"off_feedback": off_feedback,
})
protection_ok = protection_ok and cycle_ok
verifying["fan_stop_cycles"] = fan_cycle_evidence
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_command_plan":
result, details = self.execute_command_plan(request)
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 and not (
self.stop_event is not None and self.stop_event.is_set()):
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 run_bridge(transport=None, stop_event=None):
global stop_task
stop_task = False
bridge = None
try:
bridge = SeatFunctionBridge(transport=transport, stop_event=stop_event)
bridge.run()
except Exception as error:
if transport is not None:
transport.log("SEAT_FUNCTION_BRIDGE_ERROR %r" % error)
elif zcanpro is not None:
zcanpro.write_log("SEAT_FUNCTION_BRIDGE_ERROR %r" % error)
else:
raise
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)
def z_main():
run_bridge(ZcanProTransport())