update: firmware and provisioning now supports bootloader and partition tables
This commit is contained in:
@@ -30,13 +30,30 @@ class FirmwareListResponse(BaseModel):
|
||||
|
||||
|
||||
class FirmwareMetadataResponse(BaseModel):
|
||||
"""Returned by both /latest and /{version}/info endpoints."""
|
||||
"""Returned by both /latest and /{version}/info endpoints.
|
||||
|
||||
Two orthogonal axes:
|
||||
channel — the release track the device is subscribed to
|
||||
("stable" | "beta" | "development")
|
||||
Firmware validates this matches the channel it requested.
|
||||
update_type — the urgency of THIS release, set by the publisher
|
||||
("optional" | "mandatory" | "emergency")
|
||||
Firmware reads mandatory/emergency booleans derived from this.
|
||||
|
||||
Additional firmware-compatible fields:
|
||||
size — binary size in bytes (firmware reads "size", not "size_bytes")
|
||||
mandatory — True when update_type is mandatory or emergency
|
||||
emergency — True only when update_type is emergency
|
||||
"""
|
||||
hw_type: str
|
||||
channel: str
|
||||
channel: str # release track — firmware validates this
|
||||
version: str
|
||||
size_bytes: int
|
||||
size: int # firmware reads "size"
|
||||
size_bytes: int # kept for admin-panel consumers
|
||||
sha256: str
|
||||
update_type: UpdateType
|
||||
update_type: UpdateType # urgency enum — for admin panel display
|
||||
mandatory: bool # derived: update_type in (mandatory, emergency)
|
||||
emergency: bool # derived: update_type == emergency
|
||||
min_fw_version: Optional[str] = None
|
||||
download_url: str
|
||||
uploaded_at: str
|
||||
|
||||
@@ -1,13 +1,18 @@
|
||||
from fastapi import APIRouter, Depends, Query, UploadFile, File, Form
|
||||
from fastapi.responses import FileResponse
|
||||
from pydantic import BaseModel
|
||||
from typing import Optional
|
||||
import logging
|
||||
|
||||
from auth.models import TokenPayload
|
||||
from auth.dependencies import require_permission
|
||||
from firmware.models import FirmwareVersion, FirmwareListResponse, FirmwareMetadataResponse, UpdateType
|
||||
from firmware import service
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
router = APIRouter(prefix="/api/firmware", tags=["firmware"])
|
||||
ota_router = APIRouter(prefix="/api/ota", tags=["ota-telemetry"])
|
||||
|
||||
|
||||
@router.post("/upload", response_model=FirmwareVersion, status_code=201)
|
||||
@@ -44,11 +49,16 @@ def list_firmware(
|
||||
|
||||
|
||||
@router.get("/{hw_type}/{channel}/latest", response_model=FirmwareMetadataResponse)
|
||||
def get_latest_firmware(hw_type: str, channel: str):
|
||||
def get_latest_firmware(
|
||||
hw_type: str,
|
||||
channel: str,
|
||||
hw_version: Optional[str] = Query(None, description="Hardware revision from NVS, e.g. '1.0'"),
|
||||
current_version: Optional[str] = Query(None, description="Currently running firmware semver, e.g. '1.2.3'"),
|
||||
):
|
||||
"""Returns metadata for the latest firmware for a given hw_type + channel.
|
||||
No auth required — devices call this endpoint to check for updates.
|
||||
"""
|
||||
return service.get_latest(hw_type, channel)
|
||||
return service.get_latest(hw_type, channel, hw_version=hw_version, current_version=current_version)
|
||||
|
||||
|
||||
@router.get("/{hw_type}/{channel}/{version}/info", response_model=FirmwareMetadataResponse)
|
||||
@@ -76,3 +86,52 @@ def delete_firmware(
|
||||
_user: TokenPayload = Depends(require_permission("manufacturing", "delete")),
|
||||
):
|
||||
service.delete_firmware(firmware_id)
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
# OTA event telemetry — called by devices (no auth, best-effort)
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
class OtaDownloadEvent(BaseModel):
|
||||
device_uid: str
|
||||
hw_type: str
|
||||
hw_version: str
|
||||
from_version: str
|
||||
to_version: str
|
||||
channel: str
|
||||
|
||||
|
||||
class OtaFlashEvent(BaseModel):
|
||||
device_uid: str
|
||||
hw_type: str
|
||||
hw_version: str
|
||||
from_version: str
|
||||
to_version: str
|
||||
channel: str
|
||||
sha256: str
|
||||
|
||||
|
||||
@ota_router.post("/events/download", status_code=204)
|
||||
def ota_event_download(event: OtaDownloadEvent):
|
||||
"""Device reports that firmware was fully written to flash (pre-commit).
|
||||
No auth required — best-effort telemetry from the device.
|
||||
"""
|
||||
logger.info(
|
||||
"OTA download event: device=%s hw=%s/%s %s → %s (channel=%s)",
|
||||
event.device_uid, event.hw_type, event.hw_version,
|
||||
event.from_version, event.to_version, event.channel,
|
||||
)
|
||||
service.record_ota_event("download", event.model_dump())
|
||||
|
||||
|
||||
@ota_router.post("/events/flash", status_code=204)
|
||||
def ota_event_flash(event: OtaFlashEvent):
|
||||
"""Device reports that firmware partition was committed and device is rebooting.
|
||||
No auth required — best-effort telemetry from the device.
|
||||
"""
|
||||
logger.info(
|
||||
"OTA flash event: device=%s hw=%s/%s %s → %s (channel=%s sha256=%.16s...)",
|
||||
event.device_uid, event.hw_type, event.hw_version,
|
||||
event.from_version, event.to_version, event.channel, event.sha256,
|
||||
)
|
||||
service.record_ota_event("flash", event.model_dump())
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
import hashlib
|
||||
import logging
|
||||
import uuid
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from fastapi import HTTPException
|
||||
|
||||
@@ -10,6 +12,8 @@ from shared.firebase import get_db
|
||||
from shared.exceptions import NotFoundError
|
||||
from firmware.models import FirmwareVersion, FirmwareMetadataResponse, UpdateType
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
COLLECTION = "firmware_versions"
|
||||
|
||||
VALID_HW_TYPES = {"vesper", "vesper_plus", "vesper_pro", "chronos", "chronos_pro", "agnus", "agnus_mini"}
|
||||
@@ -46,13 +50,18 @@ def _doc_to_firmware_version(doc) -> FirmwareVersion:
|
||||
|
||||
def _fw_to_metadata_response(fw: FirmwareVersion) -> FirmwareMetadataResponse:
|
||||
download_url = f"/api/firmware/{fw.hw_type}/{fw.channel}/{fw.version}/firmware.bin"
|
||||
is_emergency = fw.update_type == UpdateType.emergency
|
||||
is_mandatory = fw.update_type in (UpdateType.mandatory, UpdateType.emergency)
|
||||
return FirmwareMetadataResponse(
|
||||
hw_type=fw.hw_type,
|
||||
channel=fw.channel,
|
||||
channel=fw.channel, # firmware validates this matches requested channel
|
||||
version=fw.version,
|
||||
size_bytes=fw.size_bytes,
|
||||
size=fw.size_bytes, # firmware reads "size"
|
||||
size_bytes=fw.size_bytes, # kept for admin-panel consumers
|
||||
sha256=fw.sha256,
|
||||
update_type=fw.update_type,
|
||||
update_type=fw.update_type, # urgency enum — for admin panel display
|
||||
mandatory=is_mandatory, # firmware reads this to decide auto-apply
|
||||
emergency=is_emergency, # firmware reads this to decide immediate apply
|
||||
min_fw_version=fw.min_fw_version,
|
||||
download_url=download_url,
|
||||
uploaded_at=fw.uploaded_at,
|
||||
@@ -130,7 +139,7 @@ def list_firmware(
|
||||
return items
|
||||
|
||||
|
||||
def get_latest(hw_type: str, channel: str) -> FirmwareMetadataResponse:
|
||||
def get_latest(hw_type: str, channel: str, hw_version: str | None = None, current_version: str | None = None) -> FirmwareMetadataResponse:
|
||||
if hw_type not in VALID_HW_TYPES:
|
||||
raise HTTPException(status_code=400, detail=f"Invalid hw_type '{hw_type}'")
|
||||
if channel not in VALID_CHANNELS:
|
||||
@@ -180,6 +189,22 @@ def get_firmware_path(hw_type: str, channel: str, version: str) -> Path:
|
||||
return path
|
||||
|
||||
|
||||
def record_ota_event(event_type: str, payload: dict[str, Any]) -> None:
|
||||
"""Persist an OTA telemetry event (download or flash) to Firestore.
|
||||
|
||||
Best-effort — caller should not raise on failure.
|
||||
"""
|
||||
try:
|
||||
db = get_db()
|
||||
db.collection("ota_events").add({
|
||||
"event_type": event_type,
|
||||
"received_at": datetime.now(timezone.utc),
|
||||
**payload,
|
||||
})
|
||||
except Exception as exc:
|
||||
logger.warning("Failed to persist OTA event (%s): %s", event_type, exc)
|
||||
|
||||
|
||||
def delete_firmware(doc_id: str) -> None:
|
||||
db = get_db()
|
||||
doc_ref = db.collection(COLLECTION).document(doc_id)
|
||||
|
||||
Reference in New Issue
Block a user