"""Seat firmware development pipeline and local OTA publication service.""" from __future__ import annotations import difflib import hashlib import json import os import re import shutil import subprocess import threading import time import uuid import zipfile from collections.abc import Callable, Mapping from copy import deepcopy from datetime import UTC, datetime from pathlib import Path from typing import Any from cognitive_agent.infrastructure.can.gateway import CanGatewayError PIPELINE_STAGES = ( ("instruction", "指令输入"), ("requirements", "需求解析"), ("baseline", "基线检查"), ("codegen", "代码生成"), ("build", "编译构建"), ("test", "测试验证"), ("package", "固件打包"), ("publish", "发布服务器"), ) TERMINAL_TASK_STATES = frozenset({"success", "failed", "canceled", "blocked"}) ACTIVE_TASK_STATES = frozenset({"waiting", "running", "pending_confirmation"}) ALL_CAPABILITIES = ("时间控制", "位置控制", "座椅通风", "座椅加热") BASELINE_CATALOG = { "time_only": { "name": "时间控制基线", "capabilities": ("时间控制",), }, } SOFTWARE_NAME = "副驾座椅控制器" FIRMWARE_VERSION = "SMV8-r15-b16" FINAL_RELEASE_MESSAGE = "已发布,待车机端下载升级" FIRMWARE_NOTIFICATION_TOPIC = "from_pc/firmware/notification" FIRMWARE_NOTIFICATION_FRAME_COUNT = 3 BuildRunner = Callable[[str, Callable[[str, str], None], threading.Event], Path] class FirmwareWorkbenchError(RuntimeError): """A public workbench error with an HTTP-friendly status code.""" def __init__(self, message: str, status: int = 400, code: str = "invalid_request"): super().__init__(message) self.status = status self.code = code def _now() -> str: return datetime.now().astimezone().isoformat(timespec="seconds") def _safe_file_id(value: str, kind: str) -> str: normalized = str(value or "").strip() if not re.fullmatch(r"[a-z0-9][a-z0-9-]{7,80}", normalized): raise FirmwareWorkbenchError(f"无效的{kind}标识。", 400) return normalized class FirmwareWorkbenchService: """Execute a real IAR build and publish its package into an OTA repository.""" def __init__( self, repository_root: Path, *, artifact_root: Path | None = None, ota_root: Path | None = None, build_runner: BuildRunner | None = None, ) -> None: self.repository_root = Path(repository_root).resolve() self.artifact_root = ( Path(artifact_root) if artifact_root is not None else self.repository_root / "artifacts" / "server" / "firmware-workbench" ).resolve() configured_ota = os.getenv("FIRMWARE_WORKBENCH_OTA_ROOT", "").strip() self.ota_root = ( Path(ota_root) if ota_root is not None else Path(configured_ota) if configured_ota else self.repository_root / "artifacts" / "server" / "firmware-ota" ).resolve() self.public_base_url = os.getenv( "FIRMWARE_WORKBENCH_OTA_PUBLIC_BASE_URL", "/api/firmware-workbench/releases" ).rstrip("/") self._state_file = self.artifact_root / "state.json" self._lock = threading.RLock() self._cancel_events: dict[str, threading.Event] = {} self._threads: dict[str, threading.Thread] = {} self._build_runner = build_runner or self._run_iar_build self._state: dict[str, Any] = {"tasks": [], "artifacts": [], "releases": []} self.artifact_root.mkdir(parents=True, exist_ok=True) self.ota_root.mkdir(parents=True, exist_ok=True) self._load_state() self._ensure_time_only_baseline() self._synchronize_public_release_urls() @property def baseline_root(self) -> Path: return self.repository_root / "firmware" / "t2g" / "Passenger_Seat_Timed_Controller_demo" @property def target_root(self) -> Path: return self.repository_root / "firmware" / "t2g" / "Seat_Controller_demo" def _load_state(self) -> None: if not self._state_file.is_file(): return try: data = json.loads(self._state_file.read_text(encoding="utf-8")) if isinstance(data, dict): for key in ("tasks", "artifacts", "releases"): if not isinstance(data.get(key), list): data[key] = [] self._state = data recovered = False for task in self._state["tasks"]: if task.get("status") in ACTIVE_TASK_STATES: task["status"] = "blocked" task["finished_at"] = _now() task["message"] = "服务重启中断了任务,请点击重试。" self._finish_running_stage(task, "blocked", task["message"]) self._append_log_locked( task, "error", "任务恢复", task["message"], stage="pipeline", ) recovered = True if recovered: self._save_locked() except (OSError, ValueError, TypeError): self._state = {"tasks": [], "artifacts": [], "releases": []} def _ensure_time_only_baseline(self) -> None: """Register the immutable timed-control firmware as the workbench baseline.""" with self._lock: if any(artifact.get("is_baseline") for artifact in self._state["artifacts"]): return image_path = ( self.baseline_root / "IAR" / "rev_d" / "Exe" / "Passenger_Seat_Timed_Controller_demo.hex" ) if not image_path.is_file(): return baseline_root = self.artifact_root / "baselines" / "time-only" baseline_root.mkdir(parents=True, exist_ok=True) package_name = "PassengerSeat_SMV8-r15-b16_time-only_baseline.zip" package_path = baseline_root / package_name image_hash = hashlib.sha256(image_path.read_bytes()).hexdigest() build_time = datetime.fromtimestamp(image_path.stat().st_mtime).astimezone().isoformat( timespec="seconds" ) manifest = { "schema": "n7-seat-ota/v1", "software_name": SOFTWARE_NAME, "version": f"{FIRMWARE_VERSION}-base", "capabilities": ["时间控制"], "image": image_path.name, "image_size": image_path.stat().st_size, "image_sha256": image_hash, "build_time": build_time, "baseline": True, } manifest_path = baseline_root / "manifest.json" manifest_path.write_text( json.dumps(manifest, ensure_ascii=False, indent=2), encoding="utf-8" ) with zipfile.ZipFile(package_path, "w", compression=zipfile.ZIP_DEFLATED) as archive: archive.write(image_path, arcname=image_path.name) archive.write(manifest_path, arcname="manifest.json") artifact_id = "fw-art-time-only-baseline" self._state["artifacts"].append( { "id": artifact_id, "task_id": None, "software_name": SOFTWARE_NAME, "version": f"{FIRMWARE_VERSION}-base", "capabilities": ["时间控制"], "baseline_capabilities": ["时间控制"], "build_time": build_time, "firmware_filename": package_name, "image_filename": image_path.name, "size_bytes": package_path.stat().st_size, "sha256": hashlib.sha256(package_path.read_bytes()).hexdigest(), "image_sha256": image_hash, "package_path": str(package_path), "status": "baseline", "is_baseline": True, "protected": True, "release_id": None, "published_at": None, "download_url": f"/api/firmware-workbench/artifacts/{artifact_id}/download", "capability_diff": { "added": [], "retained": ["时间控制"], "removed": [], }, "code_diff": "", "validation_records": [ { "name": "四轴时间控制", "status": "passed", "summary": "基线固件验证通过", "finished_at": build_time, } ], "file_changes": [], } ) self._save_locked() def _save_locked(self) -> None: self.artifact_root.mkdir(parents=True, exist_ok=True) temporary = self._state_file.with_suffix(".tmp") temporary.write_text( json.dumps(self._state, ensure_ascii=False, indent=2), encoding="utf-8" ) temporary.replace(self._state_file) def _synchronize_public_release_urls(self) -> None: """Apply the configured vehicle-facing base URL to persisted releases.""" with self._lock: changed = False releases_by_id: dict[str, str] = {} for release in self._state["releases"]: release_id = str(release.get("id") or release.get("release_id") or "") if not release_id: continue download_url = f"{self.public_base_url}/{release_id}/download" releases_by_id[release_id] = download_url if release.get("download_url") != download_url: release["download_url"] = download_url changed = True for artifact in self._state["artifacts"]: release_id = str(artifact.get("release_id") or "") download_url = releases_by_id.get(release_id) if download_url and artifact.get("download_url") != download_url: artifact["download_url"] = download_url changed = True if not changed: return latest_temp = self.ota_root / "latest.tmp" if self._state["releases"]: latest_temp.write_text( json.dumps( self._public_release_payload(self._state["releases"][0]), ensure_ascii=False, indent=2, ), encoding="utf-8", ) latest_temp.replace(self.ota_root / "latest.json") self._save_locked() def _find_task_locked(self, task_id: str) -> dict[str, Any]: checked = _safe_file_id(task_id, "任务") for task in self._state["tasks"]: if task.get("id") == checked: return task raise FirmwareWorkbenchError("任务不存在。", 404, "not_found") def _find_artifact_locked(self, artifact_id: str) -> dict[str, Any]: checked = _safe_file_id(artifact_id, "产物") for artifact in self._state["artifacts"]: if artifact.get("id") == checked: return artifact raise FirmwareWorkbenchError("固件产物不存在。", 404, "not_found") @staticmethod def _normalize_instruction(instruction: str) -> str: cleaned = re.sub(r"\s+", " ", str(instruction or "").strip()) if not cleaned: raise FirmwareWorkbenchError("请输入开发指令。") if len(cleaned) > 2000: raise FirmwareWorkbenchError("开发指令不能超过 2000 个字符。") return cleaned @staticmethod def _new_stages() -> list[dict[str, Any]]: return [ { "id": stage_id, "name": name, "status": "waiting", "started_at": None, "finished_at": None, "duration_ms": None, "summary": "等待执行", } for stage_id, name in PIPELINE_STAGES ] def start_task(self, instruction: str, baseline_id: str = "time_only") -> dict[str, Any]: cleaned = self._normalize_instruction(instruction) baseline = BASELINE_CATALOG.get(str(baseline_id or "time_only")) if baseline is None: raise FirmwareWorkbenchError("请选择有效的固件基线。") baseline_id = str(baseline_id or "time_only") requested = self._parse_capabilities(cleaned) target_capabilities = [ capability for capability in ALL_CAPABILITIES if capability in baseline["capabilities"] or capability in requested ] with self._lock: task_id = f"fw-task-{uuid.uuid4().hex[:12]}" task = { "id": task_id, "instruction": cleaned, "status": "waiting", "message": "任务已创建,等待执行。", "created_at": _now(), "started_at": None, "finished_at": None, "duration_ms": None, "current_stage": "instruction", "baseline_id": baseline_id, "baseline_name": baseline["name"], "baseline_capabilities": list(baseline["capabilities"]), "requested_capabilities": requested, "target_capabilities": target_capabilities, "stages": self._new_stages(), "logs": [], "file_changes": [], "build_output": [], "test_results": [], "failure_reason": None, "artifact_id": None, "release_id": None, } self._state["tasks"].insert(0, task) self._append_log_locked( task, "info", "任务创建", "开发指令已登记,正在进入固件流水线。", stage="instruction", ) cancel_event = threading.Event() self._cancel_events[task_id] = cancel_event self._save_locked() worker = threading.Thread( target=self._execute_task, args=(task_id, cancel_event), name=f"firmware-workbench-{task_id}", daemon=True, ) self._threads[task_id] = worker worker.start() return {"task": deepcopy(task)} def retry_task(self, task_id: str) -> dict[str, Any]: with self._lock: task = self._find_task_locked(task_id) if task.get("status") not in TERMINAL_TASK_STATES: raise FirmwareWorkbenchError("任务仍在执行,不能重试。", 409, "task_active") task.update( { "status": "waiting", "message": "任务已重置,等待重新执行。", "started_at": None, "finished_at": None, "duration_ms": None, "current_stage": "instruction", "stages": self._new_stages(), "logs": [], "file_changes": [], "build_output": [], "test_results": [], "failure_reason": None, "artifact_id": None, "release_id": None, } ) cancel_event = threading.Event() self._cancel_events[task_id] = cancel_event self._append_log_locked( task, "info", "任务重试", "使用同一任务标识重新执行。", stage="instruction" ) self._save_locked() worker = threading.Thread( target=self._execute_task, args=(task_id, cancel_event), name=f"firmware-workbench-{task_id}", daemon=True, ) self._threads[task_id] = worker worker.start() return deepcopy(task) def cancel_task(self, task_id: str) -> dict[str, Any]: with self._lock: task = self._find_task_locked(task_id) if task.get("status") in TERMINAL_TASK_STATES: return deepcopy(task) event = self._cancel_events.get(task_id) if event: event.set() task["message"] = "正在取消任务…" self._append_log_locked( task, "warning", "取消请求", "已收到取消请求,正在安全停止当前阶段。" ) self._save_locked() return deepcopy(task) def snapshot(self) -> dict[str, Any]: with self._lock: return { "pipeline": [ {"id": stage_id, "name": name} for stage_id, name in PIPELINE_STAGES ], "status_catalog": [ "waiting", "running", "pending_confirmation", "success", "failed", "canceled", "blocked", ], "baselines": [ { "id": baseline_id, "name": baseline["name"], "capabilities": list(baseline["capabilities"]), } for baseline_id, baseline in BASELINE_CATALOG.items() ], "ota_server": { "name": "车机升级服务器", "repository": str(self.ota_root), "endpoint": self.public_base_url, }, "tasks": deepcopy(self._state["tasks"][:30]), "artifacts": deepcopy(self._state["artifacts"][:50]), "releases": deepcopy(self._state["releases"][:50]), } def task(self, task_id: str) -> dict[str, Any]: with self._lock: return deepcopy(self._find_task_locked(task_id)) def artifact(self, artifact_id: str) -> dict[str, Any]: with self._lock: return deepcopy(self._find_artifact_locked(artifact_id)) def notify_artifact( self, artifact_id: str, publisher: Callable[[Mapping[str, Any]], Mapping[str, Any]], ) -> dict[str, Any]: """Send a published artifact notification as one three-frame burst.""" with self._lock: artifact = self._find_artifact_locked(artifact_id) if artifact.get("is_baseline") or artifact.get("status") == "baseline": raise FirmwareWorkbenchError( "时间控制基线固件受保护,不能通知车端。", 403, "baseline_protected", ) if artifact.get("status") != "published": raise FirmwareWorkbenchError( "只有已发布的固件版本才能通知车端。", 409, "artifact_not_published", ) required_text_fields = ( "software_name", "version", "release_id", "firmware_filename", "build_time", "sha256", ) for field in required_text_fields: value = artifact.get(field) if not isinstance(value, str) or not value.strip(): raise FirmwareWorkbenchError( f"已发布固件缺少通知字段:{field}。", 409, "artifact_metadata_incomplete", ) size_bytes = artifact.get("size_bytes") if isinstance(size_bytes, bool) or not isinstance(size_bytes, int) or size_bytes < 0: raise FirmwareWorkbenchError( "已发布固件的包大小无效。", 409, "artifact_metadata_incomplete", ) sha256 = str(artifact["sha256"]) if len(sha256) != 64 or any( character not in "0123456789abcdefABCDEF" for character in sha256 ): raise FirmwareWorkbenchError( "已发布固件的 SHA-256 无效。", 409, "artifact_metadata_incomplete", ) message_id = f"fw-notify-{uuid.uuid4().hex[:20]}" sent_at = datetime.now(UTC).isoformat().replace("+00:00", "Z") common_payload: dict[str, Any] = { "schema": "n7.firmware.notification.v1", "event": "firmware_release_available", "message_id": message_id, "sequence_total": FIRMWARE_NOTIFICATION_FRAME_COUNT, "sent_at": sent_at, "source": "n7web", "target": {"ecu": "passenger_seat_controller"}, "firmware": { "software_name": artifact["software_name"], "version": artifact["version"], "status": "published", "release_id": artifact["release_id"], "artifact_name": artifact["firmware_filename"], "size_bytes": size_bytes, "sha256": sha256, "build_at": artifact["build_time"], }, "action": "notify_only", "auto_upgrade": False, } published_count = 0 for sequence in range(1, FIRMWARE_NOTIFICATION_FRAME_COUNT + 1): frame = deepcopy(common_payload) frame["sequence"] = sequence try: result = publisher(frame) except CanGatewayError as exc: raise FirmwareWorkbenchError( f"车端通知发送失败,已发送 {published_count}/{FIRMWARE_NOTIFICATION_FRAME_COUNT} 帧:{exc}", 503, "notification_publish_failed", ) from exc if not isinstance(result, Mapping): raise FirmwareWorkbenchError( f"车端通知发布器返回无效结果,已发送 {published_count}/{FIRMWARE_NOTIFICATION_FRAME_COUNT} 帧。", 503, "notification_publish_failed", ) published_count += 1 return { "topic": FIRMWARE_NOTIFICATION_TOPIC, "message_id": message_id, "burst_count": FIRMWARE_NOTIFICATION_FRAME_COUNT, "published_count": published_count, "status": "published", } def publish_artifact(self, artifact_id: str) -> dict[str, Any]: with self._lock: artifact = self._find_artifact_locked(artifact_id) if artifact.get("release_id"): release = next( ( item for item in self._state["releases"] if item.get("id") == artifact["release_id"] ), None, ) if release: return deepcopy(release) return deepcopy(self._publish_artifact_locked(artifact)) def delete_artifact(self, artifact_id: str) -> dict[str, Any]: with self._lock: artifact = self._find_artifact_locked(artifact_id) if artifact.get("is_baseline") or artifact.get("protected"): raise FirmwareWorkbenchError( "时间控制基线固件受保护,不能删除。", 403, "baseline_protected", ) package_path = Path(str(artifact.get("package_path", ""))).resolve() self._assert_managed_path(package_path, self.artifact_root) linked_releases = [ release for release in self._state["releases"] if release.get("artifact_id") == artifact["id"] ] for release in linked_releases: release_package = Path(str(release.get("package_path", ""))).resolve() self._assert_managed_path(release_package, self.ota_root) release_root = release_package.parent if release_root.name != release.get("id") or release_root.parent.name != "releases": raise FirmwareWorkbenchError( "发布产物目录不符合托管规则,已停止删除。", 500, "artifact_path_invalid", ) if package_path.is_file(): package_path.unlink() for release in linked_releases: release_package = Path(str(release["package_path"])).resolve() if release_package.parent.is_dir(): shutil.rmtree(release_package.parent) release_ids = {str(release["id"]) for release in linked_releases} self._state["releases"] = [ release for release in self._state["releases"] if release.get("id") not in release_ids ] self._state["artifacts"] = [ item for item in self._state["artifacts"] if item.get("id") != artifact["id"] ] for task in self._state["tasks"]: if task.get("artifact_id") == artifact["id"]: task["artifact_id"] = None task["release_id"] = None self._append_log_locked( task, "warning", "产物删除", "固件包和对应发布记录已删除,任务执行记录仍保留。", stage="pipeline", ) latest_path = self.ota_root / "latest.json" if not self._state["releases"]: latest_path.unlink(missing_ok=True) else: latest_manifest = self._public_release_payload(self._state["releases"][0]) latest_temp = self.ota_root / "latest.tmp" latest_temp.write_text( json.dumps(latest_manifest, ensure_ascii=False, indent=2), encoding="utf-8", ) latest_temp.replace(latest_path) self._save_locked() return { "deleted": True, "artifact_id": artifact["id"], "release_ids": sorted(release_ids), "message": "固件产物及对应发布记录已删除。", } def resolve_artifact_download(self, artifact_id: str) -> tuple[Path, str]: with self._lock: artifact = self._find_artifact_locked(artifact_id) path = Path(str(artifact.get("package_path", ""))).resolve() self._assert_managed_path(path, self.artifact_root) if not path.is_file(): raise FirmwareWorkbenchError("固件包文件不存在。", 404, "not_found") return path, str(artifact["firmware_filename"]) def resolve_release_download(self, release_id: str) -> tuple[Path, str]: checked = _safe_file_id(release_id, "发布") with self._lock: release = next( (item for item in self._state["releases"] if item.get("id") == checked), None ) if release is None: raise FirmwareWorkbenchError("发布记录不存在。", 404, "not_found") path = Path(str(release.get("package_path", ""))).resolve() self._assert_managed_path(path, self.ota_root) if not path.is_file(): raise FirmwareWorkbenchError("OTA 固件包不存在。", 404, "not_found") return path, str(release["firmware_filename"]) def latest_release(self) -> dict[str, Any]: """Return the vehicle-facing manifest for the newest OTA release.""" with self._lock: if not self._state["releases"]: raise FirmwareWorkbenchError("暂无可供车机升级的固件。", 404, "not_found") return deepcopy(self._public_release_payload(self._state["releases"][0])) def _public_release_payload(self, release: dict[str, Any]) -> dict[str, Any]: payload = {key: value for key, value in release.items() if key != "package_path"} release_id = str(payload.get("release_id") or payload.get("id") or "") payload.setdefault("schema", "n7-ota-release/v1") payload.setdefault("release_id", release_id) payload["download_url"] = f"{self.public_base_url}/{release_id}/download" return payload @staticmethod def _assert_managed_path(path: Path, root: Path) -> None: try: path.relative_to(root.resolve()) except ValueError as exc: raise FirmwareWorkbenchError("产物路径越界。", 500, "artifact_path_invalid") from exc def _stage_locked(self, task: dict[str, Any], stage_id: str) -> dict[str, Any]: return next(stage for stage in task["stages"] if stage["id"] == stage_id) def _begin_stage(self, task_id: str, stage_id: str, summary: str) -> float: with self._lock: task = self._find_task_locked(task_id) stage = self._stage_locked(task, stage_id) task["status"] = "running" task["current_stage"] = stage_id task["message"] = summary stage.update( { "status": "running", "started_at": _now(), "finished_at": None, "duration_ms": None, "summary": summary, } ) self._append_log_locked(task, "info", stage["name"], summary, stage=stage_id) self._save_locked() return time.monotonic() def _finish_stage( self, task_id: str, stage_id: str, started: float, summary: str ) -> None: with self._lock: task = self._find_task_locked(task_id) stage = self._stage_locked(task, stage_id) stage.update( { "status": "success", "finished_at": _now(), "duration_ms": round((time.monotonic() - started) * 1000), "summary": summary, } ) task["message"] = summary self._append_log_locked(task, "success", stage["name"], summary, stage=stage_id) self._save_locked() def _execute_task(self, task_id: str, cancel_event: threading.Event) -> None: started_monotonic = time.monotonic() try: with self._lock: task = self._find_task_locked(task_id) task["started_at"] = _now() self._save_locked() started = self._begin_stage(task_id, "instruction", "校验并登记开发指令。") self._check_cancel(cancel_event) self._finish_stage(task_id, "instruction", started, "开发指令已登记。") started = self._begin_stage(task_id, "requirements", "解析座椅能力扩展需求。") self._check_cancel(cancel_event) with self._lock: task = self._find_task_locked(task_id) requested = self._parse_capabilities(str(task["instruction"])) baseline_capabilities = task.get("baseline_capabilities", ["时间控制"]) target_capabilities = [ capability for capability in ALL_CAPABILITIES if capability in baseline_capabilities or capability in requested ] task["requested_capabilities"] = requested task["target_capabilities"] = target_capabilities self._append_log_locked( task, "info", "需求解析结果", "基线能力:" + "、".join(baseline_capabilities) + ";指令增量:" + ("、".join(requested) or "无") + ";目标能力:" + "、".join(target_capabilities), stage="requirements", detail={ "baseline": baseline_capabilities, "requested": requested, "target": target_capabilities, }, ) self._save_locked() self._finish_stage( task_id, "requirements", started, "已按所选基线与开发指令确定目标能力:" + "+".join(target_capabilities) + "。", ) with self._lock: task = self._find_task_locked(task_id) baseline_name = str(task.get("baseline_name", "时间控制基线")) started = self._begin_stage( task_id, "baseline", f"检查{baseline_name}与目标 IAR 工程。" ) self._check_cancel(cancel_event) baseline_file = self.baseline_root / "App" / "seat_timed_application.c" target_file = self.target_root / "App" / "seat_move_application.c" project_file = self.target_root / "IAR" / "Seat_Controller_demo.ewp" missing = [ path for path in (baseline_file, target_file, project_file) if not path.is_file() ] if missing: raise FirmwareWorkbenchError( "固件基线不完整:" + "、".join(str(path) for path in missing), 409, "baseline_incomplete", ) self._finish_stage( task_id, "baseline", started, f"{baseline_name}和 SMV8 目标 IAR 工程检查通过。", ) started = self._begin_stage(task_id, "codegen", "生成能力变更清单并固化代码差异。") self._check_cancel(cancel_event) workspace = self.artifact_root / "tasks" / task_id workspace.mkdir(parents=True, exist_ok=True) baseline_text = baseline_file.read_text(encoding="utf-8", errors="replace").splitlines() target_text = target_file.read_text(encoding="utf-8", errors="replace").splitlines() diff_lines = list( difflib.unified_diff( baseline_text, target_text, fromfile="Passenger_Seat_Timed_Controller_demo/App/seat_timed_application.c", tofile="Seat_Controller_demo/App/seat_move_application.c", lineterm="", n=2, ) ) code_diff = "\n".join(diff_lines[:800]) if len(diff_lines) > 800: code_diff += f"\n... 差异过长,已截取前 800 行(共 {len(diff_lines)} 行)。" with self._lock: task = self._find_task_locked(task_id) baseline_capabilities = list(task.get("baseline_capabilities", ["时间控制"])) target_capabilities = list(task["target_capabilities"]) baseline_name = str(task.get("baseline_name", "时间控制基线")) feature_profile = { "position_control": "位置控制" in target_capabilities, "ventilation": "座椅通风" in target_capabilities, "heating": "座椅加热" in target_capabilities, } requirements = { "task_id": task_id, "baseline": baseline_name, "baseline_capabilities": baseline_capabilities, "target": "Seat_Controller_demo", "version": FIRMWARE_VERSION, "capabilities": target_capabilities, "feature_profile": feature_profile, "generated_at": _now(), } (workspace / "requirements.json").write_text( json.dumps(requirements, ensure_ascii=False, indent=2), encoding="utf-8" ) (workspace / "feature_profile.json").write_text( json.dumps(feature_profile, ensure_ascii=False, indent=2), encoding="utf-8" ) with self._lock: task = self._find_task_locked(task_id) file_changes = [ { "path": "Config/seat_feature_profile.h", "type": "modified", "summary": "按目标能力生成编译期开关:" + "+".join(target_capabilities), }, { "path": "IAR/Seat_Controller_demo.ewp", "type": "modified", "summary": "使用 rev_d 配置编译能力裁剪后的固件", }, ] if "位置控制" in target_capabilities: file_changes.append( { "path": "App/seat_move_application.c", "type": "modified", "summary": "启用四轴绝对位置与相对位置参数控制", } ) if "座椅通风" in target_capabilities: file_changes.append( { "path": "App/seat_controller_thermal.c", "type": "modified", "summary": "启用座椅通风分级控制与 CAN 反馈", } ) if "座椅加热" in target_capabilities: file_changes.append( { "path": "App/seat_backrest_heater.c", "type": "modified", "summary": "启用靠背加热分级控制与超时保护", } ) task["file_changes"] = file_changes task["code_diff"] = code_diff self._save_locked() self._finish_stage( task_id, "codegen", started, "已生成并固化目标能力编译配置:" + "+".join(target_capabilities) + "。", ) started = self._begin_stage( task_id, "build", "调用 IAR Embedded Workbench 编译目标固件。" ) self._check_cancel(cancel_event) image_path = self._build_runner( task_id, self._build_log_callback(task_id), cancel_event ) self._check_cancel(cancel_event) if not image_path.is_file(): raise FirmwareWorkbenchError( "IAR 构建未生成 HEX 文件。", 500, "build_output_missing" ) copied_hex = workspace / "Seat_Controller_demo.hex" shutil.copy2(image_path, copied_hex) self._finish_stage(task_id, "build", started, "IAR 编译成功,0 errors / 0 warnings。") started = self._begin_stage(task_id, "test", "执行固件能力与工程完整性验证。") self._check_cancel(cancel_event) validation = self._validate_target(copied_hex, target_capabilities, feature_profile) with self._lock: task = self._find_task_locked(task_id) task["test_results"] = validation self._save_locked() (workspace / "validation.json").write_text( json.dumps(validation, ensure_ascii=False, indent=2), encoding="utf-8" ) self._finish_stage( task_id, "test", started, f"{len(validation)} 项验证全部通过。", ) started = self._begin_stage(task_id, "package", "生成 OTA 固件包、清单和 SHA-256。") self._check_cancel(cancel_event) artifact = self._package_artifact(task_id, workspace, copied_hex, validation, code_diff) self._finish_stage( task_id, "package", started, f"固件包 {artifact['firmware_filename']} 已生成并完成哈希校验。", ) started = self._begin_stage( task_id, "publish", "将签名清单和固件包发布到车机升级服务器。" ) self._check_cancel(cancel_event) with self._lock: stored = self._find_artifact_locked(str(artifact["id"])) release = self._publish_artifact_locked(stored) task = self._find_task_locked(task_id) task["artifact_id"] = artifact["id"] task["release_id"] = release["id"] self._save_locked() self._finish_stage(task_id, "publish", started, FINAL_RELEASE_MESSAGE) with self._lock: task = self._find_task_locked(task_id) task.update( { "status": "success", "message": FINAL_RELEASE_MESSAGE, "finished_at": _now(), "duration_ms": round((time.monotonic() - started_monotonic) * 1000), } ) self._append_log_locked( task, "success", "流水线完成", FINAL_RELEASE_MESSAGE, stage="pipeline", detail={"release_id": release["id"], "download_url": release["download_url"]}, ) self._save_locked() except InterruptedError: with self._lock: task = self._find_task_locked(task_id) task.update( { "status": "canceled", "message": "任务已取消。", "finished_at": _now(), "duration_ms": round((time.monotonic() - started_monotonic) * 1000), } ) self._finish_running_stage(task, "canceled", "任务已由用户取消。") self._append_log_locked( task, "warning", "任务取消", "任务已安全停止。", stage="pipeline" ) self._save_locked() except Exception as exc: # noqa: BLE001 - persist arbitrary build failures in task state with self._lock: task = self._find_task_locked(task_id) message = str(exc) or exc.__class__.__name__ task.update( { "status": "failed", "message": "流水线执行失败,可查看原因并重试。", "failure_reason": message, "finished_at": _now(), "duration_ms": round((time.monotonic() - started_monotonic) * 1000), } ) self._finish_running_stage(task, "failed", message) self._append_log_locked( task, "error", "任务失败", message, stage=task.get("current_stage") ) self._save_locked() finally: with self._lock: self._cancel_events.pop(task_id, None) self._threads.pop(task_id, None) @staticmethod def _check_cancel(cancel_event: threading.Event) -> None: if cancel_event.is_set(): raise InterruptedError def _finish_running_stage( self, task: dict[str, Any], status: str, summary: str ) -> None: for stage in task.get("stages", []): if stage.get("status") == "running": stage["status"] = status stage["finished_at"] = _now() stage["summary"] = summary @staticmethod def _parse_capabilities(instruction: str) -> list[str]: requested: list[str] = [] lowered = instruction.lower() patterns = { "时间控制": ("时间", "定时", "timed"), "位置控制": ("位置", "position"), "座椅通风": ("通风", "vent"), "座椅加热": ("加热", "heater", "heat"), } for capability, aliases in patterns.items(): if any(alias in lowered for alias in aliases): requested.append(capability) return requested def _build_log_callback(self, task_id: str) -> Callable[[str, str], None]: def callback(level: str, line: str) -> None: cleaned = str(line).rstrip() if not cleaned: return with self._lock: task = self._find_task_locked(task_id) task["build_output"].append(cleaned) task["build_output"] = task["build_output"][-500:] self._append_log_locked( task, level, "IAR Build", cleaned, stage="build" ) self._save_locked() return callback def _run_iar_build( self, task_id: str, log: Callable[[str, str], None], cancel_event: threading.Event, ) -> Path: script = self.repository_root / "tools" / "build_seat_firmware.ps1" if not script.is_file(): raise FirmwareWorkbenchError("IAR 构建脚本不存在。", 500, "build_script_missing") shell = shutil.which("powershell.exe") or shutil.which("pwsh") if not shell: raise FirmwareWorkbenchError("未找到 PowerShell,无法执行 IAR 构建。", 500) with self._lock: task = self._find_task_locked(task_id) capabilities = list(task.get("target_capabilities", ["时间控制"])) output_directory = self.artifact_root / "tasks" / task_id / "build" output_directory.mkdir(parents=True, exist_ok=True) command = [ shell, "-NoProfile", "-ExecutionPolicy", "Bypass", "-File", str(script), "-RepositoryRoot", str(self.repository_root), "-OutputDirectory", str(output_directory), "-PositionControl", "1" if "位置控制" in capabilities else "0", "-Ventilation", "1" if "座椅通风" in capabilities else "0", "-Heating", "1" if "座椅加热" in capabilities else "0", ] process = subprocess.Popen( command, cwd=self.repository_root, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, text=True, encoding="utf-8", errors="replace", bufsize=1, ) assert process.stdout is not None try: while True: line = process.stdout.readline() if line: log("info", line) if cancel_event.is_set() and process.poll() is None: process.terminate() try: process.wait(timeout=5) except subprocess.TimeoutExpired: process.kill() raise InterruptedError if process.poll() is not None and not line: break if not line: time.sleep(0.05) finally: process.stdout.close() if process.returncode != 0: raise FirmwareWorkbenchError( f"IAR 构建失败,退出码 {process.returncode}。", 500, "build_failed" ) return output_directory / "Seat_Controller_demo.hex" def _validate_target( self, image_path: Path, target_capabilities: list[str], feature_profile: dict[str, bool], ) -> list[dict[str, Any]]: application = (self.target_root / "App" / "seat_move_application.c").read_text( encoding="utf-8", errors="replace" ) thermal = (self.target_root / "App" / "seat_controller_thermal.c").read_text( encoding="utf-8", errors="replace" ) heater = (self.target_root / "App" / "seat_backrest_heater.c").read_text( encoding="utf-8", errors="replace" ) project = (self.target_root / "IAR" / "Seat_Controller_demo.ewp").read_text( encoding="utf-8", errors="replace" ) checks: list[tuple[str, bool]] = [ ("四轴时间控制", "SEAT_MOVE_OPCODE_MOVE_TIMED" in application), ("能力配置与目标一致", feature_profile == { "position_control": "位置控制" in target_capabilities, "ventilation": "座椅通风" in target_capabilities, "heating": "座椅加热" in target_capabilities, }), ] if "位置控制" in target_capabilities: checks.append(( "位置参数控制", "SEAT_MOVE_OPCODE_MOVE_ABSOLUTE" in application and "SEAT_MOVE_OPCODE_MOVE_RELATIVE" in application, )) if "座椅通风" in target_capabilities: checks.append(( "座椅通风控制", "SeatControllerThermal_RequestFan" in thermal and '"bsp_seat_fan.h"' in thermal, )) if "座椅加热" in target_capabilities: checks.append(( "座椅加热控制", "SeatBackrestHeater_SetLevel" in thermal and "MAX_ACTIVE_TIME" in heater, )) checks.extend([ ( "IAR 工程模块完整性", all( name in project for name in ( "seat_move_application.c", "seat_controller_thermal.c", "seat_backrest_heater.c", ) ), ), ("HEX 镜像有效", image_path.is_file() and image_path.stat().st_size > 1024), ]) failed = [name for name, passed in checks if not passed] results = [ { "name": name, "status": "passed" if passed else "failed", "summary": "验证通过" if passed else "验证失败", "finished_at": _now(), } for name, passed in checks ] if failed: raise FirmwareWorkbenchError( "固件验证失败:" + "、".join(failed), 409, "validation_failed" ) return results def _package_artifact( self, task_id: str, workspace: Path, image_path: Path, validation: list[dict[str, Any]], code_diff: str, ) -> dict[str, Any]: with self._lock: task = self._find_task_locked(task_id) capabilities = list(task["target_capabilities"]) baseline_capabilities = list(task.get("baseline_capabilities", ["时间控制"])) artifact_id = f"fw-art-{uuid.uuid4().hex[:12]}" build_time = _now() image_hash = hashlib.sha256(image_path.read_bytes()).hexdigest() capability_slug = "-".join( { "时间控制": "time", "位置控制": "position", "座椅通风": "vent", "座椅加热": "heat", }[capability] for capability in capabilities ) package_name = ( f"PassengerSeat_{FIRMWARE_VERSION}_{capability_slug}_{artifact_id[-6:]}.zip" ) package_path = workspace / package_name manifest = { "schema": "n7-seat-ota/v1", "software_name": SOFTWARE_NAME, "version": FIRMWARE_VERSION, "capabilities": capabilities, "image": image_path.name, "image_size": image_path.stat().st_size, "image_sha256": image_hash, "build_time": build_time, "task_id": task_id, } manifest_path = workspace / "manifest.json" manifest_path.write_text( json.dumps(manifest, ensure_ascii=False, indent=2), encoding="utf-8" ) with zipfile.ZipFile(package_path, "w", compression=zipfile.ZIP_DEFLATED) as archive: archive.write(image_path, arcname=image_path.name) archive.write(manifest_path, arcname="manifest.json") archive.write(workspace / "requirements.json", arcname="requirements.json") archive.write(workspace / "validation.json", arcname="validation.json") archive.write(workspace / "feature_profile.json", arcname="feature_profile.json") package_hash = hashlib.sha256(package_path.read_bytes()).hexdigest() artifact = { "id": artifact_id, "task_id": task_id, "software_name": SOFTWARE_NAME, "version": FIRMWARE_VERSION, "capabilities": capabilities, "baseline_capabilities": baseline_capabilities, "build_time": build_time, "firmware_filename": package_name, "image_filename": image_path.name, "size_bytes": package_path.stat().st_size, "sha256": package_hash, "image_sha256": image_hash, "package_path": str(package_path), "status": "ready", "release_id": None, "published_at": None, "download_url": f"/api/firmware-workbench/artifacts/{artifact_id}/download", "capability_diff": { "added": [item for item in capabilities if item not in baseline_capabilities], "retained": [item for item in capabilities if item in baseline_capabilities], "removed": [item for item in baseline_capabilities if item not in capabilities], }, "code_diff": code_diff, "validation_records": deepcopy(validation), "file_changes": [], } with self._lock: task = self._find_task_locked(task_id) artifact["file_changes"] = deepcopy(task["file_changes"]) self._state["artifacts"].insert(0, artifact) task["artifact_id"] = artifact_id self._save_locked() return deepcopy(artifact) def _publish_artifact_locked(self, artifact: dict[str, Any]) -> dict[str, Any]: package_path = Path(str(artifact["package_path"])).resolve() self._assert_managed_path(package_path, self.artifact_root) if not package_path.is_file(): raise FirmwareWorkbenchError("待发布固件包不存在。", 404, "not_found") actual_hash = hashlib.sha256(package_path.read_bytes()).hexdigest() if actual_hash != artifact.get("sha256"): raise FirmwareWorkbenchError("固件包 SHA-256 校验失败,已阻止发布。", 409) release_id = f"fw-rel-{uuid.uuid4().hex[:12]}" download_url = f"{self.public_base_url}/{release_id}/download" release_root = self.ota_root / "releases" / release_id staging_root = self.ota_root / ".staging" / release_id staging_root.mkdir(parents=True, exist_ok=False) published_package = staging_root / str(artifact["firmware_filename"]) shutil.copy2(package_path, published_package) release_manifest = { "schema": "n7-ota-release/v1", "release_id": release_id, "artifact_id": artifact["id"], "software_name": artifact["software_name"], "version": artifact["version"], "capabilities": artifact["capabilities"], "firmware_filename": artifact["firmware_filename"], "size_bytes": artifact["size_bytes"], "sha256": artifact["sha256"], "published_at": _now(), "status": "published", "message": FINAL_RELEASE_MESSAGE, "download_url": download_url, } (staging_root / "release.json").write_text( json.dumps(release_manifest, ensure_ascii=False, indent=2), encoding="utf-8" ) release_root.parent.mkdir(parents=True, exist_ok=True) staging_root.replace(release_root) latest_temp = self.ota_root / "latest.tmp" latest_temp.write_text( json.dumps(release_manifest, ensure_ascii=False, indent=2), encoding="utf-8" ) latest_temp.replace(self.ota_root / "latest.json") final_package = release_root / str(artifact["firmware_filename"]) release = { **release_manifest, "id": release_id, "package_path": str(final_package), } artifact.update( { "status": "published", "release_id": release_id, "published_at": release["published_at"], "download_url": release["download_url"], } ) self._state["releases"].insert(0, release) self._save_locked() return release @staticmethod def _append_log_locked( task: dict[str, Any], level: str, title: str, message: str, *, stage: str | None = None, detail: dict[str, Any] | None = None, ) -> None: task.setdefault("logs", []).append( { "id": f"log-{uuid.uuid4().hex[:10]}", "time": _now(), "level": level, "stage": stage, "title": title, "message": message, "detail": detail, } ) task["logs"] = task["logs"][-1000:] def close(self) -> None: with self._lock: events = list(self._cancel_events.values()) threads = list(self._threads.values()) for event in events: event.set() for worker in threads: worker.join(timeout=3)