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 @@ - +
diff --git a/apps/web-console/firmware-workbench.js b/apps/web-console/firmware-workbench.js index 0e9638a..9b96ddd 100644 --- a/apps/web-console/firmware-workbench.js +++ b/apps/web-console/firmware-workbench.js @@ -271,6 +271,7 @@ + ${artifact.status === "published" ? `` : ""} ${artifact.is_baseline ? `受保护` : ``}
`).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("固件产物详情", `
软件名称${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