update: overhauled firmware ui. Added public flash page.
This commit is contained in:
0
backend/public/__init__.py
Normal file
0
backend/public/__init__.py
Normal file
208
backend/public/router.py
Normal file
208
backend/public/router.py
Normal file
@@ -0,0 +1,208 @@
|
||||
"""
|
||||
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
|
||||
|
||||
|
||||
@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,
|
||||
)
|
||||
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"'},
|
||||
)
|
||||
Reference in New Issue
Block a user