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> <script src="/firmware-workbench-theme.js?v=1"></script>
<link rel="stylesheet" href="/firmware-workbench.css?v=6" /> <link rel="stylesheet" href="/firmware-workbench.css?v=6" />
<script src="/vendor/lucide/0.468.0/lucide.min.js" defer></script> <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> </head>
<body> <body>
<div class="shell"> <div class="shell">
+11
View File
@@ -271,6 +271,7 @@
<button type="button" title="查看验证记录" data-artifact-action="validation" data-artifact-id="${artifact.id}">验证</button> <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> <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> <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>`} ${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> </div></td>
</tr>`).join(""); </tr>`).join("");
@@ -400,6 +401,16 @@
finally { button.disabled = false; } finally { button.disabled = false; }
return; 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") { if (action === "detail") {
showModal("固件产物详情", `<div class="detail-grid"> showModal("固件产物详情", `<div class="detail-grid">
<div class="detail-field"><span>软件名称</span><strong>${escapeHtml(artifact.software_name)}</strong></div> <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 SHELL_CACHE = `${CACHE_VERSION}-shell`;
const RUNTIME_CACHE = `${CACHE_VERSION}-runtime`; const RUNTIME_CACHE = `${CACHE_VERSION}-runtime`;
const APP_SHELL = [ const APP_SHELL = [
@@ -28,7 +28,7 @@ const APP_SHELL = [
"/firmware-workbench.html", "/firmware-workbench.html",
"/firmware-workbench-theme.js?v=1", "/firmware-workbench-theme.js?v=1",
"/firmware-workbench.css?v=6", "/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.png?v=3",
"/assets/pets/self-extension-cat-entrance.png?v=3", "/assets/pets/self-extension-cat-entrance.png?v=3",
"/manifest.webmanifest", "/manifest.webmanifest",
@@ -13,12 +13,14 @@ import threading
import time import time
import uuid import uuid
import zipfile import zipfile
from collections.abc import Callable from collections.abc import Callable, Mapping
from copy import deepcopy from copy import deepcopy
from datetime import datetime from datetime import UTC, datetime
from pathlib import Path from pathlib import Path
from typing import Any from typing import Any
from cognitive_agent.infrastructure.can.gateway import CanGatewayError
PIPELINE_STAGES = ( PIPELINE_STAGES = (
("instruction", "指令输入"), ("instruction", "指令输入"),
("requirements", "需求解析"), ("requirements", "需求解析"),
@@ -41,6 +43,8 @@ BASELINE_CATALOG = {
SOFTWARE_NAME = "副驾座椅控制器" SOFTWARE_NAME = "副驾座椅控制器"
FIRMWARE_VERSION = "SMV8-r15-b16" FIRMWARE_VERSION = "SMV8-r15-b16"
FINAL_RELEASE_MESSAGE = "已发布,待车机端下载升级" 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] BuildRunner = Callable[[str, Callable[[str, str], None], threading.Event], Path]
@@ -459,6 +463,113 @@ class FirmwareWorkbenchService:
with self._lock: with self._lock:
return deepcopy(self._find_artifact_locked(artifact_id)) 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]: def publish_artifact(self, artifact_id: str) -> dict[str, Any]:
with self._lock: with self._lock:
artifact = self._find_artifact_locked(artifact_id) artifact = self._find_artifact_locked(artifact_id)
@@ -1807,6 +1807,14 @@ class SeatDemoService(ModeScheduleServiceMixin):
return self._vehicle_mqtt_transport.publish_extension_toy_message(payload) 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( def set_extension_control_handler(
self, self,
handler: Callable[[bytes], None] | None, handler: Callable[[bytes], None] | None,
@@ -39,6 +39,8 @@ MQTT_CAN_SCHEMA = "n7.can.frame.v1"
MQTT_PET_TOPIC = "n7/pet" MQTT_PET_TOPIC = "n7/pet"
MQTT_EXTENSION_TOY_TOPIC = "extension/toy" MQTT_EXTENSION_TOY_TOPIC = "extension/toy"
MQTT_EXTENSION_TOY_QOS = 1 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_TOPIC = "extension/ctl"
MQTT_EXTENSION_CONTROL_QOS = 1 MQTT_EXTENSION_CONTROL_QOS = 1
MQTT_EXTENSION_CONTROL_MAX_PAYLOAD_BYTES = 256 * 1024 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( _EXTENSION_TOY_ACTION_KEYS = frozenset(
{ {
"type", "type",
@@ -1512,6 +1622,38 @@ class MqttCanTransport:
"message_id": getattr(result, "mid", None), "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( def publish_extension_toy_message(
self, self,
payload: Mapping[str, Any], payload: Mapping[str, Any],
+17
View File
@@ -3878,6 +3878,18 @@ class SeatDemoHandler(BaseHTTPRequestHandler):
) )
) )
return 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) self._json({"error": "not_found"}, HTTPStatus.NOT_FOUND)
except (ValueError, json.JSONDecodeError) as exc: except (ValueError, json.JSONDecodeError) as exc:
self._json( self._json(
@@ -3886,6 +3898,11 @@ class SeatDemoHandler(BaseHTTPRequestHandler):
) )
except FirmwareWorkbenchError as exc: except FirmwareWorkbenchError as exc:
self._json({"error": exc.code, "message": str(exc)}, HTTPStatus(exc.status)) 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: def _serve_firmware_download(self, kind: str, item_id: str) -> None:
try: try:
+1 -1
View File
@@ -1385,7 +1385,7 @@ operations:
self.assertIn('canvas.addEventListener("webglcontextlost"', app_script) self.assertIn('canvas.addEventListener("webglcontextlost"', app_script)
self.assertIn('"cognitive-agent:module-change"', app_script) self.assertIn('"cognitive-agent:module-change"', app_script)
self.assertIn( self.assertIn(
'const CACHE_VERSION = "cognitive-agent-pad-v323-dialog-theme"', 'const CACHE_VERSION = "cognitive-agent-pad-v324-firmware-notification"',
service_worker, service_worker,
) )
self.assertIn('/console-v2.css?v=delivery-state-69', 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(): 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") service_worker = (ROOT / "apps/web-console/service-worker.js").read_text(encoding="utf-8")
assert ( assert (
'const CACHE_VERSION = "cognitive-agent-pad-v323-dialog-theme"' 'const CACHE_VERSION = "cognitive-agent-pad-v324-firmware-notification"'
in service_worker in service_worker
) )
assert '"/assistant-chat.css?v=codex-assistant-29"' 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" "/energy-monitor.js?v=energy-monitor-5-performance"
) )
assert ( assert (
'const CACHE_VERSION = "cognitive-agent-pad-v323-dialog-theme"' 'const CACHE_VERSION = "cognitive-agent-pad-v324-firmware-notification"'
in service_worker 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.html",
"/firmware-workbench-theme.js?v=1", "/firmware-workbench-theme.js?v=1",
"/firmware-workbench.css?v=6", "/firmware-workbench.css?v=6",
"/firmware-workbench.js?v=3", "/firmware-workbench.js?v=4",
): ):
assert f'"{asset}"' in worker assert f'"{asset}"' in worker
for runtime_file in ( for runtime_file in (
@@ -99,5 +99,5 @@ def test_performance_assets_have_a_new_cache_generation() -> None:
): ):
assert asset in html assert asset in html
assert f'"{asset}"' in worker 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 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 source_asset in main
assert f'"{cached_asset}"' in service_worker assert f'"{cached_asset}"' in service_worker
assert 'import "./styles/design-system.css"' in main 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: 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 package["scripts"]["build"] == "vite build"
assert 'import vue from "@vitejs/plugin-vue"' in vite assert 'import vue from "@vitejs/plugin-vue"' in vite
assert 'outDir: outputRoot' 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 "unpkg.com" not in html
assert "cdn.jsdelivr.net" not in html assert "cdn.jsdelivr.net" not in html