from fastapi import APIRouter, Depends, Query, UploadFile, File, Form, HTTPException from fastapi.responses import FileResponse, PlainTextResponse 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) async def upload_firmware( hw_type: str = Form(...), channel: str = Form(...), version: str = Form(...), update_type: UpdateType = Form(UpdateType.mandatory), min_fw_version: Optional[str] = Form(None), changelog: Optional[str] = Form(None), release_note: Optional[str] = Form(None), bespoke_uid: Optional[str] = Form(None), file: UploadFile = File(...), _user: TokenPayload = Depends(require_permission("manufacturing", "add")), ): file_bytes = await file.read() return service.upload_firmware( hw_type=hw_type, channel=channel, version=version, file_bytes=file_bytes, update_type=update_type, min_fw_version=min_fw_version, changelog=changelog, release_note=release_note, bespoke_uid=bespoke_uid, ) @router.get("", response_model=FirmwareListResponse) def list_firmware( hw_type: Optional[str] = Query(None), channel: Optional[str] = Query(None), _user: TokenPayload = Depends(require_permission("manufacturing", "view")), ): items = service.list_firmware(hw_type=hw_type, channel=channel) return FirmwareListResponse(firmware=items, total=len(items)) @router.get("/{hw_type}/{channel}/latest", response_model=FirmwareMetadataResponse) 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, hw_version=hw_version, current_version=current_version) @router.get("/{hw_type}/{channel}/latest/changelog", response_class=PlainTextResponse) def get_latest_changelog(hw_type: str, channel: str): """Returns the full changelog for the latest firmware. Plain text.""" return service.get_latest_changelog(hw_type, channel) @router.get("/{hw_type}/{channel}/{version}/info/changelog", response_class=PlainTextResponse) def get_version_changelog(hw_type: str, channel: str, version: str): """Returns the full changelog for a specific firmware version. Plain text.""" return service.get_version_changelog(hw_type, channel, version) @router.get("/{hw_type}/{channel}/{version}/info", response_model=FirmwareMetadataResponse) def get_firmware_info(hw_type: str, channel: str, version: str): """Returns metadata for a specific firmware version. No auth required — devices call this to resolve upgrade chains. """ return service.get_version_info(hw_type, channel, version) @router.get("/{hw_type}/{channel}/{version}/firmware.bin") def download_firmware(hw_type: str, channel: str, version: str): """Download the firmware binary. No auth required — devices call this directly.""" path = service.get_firmware_path(hw_type, channel, version) return FileResponse( path=str(path), media_type="application/octet-stream", filename="firmware.bin", ) @router.put("/{firmware_id}", response_model=FirmwareVersion) async def edit_firmware( firmware_id: str, channel: Optional[str] = Form(None), version: Optional[str] = Form(None), update_type: Optional[UpdateType] = Form(None), min_fw_version: Optional[str] = Form(None), changelog: Optional[str] = Form(None), release_note: Optional[str] = Form(None), bespoke_uid: Optional[str] = Form(None), file: Optional[UploadFile] = File(None), _user: TokenPayload = Depends(require_permission("manufacturing", "add")), ): file_bytes = await file.read() if file and file.filename else None return service.edit_firmware( doc_id=firmware_id, channel=channel, version=version, update_type=update_type, min_fw_version=min_fw_version, changelog=changelog, release_note=release_note, bespoke_uid=bespoke_uid, file_bytes=file_bytes, ) @router.delete("/{firmware_id}", status_code=204) def delete_firmware( firmware_id: str, _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())