1291 lines
54 KiB
Python
1291 lines
54 KiB
Python
"""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)
|