import hashlib import logging import uuid from datetime import datetime, timezone from pathlib import Path from typing import Any from fastapi import HTTPException from config import settings 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"} VALID_CHANNELS = {"stable", "beta", "alpha", "testing"} def _storage_path(hw_type: str, channel: str, version: str) -> Path: return Path(settings.firmware_storage_path) / hw_type / channel / version / "firmware.bin" def _doc_to_firmware_version(doc) -> FirmwareVersion: data = doc.to_dict() or {} uploaded_raw = data.get("uploaded_at") if isinstance(uploaded_raw, datetime): uploaded_str = uploaded_raw.strftime("%Y-%m-%dT%H:%M:%SZ") else: uploaded_str = str(uploaded_raw) if uploaded_raw else "" return FirmwareVersion( id=doc.id, hw_type=data.get("hw_type", ""), channel=data.get("channel", ""), version=data.get("version", ""), filename=data.get("filename", "firmware.bin"), size_bytes=data.get("size_bytes", 0), sha256=data.get("sha256", ""), update_type=data.get("update_type", UpdateType.mandatory), min_fw_version=data.get("min_fw_version"), uploaded_at=uploaded_str, notes=data.get("notes"), is_latest=data.get("is_latest", False), ) 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, # firmware validates this matches requested channel version=fw.version, 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, # 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, notes=fw.notes, ) def upload_firmware( hw_type: str, channel: str, version: str, file_bytes: bytes, update_type: UpdateType = UpdateType.mandatory, min_fw_version: str | None = None, notes: str | None = None, ) -> FirmwareVersion: if hw_type not in VALID_HW_TYPES: raise HTTPException(status_code=400, detail=f"Invalid hw_type. Must be one of: {', '.join(sorted(VALID_HW_TYPES))}") if channel not in VALID_CHANNELS: raise HTTPException(status_code=400, detail=f"Invalid channel. Must be one of: {', '.join(sorted(VALID_CHANNELS))}") dest = _storage_path(hw_type, channel, version) dest.parent.mkdir(parents=True, exist_ok=True) dest.write_bytes(file_bytes) sha256 = hashlib.sha256(file_bytes).hexdigest() now = datetime.now(timezone.utc) doc_id = str(uuid.uuid4()) db = get_db() # Mark previous latest for this hw_type+channel as no longer latest prev_docs = ( db.collection(COLLECTION) .where("hw_type", "==", hw_type) .where("channel", "==", channel) .where("is_latest", "==", True) .stream() ) for prev in prev_docs: prev.reference.update({"is_latest": False}) doc_ref = db.collection(COLLECTION).document(doc_id) doc_ref.set({ "hw_type": hw_type, "channel": channel, "version": version, "filename": "firmware.bin", "size_bytes": len(file_bytes), "sha256": sha256, "update_type": update_type.value, "min_fw_version": min_fw_version, "uploaded_at": now, "notes": notes, "is_latest": True, }) return _doc_to_firmware_version(doc_ref.get()) def list_firmware( hw_type: str | None = None, channel: str | None = None, ) -> list[FirmwareVersion]: db = get_db() query = db.collection(COLLECTION) if hw_type: query = query.where("hw_type", "==", hw_type) if channel: query = query.where("channel", "==", channel) docs = list(query.stream()) items = [_doc_to_firmware_version(doc) for doc in docs] items.sort(key=lambda x: x.uploaded_at, reverse=True) return items 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: raise HTTPException(status_code=400, detail=f"Invalid channel '{channel}'") db = get_db() docs = list( db.collection(COLLECTION) .where("hw_type", "==", hw_type) .where("channel", "==", channel) .where("is_latest", "==", True) .limit(1) .stream() ) if not docs: raise NotFoundError("Firmware") return _fw_to_metadata_response(_doc_to_firmware_version(docs[0])) def get_version_info(hw_type: str, channel: str, version: str) -> FirmwareMetadataResponse: """Fetch metadata for a specific version. Used by devices resolving upgrade chains.""" 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: raise HTTPException(status_code=400, detail=f"Invalid channel '{channel}'") db = get_db() docs = list( db.collection(COLLECTION) .where("hw_type", "==", hw_type) .where("channel", "==", channel) .where("version", "==", version) .limit(1) .stream() ) if not docs: raise NotFoundError("Firmware version") return _fw_to_metadata_response(_doc_to_firmware_version(docs[0])) def get_firmware_path(hw_type: str, channel: str, version: str) -> Path: path = _storage_path(hw_type, channel, version) if not path.exists(): raise NotFoundError("Firmware binary") 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) doc = doc_ref.get() if not doc.exists: raise NotFoundError("Firmware") data = doc.to_dict() hw_type = data.get("hw_type", "") channel = data.get("channel", "") version = data.get("version", "") was_latest = data.get("is_latest", False) # Delete the binary file path = _storage_path(hw_type, channel, version) if path.exists(): path.unlink() # Remove the version directory if empty try: path.parent.rmdir() except OSError: pass doc_ref.delete() # If we deleted the latest, promote the next most recent as latest if was_latest: remaining = list( db.collection(COLLECTION) .where("hw_type", "==", hw_type) .where("channel", "==", channel) .order_by("uploaded_at", direction="DESCENDING") .limit(1) .stream() ) if remaining: remaining[0].reference.update({"is_latest": True})