feat: notify vehicle of published firmware

This commit is contained in:
Miayui
2026-09-18 12:52:36 +08:00
parent e920a92505
commit 0ab1d76781
14 changed files with 301 additions and 12 deletions
+1 -1
View File
@@ -10,7 +10,7 @@
<script src="/firmware-workbench-theme.js?v=1"></script>
<link rel="stylesheet" href="/firmware-workbench.css?v=6" />
<script src="/vendor/lucide/0.468.0/lucide.min.js" defer></script>
<script src="/firmware-workbench.js?v=3" defer></script>
<script src="/firmware-workbench.js?v=4" defer></script>
</head>
<body>
<div class="shell">
+11
View File
@@ -271,6 +271,7 @@
<button type="button" title="查看验证记录" data-artifact-action="validation" data-artifact-id="${artifact.id}">验证</button>
<a href="${escapeHtml(artifact.download_url)}" title="下载固件" aria-label="下载固件" download><i data-lucide="download"></i></a>
<button class="publish-action" type="button" title="发布到服务器" data-artifact-action="publish" data-artifact-id="${artifact.id}"><i data-lucide="cloud-upload"></i></button>
${artifact.status === "published" ? `<button class="notify-action" type="button" title="通知车端" aria-label="通知车端" data-artifact-action="notify" data-artifact-id="${artifact.id}"><i data-lucide="bell"></i></button>` : ""}
${artifact.is_baseline ? `<span class="baseline-protection" title="时间控制基线固件受保护"><i data-lucide="shield-check"></i>受保护</span>` : `<button class="delete-action" type="button" title="删除固件产物" data-artifact-action="delete" data-artifact-id="${artifact.id}"><i data-lucide="trash-2"></i></button>`}
</div></td>
</tr>`).join("");
@@ -400,6 +401,16 @@
finally { button.disabled = false; }
return;
}
if (action === "notify") {
button.disabled = true;
try {
const result = await api(`/api/firmware-workbench/artifacts/${artifact.id}/notify`, { method: "POST", body: "{}" });
toast(`已向车端发送 ${result.published_count || result.burst_count || 3} 帧通知。`, "success");
await refreshState({ quiet: true });
} catch (error) { toast(error.message, "error"); }
finally { button.disabled = false; }
return;
}
if (action === "detail") {
showModal("固件产物详情", `<div class="detail-grid">
<div class="detail-field"><span>软件名称</span><strong>${escapeHtml(artifact.software_name)}</strong></div>
+2 -2
View File
@@ -1,4 +1,4 @@
const CACHE_VERSION = "cognitive-agent-pad-v323-dialog-theme";
const CACHE_VERSION = "cognitive-agent-pad-v324-firmware-notification";
const SHELL_CACHE = `${CACHE_VERSION}-shell`;
const RUNTIME_CACHE = `${CACHE_VERSION}-runtime`;
const APP_SHELL = [
@@ -28,7 +28,7 @@ const APP_SHELL = [
"/firmware-workbench.html",
"/firmware-workbench-theme.js?v=1",
"/firmware-workbench.css?v=6",
"/firmware-workbench.js?v=3",
"/firmware-workbench.js?v=4",
"/assets/pets/self-extension-cat.png?v=3",
"/assets/pets/self-extension-cat-entrance.png?v=3",
"/manifest.webmanifest",
@@ -13,12 +13,14 @@ import threading
import time
import uuid
import zipfile
from collections.abc import Callable
from collections.abc import Callable, Mapping
from copy import deepcopy
from datetime import datetime
from datetime import UTC, datetime
from pathlib import Path
from typing import Any
from cognitive_agent.infrastructure.can.gateway import CanGatewayError
PIPELINE_STAGES = (
("instruction", "指令输入"),
("requirements", "需求解析"),
@@ -41,6 +43,8 @@ BASELINE_CATALOG = {
SOFTWARE_NAME = "副驾座椅控制器"
FIRMWARE_VERSION = "SMV8-r15-b16"
FINAL_RELEASE_MESSAGE = "已发布,待车机端下载升级"
FIRMWARE_NOTIFICATION_TOPIC = "from_pc/firmware/notification"
FIRMWARE_NOTIFICATION_FRAME_COUNT = 3
BuildRunner = Callable[[str, Callable[[str, str], None], threading.Event], Path]
@@ -459,6 +463,113 @@ class FirmwareWorkbenchService:
with self._lock:
return deepcopy(self._find_artifact_locked(artifact_id))
def notify_artifact(
self,
artifact_id: str,
publisher: Callable[[Mapping[str, Any]], Mapping[str, Any]],
) -> dict[str, Any]:
"""Send a published artifact notification as one three-frame burst."""
with self._lock:
artifact = self._find_artifact_locked(artifact_id)
if artifact.get("is_baseline") or artifact.get("status") == "baseline":
raise FirmwareWorkbenchError(
"时间控制基线固件受保护,不能通知车端。",
403,
"baseline_protected",
)
if artifact.get("status") != "published":
raise FirmwareWorkbenchError(
"只有已发布的固件版本才能通知车端。",
409,
"artifact_not_published",
)
required_text_fields = (
"software_name",
"version",
"release_id",
"firmware_filename",
"build_time",
"sha256",
)
for field in required_text_fields:
value = artifact.get(field)
if not isinstance(value, str) or not value.strip():
raise FirmwareWorkbenchError(
f"已发布固件缺少通知字段:{field}。",
409,
"artifact_metadata_incomplete",
)
size_bytes = artifact.get("size_bytes")
if isinstance(size_bytes, bool) or not isinstance(size_bytes, int) or size_bytes < 0:
raise FirmwareWorkbenchError(
"已发布固件的包大小无效。",
409,
"artifact_metadata_incomplete",
)
sha256 = str(artifact["sha256"])
if len(sha256) != 64 or any(
character not in "0123456789abcdefABCDEF" for character in sha256
):
raise FirmwareWorkbenchError(
"已发布固件的 SHA-256 无效。",
409,
"artifact_metadata_incomplete",
)
message_id = f"fw-notify-{uuid.uuid4().hex[:20]}"
sent_at = datetime.now(UTC).isoformat().replace("+00:00", "Z")
common_payload: dict[str, Any] = {
"schema": "n7.firmware.notification.v1",
"event": "firmware_release_available",
"message_id": message_id,
"sequence_total": FIRMWARE_NOTIFICATION_FRAME_COUNT,
"sent_at": sent_at,
"source": "n7web",
"target": {"ecu": "passenger_seat_controller"},
"firmware": {
"software_name": artifact["software_name"],
"version": artifact["version"],
"status": "published",
"release_id": artifact["release_id"],
"artifact_name": artifact["firmware_filename"],
"size_bytes": size_bytes,
"sha256": sha256,
"build_at": artifact["build_time"],
},
"action": "notify_only",
"auto_upgrade": False,
}
published_count = 0
for sequence in range(1, FIRMWARE_NOTIFICATION_FRAME_COUNT + 1):
frame = deepcopy(common_payload)
frame["sequence"] = sequence
try:
result = publisher(frame)
except CanGatewayError as exc:
raise FirmwareWorkbenchError(
f"车端通知发送失败,已发送 {published_count}/{FIRMWARE_NOTIFICATION_FRAME_COUNT} 帧:{exc}",
503,
"notification_publish_failed",
) from exc
if not isinstance(result, Mapping):
raise FirmwareWorkbenchError(
f"车端通知发布器返回无效结果,已发送 {published_count}/{FIRMWARE_NOTIFICATION_FRAME_COUNT} 帧。",
503,
"notification_publish_failed",
)
published_count += 1
return {
"topic": FIRMWARE_NOTIFICATION_TOPIC,
"message_id": message_id,
"burst_count": FIRMWARE_NOTIFICATION_FRAME_COUNT,
"published_count": published_count,
"status": "published",
}
def publish_artifact(self, artifact_id: str) -> dict[str, Any]:
with self._lock:
artifact = self._find_artifact_locked(artifact_id)
@@ -1807,6 +1807,14 @@ class SeatDemoService(ModeScheduleServiceMixin):
return self._vehicle_mqtt_transport.publish_extension_toy_message(payload)
def publish_firmware_notification(
self,
payload: Mapping[str, Any],
) -> dict[str, Any]:
"""Publish one firmware release notification through the vehicle broker."""
return self._vehicle_mqtt_transport.publish_firmware_notification(payload)
def set_extension_control_handler(
self,
handler: Callable[[bytes], None] | None,
@@ -39,6 +39,8 @@ MQTT_CAN_SCHEMA = "n7.can.frame.v1"
MQTT_PET_TOPIC = "n7/pet"
MQTT_EXTENSION_TOY_TOPIC = "extension/toy"
MQTT_EXTENSION_TOY_QOS = 1
MQTT_FIRMWARE_NOTIFICATION_TOPIC = "from_pc/firmware/notification"
MQTT_FIRMWARE_NOTIFICATION_QOS = 1
MQTT_EXTENSION_CONTROL_TOPIC = "extension/ctl"
MQTT_EXTENSION_CONTROL_QOS = 1
MQTT_EXTENSION_CONTROL_MAX_PAYLOAD_BYTES = 256 * 1024
@@ -535,6 +537,114 @@ def mqtt_pet_payload(payload: Mapping[str, Any]) -> str:
)
_FIRMWARE_NOTIFICATION_REQUIRED_KEYS = frozenset(
{
"schema",
"event",
"message_id",
"sequence",
"sequence_total",
"sent_at",
"source",
"target",
"firmware",
"action",
"auto_upgrade",
}
)
_FIRMWARE_NOTIFICATION_TARGET_KEYS = frozenset({"ecu"})
_FIRMWARE_NOTIFICATION_FIRMWARE_KEYS = frozenset(
{
"software_name",
"version",
"status",
"release_id",
"artifact_name",
"size_bytes",
"sha256",
"build_at",
}
)
def mqtt_firmware_notification_payload(payload: Mapping[str, Any]) -> str:
"""Serialize one vehicle-facing firmware release notification frame."""
if not isinstance(payload, Mapping) or set(payload) != _FIRMWARE_NOTIFICATION_REQUIRED_KEYS:
raise CanGatewayError(
"firmware notification payload requires the agreed top-level fields"
)
if payload.get("schema") != "n7.firmware.notification.v1":
raise CanGatewayError("firmware notification schema is invalid")
if payload.get("event") != "firmware_release_available":
raise CanGatewayError("firmware notification event is invalid")
if payload.get("source") != "n7web":
raise CanGatewayError("firmware notification source is invalid")
if payload.get("action") != "notify_only" or payload.get("auto_upgrade") is not False:
raise CanGatewayError("firmware notification must be notify_only")
for key, maximum in (("message_id", 128), ("sent_at", 64)):
value = payload.get(key)
if not isinstance(value, str) or not value.strip() or len(value) > maximum:
raise CanGatewayError(f"firmware notification {key} is invalid")
for key in ("sequence", "sequence_total"):
value = payload.get(key)
if isinstance(value, bool) or not isinstance(value, int) or value <= 0:
raise CanGatewayError(f"firmware notification {key} must be a positive integer")
if payload["sequence_total"] != 3 or payload["sequence"] > payload["sequence_total"]:
raise CanGatewayError("firmware notification sequence must be between 1 and 3")
target = payload.get("target")
if not isinstance(target, Mapping) or set(target) != _FIRMWARE_NOTIFICATION_TARGET_KEYS:
raise CanGatewayError("firmware notification target must contain only ecu")
if (
not isinstance(target.get("ecu"), str)
or not target["ecu"].strip()
or len(target["ecu"]) > 128
):
raise CanGatewayError("firmware notification target ecu is invalid")
firmware = payload.get("firmware")
if not isinstance(firmware, Mapping) or set(firmware) != _FIRMWARE_NOTIFICATION_FIRMWARE_KEYS:
raise CanGatewayError("firmware notification firmware fields are invalid")
for key, maximum in (
("software_name", 128),
("version", 128),
("status", 32),
("release_id", 128),
("artifact_name", 255),
("build_at", 64),
):
value = firmware.get(key)
if not isinstance(value, str) or not value.strip() or len(value) > maximum:
raise CanGatewayError(f"firmware notification firmware {key} is invalid")
if firmware.get("status") != "published":
raise CanGatewayError("firmware notification requires a published firmware")
size_bytes = firmware.get("size_bytes")
if isinstance(size_bytes, bool) or not isinstance(size_bytes, int) or size_bytes < 0:
raise CanGatewayError("firmware notification size_bytes is invalid")
sha256 = firmware.get("sha256")
if (
not isinstance(sha256, str)
or len(sha256) != 64
or any(character not in "0123456789abcdefABCDEF" for character in sha256)
):
raise CanGatewayError("firmware notification sha256 must be a 64-character hexadecimal hash")
try:
raw_payload = json.dumps(
deepcopy(dict(payload)),
ensure_ascii=False,
separators=(",", ":"),
allow_nan=False,
)
except (TypeError, ValueError) as exc:
raise CanGatewayError("firmware notification payload is not valid JSON") from exc
if len(raw_payload.encode("utf-8")) > 16 * 1024:
raise CanGatewayError("firmware notification payload exceeds 16 KiB")
return raw_payload
_EXTENSION_TOY_ACTION_KEYS = frozenset(
{
"type",
@@ -1512,6 +1622,38 @@ class MqttCanTransport:
"message_id": getattr(result, "mid", None),
}
def publish_firmware_notification(
self,
payload: Mapping[str, Any],
) -> dict[str, Any]:
"""Queue one validated firmware release notification frame."""
raw_payload = mqtt_firmware_notification_payload(payload)
with self._condition:
client = self._client
state = self._state
if client is None or state != "online":
raise CanGatewayError("firmware notification MQTT publisher is not connected")
result = client.publish(
MQTT_FIRMWARE_NOTIFICATION_TOPIC,
raw_payload,
qos=MQTT_FIRMWARE_NOTIFICATION_QOS,
retain=False,
)
if int(getattr(result, "rc", 0)) != 0:
raise CanGatewayError(
f"firmware notification MQTT publish failed with code {result.rc}"
)
is_published = getattr(result, "is_published", None)
broker_confirmed = bool(is_published()) if callable(is_published) else False
return {
"topic": MQTT_FIRMWARE_NOTIFICATION_TOPIC,
"qos": MQTT_FIRMWARE_NOTIFICATION_QOS,
"retain": False,
"delivery_status": "published" if broker_confirmed else "queued",
"message_id": getattr(result, "mid", None),
}
def publish_extension_toy_message(
self,
payload: Mapping[str, Any],
+17
View File
@@ -3878,6 +3878,18 @@ class SeatDemoHandler(BaseHTTPRequestHandler):
)
)
return
artifact_notify_match = re.fullmatch(
r"/api/firmware-workbench/artifacts/(?P<artifact_id>[a-z0-9-]+)/notify",
path,
)
if artifact_notify_match:
self._json(
self.firmware_workbench.notify_artifact(
artifact_notify_match.group("artifact_id"),
self.service.publish_firmware_notification,
)
)
return
self._json({"error": "not_found"}, HTTPStatus.NOT_FOUND)
except (ValueError, json.JSONDecodeError) as exc:
self._json(
@@ -3886,6 +3898,11 @@ class SeatDemoHandler(BaseHTTPRequestHandler):
)
except FirmwareWorkbenchError as exc:
self._json({"error": exc.code, "message": str(exc)}, HTTPStatus(exc.status))
except CanGatewayError as exc:
self._json(
{"error": "notification_publish_failed", "message": str(exc)},
HTTPStatus.SERVICE_UNAVAILABLE,
)
def _serve_firmware_download(self, kind: str, item_id: str) -> None:
try:
+1 -1
View File
@@ -1385,7 +1385,7 @@ operations:
self.assertIn('canvas.addEventListener("webglcontextlost"', app_script)
self.assertIn('"cognitive-agent:module-change"', app_script)
self.assertIn(
'const CACHE_VERSION = "cognitive-agent-pad-v323-dialog-theme"',
'const CACHE_VERSION = "cognitive-agent-pad-v324-firmware-notification"',
service_worker,
)
self.assertIn('/console-v2.css?v=delivery-state-69', service_worker)
@@ -27,7 +27,7 @@ def test_console_contains_ai_assistant_surface():
def test_codex_assistant_assets_are_versioned_together_in_the_service_worker():
service_worker = (ROOT / "apps/web-console/service-worker.js").read_text(encoding="utf-8")
assert (
'const CACHE_VERSION = "cognitive-agent-pad-v323-dialog-theme"'
'const CACHE_VERSION = "cognitive-agent-pad-v324-firmware-notification"'
in service_worker
)
assert '"/assistant-chat.css?v=codex-assistant-29"' in service_worker
@@ -41,7 +41,7 @@ def test_energy_monitor_assets_are_versioned_and_cached() -> None:
"/energy-monitor.js?v=energy-monitor-5-performance"
)
assert (
'const CACHE_VERSION = "cognitive-agent-pad-v323-dialog-theme"'
'const CACHE_VERSION = "cognitive-agent-pad-v324-firmware-notification"'
in service_worker
)
@@ -59,7 +59,7 @@ def test_workbench_exposes_artifact_actions_and_always_starts_a_new_task() -> No
"/firmware-workbench.html",
"/firmware-workbench-theme.js?v=1",
"/firmware-workbench.css?v=6",
"/firmware-workbench.js?v=3",
"/firmware-workbench.js?v=4",
):
assert f'"{asset}"' in worker
for runtime_file in (
@@ -99,5 +99,5 @@ def test_performance_assets_have_a_new_cache_generation() -> None:
):
assert asset in html
assert f'"{asset}"' in worker
assert 'const CACHE_VERSION = "cognitive-agent-pad-v323-dialog-theme"' in worker
assert 'const CACHE_VERSION = "cognitive-agent-pad-v324-firmware-notification"' in worker
assert '"/codepen-glass-theme.css?v=creative-cloud-6-performance"' in worker
+1 -1
View File
@@ -54,7 +54,7 @@ def test_theme_asset_versions_are_consistent_with_the_pwa_shell() -> None:
assert source_asset in main
assert f'"{cached_asset}"' in service_worker
assert 'import "./styles/design-system.css"' in main
assert 'const CACHE_VERSION = "cognitive-agent-pad-v323-dialog-theme"' in service_worker
assert 'const CACHE_VERSION = "cognitive-agent-pad-v324-firmware-notification"' in service_worker
def test_reference_glass_theme_covers_the_complete_workspace() -> None:
+1 -1
View File
@@ -31,7 +31,7 @@ def test_vue_vite_application_is_the_primary_frontend_entry() -> None:
assert package["scripts"]["build"] == "vite build"
assert 'import vue from "@vitejs/plugin-vue"' in vite
assert 'outDir: outputRoot' in vite
assert 'const CACHE_VERSION = "cognitive-agent-pad-v323-dialog-theme"' in service_worker
assert 'const CACHE_VERSION = "cognitive-agent-pad-v324-firmware-notification"' in service_worker
assert "unpkg.com" not in html
assert "cdn.jsdelivr.net" not in html