feat: integrate calibration extension and self-repair modules
This commit is contained in:
@@ -14,7 +14,7 @@ from http.cookies import CookieError, SimpleCookie
|
||||
from http import HTTPStatus
|
||||
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
||||
from pathlib import Path
|
||||
from urllib.parse import parse_qs, quote, unquote, urlparse
|
||||
from urllib.parse import ParseResult, parse_qs, quote, unquote, urlparse
|
||||
|
||||
from cognitive_agent.config_loader import load_local_env
|
||||
|
||||
@@ -28,6 +28,16 @@ load_local_env(REPOSITORY_ROOT / ".env.auth", override=True)
|
||||
from cognitive_agent.auth import AuthConfig, AuthRuntime, verify_password # noqa: E402
|
||||
from cognitive_agent.application.seat_service import SeatDemoService # noqa: E402
|
||||
from cognitive_agent.application.gesture_bridge import GestureCapabilityBridge # noqa: E402
|
||||
from cognitive_agent.application.calibration.service import ( # noqa: E402
|
||||
ApiError as CalibrationApiError,
|
||||
CalibrationService,
|
||||
)
|
||||
from cognitive_agent.application.self_extension import ( # noqa: E402
|
||||
ExtensionIntegrationError,
|
||||
GatewayError,
|
||||
SelfExtensionService,
|
||||
)
|
||||
from cognitive_agent.application.self_repair import SelfRepairService # noqa: E402
|
||||
from cognitive_agent.infrastructure.can.hardware import ( # noqa: E402
|
||||
HardwareBusyError,
|
||||
HardwareSafetyError,
|
||||
@@ -40,6 +50,7 @@ from cognitive_agent.infrastructure.llm.openai_responses import OpenAIIntentErro
|
||||
|
||||
STATIC_ROOT = REPOSITORY_ROOT / "apps" / "web-console"
|
||||
mimetypes.add_type("application/manifest+json", ".webmanifest")
|
||||
mimetypes.add_type("model/gltf-binary", ".glb")
|
||||
REQUEST_ID_PATTERN = re.compile(r"^req-[a-z0-9-]+$")
|
||||
PUBLIC_AUTH_PATHS = {
|
||||
"/login",
|
||||
@@ -68,6 +79,12 @@ def _lan_ipv4_address() -> str | None:
|
||||
class SeatDemoHandler(BaseHTTPRequestHandler):
|
||||
service = SeatDemoService()
|
||||
gesture = GestureCapabilityBridge(REPOSITORY_ROOT)
|
||||
extension = SelfExtensionService()
|
||||
calibration = CalibrationService(
|
||||
REPOSITORY_ROOT / "artifacts" / "tmp" / "self-calibration",
|
||||
repository_root=REPOSITORY_ROOT,
|
||||
)
|
||||
repair = SelfRepairService()
|
||||
auth = AuthRuntime(AuthConfig.from_env())
|
||||
server_version = "CognitiveAgentCabinDemo/2.0"
|
||||
|
||||
@@ -188,6 +205,37 @@ class SeatDemoHandler(BaseHTTPRequestHandler):
|
||||
if path == "/api/gesture/model":
|
||||
self._serve_gesture_model()
|
||||
return
|
||||
if path == "/api/extension/health":
|
||||
self._json(self.extension.health())
|
||||
return
|
||||
if path == "/api/extension/gscu/info":
|
||||
self._handle_extension_gateway("info", {})
|
||||
return
|
||||
if path.startswith("/api/calibration/"):
|
||||
if not self._require_calibration_admin(parsed_url):
|
||||
return
|
||||
if path == "/api/calibration/state":
|
||||
self._json(self.calibration.snapshot())
|
||||
return
|
||||
if path == "/api/calibration/events":
|
||||
self._serve_calibration_events()
|
||||
return
|
||||
if path == "/api/calibration/video/stream":
|
||||
self._serve_calibration_video()
|
||||
return
|
||||
self._json({"error": "not_found"}, HTTPStatus.NOT_FOUND)
|
||||
return
|
||||
if path.startswith("/api/self-repair/"):
|
||||
if not self._require_self_repair_admin(parsed_url):
|
||||
return
|
||||
if path == "/api/self-repair/state":
|
||||
self._json(self.repair.snapshot())
|
||||
return
|
||||
if path == "/api/self-repair/health":
|
||||
self._json(self.repair.health())
|
||||
return
|
||||
self._json({"error": "not_found"}, HTTPStatus.NOT_FOUND)
|
||||
return
|
||||
if path.startswith("/api/requests/"):
|
||||
self._serve_request(path.removeprefix("/api/requests/"))
|
||||
return
|
||||
@@ -212,6 +260,15 @@ class SeatDemoHandler(BaseHTTPRequestHandler):
|
||||
}:
|
||||
self._handle_hardware_access_post(path)
|
||||
return
|
||||
if path.startswith("/api/extension/"):
|
||||
self._handle_extension_post(path)
|
||||
return
|
||||
if path.startswith("/api/calibration/"):
|
||||
self._handle_calibration_post(path)
|
||||
return
|
||||
if path.startswith("/api/self-repair/"):
|
||||
self._handle_self_repair_post(path)
|
||||
return
|
||||
if path != "/api/requests":
|
||||
self._json({"error": "not_found"}, HTTPStatus.NOT_FOUND)
|
||||
return
|
||||
@@ -301,6 +358,322 @@ class SeatDemoHandler(BaseHTTPRequestHandler):
|
||||
HTTPStatus.INTERNAL_SERVER_ERROR,
|
||||
)
|
||||
|
||||
def _handle_extension_post(self, path: str) -> None:
|
||||
action_map = {
|
||||
"/api/extension/gscu/learn": "learn",
|
||||
"/api/extension/gscu/cancel": "cancel",
|
||||
"/api/extension/gscu/emit": "emit",
|
||||
"/api/extension/gscu/read": "read",
|
||||
"/api/extension/gscu/erase": "erase",
|
||||
}
|
||||
try:
|
||||
payload = self._read_json_body(max_bytes=256 * 1024)
|
||||
action = action_map.get(path)
|
||||
if action:
|
||||
self._handle_extension_gateway(action, payload)
|
||||
return
|
||||
if path == "/api/extension/assistant/plan":
|
||||
self._json(self.extension.plan(payload))
|
||||
return
|
||||
if path == "/api/extension/vehicle/execute":
|
||||
result = self.extension.execute_vehicle(
|
||||
payload,
|
||||
self.service,
|
||||
hardware_access_bypass=self._is_unproxied_loopback_request(),
|
||||
)
|
||||
self._json(result)
|
||||
return
|
||||
self._json({"error": "not_found"}, HTTPStatus.NOT_FOUND)
|
||||
except GatewayError as exc:
|
||||
self._json(
|
||||
{"success": False, "error": "gscu_unavailable", "message": str(exc)},
|
||||
HTTPStatus.SERVICE_UNAVAILABLE,
|
||||
)
|
||||
except ExtensionIntegrationError as exc:
|
||||
self._json(
|
||||
{"success": False, "error": "extension_unavailable", "message": str(exc)},
|
||||
HTTPStatus.SERVICE_UNAVAILABLE,
|
||||
)
|
||||
except HardwareAccessRequiredError as exc:
|
||||
self._json(
|
||||
{
|
||||
"success": False,
|
||||
"error": "hardware_access_required",
|
||||
"message": str(exc),
|
||||
"access_state": exc.state,
|
||||
},
|
||||
HTTPStatus.FORBIDDEN,
|
||||
)
|
||||
except HardwareBusyError as exc:
|
||||
self._json(
|
||||
{
|
||||
"success": False,
|
||||
"error": "hardware_busy",
|
||||
"message": str(exc),
|
||||
"retry_after_seconds": exc.retry_after_seconds,
|
||||
},
|
||||
HTTPStatus.CONFLICT,
|
||||
{"Retry-After": str(exc.retry_after_seconds)},
|
||||
)
|
||||
except HardwareSafetyError as exc:
|
||||
self._json(
|
||||
{"success": False, "error": "hardware_safety_gate", "message": str(exc)},
|
||||
HTTPStatus.FORBIDDEN,
|
||||
)
|
||||
except OpenAIIntentError as exc:
|
||||
self.log_error("extension vehicle intent request failed: %s", exc)
|
||||
self._json(
|
||||
{
|
||||
"success": False,
|
||||
"error": "openai_request_failed",
|
||||
"message": "车端 GPT 意图解析失败,请检查模型配置。",
|
||||
},
|
||||
HTTPStatus.BAD_GATEWAY,
|
||||
)
|
||||
except (ValueError, json.JSONDecodeError) as exc:
|
||||
self._json(
|
||||
{"success": False, "error": "invalid_request", "message": str(exc)},
|
||||
HTTPStatus.BAD_REQUEST,
|
||||
)
|
||||
except Exception as exc: # noqa: BLE001 - keep extension APIs responsive
|
||||
self.log_error("self-extension request failed: %s", exc)
|
||||
self._json(
|
||||
{
|
||||
"success": False,
|
||||
"error": "internal_error",
|
||||
"message": "自扩展服务处理失败,请查看服务端日志。",
|
||||
},
|
||||
HTTPStatus.INTERNAL_SERVER_ERROR,
|
||||
)
|
||||
|
||||
def _handle_calibration_post(self, path: str) -> None:
|
||||
if not self._require_calibration_admin():
|
||||
return
|
||||
try:
|
||||
payload = self._read_json_body(max_bytes=256 * 1024)
|
||||
action_map = {
|
||||
"/api/calibration/can/connect": lambda: self.calibration.connect_can(
|
||||
dict(payload.get("can", payload))
|
||||
),
|
||||
"/api/calibration/start": lambda: self.calibration.calibrate(
|
||||
None if payload.get("axis", "all") == "all" else int(payload["axis"])
|
||||
),
|
||||
"/api/calibration/move": lambda: self.calibration.move(
|
||||
int(payload["axis"]), int(payload["permille"])
|
||||
),
|
||||
"/api/calibration/seek": lambda: self.calibration.seek(
|
||||
int(payload["axis"]), bool(payload.get("far"))
|
||||
),
|
||||
"/api/calibration/stop": self.calibration.stop,
|
||||
"/api/calibration/settings": lambda: self.calibration.update_settings(
|
||||
dict(payload)
|
||||
),
|
||||
"/api/calibration/mqtt/connect": self.calibration.connect_mqtt,
|
||||
"/api/calibration/mqtt/disconnect": self.calibration.disconnect_mqtt,
|
||||
"/api/calibration/mqtt/test": self.calibration.mqtt_test,
|
||||
"/api/calibration/video/start": self.calibration.start_video,
|
||||
"/api/calibration/video/stop": self.calibration.stop_video,
|
||||
}
|
||||
action = action_map.get(path)
|
||||
if action is None:
|
||||
self._json({"error": "not_found"}, HTTPStatus.NOT_FOUND)
|
||||
return
|
||||
|
||||
requested_mode = str(
|
||||
dict(payload.get("can", {})).get(
|
||||
"mode", self.calibration.snapshot()["transport"]["mode"]
|
||||
)
|
||||
)
|
||||
motion_paths = {
|
||||
"/api/calibration/can/connect",
|
||||
"/api/calibration/start",
|
||||
"/api/calibration/move",
|
||||
"/api/calibration/seek",
|
||||
"/api/calibration/stop",
|
||||
}
|
||||
if path in motion_paths and requested_mode == "hardware":
|
||||
if not self._require_local_decision_point():
|
||||
return
|
||||
|
||||
self._json({"ok": True, "state": action()})
|
||||
except CalibrationApiError as exc:
|
||||
self._json(
|
||||
{"ok": False, "error": "calibration_error", "message": str(exc)},
|
||||
HTTPStatus(exc.status),
|
||||
)
|
||||
except (KeyError, TypeError, ValueError, json.JSONDecodeError) as exc:
|
||||
self._json(
|
||||
{"ok": False, "error": "invalid_request", "message": str(exc)},
|
||||
HTTPStatus.BAD_REQUEST,
|
||||
)
|
||||
except Exception as exc: # noqa: BLE001 - keep calibration APIs responsive
|
||||
self.log_error("self-calibration request failed: %s", exc)
|
||||
self._json(
|
||||
{
|
||||
"ok": False,
|
||||
"error": "internal_error",
|
||||
"message": "自标定服务处理失败,请查看服务端日志。",
|
||||
},
|
||||
HTTPStatus.INTERNAL_SERVER_ERROR,
|
||||
)
|
||||
|
||||
def _handle_self_repair_post(self, path: str) -> None:
|
||||
if not self._require_self_repair_admin():
|
||||
return
|
||||
try:
|
||||
payload = self._read_json_body(max_bytes=256 * 1024)
|
||||
physical_paths = {
|
||||
"/api/self-repair/hardware/start",
|
||||
"/api/self-repair/hardware/stop",
|
||||
"/api/self-repair/emergency-stop",
|
||||
"/api/self-repair/firmware/build-flash",
|
||||
"/api/self-repair/open-log",
|
||||
}
|
||||
if (
|
||||
path in physical_paths
|
||||
or (
|
||||
path in {
|
||||
"/api/self-repair/healing/start",
|
||||
"/api/self-repair/healing/reset",
|
||||
}
|
||||
and str(payload.get("execution_mode", "shadow")) == "bench"
|
||||
)
|
||||
) and not self._require_local_decision_point():
|
||||
return
|
||||
|
||||
if path == "/api/self-repair/healing/start":
|
||||
self._json({"ok": True, "healing": self.repair.start_healing(payload)})
|
||||
return
|
||||
if path == "/api/self-repair/healing/run-all":
|
||||
self._json({"ok": True, "batch": self.repair.start_batch(payload)})
|
||||
return
|
||||
if path == "/api/self-repair/healing/reset":
|
||||
self._json({"ok": True, **self.repair.reset_healing(payload)})
|
||||
return
|
||||
if path == "/api/self-repair/hardware/start":
|
||||
self._json({"ok": True, "session": self.repair.start_hardware()})
|
||||
return
|
||||
if path == "/api/self-repair/hardware/stop":
|
||||
self._json({"ok": True, "session": self.repair.stop_hardware()})
|
||||
return
|
||||
if path == "/api/self-repair/hardware/self-test":
|
||||
self._json(
|
||||
{"ok": True, "self_test": self.repair.run_hardware_self_test()}
|
||||
)
|
||||
return
|
||||
if path == "/api/self-repair/emergency-stop":
|
||||
self._json({"ok": True, "request": self.repair.emergency_stop()})
|
||||
return
|
||||
if path == "/api/self-repair/snapshot":
|
||||
self._json({"ok": True, "path": str(self.repair.save_snapshot())})
|
||||
return
|
||||
if path == "/api/self-repair/firmware/build-flash":
|
||||
self._json(
|
||||
{
|
||||
"ok": True,
|
||||
"firmware": self.repair.build_and_flash_firmware(payload),
|
||||
}
|
||||
)
|
||||
return
|
||||
if path == "/api/self-repair/open-log":
|
||||
opened = self.repair.open_log_dir()
|
||||
self._json({"ok": opened})
|
||||
return
|
||||
self._json({"error": "not_found"}, HTTPStatus.NOT_FOUND)
|
||||
except (KeyError, TypeError, ValueError, json.JSONDecodeError) as exc:
|
||||
self._json(
|
||||
{"ok": False, "error": "invalid_request", "message": str(exc)},
|
||||
HTTPStatus.BAD_REQUEST,
|
||||
)
|
||||
except Exception as exc: # noqa: BLE001 - keep self-repair API responsive
|
||||
self.log_error("self-repair request failed: %s", exc)
|
||||
self._json(
|
||||
{
|
||||
"ok": False,
|
||||
"error": "internal_error",
|
||||
"message": "自修复服务处理失败,请查看服务端日志。",
|
||||
},
|
||||
HTTPStatus.INTERNAL_SERVER_ERROR,
|
||||
)
|
||||
|
||||
def _require_self_repair_admin(self, parsed_url: ParseResult | None = None) -> bool:
|
||||
role = self.headers.get("X-Cognitive-Agent-Role", "").strip().lower()
|
||||
if not role and parsed_url is not None:
|
||||
role = str(parse_qs(parsed_url.query).get("role", [""])[0]).lower()
|
||||
if hmac.compare_digest(role, "admin"):
|
||||
return True
|
||||
self._json(
|
||||
{"error": "admin_required", "message": "自修复仅对 admin 开放。"},
|
||||
HTTPStatus.FORBIDDEN,
|
||||
)
|
||||
return False
|
||||
|
||||
def _require_calibration_admin(self, parsed_url: ParseResult | None = None) -> bool:
|
||||
role = self.headers.get("X-Cognitive-Agent-Role", "").strip().lower()
|
||||
if not role and parsed_url is not None:
|
||||
role = str(parse_qs(parsed_url.query).get("role", [""])[0]).lower()
|
||||
if hmac.compare_digest(role, "admin"):
|
||||
return True
|
||||
self._json(
|
||||
{
|
||||
"error": "admin_required",
|
||||
"message": "自标定仅对 admin 开放。",
|
||||
},
|
||||
HTTPStatus.FORBIDDEN,
|
||||
)
|
||||
return False
|
||||
|
||||
def _serve_calibration_events(self) -> None:
|
||||
self.send_response(HTTPStatus.OK)
|
||||
self.send_header("Content-Type", "text/event-stream; charset=utf-8")
|
||||
self.send_header("Cache-Control", "no-cache")
|
||||
self.send_header("Connection", "keep-alive")
|
||||
self.send_header("X-Accel-Buffering", "no")
|
||||
self._send_security_headers()
|
||||
self.end_headers()
|
||||
version = -1
|
||||
try:
|
||||
while True:
|
||||
state = self.calibration.wait_for_version(version, 12.0)
|
||||
version = state["version"]
|
||||
data = json.dumps(state, ensure_ascii=False, separators=(",", ":"))
|
||||
self.wfile.write(f"data: {data}\n\n".encode("utf-8"))
|
||||
self.wfile.flush()
|
||||
except (BrokenPipeError, ConnectionResetError, ConnectionAbortedError):
|
||||
pass
|
||||
|
||||
def _serve_calibration_video(self) -> None:
|
||||
self.send_response(HTTPStatus.OK)
|
||||
self.send_header("Content-Type", "multipart/x-mixed-replace; boundary=n7frame")
|
||||
self.send_header("Cache-Control", "no-store")
|
||||
self.send_header("Connection", "close")
|
||||
self._send_security_headers()
|
||||
self.end_headers()
|
||||
sequence = -1
|
||||
try:
|
||||
while True:
|
||||
sequence, frame = self.calibration.video.wait_frame(sequence)
|
||||
if frame is None:
|
||||
continue
|
||||
header = (
|
||||
b"--n7frame\r\nContent-Type: image/jpeg\r\nContent-Length: "
|
||||
+ str(len(frame)).encode("ascii")
|
||||
+ b"\r\n\r\n"
|
||||
)
|
||||
self.wfile.write(header + frame + b"\r\n")
|
||||
self.wfile.flush()
|
||||
except (BrokenPipeError, ConnectionResetError, ConnectionAbortedError):
|
||||
pass
|
||||
|
||||
def _handle_extension_gateway(self, action: str, payload: dict[str, object]) -> None:
|
||||
try:
|
||||
self._json(self.extension.execute_gateway(action, dict(payload)))
|
||||
except GatewayError as exc:
|
||||
self._json(
|
||||
{"success": False, "error": "gscu_unavailable", "message": str(exc)},
|
||||
HTTPStatus.SERVICE_UNAVAILABLE,
|
||||
)
|
||||
|
||||
def _handle_hardware_access_post(self, path: str) -> None:
|
||||
local_path = path.startswith("/api/local/")
|
||||
if local_path and not self._require_local_decision_point():
|
||||
@@ -535,7 +908,18 @@ class SeatDemoHandler(BaseHTTPRequestHandler):
|
||||
content_type, _ = mimetypes.guess_type(candidate.name)
|
||||
content = candidate.read_bytes()
|
||||
self.send_response(HTTPStatus.OK)
|
||||
self.send_header("Content-Type", f"{content_type or 'application/octet-stream'}; charset=utf-8")
|
||||
resolved_content_type = content_type or "application/octet-stream"
|
||||
if resolved_content_type.startswith("text/") or resolved_content_type in {
|
||||
"application/javascript",
|
||||
"application/json",
|
||||
"application/manifest+json",
|
||||
"image/svg+xml",
|
||||
}:
|
||||
resolved_content_type = f"{resolved_content_type}; charset=utf-8"
|
||||
self.send_header(
|
||||
"Content-Type",
|
||||
resolved_content_type,
|
||||
)
|
||||
self.send_header("Content-Length", str(len(content)))
|
||||
cache_control = "no-cache"
|
||||
if relative_path.startswith(("vendor/", "assets/", "icons/")):
|
||||
@@ -603,8 +987,8 @@ class SeatDemoHandler(BaseHTTPRequestHandler):
|
||||
"default-src 'self'; "
|
||||
"script-src 'self' 'wasm-unsafe-eval'; "
|
||||
"style-src 'self'; "
|
||||
"img-src 'self' data:; "
|
||||
"connect-src 'self'; "
|
||||
"img-src 'self' data: blob:; "
|
||||
"connect-src 'self' blob:; "
|
||||
"object-src 'none'; base-uri 'none'; frame-ancestors 'none'",
|
||||
)
|
||||
if self._is_https():
|
||||
@@ -620,6 +1004,9 @@ def main() -> None:
|
||||
parser.add_argument("--port", type=int, default=int(os.getenv("PORT", "8000")))
|
||||
args = parser.parse_args()
|
||||
server = ThreadingHTTPServer((args.host, args.port), SeatDemoHandler)
|
||||
SeatDemoHandler.extension.start()
|
||||
SeatDemoHandler.calibration.start()
|
||||
SeatDemoHandler.repair.start()
|
||||
if args.host in {"0.0.0.0", "::"}:
|
||||
lan_address = _lan_ipv4_address()
|
||||
print(f"Cabin orchestration demo (local): http://127.0.0.1:{args.port}", flush=True)
|
||||
@@ -648,6 +1035,9 @@ def main() -> None:
|
||||
pass
|
||||
finally:
|
||||
server.server_close()
|
||||
SeatDemoHandler.extension.close()
|
||||
SeatDemoHandler.calibration.close()
|
||||
SeatDemoHandler.repair.close()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
|
||||
Reference in New Issue
Block a user