feat: validate real-bench seat orchestration and self-repair
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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":
|
||||
|
||||
Reference in New Issue
Block a user