From 0ab1d76781fe47679cf45e4c95f90d98dd31c3dc Mon Sep 17 00:00:00 2001
From: Miayui <774713819@qq.com>
Date: Fri, 18 Sep 2026 12:52:36 +0800
Subject: [PATCH] feat: notify vehicle of published firmware
---
apps/web-console/firmware-workbench.html | 2 +-
apps/web-console/firmware-workbench.js | 11 ++
apps/web-console/service-worker.js | 4 +-
.../application/firmware_workbench.py | 115 +++++++++++++-
.../application/seat_service.py | 8 +
.../infrastructure/can/mqtt_transport.py | 142 ++++++++++++++++++
src/cognitive_agent/server.py | 17 +++
tests/e2e/test_seat_web_api.py | 2 +-
.../unit/test_assistant_frontend_contract.py | 2 +-
.../test_energy_monitor_frontend_contract.py | 2 +-
...st_firmware_workbench_frontend_contract.py | 2 +-
.../test_frontend_performance_contract.py | 2 +-
tests/unit/test_frontend_theme_contract.py | 2 +-
tests/unit/test_vue_frontend_contract.py | 2 +-
14 files changed, 301 insertions(+), 12 deletions(-)
diff --git a/apps/web-console/firmware-workbench.html b/apps/web-console/firmware-workbench.html
index 2f851a9..909f4a9 100644
--- a/apps/web-console/firmware-workbench.html
+++ b/apps/web-console/firmware-workbench.html
@@ -10,7 +10,7 @@
-
+
软件名称${escapeHtml(artifact.software_name)}
diff --git a/apps/web-console/service-worker.js b/apps/web-console/service-worker.js
index 06f2eca..833a5bd 100644
--- a/apps/web-console/service-worker.js
+++ b/apps/web-console/service-worker.js
@@ -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",
diff --git a/src/cognitive_agent/application/firmware_workbench.py b/src/cognitive_agent/application/firmware_workbench.py
index 1cf61f5..6738407 100644
--- a/src/cognitive_agent/application/firmware_workbench.py
+++ b/src/cognitive_agent/application/firmware_workbench.py
@@ -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)
diff --git a/src/cognitive_agent/application/seat_service.py b/src/cognitive_agent/application/seat_service.py
index 105970c..ec46fab 100755
--- a/src/cognitive_agent/application/seat_service.py
+++ b/src/cognitive_agent/application/seat_service.py
@@ -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,
diff --git a/src/cognitive_agent/infrastructure/can/mqtt_transport.py b/src/cognitive_agent/infrastructure/can/mqtt_transport.py
index 060652c..bc8de99 100644
--- a/src/cognitive_agent/infrastructure/can/mqtt_transport.py
+++ b/src/cognitive_agent/infrastructure/can/mqtt_transport.py
@@ -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],
diff --git a/src/cognitive_agent/server.py b/src/cognitive_agent/server.py
index 82cabe5..60c1e18 100755
--- a/src/cognitive_agent/server.py
+++ b/src/cognitive_agent/server.py
@@ -3878,6 +3878,18 @@ class SeatDemoHandler(BaseHTTPRequestHandler):
)
)
return
+ artifact_notify_match = re.fullmatch(
+ r"/api/firmware-workbench/artifacts/(?P
[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:
diff --git a/tests/e2e/test_seat_web_api.py b/tests/e2e/test_seat_web_api.py
index 3045a46..75cc0d4 100755
--- a/tests/e2e/test_seat_web_api.py
+++ b/tests/e2e/test_seat_web_api.py
@@ -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)
diff --git a/tests/unit/test_assistant_frontend_contract.py b/tests/unit/test_assistant_frontend_contract.py
index 9dee616..f00546b 100644
--- a/tests/unit/test_assistant_frontend_contract.py
+++ b/tests/unit/test_assistant_frontend_contract.py
@@ -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
diff --git a/tests/unit/test_energy_monitor_frontend_contract.py b/tests/unit/test_energy_monitor_frontend_contract.py
index 885a603..768e939 100644
--- a/tests/unit/test_energy_monitor_frontend_contract.py
+++ b/tests/unit/test_energy_monitor_frontend_contract.py
@@ -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
)
diff --git a/tests/unit/test_firmware_workbench_frontend_contract.py b/tests/unit/test_firmware_workbench_frontend_contract.py
index 1f05926..163076e 100644
--- a/tests/unit/test_firmware_workbench_frontend_contract.py
+++ b/tests/unit/test_firmware_workbench_frontend_contract.py
@@ -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 (
diff --git a/tests/unit/test_frontend_performance_contract.py b/tests/unit/test_frontend_performance_contract.py
index 5f14273..0449bfb 100644
--- a/tests/unit/test_frontend_performance_contract.py
+++ b/tests/unit/test_frontend_performance_contract.py
@@ -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
diff --git a/tests/unit/test_frontend_theme_contract.py b/tests/unit/test_frontend_theme_contract.py
index d2ca653..95dc9cc 100644
--- a/tests/unit/test_frontend_theme_contract.py
+++ b/tests/unit/test_frontend_theme_contract.py
@@ -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:
diff --git a/tests/unit/test_vue_frontend_contract.py b/tests/unit/test_vue_frontend_contract.py
index 5bf7ce9..a426c0b 100644
--- a/tests/unit/test_vue_frontend_contract.py
+++ b/tests/unit/test_vue_frontend_contract.py
@@ -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