feat: notify vehicle of published firmware
This commit is contained in:
@@ -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],
|
||||
|
||||
@@ -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:
|
||||
|
||||
Reference in New Issue
Block a user