feat: add seat firmware workbench and OTA delivery
This commit is contained in:
@@ -0,0 +1,253 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import json
|
||||
import threading
|
||||
import time
|
||||
import zipfile
|
||||
from pathlib import Path
|
||||
|
||||
from cognitive_agent.application.firmware_workbench import (
|
||||
FINAL_RELEASE_MESSAGE,
|
||||
FirmwareWorkbenchService,
|
||||
)
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[2]
|
||||
|
||||
|
||||
def wait_for_terminal(service: FirmwareWorkbenchService, task_id: str) -> dict[str, object]:
|
||||
deadline = time.monotonic() + 5
|
||||
while time.monotonic() < deadline:
|
||||
task = service.task(task_id)
|
||||
if task["status"] not in {"waiting", "running", "pending_confirmation"}:
|
||||
return task
|
||||
time.sleep(0.02)
|
||||
raise AssertionError("firmware pipeline did not reach a terminal state")
|
||||
|
||||
|
||||
def make_image(tmp_path: Path) -> Path:
|
||||
image = tmp_path / "Seat_Controller_demo.hex"
|
||||
image.write_bytes(b":020000040000FA\n" + b":100000000102030405060708090A0B0C0D0E0F1068\n" * 80)
|
||||
return image
|
||||
|
||||
|
||||
def test_pipeline_publishes_a_downloadable_ota_package(
|
||||
tmp_path: Path, monkeypatch
|
||||
) -> None:
|
||||
monkeypatch.setenv(
|
||||
"FIRMWARE_WORKBENCH_OTA_PUBLIC_BASE_URL",
|
||||
"http://192.168.43.130:8000/api/firmware-workbench/releases",
|
||||
)
|
||||
image = make_image(tmp_path)
|
||||
|
||||
def build_runner(
|
||||
_task_id: str, log: object, _cancel: threading.Event
|
||||
) -> Path:
|
||||
log("info", "IAR build: 0 errors, 0 warnings")
|
||||
return image
|
||||
|
||||
service = FirmwareWorkbenchService(
|
||||
ROOT,
|
||||
artifact_root=tmp_path / "workbench",
|
||||
ota_root=tmp_path / "ota",
|
||||
build_runner=build_runner,
|
||||
)
|
||||
instruction = "增加支持座椅位置参数控制的能力,增加座椅通风功能,增加座椅加热功能"
|
||||
|
||||
first = service.start_task(instruction)
|
||||
task = wait_for_terminal(service, str(first["task"]["id"]))
|
||||
assert task["status"] == "success"
|
||||
assert task["message"] == FINAL_RELEASE_MESSAGE
|
||||
assert [stage["status"] for stage in task["stages"]] == ["success"] * 8
|
||||
assert len(task["test_results"]) == 7
|
||||
|
||||
snapshot = service.snapshot()
|
||||
assert snapshot["baselines"] == [
|
||||
{
|
||||
"id": "time_only",
|
||||
"name": "时间控制基线",
|
||||
"capabilities": ["时间控制"],
|
||||
}
|
||||
]
|
||||
assert len(snapshot["artifacts"]) == 2
|
||||
assert len(snapshot["releases"]) == 1
|
||||
artifact = next(item for item in snapshot["artifacts"] if item["task_id"] == task["id"])
|
||||
baseline = next(item for item in snapshot["artifacts"] if item.get("is_baseline"))
|
||||
release = snapshot["releases"][0]
|
||||
assert baseline["capabilities"] == ["时间控制"]
|
||||
assert baseline["protected"] is True
|
||||
assert artifact["capabilities"] == ["时间控制", "位置控制", "座椅通风", "座椅加热"]
|
||||
assert artifact["status"] == "published"
|
||||
assert release["download_url"] == (
|
||||
f"http://192.168.43.130:8000/api/firmware-workbench/releases/"
|
||||
f"{release['id']}/download"
|
||||
)
|
||||
assert service.latest_release()["download_url"] == release["download_url"]
|
||||
latest_manifest = json.loads((tmp_path / "ota" / "latest.json").read_text("utf-8"))
|
||||
assert latest_manifest["download_url"] == release["download_url"]
|
||||
package, filename = service.resolve_release_download(str(release["id"]))
|
||||
assert filename == artifact["firmware_filename"]
|
||||
assert hashlib.sha256(package.read_bytes()).hexdigest() == artifact["sha256"]
|
||||
with zipfile.ZipFile(package) as archive:
|
||||
assert {
|
||||
"Seat_Controller_demo.hex",
|
||||
"manifest.json",
|
||||
"requirements.json",
|
||||
"validation.json",
|
||||
"feature_profile.json",
|
||||
} <= set(archive.namelist())
|
||||
assert (tmp_path / "ota" / "latest.json").is_file()
|
||||
assert service.publish_artifact(str(artifact["id"]))["id"] == release["id"]
|
||||
service.close()
|
||||
|
||||
monkeypatch.setenv(
|
||||
"FIRMWARE_WORKBENCH_OTA_PUBLIC_BASE_URL",
|
||||
"http://192.168.43.131:8000/api/firmware-workbench/releases",
|
||||
)
|
||||
migrated = FirmwareWorkbenchService(
|
||||
ROOT,
|
||||
artifact_root=tmp_path / "workbench",
|
||||
ota_root=tmp_path / "ota",
|
||||
build_runner=build_runner,
|
||||
)
|
||||
migrated_release = migrated.latest_release()
|
||||
assert migrated_release["download_url"].startswith("http://192.168.43.131:8000/")
|
||||
migrated_latest = json.loads((tmp_path / "ota" / "latest.json").read_text("utf-8"))
|
||||
assert migrated_latest["download_url"] == migrated_release["download_url"]
|
||||
migrated.close()
|
||||
|
||||
|
||||
def test_repeated_instruction_always_creates_and_executes_a_new_task(tmp_path: Path) -> None:
|
||||
image = make_image(tmp_path)
|
||||
|
||||
def build_runner(
|
||||
_task_id: str, log: object, _cancel: threading.Event
|
||||
) -> Path:
|
||||
log("info", "IAR build: 0 errors, 0 warnings")
|
||||
return image
|
||||
|
||||
service = FirmwareWorkbenchService(
|
||||
ROOT,
|
||||
artifact_root=tmp_path / "workbench",
|
||||
ota_root=tmp_path / "ota",
|
||||
build_runner=build_runner,
|
||||
)
|
||||
first = service.start_task("增加座椅加热功能。")
|
||||
first_task = wait_for_terminal(service, str(first["task"]["id"]))
|
||||
second = service.start_task("增加座椅加热功能。")
|
||||
|
||||
assert first["task"]["id"] != second["task"]["id"]
|
||||
assert "duplicate" not in first
|
||||
assert "duplicate" not in second
|
||||
second_task = wait_for_terminal(service, str(second["task"]["id"]))
|
||||
assert first_task["status"] == "success"
|
||||
assert second_task["status"] == "success"
|
||||
assert first_task["target_capabilities"] == ["时间控制", "座椅加热"]
|
||||
assert second_task["target_capabilities"] == ["时间控制", "座椅加热"]
|
||||
service.close()
|
||||
|
||||
|
||||
def test_non_baseline_artifact_can_be_deleted_but_baseline_is_protected(tmp_path: Path) -> None:
|
||||
image = make_image(tmp_path)
|
||||
|
||||
def build_runner(
|
||||
_task_id: str, log: object, _cancel: threading.Event
|
||||
) -> Path:
|
||||
log("info", "IAR build: 0 errors, 0 warnings")
|
||||
return image
|
||||
|
||||
service = FirmwareWorkbenchService(
|
||||
ROOT,
|
||||
artifact_root=tmp_path / "workbench",
|
||||
ota_root=tmp_path / "ota",
|
||||
build_runner=build_runner,
|
||||
)
|
||||
started = service.start_task("增加座椅通风功能")
|
||||
task = wait_for_terminal(service, str(started["task"]["id"]))
|
||||
package_path, _ = service.resolve_artifact_download(str(task["artifact_id"]))
|
||||
release_path, _ = service.resolve_release_download(str(task["release_id"]))
|
||||
|
||||
result = service.delete_artifact(str(task["artifact_id"]))
|
||||
assert result["deleted"] is True
|
||||
assert not package_path.exists()
|
||||
assert not release_path.exists()
|
||||
snapshot = service.snapshot()
|
||||
assert [artifact["id"] for artifact in snapshot["artifacts"]] == [
|
||||
"fw-art-time-only-baseline"
|
||||
]
|
||||
assert snapshot["releases"] == []
|
||||
|
||||
try:
|
||||
service.delete_artifact("fw-art-time-only-baseline")
|
||||
except Exception as exc: # noqa: BLE001 - public error type is asserted below
|
||||
assert getattr(exc, "code", None) == "baseline_protected"
|
||||
else:
|
||||
raise AssertionError("time-only baseline must not be deletable")
|
||||
service.close()
|
||||
|
||||
|
||||
def test_position_instruction_merges_with_time_only_baseline(tmp_path: Path) -> None:
|
||||
image = make_image(tmp_path)
|
||||
|
||||
def build_runner(
|
||||
_task_id: str, log: object, _cancel: threading.Event
|
||||
) -> Path:
|
||||
log("info", "IAR build: profile position=1 ventilation=0 heating=0")
|
||||
return image
|
||||
|
||||
service = FirmwareWorkbenchService(
|
||||
ROOT,
|
||||
artifact_root=tmp_path / "workbench",
|
||||
ota_root=tmp_path / "ota",
|
||||
build_runner=build_runner,
|
||||
)
|
||||
started = service.start_task("增加支持座椅位置参数控制的能力。", "time_only")
|
||||
task = wait_for_terminal(service, str(started["task"]["id"]))
|
||||
|
||||
assert task["status"] == "success"
|
||||
assert task["baseline_capabilities"] == ["时间控制"]
|
||||
assert task["requested_capabilities"] == ["位置控制"]
|
||||
assert task["target_capabilities"] == ["时间控制", "位置控制"]
|
||||
artifact = service.snapshot()["artifacts"][0]
|
||||
assert artifact["capabilities"] == ["时间控制", "位置控制"]
|
||||
assert artifact["capability_diff"]["added"] == ["位置控制"]
|
||||
assert "vent" not in artifact["firmware_filename"]
|
||||
assert "heat" not in artifact["firmware_filename"]
|
||||
service.close()
|
||||
|
||||
|
||||
def test_running_pipeline_can_be_canceled_and_retried(tmp_path: Path) -> None:
|
||||
image = make_image(tmp_path)
|
||||
allow_build = threading.Event()
|
||||
|
||||
def build_runner(
|
||||
_task_id: str, log: object, cancel: threading.Event
|
||||
) -> Path:
|
||||
log("info", "IAR build started")
|
||||
while not allow_build.is_set():
|
||||
if cancel.wait(0.01):
|
||||
raise InterruptedError
|
||||
return image
|
||||
|
||||
service = FirmwareWorkbenchService(
|
||||
ROOT,
|
||||
artifact_root=tmp_path / "workbench",
|
||||
ota_root=tmp_path / "ota",
|
||||
build_runner=build_runner,
|
||||
)
|
||||
started = service.start_task("增加座椅通风功能")
|
||||
task_id = str(started["task"]["id"])
|
||||
deadline = time.monotonic() + 3
|
||||
while service.task(task_id)["current_stage"] != "build" and time.monotonic() < deadline:
|
||||
time.sleep(0.01)
|
||||
service.cancel_task(task_id)
|
||||
canceled = wait_for_terminal(service, task_id)
|
||||
assert canceled["status"] == "canceled"
|
||||
assert any(stage["status"] == "canceled" for stage in canceled["stages"])
|
||||
|
||||
allow_build.set()
|
||||
retried = service.retry_task(task_id)
|
||||
assert retried["id"] == task_id
|
||||
completed = wait_for_terminal(service, task_id)
|
||||
assert completed["status"] == "success"
|
||||
service.close()
|
||||
Reference in New Issue
Block a user