feat: validate real-bench seat orchestration and self-repair

This commit is contained in:
vee
2026-08-09 19:00:16 +08:00
parent 3bceb0d5a1
commit 3701192a11
41 changed files with 10403 additions and 3392 deletions
+55
View File
@@ -106,6 +106,7 @@ def expected_operation_errors(
result: dict[str, Any],
expected: dict[str, int],
expected_motion: dict[str, Any] | None = None,
expected_dynamic_mode: dict[str, Any] | None = None,
) -> list[str]:
errors: list[str] = []
seat_config = (result.get("function_configs") or {}).get("seat") or result.get(
@@ -151,6 +152,47 @@ def expected_operation_errors(
f"{capability} motion.{field}={actual_value}, "
f"expected={expected_value}"
)
if expected_dynamic_mode:
expected_mode_id = int(expected_dynamic_mode["mode_id"])
capability = (
"seat.stop_dynamic_mode"
if expected_mode_id == 0
else "seat.start_dynamic_mode"
)
operation = by_capability.get(capability)
if operation is None:
errors.append(f"expected capability was not generated: {capability}")
elif expected_mode_id and dig(operation, "parameters", "mode_id") != expected_mode_id:
errors.append(
f"{capability} mode_id={dig(operation, 'parameters', 'mode_id')}, "
f"expected={expected_mode_id}"
)
hardware_results = dig(result, "execution_report", "spec", "results") or []
telemetry = next(
(
item.get("telemetry", {})
for item in hardware_results
if item.get("domain") == "seat"
and item.get("telemetry", {}).get("dynamic_mode_id") is not None
),
None,
)
if telemetry is None:
errors.append("dynamic-mode execution did not include physical 0x306 telemetry")
else:
if telemetry.get("dynamic_mode_id") != expected_mode_id:
errors.append(
f"dynamic telemetry mode_id={telemetry.get('dynamic_mode_id')}, "
f"expected={expected_mode_id}"
)
dynamic_state = telemetry.get("dynamic_state")
accepted_states = {0} if expected_mode_id == 0 else {1, 2}
if dynamic_state not in accepted_states:
errors.append(
f"dynamic telemetry state={dynamic_state}, "
f"expected one of {sorted(accepted_states)}"
)
return errors
@@ -306,6 +348,7 @@ def run_case(
result,
case.get("expected", {}),
case.get("expected_motion"),
case.get("expected_dynamic_mode"),
)
)
seat_config = (result.get("function_configs") or {}).get(
@@ -359,6 +402,18 @@ def run_case(
record["errors"].extend(
validate_physical_state(state, case.get("expected", {}), tolerance)
)
expected_dynamic_mode = case.get("expected_dynamic_mode")
if expected_dynamic_mode:
actual_dynamic_mode = dig(
state, "actuator_state", "passenger", "dynamic_mode"
) or {}
for field in ("mode_id", "state"):
expected_value = expected_dynamic_mode[field]
if actual_dynamic_mode.get(field) != expected_value:
record["errors"].append(
f"dynamic_mode.{field}={actual_dynamic_mode.get(field)}, "
f"expected={expected_value}"
)
record["physical_state"] = state.get("physical_state")
record["status"] = "passed" if not record["errors"] else "failed"
except Exception as exc: # continue the suite after transport and parse errors
+592 -33
View File
@@ -1,7 +1,7 @@
"""ZCANPRO bridge for the standalone selectable seat-function GUI.
"""ZCANPRO bridge for the web-console self-repair hardware workflow.
Run this file in ZCANPRO's extension-script window and leave it running. The
normal Windows GUI writes armed JSON requests into ``seat_function_runtime``;
web console writes armed JSON requests into the repository self-repair runtime;
this bridge is the only process that imports and calls ``zcanpro``.
"""
@@ -18,6 +18,19 @@ import time
import zcanpro
# 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:
@@ -31,15 +44,15 @@ parse_303 = _hil_common.parse_303
parse_304 = _hil_common.parse_304
parse_305 = _hil_common.parse_305
BASE_DIR = os.path.dirname(os.path.abspath(__file__))
RUNTIME_DIR = os.path.join(BASE_DIR, "seat_function_runtime")
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.path.join(RUNTIME_DIR, "bridge_status.json")
EMERGENCY_PATH = os.path.join(RUNTIME_DIR, "emergency_stop.json")
LOG_ROOT = os.path.join(BASE_DIR, "logs", "05_seat_function_gui")
LOG_ROOT = os.path.join(
REPOSITORY_ROOT, "artifacts", "self-repair", "hardware")
CONFIRMED_MAX_HALL = (480, 374, 917, 647)
POSITION_TIMEOUT_MS = 30000
NORMAL_TASK_MIN_OBSERVE_S = 5.0
@@ -81,7 +94,14 @@ SELF_HEAL_HOOK_CASES = {
"normal_task": "用户调整腿托后调用刚保存的舒适位置",
"fault_reaction": "CRC校验失败,腿托调用目标被挂起",
"resume_reaction": "重建双槽后继续执行原腿托舒适位置",
"demo_motor": 3, "demo_delta": 2200, "prelude_delta": 2800,
# 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冗余记录",
@@ -142,7 +162,7 @@ HOME_LEG_TIMEOUT_S = 120
SIGNAL_WINDOW_S = 2.5
HEATER_CURRENT_ACTIVE_DELTA_RAW = 500
FAN_CURRENT_ACTIVE_DELTA_RAW = 40
BRIDGE_VERSION = "2026-08-07-reset-home-far-target-v15"
BRIDGE_VERSION = "2026-08-09-real-self-heal-v32"
HARDWARE_MOTOR_PINS = {
0: ("JA1-4", "JA1-5", "JC1-9"),
1: ("JB1-6", "JB1-8", "JC1-15"),
@@ -177,9 +197,18 @@ def _atomic_json(path, value):
for attempt in range(8):
try:
os.replace(temporary, path)
return
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.
@@ -189,6 +218,7 @@ def _atomic_json(path, value):
os.remove(temporary)
except OSError:
pass
return False
def _read_json(path):
@@ -287,6 +317,7 @@ class SeatFunctionBridge(object):
self.latest_currents = [None, None, None, None]
self.latest_thermal_current = None
self.latest_thermal = None
self.latest_dynamic = None
self.frame_epochs = {}
self.motor_current_history = [[], [], [], []]
self.motor_hall_history = [[], [], [], []]
@@ -390,6 +421,7 @@ class SeatFunctionBridge(object):
"currents": self.latest_currents,
"thermal_current": self.latest_thermal_current,
"thermal": self.latest_thermal,
"dynamic": self.latest_dynamic,
"frame_epochs": self.frame_epochs,
"signal_window": self.signal_window(),
"last_result": self.last_result,
@@ -459,6 +491,16 @@ class SeatFunctionBridge(object):
"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 == 0x4FA and len(data) >= 1:
self.latest_thermal = data
output.append((can_id, data))
@@ -484,11 +526,21 @@ class SeatFunctionBridge(object):
return False
self.message("执行GUI紧急STOP_ALL %s" % request_id)
sequence = self.next_sequence()
self.transmit(0x300, command_payload(0x09, 0, 0, sequence),
"GUI EMERGENCY STOP_ALL")
# 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):
self.transmit(0x42A, [0x0A, 0, 0, 0, 0, 0, 0, 0],
"GUI emergency thermal off")
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):
@@ -550,11 +602,12 @@ class SeatFunctionBridge(object):
current = int(status.get("position", 0))
if not status.get("position_valid"):
return None
magnitude = max(600, min(2800, int(delta)))
magnitude = max(100, min(2800, int(delta)))
target = current + magnitude if current <= 5000 else current - magnitude
target = max(800, min(9200, target))
if abs(target - current) < 500:
target = 8000 if current < 5000 else 2000
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}
@@ -789,6 +842,7 @@ class SeatFunctionBridge(object):
# 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
@@ -816,6 +870,75 @@ class SeatFunctionBridge(object):
"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 = {
@@ -884,7 +1007,12 @@ class SeatFunctionBridge(object):
self.transmit(0x42A, [0x0A, 0, 0, 0, 0, 0, 0, 0],
"HW fan safe off")
# Heater branch: low level only, bounded to three seconds.
# 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))
@@ -893,8 +1021,8 @@ class SeatFunctionBridge(object):
"HW heater mode")
self.transmit(0x2AA, [0x01, 0, 0x1B, 0, 0, 0, 0, 0],
"HW heater config")
self.transmit(0x29E, [0x12, 0, 0x10, 0, 0, 0, 0, 0],
"HW heater level 1")
self.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
@@ -1005,6 +1133,283 @@ class SeatFunctionBridge(object):
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),
}
if opcode in (0x01, 0x03) 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": "ZCANPRO共享桥接已完成%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 []
@@ -1174,6 +1579,7 @@ class SeatFunctionBridge(object):
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):
@@ -1182,13 +1588,27 @@ class SeatFunctionBridge(object):
self.latest_system["position_valid_mask"]
homed = []
for motor, expected_max in enumerate(CONFIRMED_MAX_HALL):
if not force_all_axes and (initial_mask & (1 << motor)) != 0:
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": self.latest_motors[motor],
"diagnostic": self.latest_diagnostics[motor],
"note": "位置记录有效,未执行机械归零",
"status": initial_status,
"diagnostic": initial_diag,
"note": ("位置已有效且处于零位,未重复机械归零" if
already_zero else
"位置记录有效,未执行机械归零"),
})
continue
if self.check_emergency():
@@ -1196,8 +1616,6 @@ class SeatFunctionBridge(object):
"summary": "回最近端过程中收到紧急停止",
"axes": homed,
}
initial_status = self.latest_motors[motor]
initial_diag = self.latest_diagnostics[motor]
initial_current = self.latest_currents[motor]
initial_status = dict(initial_status) \
if initial_status is not None else None
@@ -1210,6 +1628,28 @@ class SeatFunctionBridge(object):
if initial_diag is not None:
observed_halls.append(int(initial_diag["hall"]))
current_peak = initial_current
endpoint_departure = None
if motor == 3 and (initial_status is None or
not initial_status.get("position_valid") or
initial_status.get("near") or
int(initial_status.get("position", 0)) <= 300):
departure_ok, endpoint_departure = \
self._depart_legrest_endpoint_for_home()
if not departure_ok:
self.stop_all_sync()
homed.append({
"motor": motor,
"result": "FAIL",
"endpoint_departure": endpoint_departure,
"initial_status": initial_status,
"initial_diagnostic": initial_diag,
"initial_current": initial_current,
})
return "FAIL", {
"summary": endpoint_departure.get(
"summary", "M3端点脱离失败"),
"axes": homed,
}
home_started = time.monotonic()
sequence = self.next_sequence()
state, response = self.send_command(
@@ -1299,6 +1739,7 @@ class SeatFunctionBridge(object):
"initial_status": initial_status,
"initial_diagnostic": initial_diag,
"initial_current": initial_current,
"endpoint_departure": endpoint_departure,
"peak_current": current_peak,
"home_elapsed_s": round(time.monotonic() - home_started, 3),
"observed_hall_values": distinct_halls,
@@ -1371,6 +1812,7 @@ class SeatFunctionBridge(object):
"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,
@@ -1517,10 +1959,17 @@ class SeatFunctionBridge(object):
arm_payload = self._self_heal_hook_payload(
case_index, 0, arm_token)
self.drain()
self.transmit(SELF_HEAL_HOOK_REQUEST_ID, arm_payload,
"ARM " + note)
arm_ack = self._wait_self_heal_hook_response(
case_index, 0, arm_token, 3.0)
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():
@@ -1579,6 +2028,17 @@ class SeatFunctionBridge(object):
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
@@ -1590,11 +2050,49 @@ class SeatFunctionBridge(object):
"CRC用例的故障前腿托调整未完成,未注入故障",
dict(base, request_feedback_match=False))
return "FAIL", {"summary": "CRC用例的正常腿托调整未完成"}
self.capture(1.0)
if "demo_endpoints" in profile:
# 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"])
else:
elif demo_task is None:
demo_task = self._demo_axis_task(
profile["demo_motor"], profile.get("demo_delta", 2000))
if demo_task is None:
@@ -1662,6 +2160,19 @@ class SeatFunctionBridge(object):
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(
@@ -1721,6 +2232,16 @@ class SeatFunctionBridge(object):
"未收到台架故障钩子成功应答,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)
@@ -1738,6 +2259,15 @@ class SeatFunctionBridge(object):
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"]
@@ -1809,6 +2339,33 @@ class SeatFunctionBridge(object):
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",
@@ -2145,6 +2702,8 @@ class SeatFunctionBridge(object):
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":