Files
bellsystems-cp/backend/manufacturing/router.py

246 lines
8.7 KiB
Python

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"'},
)