feat: add seat firmware workbench and OTA delivery

This commit is contained in:
vee
2026-09-16 17:55:30 +08:00
parent 3eeca2ea75
commit ab8e43050c
70 changed files with 22594 additions and 316 deletions
+59
View File
@@ -75,6 +75,65 @@ async function main() {
await page.locator('[data-execution-mode="dry_run"]').click();
assert.equal(await page.evaluate(() => window.CognitiveAgentConsole.getExecutionMode()), "dry_run");
async function submitDraft(text) {
const responsePromise = responseFor("/api/assistant/stream");
await page.locator("#utterance").fill(text);
await page.locator("#utterance").press("Enter");
const response = await responsePromise;
const body = responses.filter((item) => item.pathname === "/api/assistant/stream").at(-1).text;
assert.equal(response.status(), 200, `${text}: ${body}`);
const events = body.trim().split("\n").map((line) => JSON.parse(line));
await waitIdle();
const payload = events.find((event) => event.payload?.mode_profile_draft)?.payload;
assert.ok(payload, `Missing draft for ${text}: ${body}`);
assert.equal(payload.status, "succeeded");
assert.equal(payload.execution_mode, "dry_run");
const draft = payload.mode_profile_draft;
const card = page.locator(`[data-assistant-mode-draft="${draft.source_request_id}"]`);
await card.waitFor({ state: "visible" });
assert.equal(await card.locator(".assistant-mode-draft-row").count(), draft.operation_count);
return { payload, draft, card, request: posted.filter((item) => item.pathname === "/api/assistant/stream").at(-1).payload };
}
// The original failing two-turn request must revise the server-owned
// draft, not attempt immediate hardware execution or replace its seats.
const recline = await submitDraft("构建一个躺平模式");
const originalActions = recline.payload.actions;
await recline.card.scrollIntoViewIfNeeded();
await page.screenshot({ path: path.join(evidenceDir, "mode-device-addition-before.png") });
const expanded = await submitDraft("在躺平模式中加入空调、氛围灯、阅读灯、车窗等设备动作");
assert.deepEqual(expanded.request.mode_profile_draft_context, {
name: recline.draft.name,
source_request_id: recline.draft.source_request_id,
vehicle_id: recline.draft.vehicle_id,
});
assert.notEqual(expanded.draft.source_request_id, recline.draft.source_request_id);
assert.equal(expanded.draft.draft_revision, 2);
const actionKey = (action) => `${action.capability}:${action.target?.instance || action.target?.zone || ""}`;
const expandedActions = new Map(expanded.payload.actions.map((action) => [actionKey(action), action]));
for (const original of originalActions) {
assert.deepEqual(expandedActions.get(actionKey(original))?.parameters, original.parameters,
"adding devices must preserve all existing action values");
}
for (const capability of ["climate.set_temperature", "interior_light.set_reading_power", "window.set_position"]) {
assert.ok(expanded.payload.actions.some((action) => action.capability === capability), capability);
}
assert.ok(expanded.payload.actions.some((action) => action.domain === "ambient_light"));
assert.ok(expanded.payload.actions.every((action) => action.domain !== "seat"
|| (action.target?.instance || action.target?.zone) === "passenger"));
assert.ok(expanded.draft.operation_count > recline.draft.operation_count);
assert.equal(await recline.card.getAttribute("data-superseded"), "true");
assert.equal(await recline.card.locator(".assistant-mode-draft-save").isDisabled(), true);
assert.equal(posted.filter((item) => item.pathname === "/api/mode-profiles").length, 0);
await expanded.card.scrollIntoViewIfNeeded();
await page.screenshot({ path: path.join(evidenceDir, "mode-device-addition-after.png") });
const temperatureEdit = await submitDraft("把躺平模式的空调温度调整到26度");
assert.equal(temperatureEdit.request.mode_profile_draft_context.source_request_id, expanded.draft.source_request_id);
assert.equal(temperatureEdit.draft.draft_revision, 3);
assert.equal(temperatureEdit.draft.operation_count, expanded.draft.operation_count);
assert.ok(temperatureEdit.payload.actions.some((action) => action.capability === "climate.set_temperature"
&& action.parameters.temperature_c === 26));
// Exercise the same real assistant endpoint and rendered cards for both
// implicit passenger selection and an explicit driver request. These
// previews stay unsaved, so the timeline save/trial tests remain isolated.
+112
View File
@@ -0,0 +1,112 @@
from __future__ import annotations
import hashlib
import json
import threading
import urllib.request
from http.server import ThreadingHTTPServer
from pathlib import Path
from cognitive_agent.firmware_ota_server import FirmwareOtaHandler, FirmwareOtaRepository
class TestFirmwareOtaServer:
def test_latest_download_head_and_resume_are_read_only(self, tmp_path: Path) -> None:
release_id = "fw-rel-123456789abc"
filename = "PassengerSeat_test.zip"
package = (b"vehicle-firmware-package" * 128) + b"end"
release_root = tmp_path / "releases" / release_id
release_root.mkdir(parents=True)
package_path = release_root / filename
package_path.write_bytes(package)
manifest = {
"schema": "n7-ota-release/v1",
"release_id": release_id,
"firmware_filename": filename,
"size_bytes": len(package),
"sha256": hashlib.sha256(package).hexdigest(),
"capabilities": ["时间控制", "座椅加热"],
"published_at": "2026-09-16T13:17:17+08:00",
"status": "published",
"download_url": "http://stale-address.invalid/download",
}
(release_root / "release.json").write_text(
json.dumps(manifest, ensure_ascii=False), encoding="utf-8"
)
(tmp_path / "latest.json").write_text(
json.dumps(manifest, ensure_ascii=False), encoding="utf-8"
)
older_release_id = "fw-rel-000000000001"
older_root = tmp_path / "releases" / older_release_id
older_root.mkdir(parents=True)
older_filename = "PassengerSeat_older.zip"
older_package = b"older-firmware-package"
(older_root / older_filename).write_bytes(older_package)
older_manifest = {
**manifest,
"release_id": older_release_id,
"firmware_filename": older_filename,
"size_bytes": len(older_package),
"sha256": hashlib.sha256(older_package).hexdigest(),
"published_at": "2026-09-15T13:17:17+08:00",
}
(older_root / "release.json").write_text(
json.dumps(older_manifest, ensure_ascii=False), encoding="utf-8"
)
class Handler(FirmwareOtaHandler):
repository = FirmwareOtaRepository(
tmp_path, "https://n7-ota.example.ts.net/api/firmware-workbench/releases"
)
server = ThreadingHTTPServer(("127.0.0.1", 0), Handler)
thread = threading.Thread(target=server.serve_forever, daemon=True)
thread.start()
base_url = f"http://127.0.0.1:{server.server_port}"
try:
with urllib.request.urlopen(
f"{base_url}/api/firmware-workbench/releases/latest", timeout=3
) as response:
latest = json.loads(response.read().decode("utf-8"))
assert latest["download_url"] == (
f"https://n7-ota.example.ts.net/api/firmware-workbench/releases/"
f"{release_id}/download"
)
with urllib.request.urlopen(
f"{base_url}/api/firmware-workbench/releases", timeout=3
) as response:
releases = json.loads(response.read().decode("utf-8"))
assert releases["schema"] == "n7-ota-release-list/v1"
assert releases["count"] == 2
assert releases["latest_release_id"] == release_id
assert [item["release_id"] for item in releases["releases"]] == [
release_id,
older_release_id,
]
assert releases["releases"][1]["download_url"].endswith(
f"/{older_release_id}/download"
)
request = urllib.request.Request(
f"{base_url}/api/firmware-workbench/releases/{release_id}/download",
headers={"Range": "bytes=8-31"},
)
with urllib.request.urlopen(request, timeout=3) as response:
assert response.status == 206
assert response.headers["Content-Range"] == f"bytes 8-31/{len(package)}"
assert response.headers["X-Firmware-SHA256"] == manifest["sha256"]
assert response.read() == package[8:32]
head = urllib.request.Request(
f"{base_url}/api/firmware-workbench/releases/{release_id}/download",
method="HEAD",
)
with urllib.request.urlopen(head, timeout=3) as response:
assert response.status == 200
assert int(response.headers["Content-Length"]) == len(package)
assert response.read() == b""
finally:
server.shutdown()
server.server_close()
thread.join(timeout=3)
+167
View File
@@ -0,0 +1,167 @@
from __future__ import annotations
import json
import tempfile
import threading
import time
import urllib.request
from http.server import ThreadingHTTPServer
from pathlib import Path
from cognitive_agent.application.firmware_workbench import FirmwareWorkbenchService
from cognitive_agent.auth import AuthConfig, AuthRuntime
from cognitive_agent.server import SeatDemoHandler
ROOT = Path(__file__).resolve().parents[2]
class TestFirmwareWorkbenchApi:
@classmethod
def setup_class(cls) -> None:
cls.temporary_directory = tempfile.TemporaryDirectory()
root = Path(cls.temporary_directory.name)
image = root / "Seat_Controller_demo.hex"
image.write_bytes(b":020000040000FA\n" + b":100000000102030405060708090A0B0C0D0E0F1068\n" * 80)
def build_runner(task_id: str, log: object, cancel: threading.Event) -> Path:
log("info", f"IAR task {task_id}: 0 errors, 0 warnings")
return image
cls.workbench = FirmwareWorkbenchService(
ROOT,
artifact_root=root / "workbench",
ota_root=root / "ota",
build_runner=build_runner,
)
cls.original_workbench = SeatDemoHandler.firmware_workbench
cls.original_auth = SeatDemoHandler.auth
SeatDemoHandler.firmware_workbench = cls.workbench
SeatDemoHandler.auth = AuthRuntime(
AuthConfig(
enabled=False,
username="",
password_hash="",
session_secret=b"t" * 64,
session_ttl_seconds=3600,
login_attempts=5,
login_window_seconds=300,
requests_per_minute=120,
trust_proxy_headers=False,
)
)
cls.server = ThreadingHTTPServer(("127.0.0.1", 0), SeatDemoHandler)
cls.thread = threading.Thread(target=cls.server.serve_forever, daemon=True)
cls.thread.start()
cls.base_url = f"http://127.0.0.1:{cls.server.server_port}"
@classmethod
def teardown_class(cls) -> None:
cls.server.shutdown()
cls.server.server_close()
cls.thread.join(timeout=2)
cls.workbench.close()
SeatDemoHandler.firmware_workbench = cls.original_workbench
SeatDemoHandler.auth = cls.original_auth
cls.temporary_directory.cleanup()
@classmethod
def post(cls, path: str, payload: dict[str, object]) -> tuple[int, dict[str, object]]:
request = urllib.request.Request(
f"{cls.base_url}{path}",
method="POST",
headers={"Content-Type": "application/json"},
data=json.dumps(payload, ensure_ascii=False).encode(),
)
with urllib.request.urlopen(request, timeout=5) as response:
return response.status, json.loads(response.read().decode())
def test_real_http_task_publish_detail_and_download_flow(self) -> None:
instruction = "增加位置控制、座椅通风和座椅加热功能"
status, created = self.post(
"/api/firmware-workbench/tasks", {"instruction": instruction}
)
assert status == 202
task_id = str(created["task"]["id"])
deadline = time.monotonic() + 5
while time.monotonic() < deadline:
with urllib.request.urlopen(
f"{self.base_url}/api/firmware-workbench/tasks/{task_id}", timeout=5
) as response:
task = json.loads(response.read().decode())
if task["status"] not in {"waiting", "running", "pending_confirmation"}:
break
time.sleep(0.03)
assert task["status"] == "success"
assert task["message"] == "已发布,待车机端下载升级"
with urllib.request.urlopen(
f"{self.base_url}/api/firmware-workbench/releases/latest", timeout=5
) as response:
latest = json.loads(response.read().decode())
assert latest["release_id"] == task["release_id"]
assert latest["download_url"].endswith(f"/{task['release_id']}/download")
repeated_status, repeated = self.post(
"/api/firmware-workbench/tasks", {"instruction": instruction}
)
assert repeated_status == 202
assert repeated["task"]["id"] != task_id
repeated_task_id = str(repeated["task"]["id"])
deadline = time.monotonic() + 5
while time.monotonic() < deadline:
with urllib.request.urlopen(
f"{self.base_url}/api/firmware-workbench/tasks/{repeated_task_id}", timeout=5
) as response:
repeated_task = json.loads(response.read().decode())
if repeated_task["status"] not in {"waiting", "running", "pending_confirmation"}:
break
time.sleep(0.03)
assert repeated_task["status"] == "success"
artifact_id = task["artifact_id"]
with urllib.request.urlopen(
f"{self.base_url}/api/firmware-workbench/artifacts/{artifact_id}", timeout=5
) as response:
artifact = json.loads(response.read().decode())
assert artifact["status"] == "published"
assert artifact["sha256"]
release_id = task["release_id"]
with urllib.request.urlopen(
f"{self.base_url}/api/firmware-workbench/releases/{release_id}/download",
timeout=5,
) as response:
package = response.read()
assert response.headers["Content-Type"] == "application/zip"
assert "attachment" in response.headers["Content-Disposition"]
assert len(package) == artifact["size_bytes"]
delete_request = urllib.request.Request(
f"{self.base_url}/api/firmware-workbench/artifacts/{artifact_id}",
method="DELETE",
)
with urllib.request.urlopen(delete_request, timeout=5) as response:
deleted = json.loads(response.read().decode())
assert deleted["deleted"] is True
repeated_artifact_id = str(repeated_task["artifact_id"])
repeated_delete_request = urllib.request.Request(
f"{self.base_url}/api/firmware-workbench/artifacts/{repeated_artifact_id}",
method="DELETE",
)
with urllib.request.urlopen(repeated_delete_request, timeout=5) as response:
assert json.loads(response.read().decode())["deleted"] is True
with urllib.request.urlopen(
f"{self.base_url}/api/firmware-workbench/state", timeout=5
) as response:
state = json.loads(response.read().decode())
assert [item["id"] for item in state["artifacts"]] == [
"fw-art-time-only-baseline"
]
with urllib.request.urlopen(
f"{self.base_url}/firmware-workbench.html", timeout=5
) as response:
page = response.read().decode()
assert response.headers["X-Frame-Options"] == "SAMEORIGIN"
assert "frame-ancestors 'self'" in response.headers["Content-Security-Policy"]
assert "座椅固件开发工作台" in page
+30
View File
@@ -89,6 +89,13 @@ class TestMutationRequestSecurity:
except urllib.error.HTTPError as exc:
return exc.code, json.loads(exc.read().decode("utf-8"))
def get(self, path: str) -> tuple[int, dict[str, object]]:
try:
with urllib.request.urlopen(f"{self.base_url}{path}", timeout=3) as response:
return response.status, json.loads(response.read().decode("utf-8"))
except urllib.error.HTTPError as exc:
return exc.code, json.loads(exc.read().decode("utf-8"))
def test_same_origin_json_assistant_request_is_accepted(self) -> None:
status, body = self.post(
"/api/assistant/stream",
@@ -147,6 +154,29 @@ class TestMutationRequestSecurity:
assert status == 200
assert MutationSecurityHandler.last_hardware_bypass is False
def test_vehicle_ota_download_is_public_but_workbench_state_remains_private(self) -> None:
MutationSecurityHandler.auth = _auth(enabled=True)
try:
download_status, download_body = self.get(
"/api/firmware-workbench/releases/fw-rel-missing1234/download"
)
latest_status, latest_body = self.get(
"/api/firmware-workbench/releases/latest"
)
state_status, state_body = self.get("/api/firmware-workbench/state")
finally:
MutationSecurityHandler.auth = _auth(enabled=False)
assert download_status == 404
assert download_body["error"] == "not_found"
assert latest_status in {200, 404}
if latest_status == 404:
assert latest_body["error"] == "not_found"
else:
assert latest_body["download_url"]
assert state_status == 401
assert state_body["error"] == "authentication_required"
def test_hardware_bypass_requires_auth_enabled_and_direct_loopback(self) -> None:
handler = object.__new__(SeatDemoHandler)
handler.client_address = ("127.0.0.1", 12345)
+6 -6
View File
@@ -734,7 +734,7 @@ operations:
) as response:
extension_styles = response.read().decode("utf-8").replace("\r\n", "\n")
with self.opener.open(
f"{self.base_url}/assistant-chat.js?v=codex-assistant-29", timeout=3
f"{self.base_url}/assistant-chat.js?v=codex-assistant-30", timeout=3
) as response:
assistant_script = response.read().decode("utf-8")
with self.opener.open(
@@ -796,8 +796,8 @@ operations:
self.assertNotIn("滚动查看更多", html)
self.assertIn('aria-label="发送消息"', html)
self.assertIn('id="assistantTranscript"', html)
self.assertIn('href="/assistant-chat.css?v=codex-assistant-29"', html)
self.assertIn('src="/assistant-chat.js?v=codex-assistant-29"', html)
self.assertIn('href="/assistant-chat.css?v=codex-assistant-30"', html)
self.assertIn('src="/assistant-chat.js?v=codex-assistant-30"', html)
self.assertIn('href="/energy-monitor.css?v=energy-monitor-4"', html)
self.assertIn('src="/energy-monitor.js?v=energy-monitor-4"', html)
self.assertNotIn('"admin"', console_script)
@@ -1075,11 +1075,11 @@ operations:
self.assertIn("CAN 指令已提交,等待车辆状态确认", console_script)
self.assertIn('canvas.addEventListener("webglcontextlost"', app_script)
self.assertIn('"cognitive-agent:module-change"', app_script)
self.assertIn('const CACHE_VERSION = "cognitive-agent-pad-v283"', service_worker)
self.assertIn('const CACHE_VERSION = "cognitive-agent-pad-v284"', service_worker)
self.assertIn('/console-v2.css?v=delivery-state-68', service_worker)
self.assertIn('/console-v2.js?v=local-first-66', service_worker)
self.assertIn('/assistant-chat.css?v=codex-assistant-29', service_worker)
self.assertIn('/assistant-chat.js?v=codex-assistant-29', service_worker)
self.assertIn('/assistant-chat.css?v=codex-assistant-30', service_worker)
self.assertIn('/assistant-chat.js?v=codex-assistant-30', service_worker)
self.assertIn('/energy-monitor.css?v=energy-monitor-4', service_worker)
self.assertIn('/energy-monitor.js?v=energy-monitor-4', service_worker)
self.assertIn('"/app.js?v=dual-seat-133"', service_worker)