update: firmware and provisioning now supports bootloader and partition tables

This commit is contained in:
2026-03-16 08:52:58 +02:00
parent 360725c93f
commit 4381a6681d
15 changed files with 776 additions and 49 deletions

View File

@@ -29,6 +29,7 @@ class Settings(BaseSettings):
# Local file storage
built_melodies_storage_path: str = "./storage/built_melodies"
firmware_storage_path: str = "./storage/firmware"
flash_assets_storage_path: str = "./storage/flash_assets"
# Email (Resend)
resend_api_key: str = "re_placeholder_change_me"

View File

@@ -30,13 +30,30 @@ class FirmwareListResponse(BaseModel):
class FirmwareMetadataResponse(BaseModel):
"""Returned by both /latest and /{version}/info endpoints."""
"""Returned by both /latest and /{version}/info endpoints.
Two orthogonal axes:
channel — the release track the device is subscribed to
("stable" | "beta" | "development")
Firmware validates this matches the channel it requested.
update_type — the urgency of THIS release, set by the publisher
("optional" | "mandatory" | "emergency")
Firmware reads mandatory/emergency booleans derived from this.
Additional firmware-compatible fields:
size — binary size in bytes (firmware reads "size", not "size_bytes")
mandatory — True when update_type is mandatory or emergency
emergency — True only when update_type is emergency
"""
hw_type: str
channel: str
channel: str # release track — firmware validates this
version: str
size_bytes: int
size: int # firmware reads "size"
size_bytes: int # kept for admin-panel consumers
sha256: str
update_type: UpdateType
update_type: UpdateType # urgency enum — for admin panel display
mandatory: bool # derived: update_type in (mandatory, emergency)
emergency: bool # derived: update_type == emergency
min_fw_version: Optional[str] = None
download_url: str
uploaded_at: str

View File

@@ -1,13 +1,18 @@
from fastapi import APIRouter, Depends, Query, UploadFile, File, Form
from fastapi.responses import FileResponse
from pydantic import BaseModel
from typing import Optional
import logging
from auth.models import TokenPayload
from auth.dependencies import require_permission
from firmware.models import FirmwareVersion, FirmwareListResponse, FirmwareMetadataResponse, UpdateType
from firmware import service
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/api/firmware", tags=["firmware"])
ota_router = APIRouter(prefix="/api/ota", tags=["ota-telemetry"])
@router.post("/upload", response_model=FirmwareVersion, status_code=201)
@@ -44,11 +49,16 @@ def list_firmware(
@router.get("/{hw_type}/{channel}/latest", response_model=FirmwareMetadataResponse)
def get_latest_firmware(hw_type: str, channel: str):
def get_latest_firmware(
hw_type: str,
channel: str,
hw_version: Optional[str] = Query(None, description="Hardware revision from NVS, e.g. '1.0'"),
current_version: Optional[str] = Query(None, description="Currently running firmware semver, e.g. '1.2.3'"),
):
"""Returns metadata for the latest firmware for a given hw_type + channel.
No auth required — devices call this endpoint to check for updates.
"""
return service.get_latest(hw_type, channel)
return service.get_latest(hw_type, channel, hw_version=hw_version, current_version=current_version)
@router.get("/{hw_type}/{channel}/{version}/info", response_model=FirmwareMetadataResponse)
@@ -76,3 +86,52 @@ def delete_firmware(
_user: TokenPayload = Depends(require_permission("manufacturing", "delete")),
):
service.delete_firmware(firmware_id)
# ─────────────────────────────────────────────────────────────────────────────
# OTA event telemetry — called by devices (no auth, best-effort)
# ─────────────────────────────────────────────────────────────────────────────
class OtaDownloadEvent(BaseModel):
device_uid: str
hw_type: str
hw_version: str
from_version: str
to_version: str
channel: str
class OtaFlashEvent(BaseModel):
device_uid: str
hw_type: str
hw_version: str
from_version: str
to_version: str
channel: str
sha256: str
@ota_router.post("/events/download", status_code=204)
def ota_event_download(event: OtaDownloadEvent):
"""Device reports that firmware was fully written to flash (pre-commit).
No auth required — best-effort telemetry from the device.
"""
logger.info(
"OTA download event: device=%s hw=%s/%s %s%s (channel=%s)",
event.device_uid, event.hw_type, event.hw_version,
event.from_version, event.to_version, event.channel,
)
service.record_ota_event("download", event.model_dump())
@ota_router.post("/events/flash", status_code=204)
def ota_event_flash(event: OtaFlashEvent):
"""Device reports that firmware partition was committed and device is rebooting.
No auth required — best-effort telemetry from the device.
"""
logger.info(
"OTA flash event: device=%s hw=%s/%s %s%s (channel=%s sha256=%.16s...)",
event.device_uid, event.hw_type, event.hw_version,
event.from_version, event.to_version, event.channel, event.sha256,
)
service.record_ota_event("flash", event.model_dump())

View File

@@ -1,7 +1,9 @@
import hashlib
import logging
import uuid
from datetime import datetime, timezone
from pathlib import Path
from typing import Any
from fastapi import HTTPException
@@ -10,6 +12,8 @@ from shared.firebase import get_db
from shared.exceptions import NotFoundError
from firmware.models import FirmwareVersion, FirmwareMetadataResponse, UpdateType
logger = logging.getLogger(__name__)
COLLECTION = "firmware_versions"
VALID_HW_TYPES = {"vesper", "vesper_plus", "vesper_pro", "chronos", "chronos_pro", "agnus", "agnus_mini"}
@@ -46,13 +50,18 @@ def _doc_to_firmware_version(doc) -> FirmwareVersion:
def _fw_to_metadata_response(fw: FirmwareVersion) -> FirmwareMetadataResponse:
download_url = f"/api/firmware/{fw.hw_type}/{fw.channel}/{fw.version}/firmware.bin"
is_emergency = fw.update_type == UpdateType.emergency
is_mandatory = fw.update_type in (UpdateType.mandatory, UpdateType.emergency)
return FirmwareMetadataResponse(
hw_type=fw.hw_type,
channel=fw.channel,
channel=fw.channel, # firmware validates this matches requested channel
version=fw.version,
size_bytes=fw.size_bytes,
size=fw.size_bytes, # firmware reads "size"
size_bytes=fw.size_bytes, # kept for admin-panel consumers
sha256=fw.sha256,
update_type=fw.update_type,
update_type=fw.update_type, # urgency enum — for admin panel display
mandatory=is_mandatory, # firmware reads this to decide auto-apply
emergency=is_emergency, # firmware reads this to decide immediate apply
min_fw_version=fw.min_fw_version,
download_url=download_url,
uploaded_at=fw.uploaded_at,
@@ -130,7 +139,7 @@ def list_firmware(
return items
def get_latest(hw_type: str, channel: str) -> FirmwareMetadataResponse:
def get_latest(hw_type: str, channel: str, hw_version: str | None = None, current_version: str | None = None) -> FirmwareMetadataResponse:
if hw_type not in VALID_HW_TYPES:
raise HTTPException(status_code=400, detail=f"Invalid hw_type '{hw_type}'")
if channel not in VALID_CHANNELS:
@@ -180,6 +189,22 @@ def get_firmware_path(hw_type: str, channel: str, version: str) -> Path:
return path
def record_ota_event(event_type: str, payload: dict[str, Any]) -> None:
"""Persist an OTA telemetry event (download or flash) to Firestore.
Best-effort — caller should not raise on failure.
"""
try:
db = get_db()
db.collection("ota_events").add({
"event_type": event_type,
"received_at": datetime.now(timezone.utc),
**payload,
})
except Exception as exc:
logger.warning("Failed to persist OTA event (%s): %s", event_type, exc)
def delete_firmware(doc_id: str) -> None:
db = get_db()
doc_ref = db.collection(COLLECTION).document(doc_id)

View File

@@ -15,7 +15,7 @@ from staff.router import router as staff_router
from helpdesk.router import router as helpdesk_router
from builder.router import router as builder_router
from manufacturing.router import router as manufacturing_router
from firmware.router import router as firmware_router
from firmware.router import router as firmware_router, ota_router
from admin.router import router as admin_router
from crm.router import router as crm_products_router
from crm.customers_router import router as crm_customers_router
@@ -58,6 +58,7 @@ app.include_router(staff_router)
app.include_router(builder_router)
app.include_router(manufacturing_router)
app.include_router(firmware_router)
app.include_router(ota_router)
app.include_router(admin_router)
app.include_router(crm_products_router)
app.include_router(crm_customers_router)

View File

@@ -15,7 +15,7 @@ class BoardType(str, Enum):
BOARD_TYPE_LABELS = {
"vesper": "Vesper",
"vesper_plus": "Vesper+",
"vesper_plus": "Vesper Plus",
"vesper_pro": "Vesper Pro",
"chronos": "Chronos",
"chronos_pro": "Chronos Pro",
@@ -23,6 +23,28 @@ BOARD_TYPE_LABELS = {
"agnus": "Agnus",
}
# Family codes (BS + 4 chars = segment 1 of serial number)
BOARD_FAMILY_CODES = {
"vesper": "VSPR",
"vesper_plus": "VSPR",
"vesper_pro": "VSPR",
"agnus": "AGNS",
"agnus_mini": "AGNS",
"chronos": "CRNS",
"chronos_pro": "CRNS",
}
# Variant codes (3 chars = first part of segment 3 of serial number)
BOARD_VARIANT_CODES = {
"vesper": "STD",
"vesper_plus": "PLS",
"vesper_pro": "PRO",
"agnus": "STD",
"agnus_mini": "MIN",
"chronos": "STD",
"chronos_pro": "PRO",
}
class MfgStatus(str, Enum):
manufactured = "manufactured"

View File

@@ -1,4 +1,4 @@
from fastapi import APIRouter, Depends, Query, HTTPException
from fastapi import APIRouter, Depends, Query, HTTPException, UploadFile, File, Form
from fastapi.responses import Response
from fastapi.responses import RedirectResponse
from typing import Optional
@@ -15,6 +15,9 @@ 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"])
@@ -175,3 +178,68 @@ def redirect_firmware(
"""
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"'},
)

View File

@@ -2,9 +2,11 @@ import logging
import random
import string
from datetime import datetime, timezone
from pathlib import Path
logger = logging.getLogger(__name__)
from config import settings
from shared.firebase import get_db
from shared.exceptions import NotFoundError
from utils.serial_number import generate_serial
@@ -270,6 +272,29 @@ def delete_unprovisioned_devices() -> list[str]:
return deleted
def _flash_asset_path(hw_type: str, asset: str) -> Path:
"""Return path to a flash asset (bootloader.bin or partitions.bin) for a given hw_type."""
return Path(settings.flash_assets_storage_path) / hw_type / asset
def save_flash_asset(hw_type: str, asset: str, data: bytes) -> Path:
"""Persist a flash asset binary. asset must be 'bootloader.bin' or 'partitions.bin'."""
if asset not in ("bootloader.bin", "partitions.bin"):
raise ValueError(f"Unknown flash asset: {asset}")
path = _flash_asset_path(hw_type, asset)
path.parent.mkdir(parents=True, exist_ok=True)
path.write_bytes(data)
return path
def get_flash_asset(hw_type: str, asset: str) -> bytes:
"""Load a flash asset binary. Raises NotFoundError if not uploaded yet."""
path = _flash_asset_path(hw_type, asset)
if not path.exists():
raise NotFoundError(f"Flash asset '{asset}' for hw_type '{hw_type}' — upload it first via POST /api/manufacturing/flash-assets/{{hw_type}}/{{asset}}")
return path.read_bytes()
def get_firmware_url(sn: str) -> str:
"""Return the FastAPI download URL for the latest stable firmware for this device's hw_type."""
from firmware.service import get_latest

Binary file not shown.

Binary file not shown.

View File

@@ -180,9 +180,14 @@ def _build_page(entries: List[bytes], slot_counts: List[int], seq: int = 0) -> b
def generate(serial_number: str, hw_type: str, hw_version: str) -> bytes:
"""Generate a 0x5000-byte NVS partition binary for a Vesper device.
serial_number: full SN string e.g. 'PV-26B27-VS01R-X7KQA'
hw_type: board type e.g. 'vesper', 'vesper_plus', 'vesper_pro'
hw_version: zero-padded version e.g. '01'
serial_number: full SN string e.g. 'BSVSPR-26C13X-STD01R-X7KQA'
hw_type: board family e.g. 'vesper', 'vesper_plus', 'vesper_pro'
hw_version: zero-padded revision e.g. '01'
Writes the NEW schema keys (2.0+) expected by ConfigManager:
serial ← full serial number
hw_family ← board family (hw_type value, lowercase)
hw_revision ← hardware revision string
Returns raw bytes ready to flash at 0x9000.
"""
@@ -190,9 +195,9 @@ def generate(serial_number: str, hw_type: str, hw_version: str) -> bytes:
# Build entries for namespace "device_id"
ns_entry, ns_span = _build_namespace_entry("device_id", ns_index)
uid_entry, uid_span = _build_string_entry(ns_index, "device_uid", serial_number)
hwt_entry, hwt_span = _build_string_entry(ns_index, "hw_type", hw_type.lower())
hwv_entry, hwv_span = _build_string_entry(ns_index, "hw_version", hw_version)
uid_entry, uid_span = _build_string_entry(ns_index, "serial", serial_number)
hwt_entry, hwt_span = _build_string_entry(ns_index, "hw_family", hw_type.lower())
hwv_entry, hwv_span = _build_string_entry(ns_index, "hw_revision", hw_version)
entries = [ns_entry, uid_entry, hwt_entry, hwv_entry]
spans = [ns_span, uid_span, hwt_span, hwv_span]

View File

@@ -4,17 +4,75 @@ from datetime import datetime
MONTH_CODES = "ABCDEFGHIJKL"
SAFE_CHARS = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789" # No 0, O, 1, I — avoids label confusion
# Family segment (chars 3-6 of segment 1, after "BS")
BOARD_FAMILY_CODES = {
"vesper": "VSPR",
"vesper_plus": "VSPR",
"vesper_pro": "VSPR",
"agnus": "AGNS",
"agnus_mini": "AGNS",
"chronos": "CRNS",
"chronos_pro": "CRNS",
}
# Variant segment (first 3 chars of segment 3)
BOARD_VARIANT_CODES = {
"vesper": "STD",
"vesper_plus": "PLS",
"vesper_pro": "PRO",
"agnus": "STD",
"agnus_mini": "MIN",
"chronos": "STD",
"chronos_pro": "PRO",
}
def _version_suffix(board_version: str) -> str:
"""Convert version string to 3-char suffix.
Rules:
- Strip the dot: "2.3""23", "10.2""102"
- If result is 2 digits, append "R": "23""23R"
- If result is already 3 digits, use as-is: "102""102"
"""
digits = board_version.replace(".", "")
if len(digits) >= 3:
return digits[:3]
return digits.ljust(2, "0") + "R"
def generate_serial(board_type: str, board_version: str) -> str:
"""Generate a serial number in the format PV-YYMMM-BBTTR-XXXXX.
"""Generate a serial number in the format BSFFFF-YYMDDFX-VVVHHH-XXXXXX.
board_type: 2-char uppercase code, e.g. 'VS', 'VP', 'VX'
board_version: 2-char zero-padded version, e.g. '01'
Format: BSFFFF-YYMDDf-VVVvvv-XXXXXX
BS = Bell Systems (static)
FFFF = 4-char family code (VSPR, AGNS, CRNS)
YY = 2-digit year
M = month code A-L
DD = 2-digit day
f = random filler char
VVV = 3-char variant (STD, PLS, PRO, MIN)
vvv = 3-char version suffix (e.g. 23R, 102)
XXXXXX = 6-char random suffix
board_type: enum value e.g. 'vesper', 'vesper_plus', 'vesper_pro'
board_version: version string e.g. '2.3', '10.2'
"""
key = board_type.lower()
family = BOARD_FAMILY_CODES.get(key, "UNKN")
variant = BOARD_VARIANT_CODES.get(key, "UNK")
ver = _version_suffix(board_version)
now = datetime.utcnow()
year = now.strftime("%y")
year = now.strftime("%y")
month = MONTH_CODES[now.month - 1]
day = now.strftime("%d")
suffix = "".join(random.choices(SAFE_CHARS, k=5))
version_clean = board_version.replace(".", "")
return f"PV-{year}{month}{day}-{board_type.upper()}{version_clean}R-{suffix}"
day = now.strftime("%d")
filler = random.choice(SAFE_CHARS)
suffix = "".join(random.choices(SAFE_CHARS, k=6))
seg1 = f"BS{family}" # e.g. BSVSPR
seg2 = f"{year}{month}{day}{filler}" # e.g. 26C13X
seg3 = f"{variant}{ver}" # e.g. PRO23R
seg4 = suffix # e.g. X9K4M2
return f"{seg1}-{seg2}-{seg3}-{seg4}"

View File

@@ -9,6 +9,7 @@ services:
- ./data:/app/data
- ./data/built_melodies:/app/storage/built_melodies
- ./data/firmware:/app/storage/firmware
- ./data/flash_assets:/app/storage/flash_assets
- ./data/firebase-service-account.json:/app/firebase-service-account.json:ro
# Auto-deploy: project root so container can write the trigger file
- /home/bellsystems/bellsystems-cp:/home/bellsystems/bellsystems-cp

View File

@@ -51,6 +51,234 @@ function UpdateTypeBadge({ type }) {
);
}
// ── colour tokens for API param tags ────────────────────────────────────────
// Soft, desaturated — readable on both light and dark backgrounds
const TAG = {
hwType: { color: "var(--danger-text)", bg: "var(--danger-bg)", border: "var(--danger-text)" }, // reddish
channel: { color: "var(--badge-blue-text)", bg: "var(--badge-blue-bg)", border: "var(--badge-blue-text)" }, // muted blue
version: { color: "var(--success-text)", bg: "var(--success-bg)", border: "var(--success-text)" }, // muted green
identity: { color: "#b8922a", bg: "#2a200a", border: "#b8922a" }, // warm yellow
};
function ParamTag({ children, kind = "identity" }) {
const t = TAG[kind];
return (
<span style={{
fontFamily: "monospace", fontSize: "0.68rem",
backgroundColor: t.bg, color: t.color,
border: `1px solid ${t.border}`,
borderRadius: "4px", padding: "1px 7px",
opacity: 0.9,
}}>
{children}
</span>
);
}
function EndpointCard({ method, label, pathParts, query, bodyFields, desc }) {
const isPost = method === "POST";
const methodStyle = isPost
? { bg: "var(--success-bg)", color: "var(--success-text)" }
: { bg: "var(--badge-blue-bg)", color: "var(--badge-blue-text)" };
return (
<div className="rounded-md p-3 mb-3" style={{ backgroundColor: "var(--bg-secondary)", border: "1px solid var(--border-secondary)" }}>
<div className="flex items-center gap-2 mb-1.5">
<span className="text-xs font-bold px-1.5 py-0.5 rounded" style={{ backgroundColor: methodStyle.bg, color: methodStyle.color }}>{method}</span>
<span className="text-xs font-semibold" style={{ color: "var(--text-muted)" }}>{label}</span>
</div>
<div className="flex flex-wrap items-center mb-2" style={{ fontFamily: "monospace", fontSize: "0.72rem" }}>
<span style={{ color: "var(--text-muted)" }}>console.bellsystems.net</span>
{pathParts.map((p, i) =>
p.plain
? <span key={i} style={{ color: "var(--text-primary)" }}>{p.text}</span>
: <ParamTag key={i} kind={p.kind}>{p.text}</ParamTag>
)}
</div>
{query && (
<div className="flex flex-wrap gap-1.5 mb-2">
{query.map((q) => (
<ParamTag key={q.key} kind={q.kind}>?{q.key}=<span style={{ opacity: 0.6 }}>{q.eg}</span></ParamTag>
))}
</div>
)}
{bodyFields && (
<div className="flex flex-wrap gap-1.5 mb-2">
{bodyFields.map((f) => <ParamTag key={f.key} kind={f.kind}>{f.key}</ParamTag>)}
</div>
)}
<p className="text-xs" style={{ color: "var(--text-muted)" }}>{desc}</p>
</div>
);
}
const GET_ENDPOINTS = [
{
label: "Check for latest version",
method: "GET",
pathParts: [
{ text: "/api/firmware/", plain: true },
{ text: "{hw_type}", kind: "hwType" },
{ text: "/", plain: true },
{ text: "{channel}", kind: "channel" },
{ text: "/latest", plain: true },
],
query: [
{ key: "hw_version", kind: "version", eg: "1.0" },
{ key: "current_version", kind: "version", eg: "1.2.3" },
],
desc: "Returns metadata for the correct next firmware hop. Pass hw_version and current_version so the server resolves upgrade chains correctly.",
},
{
label: "Get specific version info",
method: "GET",
pathParts: [
{ text: "/api/firmware/", plain: true },
{ text: "{hw_type}", kind: "hwType" },
{ text: "/", plain: true },
{ text: "{channel}", kind: "channel" },
{ text: "/", plain: true },
{ text: "{version}", kind: "version" },
{ text: "/info", plain: true },
],
desc: "Returns metadata for a specific version. Used when resolving upgrade chains with min_fw_version constraints.",
},
{
label: "Download firmware binary",
method: "GET",
pathParts: [
{ text: "/api/firmware/", plain: true },
{ text: "{hw_type}", kind: "hwType" },
{ text: "/", plain: true },
{ text: "{channel}", kind: "channel" },
{ text: "/", plain: true },
{ text: "{version}", kind: "version" },
{ text: "/firmware.bin", plain: true },
],
desc: "Streams the raw .bin file. Devices fetch this after confirming the version via /latest or /info.",
},
];
const POST_ENDPOINTS = [
{
label: "OTA download event",
method: "POST",
pathParts: [{ text: "/api/ota/events/download", plain: true }],
bodyFields: [
{ key: "device_uid", kind: "identity" },
{ key: "hw_type", kind: "hwType" },
{ key: "hw_version", kind: "version" },
{ key: "from_version", kind: "version" },
{ key: "to_version", kind: "version" },
{ key: "channel", kind: "channel" },
],
desc: "Posted when the binary is fully written to the staged partition (before Update.end()). Best-effort — no retry on failure.",
},
{
label: "OTA flash confirmed",
method: "POST",
pathParts: [{ text: "/api/ota/events/flash", plain: true }],
bodyFields: [
{ key: "device_uid", kind: "identity" },
{ key: "hw_type", kind: "hwType" },
{ key: "hw_version", kind: "version" },
{ key: "from_version", kind: "version" },
{ key: "to_version", kind: "version" },
{ key: "channel", kind: "channel" },
{ key: "sha256", kind: "identity" },
],
desc: "Posted after Update.end() succeeds — partition committed, device about to reboot. This is ground truth for fleet version tracking.",
},
];
function ApiInfoModal({ onClose }) {
const [tab, setTab] = useState("get");
const TABS = [
{ id: "get", label: "GET" },
{ id: "post", label: "POST" },
{ id: "legend", label: "Legend" },
];
return (
<div
className="fixed inset-0 flex items-center justify-center z-50"
style={{ backgroundColor: "rgba(0,0,0,0.6)" }}
onClick={onClose}
>
<div
className="rounded-lg border w-full mx-4 flex flex-col"
style={{
backgroundColor: "var(--bg-card)",
borderColor: "var(--border-primary)",
maxWidth: "720px",
maxHeight: "80vh",
}}
onClick={(e) => e.stopPropagation()}
>
{/* Header */}
<div className="flex items-center justify-between px-6 pt-5 pb-4" style={{ borderBottom: "1px solid var(--border-secondary)" }}>
<div>
<h3 className="text-base font-semibold" style={{ color: "var(--text-heading)" }}>Firmware API</h3>
<p className="text-xs mt-0.5" style={{ color: "var(--text-muted)" }}>All endpoints are unauthenticated devices call them directly.</p>
</div>
<button
autoFocus
onClick={onClose}
onKeyDown={(e) => e.key === "Escape" && onClose()}
className="text-lg leading-none hover:opacity-70 cursor-pointer"
style={{ color: "var(--text-muted)" }}
>
</button>
</div>
{/* Tabs */}
<div className="flex gap-1 px-6 pt-3" style={{ borderBottom: "1px solid var(--border-secondary)" }}>
{TABS.map((t) => (
<button
key={t.id}
onClick={() => setTab(t.id)}
className="px-4 py-1.5 text-xs font-medium rounded-t cursor-pointer transition-colors"
style={{
backgroundColor: tab === t.id ? "var(--bg-secondary)" : "transparent",
color: tab === t.id ? "var(--text-primary)" : "var(--text-muted)",
borderBottom: tab === t.id ? "2px solid var(--btn-primary)" : "2px solid transparent",
marginBottom: "-1px",
}}
>
{t.label}
</button>
))}
</div>
{/* Scrollable content */}
<div className="overflow-y-auto px-6 py-4" style={{ flex: 1 }}>
{tab === "get" && GET_ENDPOINTS.map((ep) => <EndpointCard key={ep.label} {...ep} />)}
{tab === "post" && POST_ENDPOINTS.map((ep) => <EndpointCard key={ep.label} {...ep} />)}
{tab === "legend" && (
<div style={{ display: "flex", flexDirection: "column", gap: "0.75rem" }}>
{[
{ kind: "hwType", label: "{hw_type}", eg: "vesper_plus, chronos, agnus" },
{ kind: "channel", label: "{channel}", eg: "stable, beta, alpha, testing" },
{ kind: "version", label: "{version} / hw_version / current_version", eg: "1.0 · 2.5.1 · 1.2.3" },
{ kind: "identity", label: "device_uid / sha256", eg: "BSVSPR-26C13X-… · a3f1…" },
].map((row) => (
<div key={row.kind} className="flex items-start gap-3 rounded-md p-3" style={{ backgroundColor: "var(--bg-secondary)", border: "1px solid var(--border-secondary)" }}>
<ParamTag kind={row.kind}>{row.label}</ParamTag>
<div>
<div className="text-xs" style={{ color: "var(--text-secondary)", fontFamily: "monospace" }}>{row.eg}</div>
</div>
</div>
))}
<p className="text-xs mt-1" style={{ color: "var(--text-muted)" }}>
Colour coding is consistent across all tabs the same token type always appears in the same colour whether it is a path segment, query param, or POST body field.
</p>
</div>
)}
</div>
</div>
</div>
);
}
function formatBytes(bytes) {
if (!bytes) return "—";
if (bytes < 1024) return `${bytes} B`;
@@ -83,6 +311,8 @@ export default function FirmwareManager() {
const [channelFilter, setChannelFilter] = useState("");
const [showUpload, setShowUpload] = useState(false);
const [showFlashAssets, setShowFlashAssets] = useState(false);
const [showApiInfo, setShowApiInfo] = useState(false);
const [uploadHwType, setUploadHwType] = useState("vesper");
const [uploadChannel, setUploadChannel] = useState("stable");
const [uploadVersion, setUploadVersion] = useState("");
@@ -94,6 +324,15 @@ export default function FirmwareManager() {
const [uploadError, setUploadError] = useState("");
const fileInputRef = useRef(null);
const [flashAssetsHwType, setFlashAssetsHwType] = useState("vesper");
const [flashAssetsBootloader, setFlashAssetsBootloader] = useState(null);
const [flashAssetsPartitions, setFlashAssetsPartitions] = useState(null);
const [flashAssetsUploading, setFlashAssetsUploading] = useState(false);
const [flashAssetsError, setFlashAssetsError] = useState("");
const [flashAssetsSuccess, setFlashAssetsSuccess] = useState("");
const blInputRef = useRef(null);
const partInputRef = useRef(null);
const [deleteTarget, setDeleteTarget] = useState(null);
const [deleting, setDeleting] = useState(false);
@@ -159,6 +398,43 @@ export default function FirmwareManager() {
}
};
const handleFlashAssetsUpload = async () => {
if (!flashAssetsBootloader && !flashAssetsPartitions) return;
setFlashAssetsError("");
setFlashAssetsSuccess("");
setFlashAssetsUploading(true);
const token = localStorage.getItem("access_token");
try {
const uploads = [];
if (flashAssetsBootloader) uploads.push({ file: flashAssetsBootloader, asset: "bootloader.bin" });
if (flashAssetsPartitions) uploads.push({ file: flashAssetsPartitions, asset: "partitions.bin" });
for (const { file, asset } of uploads) {
const formData = new FormData();
formData.append("file", file);
const res = await fetch(`/api/manufacturing/flash-assets/${flashAssetsHwType}/${asset}`, {
method: "POST",
headers: { Authorization: `Bearer ${token}` },
body: formData,
});
if (!res.ok) {
const err = await res.json().catch(() => ({}));
throw new Error(err.detail || `Failed to upload ${asset}`);
}
}
setFlashAssetsSuccess(`Flash assets saved for ${BOARD_TYPES.find(b => b.value === flashAssetsHwType)?.label || flashAssetsHwType}.`);
setFlashAssetsBootloader(null);
setFlashAssetsPartitions(null);
if (blInputRef.current) blInputRef.current.value = "";
if (partInputRef.current) partInputRef.current.value = "";
} catch (err) {
setFlashAssetsError(err.message);
} finally {
setFlashAssetsUploading(false);
}
};
const handleDelete = async () => {
if (!deleteTarget) return;
setDeleting(true);
@@ -173,7 +449,7 @@ export default function FirmwareManager() {
}
};
const BOARD_TYPE_LABELS = { vesper: "Vesper", vesper_plus: "Vesper+", vesper_pro: "Vesper Pro", chronos: "Chronos", chronos_pro: "Chronos Pro", agnus: "Agnus", agnus_mini: "Agnus Mini" };
const BOARD_TYPE_LABELS = { vesper: "Vesper", vesper_plus: "Vesper Plus", vesper_pro: "Vesper Pro", chronos: "Chronos", chronos_pro: "Chronos Pro", agnus: "Agnus", agnus_mini: "Agnus Mini" };
return (
<div>
@@ -187,17 +463,153 @@ export default function FirmwareManager() {
{hwTypeFilter || channelFilter ? " (filtered)" : ""}
</p>
</div>
{canAdd && (
<div className="flex gap-2">
<button
onClick={() => setShowUpload(true)}
onClick={() => setShowApiInfo(true)}
className="px-4 py-2 text-sm rounded-md font-medium hover:opacity-90 transition-opacity cursor-pointer"
style={{ backgroundColor: "var(--btn-primary)", color: "var(--text-white)" }}
style={{ backgroundColor: "var(--bg-card-hover)", color: "var(--text-secondary)", border: "1px solid var(--border-primary)" }}
>
+ Upload Firmware
Firmware API Info
</button>
)}
{canAdd && (
<>
<button
onClick={() => { setShowFlashAssets((v) => !v); setShowUpload(false); }}
className="px-4 py-2 text-sm rounded-md font-medium hover:opacity-90 transition-opacity cursor-pointer"
style={{
backgroundColor: showFlashAssets ? "var(--bg-card-hover)" : "var(--bg-card-hover)",
color: "var(--text-secondary)",
border: `1px solid ${showFlashAssets ? "var(--btn-primary)" : "var(--border-primary)"}`,
}}
>
Flash Assets
</button>
<button
onClick={() => { setShowUpload((v) => !v); setShowFlashAssets(false); }}
className="px-4 py-2 text-sm rounded-md font-medium hover:opacity-90 transition-opacity cursor-pointer"
style={{ backgroundColor: "var(--btn-primary)", color: "var(--text-white)" }}
>
+ Upload Firmware
</button>
</>
)}
</div>
</div>
{/* Flash Assets panel */}
{showFlashAssets && (
<div className="ui-section-card mb-5">
<div className="ui-section-card__title-row">
<div>
<h2 className="ui-section-card__title">Flash Assets</h2>
<p className="text-xs mt-0.5" style={{ color: "var(--text-muted)" }}>
Bootloader and partition table binaries used during full provisioning. One set per board type.
Built by PlatformIO find them in <span style={{ fontFamily: "monospace" }}>.pio/build/&#123;env&#125;/</span>
</p>
</div>
</div>
{flashAssetsError && (
<div className="text-sm rounded-md p-3 mb-4 border" style={{ backgroundColor: "var(--danger-bg)", borderColor: "var(--danger)", color: "var(--danger-text)" }}>
{flashAssetsError}
</div>
)}
{flashAssetsSuccess && (
<div className="text-sm rounded-md p-3 mb-4 border" style={{ backgroundColor: "var(--success-bg)", borderColor: "var(--success-text)", color: "var(--success-text)" }}>
{flashAssetsSuccess}
</div>
)}
<div style={{ display: "grid", gridTemplateColumns: "200px 1fr 1fr", gap: "1.5rem", alignItems: "start" }}>
{/* Board type selector + action buttons */}
<div style={{ display: "flex", flexDirection: "column", gap: "1rem" }}>
<div>
<label className="block text-xs font-medium mb-1" style={{ color: "var(--text-muted)" }}>Board Type</label>
<select
value={flashAssetsHwType}
onChange={(e) => { setFlashAssetsHwType(e.target.value); setFlashAssetsSuccess(""); }}
className="w-full px-3 py-2 rounded-md text-sm border"
style={{ backgroundColor: "var(--bg-input)", borderColor: "var(--border-input)", color: "var(--text-primary)" }}
>
{BOARD_TYPES.map((bt) => (
<option key={bt.value} value={bt.value}>{bt.label}</option>
))}
</select>
</div>
<div style={{ display: "flex", flexDirection: "column", gap: "0.5rem", marginTop: "auto" }}>
<button
onClick={handleFlashAssetsUpload}
disabled={flashAssetsUploading || (!flashAssetsBootloader && !flashAssetsPartitions)}
className="px-4 py-2 text-sm rounded-md font-medium hover:opacity-90 transition-opacity cursor-pointer disabled:opacity-50"
style={{ backgroundColor: "var(--btn-primary)", color: "var(--text-white)" }}
>
{flashAssetsUploading ? "Uploading…" : "Save Assets"}
</button>
<button
type="button"
onClick={() => { setShowFlashAssets(false); setFlashAssetsError(""); setFlashAssetsSuccess(""); }}
className="px-4 py-2 text-sm rounded-md hover:opacity-80 cursor-pointer"
style={{ backgroundColor: "var(--bg-card-hover)", color: "var(--text-secondary)" }}
>
Cancel
</button>
</div>
</div>
{/* Bootloader drop zone */}
{[
{ label: "Bootloader (0x1000)", file: flashAssetsBootloader, setFile: setFlashAssetsBootloader, ref: blInputRef, hint: "bootloader.bin" },
{ label: "Partition Table (0x8000)", file: flashAssetsPartitions, setFile: setFlashAssetsPartitions, ref: partInputRef, hint: "partitions.bin" },
].map(({ label, file, setFile, ref, hint }) => (
<div key={hint} style={{ display: "flex", flexDirection: "column", gap: "0.5rem" }}>
<label className="block text-xs font-medium" style={{ color: "var(--text-muted)" }}>{label}</label>
<div
onClick={() => ref.current?.click()}
onDragOver={(e) => e.preventDefault()}
onDrop={(e) => {
e.preventDefault();
const f = e.dataTransfer.files[0];
if (f && f.name.endsWith(".bin")) { setFile(f); setFlashAssetsSuccess(""); }
}}
style={{
display: "flex", flexDirection: "column", alignItems: "center", justifyContent: "center",
gap: "0.5rem", padding: "1.25rem 1rem",
border: `2px dashed ${file ? "var(--btn-primary)" : "var(--border-input)"}`,
borderRadius: "0.625rem",
backgroundColor: file ? "var(--badge-blue-bg)" : "var(--bg-input)",
cursor: "pointer", transition: "all 0.15s ease",
}}
>
<input ref={ref} type="file" accept=".bin" onChange={(e) => { setFile(e.target.files[0] || null); setFlashAssetsSuccess(""); }} style={{ display: "none" }} />
{file ? (
<>
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="var(--btn-primary)" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round">
<polyline points="20 6 9 17 4 12" />
</svg>
<span style={{ fontSize: "0.75rem", fontWeight: 600, color: "var(--badge-blue-text)", textAlign: "center", wordBreak: "break-all" }}>
{file.name}
</span>
<span style={{ fontSize: "0.68rem", color: "var(--text-muted)" }}>{formatBytes(file.size)}</span>
</>
) : (
<>
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="var(--text-muted)" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round">
<line x1="12" y1="5" x2="12" y2="19" /><line x1="5" y1="12" x2="19" y2="12" />
</svg>
<span style={{ fontSize: "0.75rem", color: "var(--text-muted)", textAlign: "center" }}>
Click or drop <span style={{ fontFamily: "monospace" }}>{hint}</span>
</span>
</>
)}
</div>
</div>
))}
</div>
</div>
)}
{/* Upload form */}
{showUpload && (
<div
@@ -598,6 +1010,11 @@ export default function FirmwareManager() {
</div>
</div>
{/* Firmware API Info modal */}
{showApiInfo && (
<ApiInfoModal onClose={() => setShowApiInfo(false)} />
)}
{/* Delete confirmation */}
{deleteTarget && (
<div className="fixed inset-0 flex items-center justify-center z-50" style={{ backgroundColor: "rgba(0,0,0,0.6)" }}>

View File

@@ -92,9 +92,9 @@ function StatusBadge({ status }) {
);
}
function ProgressBar({ label, percent }) {
function ProgressBar({ label, percent, flex = false }) {
return (
<div className="space-y-1.5">
<div className="space-y-1.5" style={{ flex: flex ? 1 : undefined }}>
<div className="flex justify-between text-xs" style={{ color: "var(--text-secondary)" }}>
<span>{label}</span>
<span>{Math.round(percent)}%</span>
@@ -649,6 +649,8 @@ function StepFlash({ device, onFlashed }) {
const [connecting, setConnecting] = useState(false);
const [flashing, setFlashing] = useState(false);
const [done, setDone] = useState(false);
const [blProgress, setBlProgress] = useState(0);
const [partProgress, setPartProgress] = useState(0);
const [nvsProgress, setNvsProgress] = useState(0);
const [fwProgress, setFwProgress] = useState(0);
const [log, setLog] = useState([]);
@@ -777,6 +779,8 @@ function StepFlash({ device, onFlashed }) {
setError("");
setLog([]);
setSerial([]);
setBlProgress(0);
setPartProgress(0);
setNvsProgress(0);
setFwProgress(0);
setDone(false);
@@ -785,13 +789,23 @@ function StepFlash({ device, onFlashed }) {
try {
// 1. Fetch binaries
const sn = device.serial_number;
appendLog("Fetching bootloader binary…");
const blBuffer = await fetchBinary(`/api/manufacturing/devices/${sn}/bootloader.bin`);
appendLog(`Bootloader: ${blBuffer.byteLength} bytes`);
appendLog("Fetching partition table binary…");
const partBuffer = await fetchBinary(`/api/manufacturing/devices/${sn}/partitions.bin`);
appendLog(`Partition table: ${partBuffer.byteLength} bytes`);
appendLog("Fetching NVS binary…");
const nvsBuffer = await fetchBinary(`/api/manufacturing/devices/${device.serial_number}/nvs.bin`);
appendLog(`NVS binary: ${nvsBuffer.byteLength} bytes`);
const nvsBuffer = await fetchBinary(`/api/manufacturing/devices/${sn}/nvs.bin`);
appendLog(`NVS: ${nvsBuffer.byteLength} bytes`);
appendLog("Fetching firmware binary…");
const fwBuffer = await fetchBinary(`/api/manufacturing/devices/${device.serial_number}/firmware.bin`);
appendLog(`Firmware binary: ${fwBuffer.byteLength} bytes`);
const fwBuffer = await fetchBinary(`/api/manufacturing/devices/${sn}/firmware.bin`);
appendLog(`Firmware: ${fwBuffer.byteLength} bytes`);
// 2. Connect ESPLoader
setFlashing(true);
@@ -811,24 +825,34 @@ function StepFlash({ device, onFlashed }) {
await loaderRef.current.main();
appendLog("ESP32 connected.");
// 3. Flash NVS + firmware
const nvsData = arrayBufferToString(nvsBuffer);
const fwData = arrayBufferToString(fwBuffer);
// 3. Flash all four regions: bootloader → partition table → NVS firmware
// fileIndex: 0=bootloader, 1=partitions, 2=nvs, 3=firmware
const blData = arrayBufferToString(blBuffer);
const partData = arrayBufferToString(partBuffer);
const nvsData = arrayBufferToString(nvsBuffer);
const fwData = arrayBufferToString(fwBuffer);
await loaderRef.current.writeFlash({
fileArray: [
{ data: nvsData, address: NVS_ADDRESS },
{ data: fwData, address: FW_ADDRESS },
{ data: blData, address: 0x1000 }, // bootloader
{ data: partData, address: 0x8000 }, // partition table
{ data: nvsData, address: NVS_ADDRESS }, // 0x9000
{ data: fwData, address: FW_ADDRESS }, // 0x10000
],
flashSize: "keep", flashMode: "keep", flashFreq: "keep",
eraseAll: false, compress: true,
reportProgress(fileIndex, written, total) {
if (fileIndex === 0) setNvsProgress((written / total) * 100);
else { setNvsProgress(100); setFwProgress((written / total) * 100); }
const pct = (written / total) * 100;
if (fileIndex === 0) { setBlProgress(pct); }
else if (fileIndex === 1) { setBlProgress(100); setPartProgress(pct); }
else if (fileIndex === 2) { setPartProgress(100); setNvsProgress(pct); }
else { setNvsProgress(100); setFwProgress(pct); }
},
calculateMD5Hash: () => "",
});
setBlProgress(100);
setPartProgress(100);
setNvsProgress(100);
setFwProgress(100);
appendLog("Flash complete. Resetting device…");
@@ -913,9 +937,13 @@ function StepFlash({ device, onFlashed }) {
<ErrorBox msg={error} />
{error && <div className="h-2" />}
{(flashing || nvsProgress > 0) && (
<div className="space-y-3 mb-4">
<ProgressBar label="NVS Partition (0x9000)" percent={nvsProgress} />
{(flashing || blProgress > 0) && (
<div className="space-y-3 mb-1">
<div style={{ display: "flex", gap: "1rem" }}>
<ProgressBar flex label="Bootloader (0x1000)" percent={blProgress} />
<ProgressBar flex label="Partition Table (0x8000)" percent={partProgress} />
</div>
<ProgressBar label="NVS (0x9000)" percent={nvsProgress} />
<ProgressBar label="Firmware (0x10000)" percent={fwProgress} />
</div>
)}