"""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())