update: firmware and provisioning now supports bootloader and partition tables
This commit is contained in:
@@ -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"
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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())
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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"'},
|
||||
)
|
||||
|
||||
@@ -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
|
||||
|
||||
BIN
backend/storage/flash_assets/agnus/bootloader.bin
Normal file
BIN
backend/storage/flash_assets/agnus/bootloader.bin
Normal file
Binary file not shown.
BIN
backend/storage/flash_assets/agnus/partitions.bin
Normal file
BIN
backend/storage/flash_assets/agnus/partitions.bin
Normal file
Binary file not shown.
@@ -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]
|
||||
|
||||
@@ -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}"
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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/{env}/</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)" }}>
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
Reference in New Issue
Block a user