215 lines
7.3 KiB
Python
215 lines
7.3 KiB
Python
"""
|
|
Public (no-auth) endpoints for CloudFlash and feature gate checks.
|
|
"""
|
|
from fastapi import APIRouter, HTTPException
|
|
from fastapi.responses import Response
|
|
from pydantic import BaseModel
|
|
from typing import List, Optional
|
|
|
|
from settings.public_features_service import get_public_features
|
|
from firmware.service import list_firmware
|
|
from utils.nvs_generator import generate as generate_nvs
|
|
from manufacturing.service import get_device_by_sn
|
|
from shared.exceptions import NotFoundError
|
|
|
|
router = APIRouter(prefix="/api/public", tags=["public"])
|
|
|
|
|
|
# ── Feature gate ──────────────────────────────────────────────────────────────
|
|
|
|
class CloudFlashStatus(BaseModel):
|
|
enabled: bool
|
|
|
|
|
|
@router.get("/cloudflash/status", response_model=CloudFlashStatus)
|
|
async def cloudflash_status():
|
|
"""Returns whether the CloudFlash public page is currently enabled."""
|
|
settings = get_public_features()
|
|
return CloudFlashStatus(enabled=settings.cloudflash_enabled)
|
|
|
|
|
|
def _require_cloudflash_enabled():
|
|
"""Raises 403 if CloudFlash is disabled."""
|
|
settings = get_public_features()
|
|
if not settings.cloudflash_enabled:
|
|
raise HTTPException(status_code=403, detail="CloudFlash is currently disabled.")
|
|
|
|
|
|
# ── Public firmware list ───────────────────────────────────────────────────────
|
|
|
|
class PublicFirmwareOption(BaseModel):
|
|
hw_type: str
|
|
hw_type_label: str
|
|
channel: str
|
|
version: str
|
|
download_url: str
|
|
|
|
|
|
HW_TYPE_LABELS = {
|
|
"vesper": "Vesper",
|
|
"vesper_plus": "Vesper Plus",
|
|
"vesper_pro": "Vesper Pro",
|
|
"agnus": "Agnus",
|
|
"agnus_mini": "Agnus Mini",
|
|
"chronos": "Chronos",
|
|
"chronos_pro": "Chronos Pro",
|
|
}
|
|
|
|
|
|
@router.get("/cloudflash/firmware", response_model=List[PublicFirmwareOption])
|
|
async def list_public_firmware():
|
|
"""
|
|
Returns all available firmware options (is_latest=True, non-bespoke, stable channel only).
|
|
No authentication required — used by the public CloudFlash page.
|
|
"""
|
|
_require_cloudflash_enabled()
|
|
|
|
all_fw = list_firmware()
|
|
options = []
|
|
for fw in all_fw:
|
|
if not fw.is_latest:
|
|
continue
|
|
if fw.hw_type == "bespoke":
|
|
continue
|
|
if fw.channel != "stable":
|
|
continue
|
|
options.append(PublicFirmwareOption(
|
|
hw_type=fw.hw_type,
|
|
hw_type_label=HW_TYPE_LABELS.get(fw.hw_type, fw.hw_type.replace("_", " ").title()),
|
|
channel=fw.channel,
|
|
version=fw.version,
|
|
download_url=f"/api/firmware/{fw.hw_type}/{fw.channel}/{fw.version}/firmware.bin",
|
|
))
|
|
|
|
# Sort by hw_type label
|
|
options.sort(key=lambda x: x.hw_type_label)
|
|
return options
|
|
|
|
|
|
# ── Public serial number validation ──────────────────────────────────────────
|
|
|
|
class SerialValidationResult(BaseModel):
|
|
valid: bool
|
|
hw_type: Optional[str] = None
|
|
hw_type_label: Optional[str] = None
|
|
hw_version: Optional[str] = None
|
|
|
|
|
|
@router.get("/cloudflash/validate-serial/{serial_number}", response_model=SerialValidationResult)
|
|
async def validate_serial(serial_number: str):
|
|
"""
|
|
Check whether a serial number exists in the device database.
|
|
Returns hw_type info if found so the frontend can confirm it matches the user's selection.
|
|
No sensitive device data is returned.
|
|
"""
|
|
_require_cloudflash_enabled()
|
|
|
|
sn = serial_number.strip().upper()
|
|
try:
|
|
device = get_device_by_sn(sn)
|
|
return SerialValidationResult(
|
|
valid=True,
|
|
hw_type=device.hw_type,
|
|
hw_type_label=HW_TYPE_LABELS.get(device.hw_type, device.hw_type.replace("_", " ").title()),
|
|
hw_version=device.hw_version,
|
|
)
|
|
except Exception:
|
|
return SerialValidationResult(valid=False)
|
|
|
|
|
|
# ── Public NVS generation ─────────────────────────────────────────────────────
|
|
|
|
class NvsRequest(BaseModel):
|
|
serial_number: str
|
|
hw_type: str
|
|
hw_revision: str
|
|
nvs_schema: str = "new" # "legacy" | "new"
|
|
|
|
@property
|
|
def legacy(self) -> bool:
|
|
return self.nvs_schema == "legacy"
|
|
|
|
|
|
@router.post("/cloudflash/nvs.bin")
|
|
async def generate_public_nvs(body: NvsRequest):
|
|
"""
|
|
Generate an NVS binary for a given serial number + hardware info.
|
|
No authentication required — used by the public CloudFlash page for Full Wipe flash.
|
|
The serial number is provided by the user (they read it from the sticker on their device).
|
|
"""
|
|
_require_cloudflash_enabled()
|
|
|
|
sn = body.serial_number.strip().upper()
|
|
if not sn:
|
|
raise HTTPException(status_code=422, detail="Serial number is required.")
|
|
|
|
hw_type = body.hw_type.strip().lower()
|
|
hw_revision = body.hw_revision.strip()
|
|
|
|
if not hw_type or not hw_revision:
|
|
raise HTTPException(status_code=422, detail="hw_type and hw_revision are required.")
|
|
|
|
try:
|
|
nvs_bytes = generate_nvs(
|
|
serial_number=sn,
|
|
hw_family=hw_type,
|
|
hw_revision=hw_revision,
|
|
legacy=body.legacy,
|
|
)
|
|
except Exception as e:
|
|
raise HTTPException(status_code=500, detail=f"NVS generation failed: {str(e)}")
|
|
|
|
return Response(
|
|
content=nvs_bytes,
|
|
media_type="application/octet-stream",
|
|
headers={"Content-Disposition": f'attachment; filename="{sn}_nvs.bin"'},
|
|
)
|
|
|
|
|
|
# ── Public flash assets (bootloader + partitions) ─────────────────────────────
|
|
|
|
@router.get("/cloudflash/{hw_type}/bootloader.bin")
|
|
async def get_public_bootloader(hw_type: str):
|
|
"""
|
|
Serve the bootloader binary for a given hw_type.
|
|
No authentication required — used by the public CloudFlash page.
|
|
"""
|
|
_require_cloudflash_enabled()
|
|
|
|
import os
|
|
from config import settings as cfg
|
|
from pathlib import Path
|
|
|
|
asset_path = Path(cfg.flash_assets_storage_path) / hw_type / "bootloader.bin"
|
|
if not asset_path.exists():
|
|
raise HTTPException(status_code=404, detail=f"Bootloader not found for {hw_type}.")
|
|
|
|
return Response(
|
|
content=asset_path.read_bytes(),
|
|
media_type="application/octet-stream",
|
|
headers={"Content-Disposition": f'attachment; filename="bootloader_{hw_type}.bin"'},
|
|
)
|
|
|
|
|
|
@router.get("/cloudflash/{hw_type}/partitions.bin")
|
|
async def get_public_partitions(hw_type: str):
|
|
"""
|
|
Serve the partition table binary for a given hw_type.
|
|
No authentication required — used by the public CloudFlash page.
|
|
"""
|
|
_require_cloudflash_enabled()
|
|
|
|
import os
|
|
from config import settings as cfg
|
|
from pathlib import Path
|
|
|
|
asset_path = Path(cfg.flash_assets_storage_path) / hw_type / "partitions.bin"
|
|
if not asset_path.exists():
|
|
raise HTTPException(status_code=404, detail=f"Partition table not found for {hw_type}.")
|
|
|
|
return Response(
|
|
content=asset_path.read_bytes(),
|
|
media_type="application/octet-stream",
|
|
headers={"Content-Disposition": f'attachment; filename="partitions_{hw_type}.bin"'},
|
|
)
|