update: overhauled firmware ui. Added public flash page.

This commit is contained in:
2026-03-18 17:49:40 +02:00
parent 4381a6681d
commit d0ac4f1d91
45 changed files with 6798 additions and 1723 deletions

View File

208
backend/public/router.py Normal file
View 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"'},
)