from fastapi import APIRouter, Depends, Query, HTTPException, UploadFile, File, Form from fastapi.responses import Response from fastapi.responses import RedirectResponse from typing import Optional from auth.models import TokenPayload from auth.dependencies import require_permission from manufacturing.models import ( BatchCreate, BatchResponse, DeviceInventoryItem, DeviceInventoryListResponse, DeviceStatusUpdate, DeviceAssign, ManufacturingStats, ) from manufacturing import service from manufacturing import audit from shared.exceptions import NotFoundError VALID_FLASH_ASSETS = {"bootloader.bin", "partitions.bin"} VALID_HW_TYPES_MFG = {"vesper", "vesper_plus", "vesper_pro", "agnus", "agnus_mini", "chronos", "chronos_pro"} router = APIRouter(prefix="/api/manufacturing", tags=["manufacturing"]) @router.get("/stats", response_model=ManufacturingStats) def get_stats( _user: TokenPayload = Depends(require_permission("manufacturing", "view")), ): return service.get_stats() @router.get("/audit-log") async def get_audit_log( limit: int = Query(20, ge=1, le=100), _user: TokenPayload = Depends(require_permission("manufacturing", "view")), ): entries = await audit.get_recent(limit=limit) return {"entries": entries} @router.post("/batch", response_model=BatchResponse, status_code=201) async def create_batch( body: BatchCreate, user: TokenPayload = Depends(require_permission("manufacturing", "add")), ): result = service.create_batch(body) await audit.log_action( admin_user=user.email, action="batch_created", detail={ "batch_id": result.batch_id, "board_type": result.board_type, "board_version": result.board_version, "quantity": len(result.serial_numbers), }, ) return result @router.get("/devices", response_model=DeviceInventoryListResponse) def list_devices( status: Optional[str] = Query(None), hw_type: Optional[str] = Query(None), search: Optional[str] = Query(None), limit: int = Query(100, ge=1, le=500), offset: int = Query(0, ge=0), _user: TokenPayload = Depends(require_permission("manufacturing", "view")), ): items = service.list_devices( status=status, hw_type=hw_type, search=search, limit=limit, offset=offset, ) return DeviceInventoryListResponse(devices=items, total=len(items)) @router.get("/devices/{sn}", response_model=DeviceInventoryItem) def get_device( sn: str, _user: TokenPayload = Depends(require_permission("manufacturing", "view")), ): return service.get_device_by_sn(sn) @router.patch("/devices/{sn}/status", response_model=DeviceInventoryItem) async def update_status( sn: str, body: DeviceStatusUpdate, user: TokenPayload = Depends(require_permission("manufacturing", "edit")), ): result = service.update_device_status(sn, body) await audit.log_action( admin_user=user.email, action="status_updated", serial_number=sn, detail={"status": body.status.value, "note": body.note}, ) return result @router.get("/devices/{sn}/nvs.bin") async def download_nvs( sn: str, user: TokenPayload = Depends(require_permission("manufacturing", "view")), ): binary = service.get_nvs_binary(sn) await audit.log_action( admin_user=user.email, action="device_flashed", serial_number=sn, ) return Response( content=binary, media_type="application/octet-stream", headers={"Content-Disposition": f'attachment; filename="{sn}_nvs.bin"'}, ) @router.post("/devices/{sn}/assign", response_model=DeviceInventoryItem) async def assign_device( sn: str, body: DeviceAssign, user: TokenPayload = Depends(require_permission("manufacturing", "edit")), ): result = service.assign_device(sn, body) await audit.log_action( admin_user=user.email, action="device_assigned", serial_number=sn, detail={"customer_email": body.customer_email, "customer_name": body.customer_name}, ) return result @router.delete("/devices/{sn}", status_code=204) async def delete_device( sn: str, force: bool = Query(False, description="Required to delete sold/claimed devices"), user: TokenPayload = Depends(require_permission("manufacturing", "delete")), ): """Delete a device. Sold/claimed devices require force=true.""" try: service.delete_device(sn, force=force) except NotFoundError: raise HTTPException(status_code=404, detail="Device not found") except PermissionError as e: raise HTTPException(status_code=403, detail=str(e)) await audit.log_action( admin_user=user.email, action="device_deleted", serial_number=sn, detail={"force": force}, ) @router.delete("/devices", status_code=200) async def delete_unprovisioned( user: TokenPayload = Depends(require_permission("manufacturing", "delete")), ): """Delete all devices with status 'manufactured' (never provisioned).""" deleted = service.delete_unprovisioned_devices() await audit.log_action( admin_user=user.email, action="bulk_delete_unprovisioned", detail={"count": len(deleted), "serial_numbers": deleted}, ) return {"deleted": deleted, "count": len(deleted)} @router.get("/devices/{sn}/firmware.bin") def redirect_firmware( sn: str, _user: TokenPayload = Depends(require_permission("manufacturing", "view")), ): """Redirect to the latest stable firmware binary for this device's hw_type. Resolves to GET /api/firmware/{hw_type}/stable/{version}/firmware.bin. """ url = service.get_firmware_url(sn) return RedirectResponse(url=url, status_code=302) # ───────────────────────────────────────────────────────────────────────────── # Flash assets — bootloader.bin and partitions.bin per hw_type # These are the binaries that must be flashed at fixed addresses during full # provisioning (0x1000 bootloader, 0x8000 partition table). # They are NOT flashed during OTA updates — only during initial provisioning. # Upload once per hw_type after each PlatformIO build that changes the layout. # ───────────────────────────────────────────────────────────────────────────── @router.post("/flash-assets/{hw_type}/{asset}", status_code=204) async def upload_flash_asset( hw_type: str, asset: str, file: UploadFile = File(...), _user: TokenPayload = Depends(require_permission("manufacturing", "add")), ): """Upload a bootloader.bin or partitions.bin for a given hw_type. These are build artifacts from PlatformIO (.pio/build/{env}/bootloader.bin and .pio/build/{env}/partitions.bin). Upload them once per hw_type after each PlatformIO build that changes the partition layout. """ if hw_type not in VALID_HW_TYPES_MFG: raise HTTPException(status_code=400, detail=f"Invalid hw_type. Must be one of: {', '.join(sorted(VALID_HW_TYPES_MFG))}") if asset not in VALID_FLASH_ASSETS: raise HTTPException(status_code=400, detail=f"Invalid asset. Must be one of: {', '.join(sorted(VALID_FLASH_ASSETS))}") data = await file.read() service.save_flash_asset(hw_type, asset, data) @router.get("/devices/{sn}/bootloader.bin") def download_bootloader( sn: str, _user: TokenPayload = Depends(require_permission("manufacturing", "view")), ): """Return the bootloader.bin for this device's hw_type (flashed at 0x1000).""" item = service.get_device_by_sn(sn) try: data = service.get_flash_asset(item.hw_type, "bootloader.bin") except NotFoundError as e: raise HTTPException(status_code=404, detail=str(e)) return Response( content=data, media_type="application/octet-stream", headers={"Content-Disposition": f'attachment; filename="bootloader_{item.hw_type}.bin"'}, ) @router.get("/devices/{sn}/partitions.bin") def download_partitions( sn: str, _user: TokenPayload = Depends(require_permission("manufacturing", "view")), ): """Return the partitions.bin for this device's hw_type (flashed at 0x8000).""" item = service.get_device_by_sn(sn) try: data = service.get_flash_asset(item.hw_type, "partitions.bin") except NotFoundError as e: raise HTTPException(status_code=404, detail=str(e)) return Response( content=data, media_type="application/octet-stream", headers={"Content-Disposition": f'attachment; filename="partitions_{item.hw_type}.bin"'}, )