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
|
# Local file storage
|
||||||
built_melodies_storage_path: str = "./storage/built_melodies"
|
built_melodies_storage_path: str = "./storage/built_melodies"
|
||||||
firmware_storage_path: str = "./storage/firmware"
|
firmware_storage_path: str = "./storage/firmware"
|
||||||
|
flash_assets_storage_path: str = "./storage/flash_assets"
|
||||||
|
|
||||||
# Email (Resend)
|
# Email (Resend)
|
||||||
resend_api_key: str = "re_placeholder_change_me"
|
resend_api_key: str = "re_placeholder_change_me"
|
||||||
|
|||||||
@@ -30,13 +30,30 @@ class FirmwareListResponse(BaseModel):
|
|||||||
|
|
||||||
|
|
||||||
class FirmwareMetadataResponse(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
|
hw_type: str
|
||||||
channel: str
|
channel: str # release track — firmware validates this
|
||||||
version: str
|
version: str
|
||||||
size_bytes: int
|
size: int # firmware reads "size"
|
||||||
|
size_bytes: int # kept for admin-panel consumers
|
||||||
sha256: str
|
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
|
min_fw_version: Optional[str] = None
|
||||||
download_url: str
|
download_url: str
|
||||||
uploaded_at: str
|
uploaded_at: str
|
||||||
|
|||||||
@@ -1,13 +1,18 @@
|
|||||||
from fastapi import APIRouter, Depends, Query, UploadFile, File, Form
|
from fastapi import APIRouter, Depends, Query, UploadFile, File, Form
|
||||||
from fastapi.responses import FileResponse
|
from fastapi.responses import FileResponse
|
||||||
|
from pydantic import BaseModel
|
||||||
from typing import Optional
|
from typing import Optional
|
||||||
|
import logging
|
||||||
|
|
||||||
from auth.models import TokenPayload
|
from auth.models import TokenPayload
|
||||||
from auth.dependencies import require_permission
|
from auth.dependencies import require_permission
|
||||||
from firmware.models import FirmwareVersion, FirmwareListResponse, FirmwareMetadataResponse, UpdateType
|
from firmware.models import FirmwareVersion, FirmwareListResponse, FirmwareMetadataResponse, UpdateType
|
||||||
from firmware import service
|
from firmware import service
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
router = APIRouter(prefix="/api/firmware", tags=["firmware"])
|
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)
|
@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)
|
@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.
|
"""Returns metadata for the latest firmware for a given hw_type + channel.
|
||||||
No auth required — devices call this endpoint to check for updates.
|
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)
|
@router.get("/{hw_type}/{channel}/{version}/info", response_model=FirmwareMetadataResponse)
|
||||||
@@ -76,3 +86,52 @@ def delete_firmware(
|
|||||||
_user: TokenPayload = Depends(require_permission("manufacturing", "delete")),
|
_user: TokenPayload = Depends(require_permission("manufacturing", "delete")),
|
||||||
):
|
):
|
||||||
service.delete_firmware(firmware_id)
|
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 hashlib
|
||||||
|
import logging
|
||||||
import uuid
|
import uuid
|
||||||
from datetime import datetime, timezone
|
from datetime import datetime, timezone
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
from fastapi import HTTPException
|
from fastapi import HTTPException
|
||||||
|
|
||||||
@@ -10,6 +12,8 @@ from shared.firebase import get_db
|
|||||||
from shared.exceptions import NotFoundError
|
from shared.exceptions import NotFoundError
|
||||||
from firmware.models import FirmwareVersion, FirmwareMetadataResponse, UpdateType
|
from firmware.models import FirmwareVersion, FirmwareMetadataResponse, UpdateType
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
COLLECTION = "firmware_versions"
|
COLLECTION = "firmware_versions"
|
||||||
|
|
||||||
VALID_HW_TYPES = {"vesper", "vesper_plus", "vesper_pro", "chronos", "chronos_pro", "agnus", "agnus_mini"}
|
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:
|
def _fw_to_metadata_response(fw: FirmwareVersion) -> FirmwareMetadataResponse:
|
||||||
download_url = f"/api/firmware/{fw.hw_type}/{fw.channel}/{fw.version}/firmware.bin"
|
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(
|
return FirmwareMetadataResponse(
|
||||||
hw_type=fw.hw_type,
|
hw_type=fw.hw_type,
|
||||||
channel=fw.channel,
|
channel=fw.channel, # firmware validates this matches requested channel
|
||||||
version=fw.version,
|
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,
|
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,
|
min_fw_version=fw.min_fw_version,
|
||||||
download_url=download_url,
|
download_url=download_url,
|
||||||
uploaded_at=fw.uploaded_at,
|
uploaded_at=fw.uploaded_at,
|
||||||
@@ -130,7 +139,7 @@ def list_firmware(
|
|||||||
return items
|
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:
|
if hw_type not in VALID_HW_TYPES:
|
||||||
raise HTTPException(status_code=400, detail=f"Invalid hw_type '{hw_type}'")
|
raise HTTPException(status_code=400, detail=f"Invalid hw_type '{hw_type}'")
|
||||||
if channel not in VALID_CHANNELS:
|
if channel not in VALID_CHANNELS:
|
||||||
@@ -180,6 +189,22 @@ def get_firmware_path(hw_type: str, channel: str, version: str) -> Path:
|
|||||||
return 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:
|
def delete_firmware(doc_id: str) -> None:
|
||||||
db = get_db()
|
db = get_db()
|
||||||
doc_ref = db.collection(COLLECTION).document(doc_id)
|
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 helpdesk.router import router as helpdesk_router
|
||||||
from builder.router import router as builder_router
|
from builder.router import router as builder_router
|
||||||
from manufacturing.router import router as manufacturing_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 admin.router import router as admin_router
|
||||||
from crm.router import router as crm_products_router
|
from crm.router import router as crm_products_router
|
||||||
from crm.customers_router import router as crm_customers_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(builder_router)
|
||||||
app.include_router(manufacturing_router)
|
app.include_router(manufacturing_router)
|
||||||
app.include_router(firmware_router)
|
app.include_router(firmware_router)
|
||||||
|
app.include_router(ota_router)
|
||||||
app.include_router(admin_router)
|
app.include_router(admin_router)
|
||||||
app.include_router(crm_products_router)
|
app.include_router(crm_products_router)
|
||||||
app.include_router(crm_customers_router)
|
app.include_router(crm_customers_router)
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ class BoardType(str, Enum):
|
|||||||
|
|
||||||
BOARD_TYPE_LABELS = {
|
BOARD_TYPE_LABELS = {
|
||||||
"vesper": "Vesper",
|
"vesper": "Vesper",
|
||||||
"vesper_plus": "Vesper+",
|
"vesper_plus": "Vesper Plus",
|
||||||
"vesper_pro": "Vesper Pro",
|
"vesper_pro": "Vesper Pro",
|
||||||
"chronos": "Chronos",
|
"chronos": "Chronos",
|
||||||
"chronos_pro": "Chronos Pro",
|
"chronos_pro": "Chronos Pro",
|
||||||
@@ -23,6 +23,28 @@ BOARD_TYPE_LABELS = {
|
|||||||
"agnus": "Agnus",
|
"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):
|
class MfgStatus(str, Enum):
|
||||||
manufactured = "manufactured"
|
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 Response
|
||||||
from fastapi.responses import RedirectResponse
|
from fastapi.responses import RedirectResponse
|
||||||
from typing import Optional
|
from typing import Optional
|
||||||
@@ -15,6 +15,9 @@ from manufacturing import service
|
|||||||
from manufacturing import audit
|
from manufacturing import audit
|
||||||
from shared.exceptions import NotFoundError
|
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"])
|
router = APIRouter(prefix="/api/manufacturing", tags=["manufacturing"])
|
||||||
|
|
||||||
|
|
||||||
@@ -175,3 +178,68 @@ def redirect_firmware(
|
|||||||
"""
|
"""
|
||||||
url = service.get_firmware_url(sn)
|
url = service.get_firmware_url(sn)
|
||||||
return RedirectResponse(url=url, status_code=302)
|
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 random
|
||||||
import string
|
import string
|
||||||
from datetime import datetime, timezone
|
from datetime import datetime, timezone
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
from config import settings
|
||||||
from shared.firebase import get_db
|
from shared.firebase import get_db
|
||||||
from shared.exceptions import NotFoundError
|
from shared.exceptions import NotFoundError
|
||||||
from utils.serial_number import generate_serial
|
from utils.serial_number import generate_serial
|
||||||
@@ -270,6 +272,29 @@ def delete_unprovisioned_devices() -> list[str]:
|
|||||||
return deleted
|
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:
|
def get_firmware_url(sn: str) -> str:
|
||||||
"""Return the FastAPI download URL for the latest stable firmware for this device's hw_type."""
|
"""Return the FastAPI download URL for the latest stable firmware for this device's hw_type."""
|
||||||
from firmware.service import get_latest
|
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:
|
def generate(serial_number: str, hw_type: str, hw_version: str) -> bytes:
|
||||||
"""Generate a 0x5000-byte NVS partition binary for a Vesper device.
|
"""Generate a 0x5000-byte NVS partition binary for a Vesper device.
|
||||||
|
|
||||||
serial_number: full SN string e.g. 'PV-26B27-VS01R-X7KQA'
|
serial_number: full SN string e.g. 'BSVSPR-26C13X-STD01R-X7KQA'
|
||||||
hw_type: board type e.g. 'vesper', 'vesper_plus', 'vesper_pro'
|
hw_type: board family e.g. 'vesper', 'vesper_plus', 'vesper_pro'
|
||||||
hw_version: zero-padded version e.g. '01'
|
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.
|
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"
|
# Build entries for namespace "device_id"
|
||||||
ns_entry, ns_span = _build_namespace_entry("device_id", ns_index)
|
ns_entry, ns_span = _build_namespace_entry("device_id", ns_index)
|
||||||
uid_entry, uid_span = _build_string_entry(ns_index, "device_uid", serial_number)
|
uid_entry, uid_span = _build_string_entry(ns_index, "serial", serial_number)
|
||||||
hwt_entry, hwt_span = _build_string_entry(ns_index, "hw_type", hw_type.lower())
|
hwt_entry, hwt_span = _build_string_entry(ns_index, "hw_family", hw_type.lower())
|
||||||
hwv_entry, hwv_span = _build_string_entry(ns_index, "hw_version", hw_version)
|
hwv_entry, hwv_span = _build_string_entry(ns_index, "hw_revision", hw_version)
|
||||||
|
|
||||||
entries = [ns_entry, uid_entry, hwt_entry, hwv_entry]
|
entries = [ns_entry, uid_entry, hwt_entry, hwv_entry]
|
||||||
spans = [ns_span, uid_span, hwt_span, hwv_span]
|
spans = [ns_span, uid_span, hwt_span, hwv_span]
|
||||||
|
|||||||
@@ -4,17 +4,75 @@ from datetime import datetime
|
|||||||
MONTH_CODES = "ABCDEFGHIJKL"
|
MONTH_CODES = "ABCDEFGHIJKL"
|
||||||
SAFE_CHARS = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789" # No 0, O, 1, I — avoids label confusion
|
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:
|
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'
|
Format: BSFFFF-YYMDDf-VVVvvv-XXXXXX
|
||||||
board_version: 2-char zero-padded version, e.g. '01'
|
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()
|
now = datetime.utcnow()
|
||||||
year = now.strftime("%y")
|
year = now.strftime("%y")
|
||||||
month = MONTH_CODES[now.month - 1]
|
month = MONTH_CODES[now.month - 1]
|
||||||
day = now.strftime("%d")
|
day = now.strftime("%d")
|
||||||
suffix = "".join(random.choices(SAFE_CHARS, k=5))
|
filler = random.choice(SAFE_CHARS)
|
||||||
version_clean = board_version.replace(".", "")
|
suffix = "".join(random.choices(SAFE_CHARS, k=6))
|
||||||
return f"PV-{year}{month}{day}-{board_type.upper()}{version_clean}R-{suffix}"
|
|
||||||
|
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:/app/data
|
||||||
- ./data/built_melodies:/app/storage/built_melodies
|
- ./data/built_melodies:/app/storage/built_melodies
|
||||||
- ./data/firmware:/app/storage/firmware
|
- ./data/firmware:/app/storage/firmware
|
||||||
|
- ./data/flash_assets:/app/storage/flash_assets
|
||||||
- ./data/firebase-service-account.json:/app/firebase-service-account.json:ro
|
- ./data/firebase-service-account.json:/app/firebase-service-account.json:ro
|
||||||
# Auto-deploy: project root so container can write the trigger file
|
# Auto-deploy: project root so container can write the trigger file
|
||||||
- /home/bellsystems/bellsystems-cp:/home/bellsystems/bellsystems-cp
|
- /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) {
|
function formatBytes(bytes) {
|
||||||
if (!bytes) return "—";
|
if (!bytes) return "—";
|
||||||
if (bytes < 1024) return `${bytes} B`;
|
if (bytes < 1024) return `${bytes} B`;
|
||||||
@@ -83,6 +311,8 @@ export default function FirmwareManager() {
|
|||||||
const [channelFilter, setChannelFilter] = useState("");
|
const [channelFilter, setChannelFilter] = useState("");
|
||||||
|
|
||||||
const [showUpload, setShowUpload] = useState(false);
|
const [showUpload, setShowUpload] = useState(false);
|
||||||
|
const [showFlashAssets, setShowFlashAssets] = useState(false);
|
||||||
|
const [showApiInfo, setShowApiInfo] = useState(false);
|
||||||
const [uploadHwType, setUploadHwType] = useState("vesper");
|
const [uploadHwType, setUploadHwType] = useState("vesper");
|
||||||
const [uploadChannel, setUploadChannel] = useState("stable");
|
const [uploadChannel, setUploadChannel] = useState("stable");
|
||||||
const [uploadVersion, setUploadVersion] = useState("");
|
const [uploadVersion, setUploadVersion] = useState("");
|
||||||
@@ -94,6 +324,15 @@ export default function FirmwareManager() {
|
|||||||
const [uploadError, setUploadError] = useState("");
|
const [uploadError, setUploadError] = useState("");
|
||||||
const fileInputRef = useRef(null);
|
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 [deleteTarget, setDeleteTarget] = useState(null);
|
||||||
const [deleting, setDeleting] = useState(false);
|
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 () => {
|
const handleDelete = async () => {
|
||||||
if (!deleteTarget) return;
|
if (!deleteTarget) return;
|
||||||
setDeleting(true);
|
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 (
|
return (
|
||||||
<div>
|
<div>
|
||||||
@@ -187,16 +463,152 @@ export default function FirmwareManager() {
|
|||||||
{hwTypeFilter || channelFilter ? " (filtered)" : ""}
|
{hwTypeFilter || channelFilter ? " (filtered)" : ""}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
{canAdd && (
|
<div className="flex gap-2">
|
||||||
<button
|
<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(--bg-card-hover)", color: "var(--text-secondary)", border: "1px solid var(--border-primary)" }}
|
||||||
|
>
|
||||||
|
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"
|
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(--btn-primary)", color: "var(--text-white)" }}
|
||||||
>
|
>
|
||||||
+ Upload Firmware
|
+ Upload Firmware
|
||||||
</button>
|
</button>
|
||||||
|
</>
|
||||||
)}
|
)}
|
||||||
</div>
|
</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 */}
|
{/* Upload form */}
|
||||||
{showUpload && (
|
{showUpload && (
|
||||||
@@ -598,6 +1010,11 @@ export default function FirmwareManager() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Firmware API Info modal */}
|
||||||
|
{showApiInfo && (
|
||||||
|
<ApiInfoModal onClose={() => setShowApiInfo(false)} />
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Delete confirmation */}
|
{/* Delete confirmation */}
|
||||||
{deleteTarget && (
|
{deleteTarget && (
|
||||||
<div className="fixed inset-0 flex items-center justify-center z-50" style={{ backgroundColor: "rgba(0,0,0,0.6)" }}>
|
<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 (
|
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)" }}>
|
<div className="flex justify-between text-xs" style={{ color: "var(--text-secondary)" }}>
|
||||||
<span>{label}</span>
|
<span>{label}</span>
|
||||||
<span>{Math.round(percent)}%</span>
|
<span>{Math.round(percent)}%</span>
|
||||||
@@ -649,6 +649,8 @@ function StepFlash({ device, onFlashed }) {
|
|||||||
const [connecting, setConnecting] = useState(false);
|
const [connecting, setConnecting] = useState(false);
|
||||||
const [flashing, setFlashing] = useState(false);
|
const [flashing, setFlashing] = useState(false);
|
||||||
const [done, setDone] = useState(false);
|
const [done, setDone] = useState(false);
|
||||||
|
const [blProgress, setBlProgress] = useState(0);
|
||||||
|
const [partProgress, setPartProgress] = useState(0);
|
||||||
const [nvsProgress, setNvsProgress] = useState(0);
|
const [nvsProgress, setNvsProgress] = useState(0);
|
||||||
const [fwProgress, setFwProgress] = useState(0);
|
const [fwProgress, setFwProgress] = useState(0);
|
||||||
const [log, setLog] = useState([]);
|
const [log, setLog] = useState([]);
|
||||||
@@ -777,6 +779,8 @@ function StepFlash({ device, onFlashed }) {
|
|||||||
setError("");
|
setError("");
|
||||||
setLog([]);
|
setLog([]);
|
||||||
setSerial([]);
|
setSerial([]);
|
||||||
|
setBlProgress(0);
|
||||||
|
setPartProgress(0);
|
||||||
setNvsProgress(0);
|
setNvsProgress(0);
|
||||||
setFwProgress(0);
|
setFwProgress(0);
|
||||||
setDone(false);
|
setDone(false);
|
||||||
@@ -785,13 +789,23 @@ function StepFlash({ device, onFlashed }) {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
// 1. Fetch binaries
|
// 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…");
|
appendLog("Fetching NVS binary…");
|
||||||
const nvsBuffer = await fetchBinary(`/api/manufacturing/devices/${device.serial_number}/nvs.bin`);
|
const nvsBuffer = await fetchBinary(`/api/manufacturing/devices/${sn}/nvs.bin`);
|
||||||
appendLog(`NVS binary: ${nvsBuffer.byteLength} bytes`);
|
appendLog(`NVS: ${nvsBuffer.byteLength} bytes`);
|
||||||
|
|
||||||
appendLog("Fetching firmware binary…");
|
appendLog("Fetching firmware binary…");
|
||||||
const fwBuffer = await fetchBinary(`/api/manufacturing/devices/${device.serial_number}/firmware.bin`);
|
const fwBuffer = await fetchBinary(`/api/manufacturing/devices/${sn}/firmware.bin`);
|
||||||
appendLog(`Firmware binary: ${fwBuffer.byteLength} bytes`);
|
appendLog(`Firmware: ${fwBuffer.byteLength} bytes`);
|
||||||
|
|
||||||
// 2. Connect ESPLoader
|
// 2. Connect ESPLoader
|
||||||
setFlashing(true);
|
setFlashing(true);
|
||||||
@@ -811,24 +825,34 @@ function StepFlash({ device, onFlashed }) {
|
|||||||
await loaderRef.current.main();
|
await loaderRef.current.main();
|
||||||
appendLog("ESP32 connected.");
|
appendLog("ESP32 connected.");
|
||||||
|
|
||||||
// 3. Flash NVS + firmware
|
// 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 nvsData = arrayBufferToString(nvsBuffer);
|
||||||
const fwData = arrayBufferToString(fwBuffer);
|
const fwData = arrayBufferToString(fwBuffer);
|
||||||
|
|
||||||
await loaderRef.current.writeFlash({
|
await loaderRef.current.writeFlash({
|
||||||
fileArray: [
|
fileArray: [
|
||||||
{ data: nvsData, address: NVS_ADDRESS },
|
{ data: blData, address: 0x1000 }, // bootloader
|
||||||
{ data: fwData, address: FW_ADDRESS },
|
{ data: partData, address: 0x8000 }, // partition table
|
||||||
|
{ data: nvsData, address: NVS_ADDRESS }, // 0x9000
|
||||||
|
{ data: fwData, address: FW_ADDRESS }, // 0x10000
|
||||||
],
|
],
|
||||||
flashSize: "keep", flashMode: "keep", flashFreq: "keep",
|
flashSize: "keep", flashMode: "keep", flashFreq: "keep",
|
||||||
eraseAll: false, compress: true,
|
eraseAll: false, compress: true,
|
||||||
reportProgress(fileIndex, written, total) {
|
reportProgress(fileIndex, written, total) {
|
||||||
if (fileIndex === 0) setNvsProgress((written / total) * 100);
|
const pct = (written / total) * 100;
|
||||||
else { setNvsProgress(100); setFwProgress((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: () => "",
|
calculateMD5Hash: () => "",
|
||||||
});
|
});
|
||||||
|
|
||||||
|
setBlProgress(100);
|
||||||
|
setPartProgress(100);
|
||||||
setNvsProgress(100);
|
setNvsProgress(100);
|
||||||
setFwProgress(100);
|
setFwProgress(100);
|
||||||
appendLog("Flash complete. Resetting device…");
|
appendLog("Flash complete. Resetting device…");
|
||||||
@@ -913,9 +937,13 @@ function StepFlash({ device, onFlashed }) {
|
|||||||
<ErrorBox msg={error} />
|
<ErrorBox msg={error} />
|
||||||
{error && <div className="h-2" />}
|
{error && <div className="h-2" />}
|
||||||
|
|
||||||
{(flashing || nvsProgress > 0) && (
|
{(flashing || blProgress > 0) && (
|
||||||
<div className="space-y-3 mb-4">
|
<div className="space-y-3 mb-1">
|
||||||
<ProgressBar label="NVS Partition (0x9000)" percent={nvsProgress} />
|
<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} />
|
<ProgressBar label="Firmware (0x10000)" percent={fwProgress} />
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|||||||
Reference in New Issue
Block a user