feat: complete dual-seat whole-vehicle orchestration

This commit is contained in:
vee
2026-08-12 15:12:40 +08:00
parent 32379a1573
commit 9ce64b3a01
62 changed files with 9741 additions and 3950 deletions
+103 -53
View File
@@ -1,8 +1,8 @@
"""ZCANPRO bridge for the web-console self-repair hardware workflow.
"""Transport-neutral bridge for the seat/cabin hardware workflow.
Run this file in ZCANPRO's extension-script window and leave it running. The
web console writes armed JSON requests into the repository self-repair runtime;
this bridge is the only process that imports and calls ``zcanpro``.
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
@@ -16,7 +16,10 @@ import os
import sys
import time
import zcanpro
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
@@ -44,16 +47,24 @@ parse_303 = _hil_common.parse_303
parse_304 = _hil_common.parse_304
parse_305 = _hil_common.parse_305
RUNTIME_DIR = os.path.join(
REPOSITORY_ROOT, "artifacts", "tmp", "self-repair", "runtime")
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.path.join(RUNTIME_DIR, "bridge_status.json")
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.path.join(
REPOSITORY_ROOT, "artifacts", "self-repair", "hardware")
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
@@ -162,7 +173,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-09-real-self-heal-v32"
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"),
@@ -178,6 +189,36 @@ HARDWARE_ENABLED_PINS = (
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
@@ -301,15 +342,14 @@ class RunLogger(object):
class SeatFunctionBridge(object):
def __init__(self):
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)
buses = zcanpro.get_buses()
if not buses:
raise RuntimeError("ZCANPRO中没有已启动的CAN通道")
self.bus = buses[0]
self.bus_id = self.bus["busID"]
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]
@@ -318,6 +358,7 @@ class SeatFunctionBridge(object):
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 = [[], [], [], []]
@@ -336,7 +377,7 @@ class SeatFunctionBridge(object):
def message(self, value):
text = str(value)
zcanpro.write_log(text)
self.transport.log(text)
stamp = datetime.datetime.now().strftime("%H:%M:%S")
self.messages.append("[%s] %s" % (stamp, text))
self.messages = self.messages[-40:]
@@ -422,6 +463,7 @@ class SeatFunctionBridge(object):
"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,
@@ -436,15 +478,15 @@ class SeatFunctionBridge(object):
values = [int(item) & 0xFF for item in data]
if self.logger is not None:
self.logger.frame("TX", can_id, values, note=note)
frame = {"can_id": int(can_id), "is_canfd": 0, "canfd_brs": 0,
"data": values}
if not zcanpro.transmit(self.bus_id, [frame]):
try:
self.transport.send(can_id, values)
except Exception:
raise RuntimeError("发送失败 0x%03X %s" %
(can_id, hex_data(values)))
def receive(self):
ok, frames = zcanpro.receive(self.bus_id)
if not ok or not frames:
frames = self.transport.receive()
if not frames:
return []
output = []
for frame in frames:
@@ -501,6 +543,22 @@ class SeatFunctionBridge(object):
"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))
@@ -949,7 +1007,7 @@ class SeatFunctionBridge(object):
motor_results = []
self._set_pin_result(
pin_results, ("JC1-1", "JC1-21"), "OK",
"已接收本工程0x301~0x305且STOP_ALL得到ACK",
"已接收本工程0x301~0x307且STOP_ALL得到ACK",
"direct")
self._publish_hardware_diagnostic(
request_id, "BASELINE", "建立安全基线",
@@ -1266,7 +1324,12 @@ class SeatFunctionBridge(object):
(opcode, motor),
}
if opcode in (0x01, 0x03) and self.latest_system is not None and \
# 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", {
@@ -1400,7 +1463,7 @@ class SeatFunctionBridge(object):
self.capture(0.3)
return "PASS", {
"summary": "ZCANPRO共享桥接已完成%d条座椅命令并验证实物反馈" %
"summary": "共享CAN Runtime已完成%d条座椅命令并验证实物反馈" %
len(commands),
"results": results,
"system": self.latest_system,
@@ -1628,28 +1691,6 @@ 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(
@@ -1739,7 +1780,6 @@ 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,
@@ -2753,7 +2793,8 @@ class SeatFunctionBridge(object):
def run(self):
self.publish(force=True)
while not stop_task:
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(
@@ -2766,15 +2807,20 @@ class SeatFunctionBridge(object):
time.sleep(0.01)
def z_main():
def run_bridge(transport=None, stop_event=None):
global stop_task
stop_task = False
bridge = None
try:
bridge = SeatFunctionBridge()
bridge = SeatFunctionBridge(transport=transport, stop_event=stop_event)
bridge.run()
except Exception as error:
zcanpro.write_log("SEAT_FUNCTION_BRIDGE_ERROR %r" % 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"
@@ -2789,3 +2835,7 @@ def z_main():
except Exception as error:
bridge.message("退出安全关闭失败:%r" % error)
bridge.publish(force=True)
def z_main():
run_bridge(ZcanProTransport())