diff --git a/backend/config.py b/backend/config.py index 604ead7..9a77622 100644 --- a/backend/config.py +++ b/backend/config.py @@ -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" diff --git a/backend/firmware/models.py b/backend/firmware/models.py index 65b5add..17a41d6 100644 --- a/backend/firmware/models.py +++ b/backend/firmware/models.py @@ -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 diff --git a/backend/firmware/router.py b/backend/firmware/router.py index 1806dc2..9cde742 100644 --- a/backend/firmware/router.py +++ b/backend/firmware/router.py @@ -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()) diff --git a/backend/firmware/service.py b/backend/firmware/service.py index 5772917..aff62c9 100644 --- a/backend/firmware/service.py +++ b/backend/firmware/service.py @@ -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) diff --git a/backend/main.py b/backend/main.py index 70dfbc4..764a7a3 100644 --- a/backend/main.py +++ b/backend/main.py @@ -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) diff --git a/backend/manufacturing/models.py b/backend/manufacturing/models.py index 78cc77b..491876e 100644 --- a/backend/manufacturing/models.py +++ b/backend/manufacturing/models.py @@ -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" diff --git a/backend/manufacturing/router.py b/backend/manufacturing/router.py index b028713..f5ee1c1 100644 --- a/backend/manufacturing/router.py +++ b/backend/manufacturing/router.py @@ -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"'}, + ) diff --git a/backend/manufacturing/service.py b/backend/manufacturing/service.py index a7e5503..a3b1a82 100644 --- a/backend/manufacturing/service.py +++ b/backend/manufacturing/service.py @@ -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 diff --git a/backend/storage/flash_assets/agnus/bootloader.bin b/backend/storage/flash_assets/agnus/bootloader.bin new file mode 100644 index 0000000..d592359 Binary files /dev/null and b/backend/storage/flash_assets/agnus/bootloader.bin differ diff --git a/backend/storage/flash_assets/agnus/partitions.bin b/backend/storage/flash_assets/agnus/partitions.bin new file mode 100644 index 0000000..21800fa Binary files /dev/null and b/backend/storage/flash_assets/agnus/partitions.bin differ diff --git a/backend/utils/nvs_generator.py b/backend/utils/nvs_generator.py index 58f68e9..58541a9 100644 --- a/backend/utils/nvs_generator.py +++ b/backend/utils/nvs_generator.py @@ -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] diff --git a/backend/utils/serial_number.py b/backend/utils/serial_number.py index 1b2df29..5586feb 100644 --- a/backend/utils/serial_number.py +++ b/backend/utils/serial_number.py @@ -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}" diff --git a/docker-compose.yml b/docker-compose.yml index 15f2b12..78ce15d 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -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 diff --git a/frontend/src/firmware/FirmwareManager.jsx b/frontend/src/firmware/FirmwareManager.jsx index ba8597d..e9a4664 100644 --- a/frontend/src/firmware/FirmwareManager.jsx +++ b/frontend/src/firmware/FirmwareManager.jsx @@ -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 ( + + {children} + + ); +} + +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 ( +
+
+ {method} + {label} +
+
+ console.bellsystems.net + {pathParts.map((p, i) => + p.plain + ? {p.text} + : {p.text} + )} +
+ {query && ( +
+ {query.map((q) => ( + ?{q.key}={q.eg} + ))} +
+ )} + {bodyFields && ( +
+ {bodyFields.map((f) => {f.key})} +
+ )} +

{desc}

+
+ ); +} + +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 ( +
+
e.stopPropagation()} + > + {/* Header */} +
+
+

Firmware API

+

All endpoints are unauthenticated — devices call them directly.

+
+ +
+ + {/* Tabs */} +
+ {TABS.map((t) => ( + + ))} +
+ + {/* Scrollable content */} +
+ {tab === "get" && GET_ENDPOINTS.map((ep) => )} + {tab === "post" && POST_ENDPOINTS.map((ep) => )} + {tab === "legend" && ( +
+ {[ + { 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) => ( +
+ {row.label} +
+
{row.eg}
+
+
+ ))} +

+ 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. +

+
+ )} +
+
+
+ ); +} + 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 (
@@ -187,17 +463,153 @@ export default function FirmwareManager() { {hwTypeFilter || channelFilter ? " (filtered)" : ""}

- {canAdd && ( +
- )} + {canAdd && ( + <> + + + + )} +
+ {/* Flash Assets panel */} + {showFlashAssets && ( +
+
+
+

Flash Assets

+

+ Bootloader and partition table binaries used during full provisioning. One set per board type. + Built by PlatformIO — find them in .pio/build/{env}/ +

+
+
+ + {flashAssetsError && ( +
+ {flashAssetsError} +
+ )} + {flashAssetsSuccess && ( +
+ {flashAssetsSuccess} +
+ )} + +
+ + {/* Board type selector + action buttons */} +
+
+ + +
+
+ + +
+
+ + {/* 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 }) => ( +
+ +
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", + }} + > + { setFile(e.target.files[0] || null); setFlashAssetsSuccess(""); }} style={{ display: "none" }} /> + {file ? ( + <> + + + + + {file.name} + + {formatBytes(file.size)} + + ) : ( + <> + + + + + Click or drop {hint} + + + )} +
+
+ ))} + +
+
+ )} + {/* Upload form */} {showUpload && (
+ {/* Firmware API Info modal */} + {showApiInfo && ( + setShowApiInfo(false)} /> + )} + {/* Delete confirmation */} {deleteTarget && (
diff --git a/frontend/src/manufacturing/ProvisioningWizard.jsx b/frontend/src/manufacturing/ProvisioningWizard.jsx index 0b09025..ad19507 100644 --- a/frontend/src/manufacturing/ProvisioningWizard.jsx +++ b/frontend/src/manufacturing/ProvisioningWizard.jsx @@ -92,9 +92,9 @@ function StatusBadge({ status }) { ); } -function ProgressBar({ label, percent }) { +function ProgressBar({ label, percent, flex = false }) { return ( -
+
{label} {Math.round(percent)}% @@ -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 }) { {error &&
} - {(flashing || nvsProgress > 0) && ( -
- + {(flashing || blProgress > 0) && ( +
+
+ + +
+
)}