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
@@ -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: