Files
Cognitive_Agent/src/cognitive_agent/application/firmware_workbench.py
T

1291 lines
54 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""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
from copy import deepcopy
from datetime import datetime
from pathlib import Path
from typing import Any
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 = "已发布,待车机端下载升级"
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 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)