From 4381a6681dd52f0c5d50c8e11b76e1dc4bdf56e8 Mon Sep 17 00:00:00 2001 From: bonamin Date: Mon, 16 Mar 2026 08:52:58 +0200 Subject: [PATCH] update: firmware and provisioning now supports bootloader and partition tables --- backend/config.py | 1 + backend/firmware/models.py | 25 +- backend/firmware/router.py | 63 ++- backend/firmware/service.py | 33 +- backend/main.py | 3 +- backend/manufacturing/models.py | 24 +- backend/manufacturing/router.py | 70 ++- backend/manufacturing/service.py | 25 + .../storage/flash_assets/agnus/bootloader.bin | Bin 0 -> 17536 bytes .../storage/flash_assets/agnus/partitions.bin | Bin 0 -> 3072 bytes backend/utils/nvs_generator.py | 17 +- backend/utils/serial_number.py | 74 ++- docker-compose.yml | 1 + frontend/src/firmware/FirmwareManager.jsx | 429 +++++++++++++++++- .../src/manufacturing/ProvisioningWizard.jsx | 60 ++- 15 files changed, 776 insertions(+), 49 deletions(-) create mode 100644 backend/storage/flash_assets/agnus/bootloader.bin create mode 100644 backend/storage/flash_assets/agnus/partitions.bin 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 0000000000000000000000000000000000000000..d592359371203c87798f24f9939173b4ba97f0d0 GIT binary patch literal 17536 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π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 literal 0 HcmV?d00001 diff --git a/backend/storage/flash_assets/agnus/partitions.bin b/backend/storage/flash_assets/agnus/partitions.bin new file mode 100644 index 0000000000000000000000000000000000000000..21800fab2305f464c5c5f081594acb714cbf4d4d GIT binary patch literal 3072 zcmZ1#z{tcffq{V`fPo>etQg2Z1*-xW85kY_#S|DA@=Fp^5=#cs$r00 zNGvEYK#>G;fbxP24Dv8}Lri(bCI$vupfD3daY1HU8k!S;_A@gu*aPJm8ItphQd3HE z3y{rt{rW!&*kUTYQR#?8@k&PSrZ-lqdr>4uIin#k8UmvsFd71*Aut*OqaiRF0;3@? H!b1Q6pG-mU literal 0 HcmV?d00001 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) && ( +
+
+ + +
+
)}