feat: Phase 3 manufacturing + firmware management
This commit is contained in:
@@ -13,11 +13,16 @@ MQTT_BROKER_PORT=1883
|
|||||||
MQTT_ADMIN_USERNAME=admin
|
MQTT_ADMIN_USERNAME=admin
|
||||||
MQTT_ADMIN_PASSWORD=your-mqtt-admin-password
|
MQTT_ADMIN_PASSWORD=your-mqtt-admin-password
|
||||||
MOSQUITTO_PASSWORD_FILE=/etc/mosquitto/passwd
|
MOSQUITTO_PASSWORD_FILE=/etc/mosquitto/passwd
|
||||||
|
# HMAC secret used to derive per-device MQTT passwords (must match firmware)
|
||||||
|
MQTT_SECRET=change-me-in-production
|
||||||
|
|
||||||
# App
|
# App
|
||||||
BACKEND_CORS_ORIGINS=["http://localhost:5173"]
|
BACKEND_CORS_ORIGINS=["http://localhost:5173"]
|
||||||
DEBUG=true
|
DEBUG=true
|
||||||
|
# Port nginx binds on the host (use 90 on VPS if 80 is taken)
|
||||||
|
NGINX_PORT=80
|
||||||
|
|
||||||
# Local file storage (override if you want to store data elsewhere)
|
# Local file storage (override if you want to store data elsewhere)
|
||||||
SQLITE_DB_PATH=./mqtt_data.db
|
SQLITE_DB_PATH=./mqtt_data.db
|
||||||
BUILT_MELODIES_STORAGE_PATH=./storage/built_melodies
|
BUILT_MELODIES_STORAGE_PATH=./storage/built_melodies
|
||||||
|
FIRMWARE_STORAGE_PATH=./storage/firmware
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ class StaffPermissions(BaseModel):
|
|||||||
devices: SectionPermissions = SectionPermissions()
|
devices: SectionPermissions = SectionPermissions()
|
||||||
app_users: SectionPermissions = SectionPermissions()
|
app_users: SectionPermissions = SectionPermissions()
|
||||||
equipment: SectionPermissions = SectionPermissions()
|
equipment: SectionPermissions = SectionPermissions()
|
||||||
|
manufacturing: SectionPermissions = SectionPermissions()
|
||||||
mqtt: bool = False
|
mqtt: bool = False
|
||||||
|
|
||||||
|
|
||||||
@@ -37,6 +38,7 @@ def default_permissions_for_role(role: str) -> Optional[dict]:
|
|||||||
"devices": full,
|
"devices": full,
|
||||||
"app_users": full,
|
"app_users": full,
|
||||||
"equipment": full,
|
"equipment": full,
|
||||||
|
"manufacturing": view_only,
|
||||||
"mqtt": True,
|
"mqtt": True,
|
||||||
}
|
}
|
||||||
# user role - view only
|
# user role - view only
|
||||||
@@ -45,6 +47,7 @@ def default_permissions_for_role(role: str) -> Optional[dict]:
|
|||||||
"devices": view_only,
|
"devices": view_only,
|
||||||
"app_users": view_only,
|
"app_users": view_only,
|
||||||
"equipment": view_only,
|
"equipment": view_only,
|
||||||
|
"manufacturing": {"view": False, "add": False, "edit": False, "delete": False},
|
||||||
"mqtt": False,
|
"mqtt": False,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ class Settings(BaseSettings):
|
|||||||
mqtt_broker_port: int = 1883
|
mqtt_broker_port: int = 1883
|
||||||
mqtt_admin_username: str = "admin"
|
mqtt_admin_username: str = "admin"
|
||||||
mqtt_admin_password: str = ""
|
mqtt_admin_password: str = ""
|
||||||
|
mqtt_secret: str = "change-me-in-production"
|
||||||
mosquitto_password_file: str = "/etc/mosquitto/passwd"
|
mosquitto_password_file: str = "/etc/mosquitto/passwd"
|
||||||
|
|
||||||
# SQLite (MQTT data storage)
|
# SQLite (MQTT data storage)
|
||||||
@@ -26,6 +27,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"
|
||||||
|
|
||||||
# App
|
# App
|
||||||
backend_cors_origins: str = '["http://localhost:5173"]'
|
backend_cors_origins: str = '["http://localhost:5173"]'
|
||||||
|
|||||||
@@ -7,7 +7,6 @@ from google.cloud.firestore_v1 import GeoPoint, DocumentReference
|
|||||||
from shared.firebase import get_db
|
from shared.firebase import get_db
|
||||||
from shared.exceptions import NotFoundError
|
from shared.exceptions import NotFoundError
|
||||||
from devices.models import DeviceCreate, DeviceUpdate, DeviceInDB
|
from devices.models import DeviceCreate, DeviceUpdate, DeviceInDB
|
||||||
from mqtt.mosquitto import register_device_password
|
|
||||||
|
|
||||||
COLLECTION = "devices"
|
COLLECTION = "devices"
|
||||||
|
|
||||||
@@ -154,10 +153,6 @@ def create_device(data: DeviceCreate) -> DeviceInDB:
|
|||||||
# Generate unique serial number
|
# Generate unique serial number
|
||||||
serial_number = _ensure_unique_serial(db)
|
serial_number = _ensure_unique_serial(db)
|
||||||
|
|
||||||
# Generate MQTT password and register with Mosquitto
|
|
||||||
mqtt_password = secrets.token_urlsafe(24)
|
|
||||||
register_device_password(serial_number, mqtt_password)
|
|
||||||
|
|
||||||
doc_data = data.model_dump()
|
doc_data = data.model_dump()
|
||||||
doc_data["device_id"] = serial_number
|
doc_data["device_id"] = serial_number
|
||||||
|
|
||||||
|
|||||||
0
backend/firmware/__init__.py
Normal file
0
backend/firmware/__init__.py
Normal file
31
backend/firmware/models.py
Normal file
31
backend/firmware/models.py
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
from pydantic import BaseModel
|
||||||
|
from typing import Optional, List
|
||||||
|
|
||||||
|
|
||||||
|
class FirmwareVersion(BaseModel):
|
||||||
|
id: str
|
||||||
|
hw_type: str # "vs", "vp", "vx"
|
||||||
|
channel: str # "stable", "beta", "alpha", "testing"
|
||||||
|
version: str # semver e.g. "1.4.2"
|
||||||
|
filename: str
|
||||||
|
size_bytes: int
|
||||||
|
sha256: str
|
||||||
|
uploaded_at: str
|
||||||
|
notes: Optional[str] = None
|
||||||
|
is_latest: bool = False
|
||||||
|
|
||||||
|
|
||||||
|
class FirmwareListResponse(BaseModel):
|
||||||
|
firmware: List[FirmwareVersion]
|
||||||
|
total: int
|
||||||
|
|
||||||
|
|
||||||
|
class FirmwareLatestResponse(BaseModel):
|
||||||
|
hw_type: str
|
||||||
|
channel: str
|
||||||
|
version: str
|
||||||
|
size_bytes: int
|
||||||
|
sha256: str
|
||||||
|
download_url: str
|
||||||
|
uploaded_at: str
|
||||||
|
notes: Optional[str] = None
|
||||||
66
backend/firmware/router.py
Normal file
66
backend/firmware/router.py
Normal file
@@ -0,0 +1,66 @@
|
|||||||
|
from fastapi import APIRouter, Depends, Query, UploadFile, File, Form
|
||||||
|
from fastapi.responses import FileResponse
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
from auth.models import TokenPayload
|
||||||
|
from auth.dependencies import require_permission
|
||||||
|
from firmware.models import FirmwareVersion, FirmwareListResponse, FirmwareLatestResponse
|
||||||
|
from firmware import service
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/api/firmware", tags=["firmware"])
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/upload", response_model=FirmwareVersion, status_code=201)
|
||||||
|
async def upload_firmware(
|
||||||
|
hw_type: str = Form(...),
|
||||||
|
channel: str = Form(...),
|
||||||
|
version: str = Form(...),
|
||||||
|
notes: Optional[str] = Form(None),
|
||||||
|
file: UploadFile = File(...),
|
||||||
|
_user: TokenPayload = Depends(require_permission("manufacturing", "add")),
|
||||||
|
):
|
||||||
|
file_bytes = await file.read()
|
||||||
|
return service.upload_firmware(
|
||||||
|
hw_type=hw_type,
|
||||||
|
channel=channel,
|
||||||
|
version=version,
|
||||||
|
file_bytes=file_bytes,
|
||||||
|
notes=notes,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("", response_model=FirmwareListResponse)
|
||||||
|
def list_firmware(
|
||||||
|
hw_type: Optional[str] = Query(None),
|
||||||
|
channel: Optional[str] = Query(None),
|
||||||
|
_user: TokenPayload = Depends(require_permission("manufacturing", "view")),
|
||||||
|
):
|
||||||
|
items = service.list_firmware(hw_type=hw_type, channel=channel)
|
||||||
|
return FirmwareListResponse(firmware=items, total=len(items))
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/{hw_type}/{channel}/latest", response_model=FirmwareLatestResponse)
|
||||||
|
def get_latest_firmware(hw_type: str, channel: str):
|
||||||
|
"""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)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/{hw_type}/{channel}/{version}/firmware.bin")
|
||||||
|
def download_firmware(hw_type: str, channel: str, version: str):
|
||||||
|
"""Download the firmware binary. No auth required — devices call this directly."""
|
||||||
|
path = service.get_firmware_path(hw_type, channel, version)
|
||||||
|
return FileResponse(
|
||||||
|
path=str(path),
|
||||||
|
media_type="application/octet-stream",
|
||||||
|
filename="firmware.bin",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete("/{firmware_id}", status_code=204)
|
||||||
|
def delete_firmware(
|
||||||
|
firmware_id: str,
|
||||||
|
_user: TokenPayload = Depends(require_permission("manufacturing", "delete")),
|
||||||
|
):
|
||||||
|
service.delete_firmware(firmware_id)
|
||||||
186
backend/firmware/service.py
Normal file
186
backend/firmware/service.py
Normal file
@@ -0,0 +1,186 @@
|
|||||||
|
import hashlib
|
||||||
|
import uuid
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from fastapi import HTTPException
|
||||||
|
|
||||||
|
from config import settings
|
||||||
|
from shared.firebase import get_db
|
||||||
|
from shared.exceptions import NotFoundError
|
||||||
|
from firmware.models import FirmwareVersion, FirmwareLatestResponse
|
||||||
|
|
||||||
|
COLLECTION = "firmware_versions"
|
||||||
|
|
||||||
|
VALID_HW_TYPES = {"vs", "vp", "vx"}
|
||||||
|
VALID_CHANNELS = {"stable", "beta", "alpha", "testing"}
|
||||||
|
|
||||||
|
|
||||||
|
def _storage_path(hw_type: str, channel: str, version: str) -> Path:
|
||||||
|
return Path(settings.firmware_storage_path) / hw_type / channel / version / "firmware.bin"
|
||||||
|
|
||||||
|
|
||||||
|
def _doc_to_firmware_version(doc) -> FirmwareVersion:
|
||||||
|
data = doc.to_dict() or {}
|
||||||
|
uploaded_raw = data.get("uploaded_at")
|
||||||
|
if isinstance(uploaded_raw, datetime):
|
||||||
|
uploaded_str = uploaded_raw.strftime("%Y-%m-%dT%H:%M:%SZ")
|
||||||
|
else:
|
||||||
|
uploaded_str = str(uploaded_raw) if uploaded_raw else ""
|
||||||
|
|
||||||
|
return FirmwareVersion(
|
||||||
|
id=doc.id,
|
||||||
|
hw_type=data.get("hw_type", ""),
|
||||||
|
channel=data.get("channel", ""),
|
||||||
|
version=data.get("version", ""),
|
||||||
|
filename=data.get("filename", "firmware.bin"),
|
||||||
|
size_bytes=data.get("size_bytes", 0),
|
||||||
|
sha256=data.get("sha256", ""),
|
||||||
|
uploaded_at=uploaded_str,
|
||||||
|
notes=data.get("notes"),
|
||||||
|
is_latest=data.get("is_latest", False),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def upload_firmware(
|
||||||
|
hw_type: str,
|
||||||
|
channel: str,
|
||||||
|
version: str,
|
||||||
|
file_bytes: bytes,
|
||||||
|
notes: str | None = None,
|
||||||
|
) -> FirmwareVersion:
|
||||||
|
if hw_type not in VALID_HW_TYPES:
|
||||||
|
raise HTTPException(status_code=400, detail=f"Invalid hw_type. Must be one of: {', '.join(VALID_HW_TYPES)}")
|
||||||
|
if channel not in VALID_CHANNELS:
|
||||||
|
raise HTTPException(status_code=400, detail=f"Invalid channel. Must be one of: {', '.join(VALID_CHANNELS)}")
|
||||||
|
|
||||||
|
dest = _storage_path(hw_type, channel, version)
|
||||||
|
dest.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
dest.write_bytes(file_bytes)
|
||||||
|
|
||||||
|
sha256 = hashlib.sha256(file_bytes).hexdigest()
|
||||||
|
now = datetime.now(timezone.utc)
|
||||||
|
doc_id = str(uuid.uuid4())
|
||||||
|
|
||||||
|
db = get_db()
|
||||||
|
|
||||||
|
# Mark previous latest for this hw_type+channel as no longer latest
|
||||||
|
prev_docs = (
|
||||||
|
db.collection(COLLECTION)
|
||||||
|
.where("hw_type", "==", hw_type)
|
||||||
|
.where("channel", "==", channel)
|
||||||
|
.where("is_latest", "==", True)
|
||||||
|
.stream()
|
||||||
|
)
|
||||||
|
for prev in prev_docs:
|
||||||
|
prev.reference.update({"is_latest": False})
|
||||||
|
|
||||||
|
doc_ref = db.collection(COLLECTION).document(doc_id)
|
||||||
|
doc_ref.set({
|
||||||
|
"hw_type": hw_type,
|
||||||
|
"channel": channel,
|
||||||
|
"version": version,
|
||||||
|
"filename": "firmware.bin",
|
||||||
|
"size_bytes": len(file_bytes),
|
||||||
|
"sha256": sha256,
|
||||||
|
"uploaded_at": now,
|
||||||
|
"notes": notes,
|
||||||
|
"is_latest": True,
|
||||||
|
})
|
||||||
|
|
||||||
|
return _doc_to_firmware_version(doc_ref.get())
|
||||||
|
|
||||||
|
|
||||||
|
def list_firmware(
|
||||||
|
hw_type: str | None = None,
|
||||||
|
channel: str | None = None,
|
||||||
|
) -> list[FirmwareVersion]:
|
||||||
|
db = get_db()
|
||||||
|
query = db.collection(COLLECTION)
|
||||||
|
if hw_type:
|
||||||
|
query = query.where("hw_type", "==", hw_type)
|
||||||
|
if channel:
|
||||||
|
query = query.where("channel", "==", channel)
|
||||||
|
|
||||||
|
docs = list(query.stream())
|
||||||
|
items = [_doc_to_firmware_version(doc) for doc in docs]
|
||||||
|
items.sort(key=lambda x: x.uploaded_at, reverse=True)
|
||||||
|
return items
|
||||||
|
|
||||||
|
|
||||||
|
def get_latest(hw_type: str, channel: str) -> FirmwareLatestResponse:
|
||||||
|
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:
|
||||||
|
raise HTTPException(status_code=400, detail=f"Invalid channel '{channel}'")
|
||||||
|
|
||||||
|
db = get_db()
|
||||||
|
docs = list(
|
||||||
|
db.collection(COLLECTION)
|
||||||
|
.where("hw_type", "==", hw_type)
|
||||||
|
.where("channel", "==", channel)
|
||||||
|
.where("is_latest", "==", True)
|
||||||
|
.limit(1)
|
||||||
|
.stream()
|
||||||
|
)
|
||||||
|
if not docs:
|
||||||
|
raise NotFoundError("Firmware")
|
||||||
|
|
||||||
|
fw = _doc_to_firmware_version(docs[0])
|
||||||
|
download_url = f"/api/firmware/{hw_type}/{channel}/{fw.version}/firmware.bin"
|
||||||
|
return FirmwareLatestResponse(
|
||||||
|
hw_type=fw.hw_type,
|
||||||
|
channel=fw.channel,
|
||||||
|
version=fw.version,
|
||||||
|
size_bytes=fw.size_bytes,
|
||||||
|
sha256=fw.sha256,
|
||||||
|
download_url=download_url,
|
||||||
|
uploaded_at=fw.uploaded_at,
|
||||||
|
notes=fw.notes,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def get_firmware_path(hw_type: str, channel: str, version: str) -> Path:
|
||||||
|
path = _storage_path(hw_type, channel, version)
|
||||||
|
if not path.exists():
|
||||||
|
raise NotFoundError("Firmware binary")
|
||||||
|
return path
|
||||||
|
|
||||||
|
|
||||||
|
def delete_firmware(doc_id: str) -> None:
|
||||||
|
db = get_db()
|
||||||
|
doc_ref = db.collection(COLLECTION).document(doc_id)
|
||||||
|
doc = doc_ref.get()
|
||||||
|
if not doc.exists:
|
||||||
|
raise NotFoundError("Firmware")
|
||||||
|
|
||||||
|
data = doc.to_dict()
|
||||||
|
hw_type = data.get("hw_type", "")
|
||||||
|
channel = data.get("channel", "")
|
||||||
|
version = data.get("version", "")
|
||||||
|
was_latest = data.get("is_latest", False)
|
||||||
|
|
||||||
|
# Delete the binary file
|
||||||
|
path = _storage_path(hw_type, channel, version)
|
||||||
|
if path.exists():
|
||||||
|
path.unlink()
|
||||||
|
# Remove the version directory if empty
|
||||||
|
try:
|
||||||
|
path.parent.rmdir()
|
||||||
|
except OSError:
|
||||||
|
pass
|
||||||
|
|
||||||
|
doc_ref.delete()
|
||||||
|
|
||||||
|
# If we deleted the latest, promote the next most recent as latest
|
||||||
|
if was_latest:
|
||||||
|
remaining = list(
|
||||||
|
db.collection(COLLECTION)
|
||||||
|
.where("hw_type", "==", hw_type)
|
||||||
|
.where("channel", "==", channel)
|
||||||
|
.order_by("uploaded_at", direction="DESCENDING")
|
||||||
|
.limit(1)
|
||||||
|
.stream()
|
||||||
|
)
|
||||||
|
if remaining:
|
||||||
|
remaining[0].reference.update({"is_latest": True})
|
||||||
@@ -9,10 +9,13 @@ from devices.router import router as devices_router
|
|||||||
from settings.router import router as settings_router
|
from settings.router import router as settings_router
|
||||||
from users.router import router as users_router
|
from users.router import router as users_router
|
||||||
from mqtt.router import router as mqtt_router
|
from mqtt.router import router as mqtt_router
|
||||||
|
from mqtt.auth import router as mqtt_auth_router
|
||||||
from equipment.router import router as equipment_router
|
from equipment.router import router as equipment_router
|
||||||
from staff.router import router as staff_router
|
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 firmware.router import router as firmware_router
|
||||||
from mqtt.client import mqtt_manager
|
from mqtt.client import mqtt_manager
|
||||||
from mqtt import database as mqtt_db
|
from mqtt import database as mqtt_db
|
||||||
from melodies import service as melody_service
|
from melodies import service as melody_service
|
||||||
@@ -38,10 +41,13 @@ app.include_router(devices_router)
|
|||||||
app.include_router(settings_router)
|
app.include_router(settings_router)
|
||||||
app.include_router(users_router)
|
app.include_router(users_router)
|
||||||
app.include_router(mqtt_router)
|
app.include_router(mqtt_router)
|
||||||
|
app.include_router(mqtt_auth_router)
|
||||||
app.include_router(equipment_router)
|
app.include_router(equipment_router)
|
||||||
app.include_router(helpdesk_router)
|
app.include_router(helpdesk_router)
|
||||||
app.include_router(staff_router)
|
app.include_router(staff_router)
|
||||||
app.include_router(builder_router)
|
app.include_router(builder_router)
|
||||||
|
app.include_router(manufacturing_router)
|
||||||
|
app.include_router(firmware_router)
|
||||||
|
|
||||||
|
|
||||||
@app.on_event("startup")
|
@app.on_event("startup")
|
||||||
|
|||||||
0
backend/manufacturing/__init__.py
Normal file
0
backend/manufacturing/__init__.py
Normal file
61
backend/manufacturing/models.py
Normal file
61
backend/manufacturing/models.py
Normal file
@@ -0,0 +1,61 @@
|
|||||||
|
from pydantic import BaseModel, Field
|
||||||
|
from typing import Optional, List
|
||||||
|
from enum import Enum
|
||||||
|
|
||||||
|
|
||||||
|
class BoardType(str, Enum):
|
||||||
|
vs = "vs" # Vesper
|
||||||
|
vp = "vp" # Vesper+
|
||||||
|
vx = "vx" # VesperPro
|
||||||
|
|
||||||
|
|
||||||
|
BOARD_TYPE_LABELS = {
|
||||||
|
"vs": "Vesper",
|
||||||
|
"vp": "Vesper+",
|
||||||
|
"vx": "VesperPro",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
class MfgStatus(str, Enum):
|
||||||
|
manufactured = "manufactured"
|
||||||
|
flashed = "flashed"
|
||||||
|
provisioned = "provisioned"
|
||||||
|
sold = "sold"
|
||||||
|
claimed = "claimed"
|
||||||
|
decommissioned = "decommissioned"
|
||||||
|
|
||||||
|
|
||||||
|
class BatchCreate(BaseModel):
|
||||||
|
board_type: BoardType
|
||||||
|
board_version: str = Field(..., pattern=r"^\d{2}$", description="2-digit zero-padded version, e.g. '01'")
|
||||||
|
quantity: int = Field(..., ge=1, le=100)
|
||||||
|
|
||||||
|
|
||||||
|
class BatchResponse(BaseModel):
|
||||||
|
batch_id: str
|
||||||
|
serial_numbers: List[str]
|
||||||
|
board_type: str
|
||||||
|
board_version: str
|
||||||
|
created_at: str
|
||||||
|
|
||||||
|
|
||||||
|
class DeviceInventoryItem(BaseModel):
|
||||||
|
id: str
|
||||||
|
serial_number: str
|
||||||
|
hw_type: str
|
||||||
|
hw_version: str
|
||||||
|
mfg_status: str
|
||||||
|
mfg_batch_id: Optional[str] = None
|
||||||
|
created_at: Optional[str] = None
|
||||||
|
owner: Optional[str] = None
|
||||||
|
assigned_to: Optional[str] = None
|
||||||
|
|
||||||
|
|
||||||
|
class DeviceInventoryListResponse(BaseModel):
|
||||||
|
devices: List[DeviceInventoryItem]
|
||||||
|
total: int
|
||||||
|
|
||||||
|
|
||||||
|
class DeviceStatusUpdate(BaseModel):
|
||||||
|
status: MfgStatus
|
||||||
|
note: Optional[str] = None
|
||||||
71
backend/manufacturing/router.py
Normal file
71
backend/manufacturing/router.py
Normal file
@@ -0,0 +1,71 @@
|
|||||||
|
from fastapi import APIRouter, Depends, Query
|
||||||
|
from fastapi.responses import Response
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
from auth.models import TokenPayload
|
||||||
|
from auth.dependencies import require_permission
|
||||||
|
from manufacturing.models import (
|
||||||
|
BatchCreate, BatchResponse,
|
||||||
|
DeviceInventoryItem, DeviceInventoryListResponse,
|
||||||
|
DeviceStatusUpdate,
|
||||||
|
)
|
||||||
|
from manufacturing import service
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/api/manufacturing", tags=["manufacturing"])
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/batch", response_model=BatchResponse, status_code=201)
|
||||||
|
def create_batch(
|
||||||
|
body: BatchCreate,
|
||||||
|
_user: TokenPayload = Depends(require_permission("manufacturing", "add")),
|
||||||
|
):
|
||||||
|
return service.create_batch(body)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/devices", response_model=DeviceInventoryListResponse)
|
||||||
|
def list_devices(
|
||||||
|
status: Optional[str] = Query(None),
|
||||||
|
hw_type: Optional[str] = Query(None),
|
||||||
|
search: Optional[str] = Query(None),
|
||||||
|
limit: int = Query(100, ge=1, le=500),
|
||||||
|
offset: int = Query(0, ge=0),
|
||||||
|
_user: TokenPayload = Depends(require_permission("manufacturing", "view")),
|
||||||
|
):
|
||||||
|
items = service.list_devices(
|
||||||
|
status=status,
|
||||||
|
hw_type=hw_type,
|
||||||
|
search=search,
|
||||||
|
limit=limit,
|
||||||
|
offset=offset,
|
||||||
|
)
|
||||||
|
return DeviceInventoryListResponse(devices=items, total=len(items))
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/devices/{sn}", response_model=DeviceInventoryItem)
|
||||||
|
def get_device(
|
||||||
|
sn: str,
|
||||||
|
_user: TokenPayload = Depends(require_permission("manufacturing", "view")),
|
||||||
|
):
|
||||||
|
return service.get_device_by_sn(sn)
|
||||||
|
|
||||||
|
|
||||||
|
@router.patch("/devices/{sn}/status", response_model=DeviceInventoryItem)
|
||||||
|
def update_status(
|
||||||
|
sn: str,
|
||||||
|
body: DeviceStatusUpdate,
|
||||||
|
_user: TokenPayload = Depends(require_permission("manufacturing", "edit")),
|
||||||
|
):
|
||||||
|
return service.update_device_status(sn, body)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/devices/{sn}/nvs.bin")
|
||||||
|
def download_nvs(
|
||||||
|
sn: str,
|
||||||
|
_user: TokenPayload = Depends(require_permission("manufacturing", "view")),
|
||||||
|
):
|
||||||
|
binary = service.get_nvs_binary(sn)
|
||||||
|
return Response(
|
||||||
|
content=binary,
|
||||||
|
media_type="application/octet-stream",
|
||||||
|
headers={"Content-Disposition": f'attachment; filename="{sn}_nvs.bin"'},
|
||||||
|
)
|
||||||
153
backend/manufacturing/service.py
Normal file
153
backend/manufacturing/service.py
Normal file
@@ -0,0 +1,153 @@
|
|||||||
|
import random
|
||||||
|
import string
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
|
||||||
|
from shared.firebase import get_db
|
||||||
|
from shared.exceptions import NotFoundError
|
||||||
|
from utils.serial_number import generate_serial
|
||||||
|
from utils.nvs_generator import generate as generate_nvs_binary
|
||||||
|
from manufacturing.models import BatchCreate, BatchResponse, DeviceInventoryItem, DeviceStatusUpdate
|
||||||
|
|
||||||
|
COLLECTION = "devices"
|
||||||
|
_BATCH_ID_CHARS = string.ascii_uppercase + string.digits
|
||||||
|
|
||||||
|
|
||||||
|
def _make_batch_id() -> str:
|
||||||
|
today = datetime.utcnow().strftime("%y%m%d")
|
||||||
|
suffix = "".join(random.choices(_BATCH_ID_CHARS, k=4))
|
||||||
|
return f"BATCH-{today}-{suffix}"
|
||||||
|
|
||||||
|
|
||||||
|
def _get_existing_sns(db) -> set:
|
||||||
|
existing = set()
|
||||||
|
for doc in db.collection(COLLECTION).select(["serial_number"]).stream():
|
||||||
|
data = doc.to_dict()
|
||||||
|
sn = data.get("serial_number")
|
||||||
|
if sn:
|
||||||
|
existing.add(sn)
|
||||||
|
return existing
|
||||||
|
|
||||||
|
|
||||||
|
def _doc_to_inventory_item(doc) -> DeviceInventoryItem:
|
||||||
|
data = doc.to_dict() or {}
|
||||||
|
created_raw = data.get("created_at")
|
||||||
|
if isinstance(created_raw, datetime):
|
||||||
|
created_str = created_raw.strftime("%Y-%m-%dT%H:%M:%SZ")
|
||||||
|
else:
|
||||||
|
created_str = str(created_raw) if created_raw else None
|
||||||
|
|
||||||
|
return DeviceInventoryItem(
|
||||||
|
id=doc.id,
|
||||||
|
serial_number=data.get("serial_number", ""),
|
||||||
|
hw_type=data.get("hw_type", ""),
|
||||||
|
hw_version=data.get("hw_version", ""),
|
||||||
|
mfg_status=data.get("mfg_status", "manufactured"),
|
||||||
|
mfg_batch_id=data.get("mfg_batch_id"),
|
||||||
|
created_at=created_str,
|
||||||
|
owner=data.get("owner"),
|
||||||
|
assigned_to=data.get("assigned_to"),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def create_batch(data: BatchCreate) -> BatchResponse:
|
||||||
|
db = get_db()
|
||||||
|
existing_sns = _get_existing_sns(db)
|
||||||
|
batch_id = _make_batch_id()
|
||||||
|
now = datetime.now(timezone.utc)
|
||||||
|
serial_numbers = []
|
||||||
|
|
||||||
|
for _ in range(data.quantity):
|
||||||
|
for attempt in range(200):
|
||||||
|
sn = generate_serial(data.board_type.value, data.board_version)
|
||||||
|
if sn not in existing_sns:
|
||||||
|
existing_sns.add(sn)
|
||||||
|
break
|
||||||
|
else:
|
||||||
|
raise RuntimeError("Could not generate unique serial numbers — collision limit hit")
|
||||||
|
|
||||||
|
db.collection(COLLECTION).add({
|
||||||
|
"serial_number": sn,
|
||||||
|
"hw_type": data.board_type.value,
|
||||||
|
"hw_version": data.board_version,
|
||||||
|
"mfg_status": "manufactured",
|
||||||
|
"mfg_batch_id": batch_id,
|
||||||
|
"created_at": now,
|
||||||
|
"owner": None,
|
||||||
|
"assigned_to": None,
|
||||||
|
"users_list": [],
|
||||||
|
# Legacy fields left empty so existing device views don't break
|
||||||
|
"device_name": "",
|
||||||
|
"device_location": "",
|
||||||
|
"is_Online": False,
|
||||||
|
})
|
||||||
|
serial_numbers.append(sn)
|
||||||
|
|
||||||
|
return BatchResponse(
|
||||||
|
batch_id=batch_id,
|
||||||
|
serial_numbers=serial_numbers,
|
||||||
|
board_type=data.board_type.value,
|
||||||
|
board_version=data.board_version,
|
||||||
|
created_at=now.strftime("%Y-%m-%dT%H:%M:%SZ"),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def list_devices(
|
||||||
|
status: str | None = None,
|
||||||
|
hw_type: str | None = None,
|
||||||
|
search: str | None = None,
|
||||||
|
limit: int = 100,
|
||||||
|
offset: int = 0,
|
||||||
|
) -> list[DeviceInventoryItem]:
|
||||||
|
db = get_db()
|
||||||
|
query = db.collection(COLLECTION)
|
||||||
|
|
||||||
|
if status:
|
||||||
|
query = query.where("mfg_status", "==", status)
|
||||||
|
if hw_type:
|
||||||
|
query = query.where("hw_type", "==", hw_type)
|
||||||
|
|
||||||
|
docs = list(query.stream())
|
||||||
|
items = [_doc_to_inventory_item(doc) for doc in docs]
|
||||||
|
|
||||||
|
if search:
|
||||||
|
search_lower = search.lower()
|
||||||
|
items = [
|
||||||
|
item for item in items
|
||||||
|
if search_lower in (item.serial_number or "").lower()
|
||||||
|
or search_lower in (item.owner or "").lower()
|
||||||
|
or search_lower in (item.mfg_batch_id or "").lower()
|
||||||
|
]
|
||||||
|
|
||||||
|
return items[offset: offset + limit]
|
||||||
|
|
||||||
|
|
||||||
|
def get_device_by_sn(sn: str) -> DeviceInventoryItem:
|
||||||
|
db = get_db()
|
||||||
|
docs = list(db.collection(COLLECTION).where("serial_number", "==", sn).limit(1).stream())
|
||||||
|
if not docs:
|
||||||
|
raise NotFoundError("Device")
|
||||||
|
return _doc_to_inventory_item(docs[0])
|
||||||
|
|
||||||
|
|
||||||
|
def update_device_status(sn: str, data: DeviceStatusUpdate) -> DeviceInventoryItem:
|
||||||
|
db = get_db()
|
||||||
|
docs = list(db.collection(COLLECTION).where("serial_number", "==", sn).limit(1).stream())
|
||||||
|
if not docs:
|
||||||
|
raise NotFoundError("Device")
|
||||||
|
|
||||||
|
doc_ref = docs[0].reference
|
||||||
|
update = {"mfg_status": data.status.value}
|
||||||
|
if data.note:
|
||||||
|
update["mfg_status_note"] = data.note
|
||||||
|
doc_ref.update(update)
|
||||||
|
|
||||||
|
return _doc_to_inventory_item(doc_ref.get())
|
||||||
|
|
||||||
|
|
||||||
|
def get_nvs_binary(sn: str) -> bytes:
|
||||||
|
item = get_device_by_sn(sn)
|
||||||
|
return generate_nvs_binary(
|
||||||
|
serial_number=item.serial_number,
|
||||||
|
hw_type=item.hw_type,
|
||||||
|
hw_version=item.hw_version,
|
||||||
|
)
|
||||||
126
backend/mqtt/auth.py
Normal file
126
backend/mqtt/auth.py
Normal file
@@ -0,0 +1,126 @@
|
|||||||
|
"""
|
||||||
|
MQTT authentication and ACL endpoints for mosquitto-go-auth HTTP backend.
|
||||||
|
|
||||||
|
Mosquitto calls these on every CONNECT, SUBSCRIBE, and PUBLISH.
|
||||||
|
- POST /mqtt/auth/user → validate device credentials
|
||||||
|
- POST /mqtt/auth/acl → enforce per-device topic isolation
|
||||||
|
|
||||||
|
Password strategy: HMAC-SHA256(MQTT_SECRET, username)[:32]
|
||||||
|
- Deterministic: no storage needed, re-derive on every auth check
|
||||||
|
- Rotating MQTT_SECRET invalidates all passwords at once if needed
|
||||||
|
|
||||||
|
Transition support: during rollout, the legacy password "vesper" is also
|
||||||
|
accepted so that devices still on old firmware stay connected.
|
||||||
|
|
||||||
|
User types handled:
|
||||||
|
- Device users (e.g. "PV25L22BP01R01", "PV-26A18-BC02R-X7KQA"):
|
||||||
|
Authenticated via HMAC. ACL restricted to their own vesper/{sn}/... topics.
|
||||||
|
- Kiosk users (e.g. "PV25L22BP01R01-kiosk"):
|
||||||
|
Same HMAC auth derived from the full kiosk username.
|
||||||
|
ACL: allowed to access topics of their base device (suffix stripped).
|
||||||
|
- bonamin, NodeRED, and other non-device users:
|
||||||
|
These connect via the passwd file backend (go-auth file backend).
|
||||||
|
They never reach this HTTP backend — go-auth resolves them first.
|
||||||
|
The ACL endpoint below handles them defensively anyway (superuser list).
|
||||||
|
"""
|
||||||
|
|
||||||
|
import hmac
|
||||||
|
import hashlib
|
||||||
|
from fastapi import APIRouter, Form, Response
|
||||||
|
from config import settings
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/mqtt/auth", tags=["mqtt-auth"])
|
||||||
|
|
||||||
|
LEGACY_PASSWORD = "vesper"
|
||||||
|
|
||||||
|
# Users authenticated via passwd file (go-auth file backend).
|
||||||
|
# If they somehow reach the HTTP ACL endpoint, grant full access.
|
||||||
|
SUPERUSERS = {"bonamin", "NodeRED"}
|
||||||
|
|
||||||
|
|
||||||
|
def _derive_password(username: str) -> str:
|
||||||
|
"""Derive the expected MQTT password for a given username."""
|
||||||
|
return hmac.new(
|
||||||
|
settings.mqtt_secret.encode(),
|
||||||
|
username.encode(),
|
||||||
|
hashlib.sha256,
|
||||||
|
).hexdigest()[:32]
|
||||||
|
|
||||||
|
|
||||||
|
def _is_valid_password(username: str, password: str) -> bool:
|
||||||
|
"""
|
||||||
|
Accept the password if it matches either:
|
||||||
|
- The HMAC-derived password (new firmware)
|
||||||
|
- The legacy hardcoded "vesper" password (old firmware, transition period)
|
||||||
|
|
||||||
|
Remove the legacy check in Stage 7 once all devices are on new firmware.
|
||||||
|
"""
|
||||||
|
expected = _derive_password(username)
|
||||||
|
hmac_ok = hmac.compare_digest(expected, password)
|
||||||
|
legacy_ok = hmac.compare_digest(LEGACY_PASSWORD, password)
|
||||||
|
return hmac_ok or legacy_ok
|
||||||
|
|
||||||
|
|
||||||
|
def _base_sn(username: str) -> str:
|
||||||
|
"""
|
||||||
|
Strip the -kiosk suffix if present, returning the base serial number.
|
||||||
|
e.g. "PV25L22BP01R01-kiosk" -> "PV25L22BP01R01"
|
||||||
|
"PV25L22BP01R01" -> "PV25L22BP01R01"
|
||||||
|
"""
|
||||||
|
if username.endswith("-kiosk"):
|
||||||
|
return username[: -len("-kiosk")]
|
||||||
|
return username
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/user")
|
||||||
|
async def mqtt_auth_user(
|
||||||
|
username: str = Form(...),
|
||||||
|
password: str = Form(...),
|
||||||
|
clientid: str = Form(default=""),
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
Called by Mosquitto on every CONNECT.
|
||||||
|
Returns 200 to allow, 403 to deny.
|
||||||
|
|
||||||
|
Username = device SN (new format: "PV-26A18-BC02R-X7KQA", old format: "PV25L22BP01R01")
|
||||||
|
or kiosk variant: "PV25L22BP01R01-kiosk"
|
||||||
|
Password = HMAC-derived (new firmware) or "vesper" (legacy firmware)
|
||||||
|
|
||||||
|
Note: bonamin and NodeRED authenticate via the go-auth passwd file backend
|
||||||
|
and never reach this endpoint.
|
||||||
|
"""
|
||||||
|
if _is_valid_password(username, password):
|
||||||
|
return Response(status_code=200)
|
||||||
|
|
||||||
|
return Response(status_code=403)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/acl")
|
||||||
|
async def mqtt_auth_acl(
|
||||||
|
username: str = Form(...),
|
||||||
|
topic: str = Form(...),
|
||||||
|
clientid: str = Form(default=""),
|
||||||
|
acc: int = Form(...), # 1 = subscribe, 2 = publish, 3 = subscribe+publish
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
Called by Mosquitto on every SUBSCRIBE and PUBLISH.
|
||||||
|
Returns 200 to allow, 403 to deny.
|
||||||
|
|
||||||
|
Topic pattern: vesper/{sn}/...
|
||||||
|
- Device users: may only access their own SN segment
|
||||||
|
- Kiosk users: stripped of -kiosk suffix, then same rule applies
|
||||||
|
- Superusers (bonamin, NodeRED): full access
|
||||||
|
"""
|
||||||
|
# Superusers get full access (shouldn't reach here but handled defensively)
|
||||||
|
if username in SUPERUSERS:
|
||||||
|
return Response(status_code=200)
|
||||||
|
|
||||||
|
# Derive the base SN (handles -kiosk suffix)
|
||||||
|
base = _base_sn(username)
|
||||||
|
|
||||||
|
# Topic must be vesper/{base_sn}/...
|
||||||
|
parts = topic.split("/")
|
||||||
|
if len(parts) >= 2 and parts[0] == "vesper" and parts[1] == base:
|
||||||
|
return Response(status_code=200)
|
||||||
|
|
||||||
|
return Response(status_code=403)
|
||||||
@@ -1,52 +1,17 @@
|
|||||||
import subprocess
|
"""
|
||||||
import os
|
mqtt/mosquitto.py — no-ops since Stage 5.
|
||||||
from config import settings
|
|
||||||
|
Auth is now HMAC-based via the go-auth HTTP plugin.
|
||||||
|
These functions are kept as no-ops so existing call sites don't break.
|
||||||
|
They can be removed entirely in Phase 6 cleanup.
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
def register_device_password(serial_number: str, password: str) -> bool:
|
def register_device_password(serial_number: str, password: str) -> bool:
|
||||||
"""Register a device in the Mosquitto password file.
|
"""No-op. HMAC auth is derived on demand — no registration needed."""
|
||||||
|
return True
|
||||||
Uses mosquitto_passwd to add/update the device credentials.
|
|
||||||
The serial number is used as the MQTT username.
|
|
||||||
Returns True on success, False on failure.
|
|
||||||
"""
|
|
||||||
passwd_file = settings.mosquitto_password_file
|
|
||||||
|
|
||||||
# Ensure the password file exists
|
|
||||||
if not os.path.exists(passwd_file):
|
|
||||||
# Create the file if it doesn't exist
|
|
||||||
os.makedirs(os.path.dirname(passwd_file), exist_ok=True)
|
|
||||||
open(passwd_file, "a").close()
|
|
||||||
|
|
||||||
try:
|
|
||||||
# Use mosquitto_passwd with -b flag (batch mode) to set password
|
|
||||||
result = subprocess.run(
|
|
||||||
["mosquitto_passwd", "-b", passwd_file, serial_number, password],
|
|
||||||
capture_output=True,
|
|
||||||
text=True,
|
|
||||||
timeout=10,
|
|
||||||
)
|
|
||||||
return result.returncode == 0
|
|
||||||
except (subprocess.TimeoutExpired, FileNotFoundError) as e:
|
|
||||||
print(f"[WARNING] Mosquitto password registration failed: {e}")
|
|
||||||
return False
|
|
||||||
|
|
||||||
|
|
||||||
def remove_device_password(serial_number: str) -> bool:
|
def remove_device_password(serial_number: str) -> bool:
|
||||||
"""Remove a device from the Mosquitto password file."""
|
"""No-op. HMAC auth is derived on demand — no removal needed."""
|
||||||
passwd_file = settings.mosquitto_password_file
|
return True
|
||||||
|
|
||||||
if not os.path.exists(passwd_file):
|
|
||||||
return True
|
|
||||||
|
|
||||||
try:
|
|
||||||
result = subprocess.run(
|
|
||||||
["mosquitto_passwd", "-D", passwd_file, serial_number],
|
|
||||||
capture_output=True,
|
|
||||||
text=True,
|
|
||||||
timeout=10,
|
|
||||||
)
|
|
||||||
return result.returncode == 0
|
|
||||||
except (subprocess.TimeoutExpired, FileNotFoundError) as e:
|
|
||||||
print(f"[WARNING] Mosquitto password removal failed: {e}")
|
|
||||||
return False
|
|
||||||
|
|||||||
0
backend/utils/__init__.py
Normal file
0
backend/utils/__init__.py
Normal file
205
backend/utils/nvs_generator.py
Normal file
205
backend/utils/nvs_generator.py
Normal file
@@ -0,0 +1,205 @@
|
|||||||
|
"""
|
||||||
|
Pure-Python ESP32 NVS partition binary generator.
|
||||||
|
|
||||||
|
Generates a binary-compatible NVS partition for a Vesper device identity.
|
||||||
|
No ESP-IDF toolchain required on the server.
|
||||||
|
|
||||||
|
NVS partition layout (ESP32 NVS format v2):
|
||||||
|
- Partition size: 0x5000 (20480 bytes) = 5 pages
|
||||||
|
- Page size: 4096 bytes
|
||||||
|
- Page structure:
|
||||||
|
Offset 0x000 - 0x01F : Page header (32 bytes)
|
||||||
|
Offset 0x020 - 0x03F : Entry state bitmap (32 bytes, 2 bits per slot)
|
||||||
|
Offset 0x040 - 0xFFF : Entry storage (120 slots × 32 bytes each)
|
||||||
|
|
||||||
|
Entry state bitmap: 2 bits per entry
|
||||||
|
11 = empty
|
||||||
|
10 = written (active)
|
||||||
|
00 = erased
|
||||||
|
|
||||||
|
Page header (32 bytes):
|
||||||
|
uint32 page_state (0xFFFFFFFE = active)
|
||||||
|
uint32 sequence_number
|
||||||
|
uint8 version (0xFE = v2)
|
||||||
|
uint8 reserved[19]
|
||||||
|
uint32 crc32 (of bytes 4..27)
|
||||||
|
|
||||||
|
Entry (32 bytes):
|
||||||
|
uint8 ns_index (namespace index, 0 = namespace entry itself)
|
||||||
|
uint8 type (0x01=uint8, 0x02=uint16, 0x04=uint32, 0x08=uint64, 0x21=string, 0x41=blob)
|
||||||
|
uint8 span (number of 32-byte slots this entry occupies)
|
||||||
|
uint8 chunk_index (0xFF for non-blob)
|
||||||
|
uint32 crc32 (of the entry header bytes 0..3 and data, excluding the crc field itself)
|
||||||
|
char key[16] (null-terminated, max 15 chars + null)
|
||||||
|
<data> [8 bytes for primitives, or inline for short strings]
|
||||||
|
|
||||||
|
For strings:
|
||||||
|
- If len <= 8 bytes (incl. null): fits in the data field of the same entry (span=1)
|
||||||
|
- Longer strings: data follows in subsequent 32-byte "data entries" (span = 1 + ceil(strlen+1, 32))
|
||||||
|
- The entry header data field contains: uint16 data_size, uint16 reserved=0xFFFF, uint32 crc32_of_data
|
||||||
|
"""
|
||||||
|
|
||||||
|
import struct
|
||||||
|
import binascii
|
||||||
|
from typing import List, Tuple
|
||||||
|
|
||||||
|
|
||||||
|
NVS_PAGE_SIZE = 4096
|
||||||
|
NVS_PARTITION_SIZE = 0x5000 # 20480 bytes = 5 pages
|
||||||
|
NVS_ENTRY_SIZE = 32
|
||||||
|
NVS_ENTRY_COUNT = 126 # entries per page (first 3 slots are header + bitmap)
|
||||||
|
|
||||||
|
NVS_PAGE_STATE_ACTIVE = 0xFFFFFFFE
|
||||||
|
NVS_PAGE_VERSION = 0xFE
|
||||||
|
|
||||||
|
ENTRY_STATE_WRITTEN = 0b10 # 2 bits
|
||||||
|
ENTRY_STATE_EMPTY = 0b11 # 2 bits (erased flash)
|
||||||
|
|
||||||
|
ENTRY_TYPE_NAMESPACE = 0x01 # used for namespace entries (uint8)
|
||||||
|
ENTRY_TYPE_STRING = 0x21
|
||||||
|
|
||||||
|
|
||||||
|
def _crc32(data: bytes) -> int:
|
||||||
|
return binascii.crc32(data) & 0xFFFFFFFF
|
||||||
|
|
||||||
|
|
||||||
|
def _page_header_crc(seq: int, version: int) -> int:
|
||||||
|
"""CRC covers bytes 4..27 of the page header (seq + version + reserved)."""
|
||||||
|
buf = struct.pack("<IB", seq, version) + b"\xFF" * 19
|
||||||
|
return _crc32(buf)
|
||||||
|
|
||||||
|
|
||||||
|
def _entry_crc(ns_index: int, entry_type: int, span: int, chunk_index: int,
|
||||||
|
key: bytes, data: bytes) -> int:
|
||||||
|
"""CRC covers the entry minus the 4-byte crc field at offset 4..7."""
|
||||||
|
header_no_crc = struct.pack("BBBB", ns_index, entry_type, span, chunk_index)
|
||||||
|
return _crc32(header_no_crc + key + data)
|
||||||
|
|
||||||
|
|
||||||
|
def _pack_entry(ns_index: int, entry_type: int, span: int, chunk_index: int,
|
||||||
|
key: str, data: bytes) -> bytes:
|
||||||
|
key_bytes = key.encode("ascii").ljust(16, b"\x00")[:16]
|
||||||
|
data_bytes = data.ljust(8, b"\xFF")[:8]
|
||||||
|
crc = _entry_crc(ns_index, entry_type, span, chunk_index, key_bytes, data_bytes)
|
||||||
|
return struct.pack("BBBBI", ns_index, entry_type, span, chunk_index, crc) + key_bytes + data_bytes
|
||||||
|
|
||||||
|
|
||||||
|
def _bitmap_set_written(bitmap: bytearray, slot_index: int) -> None:
|
||||||
|
"""Mark a slot as written (10) in the entry state bitmap."""
|
||||||
|
bit_pos = slot_index * 2
|
||||||
|
byte_idx = bit_pos // 8
|
||||||
|
bit_off = bit_pos % 8
|
||||||
|
# Clear both bits for this slot (set to 00 then OR in 10)
|
||||||
|
bitmap[byte_idx] &= ~(0b11 << bit_off)
|
||||||
|
bitmap[byte_idx] |= (ENTRY_STATE_WRITTEN << bit_off)
|
||||||
|
|
||||||
|
|
||||||
|
def _build_namespace_entry(ns_name: str, ns_index: int) -> Tuple[bytes, int]:
|
||||||
|
"""Build the namespace declaration entry. ns_index is the assigned namespace id (1-based)."""
|
||||||
|
data = struct.pack("<B", ns_index) + b"\xFF" * 7
|
||||||
|
entry = _pack_entry(
|
||||||
|
ns_index=0,
|
||||||
|
entry_type=ENTRY_TYPE_NAMESPACE,
|
||||||
|
span=1,
|
||||||
|
chunk_index=0xFF,
|
||||||
|
key=ns_name,
|
||||||
|
data=data,
|
||||||
|
)
|
||||||
|
return entry, 1 # consumes 1 slot
|
||||||
|
|
||||||
|
|
||||||
|
def _build_string_entry(ns_index: int, key: str, value: str) -> Tuple[bytes, int]:
|
||||||
|
"""Build a string entry. May span multiple 32-byte slots for long strings."""
|
||||||
|
value_bytes = value.encode("utf-8") + b"\x00" # null-terminated
|
||||||
|
value_len = len(value_bytes)
|
||||||
|
|
||||||
|
# Pad to multiple of 32
|
||||||
|
padded_len = ((value_len + 31) // 32) * 32
|
||||||
|
value_padded = value_bytes.ljust(padded_len, b"\xFF")
|
||||||
|
|
||||||
|
span = 1 + (padded_len // 32)
|
||||||
|
|
||||||
|
# Data field in the header entry: uint16 data_size, uint16 0xFFFF, uint32 crc_of_data
|
||||||
|
data_crc = _crc32(value_bytes)
|
||||||
|
header_data = struct.pack("<HHI", value_len, 0xFFFF, data_crc)
|
||||||
|
|
||||||
|
entry = _pack_entry(
|
||||||
|
ns_index=ns_index,
|
||||||
|
entry_type=ENTRY_TYPE_STRING,
|
||||||
|
span=span,
|
||||||
|
chunk_index=0xFF,
|
||||||
|
key=key,
|
||||||
|
data=header_data,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Append data chunks (each 32 bytes)
|
||||||
|
full_entry = entry + value_padded
|
||||||
|
return full_entry, span
|
||||||
|
|
||||||
|
|
||||||
|
def _build_page(entries: List[bytes], slot_counts: List[int], seq: int = 0) -> bytes:
|
||||||
|
"""Assemble a full 4096-byte NVS page."""
|
||||||
|
# Build entry storage area
|
||||||
|
storage = bytearray(NVS_ENTRY_COUNT * NVS_ENTRY_SIZE) # all 0xFF (erased)
|
||||||
|
storage[:] = b"\xFF" * len(storage)
|
||||||
|
bitmap = bytearray(b"\xFF" * 32) # all slots empty (11 bits)
|
||||||
|
|
||||||
|
slot = 0
|
||||||
|
for entry_bytes, span in zip(entries, slot_counts):
|
||||||
|
entry_offset = slot * NVS_ENTRY_SIZE
|
||||||
|
storage[entry_offset:entry_offset + len(entry_bytes)] = entry_bytes
|
||||||
|
for s in range(span):
|
||||||
|
_bitmap_set_written(bitmap, slot + s)
|
||||||
|
slot += span
|
||||||
|
|
||||||
|
# Page header
|
||||||
|
header_crc = _page_header_crc(seq, NVS_PAGE_VERSION)
|
||||||
|
header = struct.pack(
|
||||||
|
"<IIBI19sI",
|
||||||
|
NVS_PAGE_STATE_ACTIVE,
|
||||||
|
seq,
|
||||||
|
NVS_PAGE_VERSION,
|
||||||
|
0, # padding
|
||||||
|
b"\xFF" * 19,
|
||||||
|
header_crc,
|
||||||
|
)
|
||||||
|
# Trim header to exactly 32 bytes
|
||||||
|
header = struct.pack("<I", NVS_PAGE_STATE_ACTIVE)
|
||||||
|
header += struct.pack("<I", seq)
|
||||||
|
header += struct.pack("<B", NVS_PAGE_VERSION)
|
||||||
|
header += b"\xFF" * 19
|
||||||
|
header += struct.pack("<I", header_crc)
|
||||||
|
assert len(header) == 32, f"Header size mismatch: {len(header)}"
|
||||||
|
|
||||||
|
page = header + bytes(bitmap) + bytes(storage)
|
||||||
|
assert len(page) == NVS_PAGE_SIZE, f"Page size mismatch: {len(page)}"
|
||||||
|
return page
|
||||||
|
|
||||||
|
|
||||||
|
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: lowercase board type e.g. 'vs', 'vp', 'vx'
|
||||||
|
hw_version: zero-padded version e.g. '01'
|
||||||
|
|
||||||
|
Returns raw bytes ready to flash at 0x9000.
|
||||||
|
"""
|
||||||
|
ns_index = 1 # first (and only) namespace
|
||||||
|
|
||||||
|
# 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)
|
||||||
|
|
||||||
|
entries = [ns_entry, uid_entry, hwt_entry, hwv_entry]
|
||||||
|
spans = [ns_span, uid_span, hwt_span, hwv_span]
|
||||||
|
|
||||||
|
page0 = _build_page(entries, spans, seq=0)
|
||||||
|
|
||||||
|
# Remaining pages are blank (erased flash = 0xFF)
|
||||||
|
blank_page = b"\xFF" * NVS_PAGE_SIZE
|
||||||
|
remaining_pages = (NVS_PARTITION_SIZE // NVS_PAGE_SIZE) - 1
|
||||||
|
|
||||||
|
return page0 + blank_page * remaining_pages
|
||||||
19
backend/utils/serial_number.py
Normal file
19
backend/utils/serial_number.py
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
import random
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
MONTH_CODES = "ABCDEFGHIJKL"
|
||||||
|
SAFE_CHARS = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789" # No 0, O, 1, I — avoids label confusion
|
||||||
|
|
||||||
|
|
||||||
|
def generate_serial(board_type: str, board_version: str) -> str:
|
||||||
|
"""Generate a serial number in the format PV-YYMMM-BBTTR-XXXXX.
|
||||||
|
|
||||||
|
board_type: 2-char uppercase code, e.g. 'VS', 'VP', 'VX'
|
||||||
|
board_version: 2-char zero-padded version, e.g. '01'
|
||||||
|
"""
|
||||||
|
now = datetime.utcnow()
|
||||||
|
year = now.strftime("%y")
|
||||||
|
month = MONTH_CODES[now.month - 1]
|
||||||
|
day = now.strftime("%d")
|
||||||
|
suffix = "".join(random.choices(SAFE_CHARS, k=5))
|
||||||
|
return f"PV-{year}{month}{day}-{board_type.upper()}{board_version}R-{suffix}"
|
||||||
@@ -8,6 +8,7 @@ services:
|
|||||||
# Persistent data - lives outside the container
|
# Persistent data - lives outside the container
|
||||||
- ./data/mqtt_data.db:/app/mqtt_data.db
|
- ./data/mqtt_data.db:/app/mqtt_data.db
|
||||||
- ./data/built_melodies:/app/storage/built_melodies
|
- ./data/built_melodies:/app/storage/built_melodies
|
||||||
|
- ./data/firmware:/app/storage/firmware
|
||||||
- ./data/firebase-service-account.json:/app/firebase-service-account.json:ro
|
- ./data/firebase-service-account.json:/app/firebase-service-account.json:ro
|
||||||
ports:
|
ports:
|
||||||
- "8000:8000"
|
- "8000:8000"
|
||||||
@@ -26,7 +27,7 @@ services:
|
|||||||
image: nginx:alpine
|
image: nginx:alpine
|
||||||
container_name: bellsystems-nginx
|
container_name: bellsystems-nginx
|
||||||
ports:
|
ports:
|
||||||
- "80:80"
|
- "${NGINX_PORT:-80}:80"
|
||||||
volumes:
|
volumes:
|
||||||
- ./nginx/nginx.conf:/etc/nginx/nginx.conf:ro
|
- ./nginx/nginx.conf:/etc/nginx/nginx.conf:ro
|
||||||
depends_on:
|
depends_on:
|
||||||
|
|||||||
@@ -24,6 +24,10 @@ import NoteForm from "./equipment/NoteForm";
|
|||||||
import StaffList from "./settings/StaffList";
|
import StaffList from "./settings/StaffList";
|
||||||
import StaffDetail from "./settings/StaffDetail";
|
import StaffDetail from "./settings/StaffDetail";
|
||||||
import StaffForm from "./settings/StaffForm";
|
import StaffForm from "./settings/StaffForm";
|
||||||
|
import DeviceInventory from "./manufacturing/DeviceInventory";
|
||||||
|
import BatchCreator from "./manufacturing/BatchCreator";
|
||||||
|
import DeviceInventoryDetail from "./manufacturing/DeviceInventoryDetail";
|
||||||
|
import FirmwareManager from "./firmware/FirmwareManager";
|
||||||
|
|
||||||
function ProtectedRoute({ children }) {
|
function ProtectedRoute({ children }) {
|
||||||
const { user, loading } = useAuth();
|
const { user, loading } = useAuth();
|
||||||
@@ -149,6 +153,12 @@ export default function App() {
|
|||||||
<Route path="equipment/notes/:id" element={<PermissionGate section="equipment"><NoteDetail /></PermissionGate>} />
|
<Route path="equipment/notes/:id" element={<PermissionGate section="equipment"><NoteDetail /></PermissionGate>} />
|
||||||
<Route path="equipment/notes/:id/edit" element={<PermissionGate section="equipment" action="edit"><NoteForm /></PermissionGate>} />
|
<Route path="equipment/notes/:id/edit" element={<PermissionGate section="equipment" action="edit"><NoteForm /></PermissionGate>} />
|
||||||
|
|
||||||
|
{/* Manufacturing */}
|
||||||
|
<Route path="manufacturing" element={<PermissionGate section="manufacturing"><DeviceInventory /></PermissionGate>} />
|
||||||
|
<Route path="manufacturing/batch/new" element={<PermissionGate section="manufacturing" action="add"><BatchCreator /></PermissionGate>} />
|
||||||
|
<Route path="manufacturing/devices/:sn" element={<PermissionGate section="manufacturing"><DeviceInventoryDetail /></PermissionGate>} />
|
||||||
|
<Route path="firmware" element={<PermissionGate section="manufacturing"><FirmwareManager /></PermissionGate>} />
|
||||||
|
|
||||||
{/* Settings - Staff Management */}
|
{/* Settings - Staff Management */}
|
||||||
<Route path="settings/staff" element={<RoleGate roles={["sysadmin", "admin"]}><StaffList /></RoleGate>} />
|
<Route path="settings/staff" element={<RoleGate roles={["sysadmin", "admin"]}><StaffList /></RoleGate>} />
|
||||||
<Route path="settings/staff/new" element={<RoleGate roles={["sysadmin", "admin"]}><StaffForm /></RoleGate>} />
|
<Route path="settings/staff/new" element={<RoleGate roles={["sysadmin", "admin"]}><StaffForm /></RoleGate>} />
|
||||||
|
|||||||
488
frontend/src/firmware/FirmwareManager.jsx
Normal file
488
frontend/src/firmware/FirmwareManager.jsx
Normal file
@@ -0,0 +1,488 @@
|
|||||||
|
import { useState, useEffect, useRef } from "react";
|
||||||
|
import { useAuth } from "../auth/AuthContext";
|
||||||
|
import api from "../api/client";
|
||||||
|
|
||||||
|
const BOARD_TYPES = [
|
||||||
|
{ value: "vs", label: "Vesper (VS)" },
|
||||||
|
{ value: "vp", label: "Vesper+ (VP)" },
|
||||||
|
{ value: "vx", label: "VesperPro (VX)" },
|
||||||
|
];
|
||||||
|
|
||||||
|
const CHANNELS = ["stable", "beta", "alpha", "testing"];
|
||||||
|
|
||||||
|
const CHANNEL_STYLES = {
|
||||||
|
stable: { bg: "var(--success-bg)", color: "var(--success-text)" },
|
||||||
|
beta: { bg: "var(--badge-blue-bg)", color: "var(--badge-blue-text)" },
|
||||||
|
alpha: { bg: "#2e1a00", color: "#fb923c" },
|
||||||
|
testing: { bg: "var(--bg-card-hover)", color: "var(--text-muted)" },
|
||||||
|
};
|
||||||
|
|
||||||
|
function ChannelBadge({ channel }) {
|
||||||
|
const style = CHANNEL_STYLES[channel] || CHANNEL_STYLES.testing;
|
||||||
|
return (
|
||||||
|
<span
|
||||||
|
className="px-2 py-0.5 text-xs rounded-full capitalize font-medium"
|
||||||
|
style={{ backgroundColor: style.bg, color: style.color }}
|
||||||
|
>
|
||||||
|
{channel}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatBytes(bytes) {
|
||||||
|
if (!bytes) return "—";
|
||||||
|
if (bytes < 1024) return `${bytes} B`;
|
||||||
|
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
|
||||||
|
return `${(bytes / (1024 * 1024)).toFixed(2)} MB`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatDate(iso) {
|
||||||
|
if (!iso) return "—";
|
||||||
|
try {
|
||||||
|
return new Date(iso).toLocaleString("en-US", {
|
||||||
|
year: "numeric", month: "short", day: "numeric",
|
||||||
|
hour: "2-digit", minute: "2-digit",
|
||||||
|
});
|
||||||
|
} catch {
|
||||||
|
return iso;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function FirmwareManager() {
|
||||||
|
const { hasPermission } = useAuth();
|
||||||
|
const canAdd = hasPermission("manufacturing", "add");
|
||||||
|
const canDelete = hasPermission("manufacturing", "delete");
|
||||||
|
|
||||||
|
const [firmware, setFirmware] = useState([]);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [error, setError] = useState("");
|
||||||
|
|
||||||
|
const [hwTypeFilter, setHwTypeFilter] = useState("");
|
||||||
|
const [channelFilter, setChannelFilter] = useState("");
|
||||||
|
|
||||||
|
const [showUpload, setShowUpload] = useState(false);
|
||||||
|
const [uploadHwType, setUploadHwType] = useState("vs");
|
||||||
|
const [uploadChannel, setUploadChannel] = useState("stable");
|
||||||
|
const [uploadVersion, setUploadVersion] = useState("");
|
||||||
|
const [uploadNotes, setUploadNotes] = useState("");
|
||||||
|
const [uploadFile, setUploadFile] = useState(null);
|
||||||
|
const [uploading, setUploading] = useState(false);
|
||||||
|
const [uploadError, setUploadError] = useState("");
|
||||||
|
const fileInputRef = useRef(null);
|
||||||
|
|
||||||
|
const [deleteTarget, setDeleteTarget] = useState(null);
|
||||||
|
const [deleting, setDeleting] = useState(false);
|
||||||
|
|
||||||
|
const fetchFirmware = async () => {
|
||||||
|
setLoading(true);
|
||||||
|
setError("");
|
||||||
|
try {
|
||||||
|
const params = new URLSearchParams();
|
||||||
|
if (hwTypeFilter) params.set("hw_type", hwTypeFilter);
|
||||||
|
if (channelFilter) params.set("channel", channelFilter);
|
||||||
|
const qs = params.toString();
|
||||||
|
const data = await api.get(`/firmware${qs ? `?${qs}` : ""}`);
|
||||||
|
setFirmware(data.firmware);
|
||||||
|
} catch (err) {
|
||||||
|
setError(err.message);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
fetchFirmware();
|
||||||
|
}, [hwTypeFilter, channelFilter]);
|
||||||
|
|
||||||
|
const handleUpload = async (e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
if (!uploadFile) return;
|
||||||
|
setUploadError("");
|
||||||
|
setUploading(true);
|
||||||
|
try {
|
||||||
|
const formData = new FormData();
|
||||||
|
formData.append("hw_type", uploadHwType);
|
||||||
|
formData.append("channel", uploadChannel);
|
||||||
|
formData.append("version", uploadVersion);
|
||||||
|
if (uploadNotes) formData.append("notes", uploadNotes);
|
||||||
|
formData.append("file", uploadFile);
|
||||||
|
|
||||||
|
const token = localStorage.getItem("access_token");
|
||||||
|
const response = await fetch("/api/firmware/upload", {
|
||||||
|
method: "POST",
|
||||||
|
headers: { Authorization: `Bearer ${token}` },
|
||||||
|
body: formData,
|
||||||
|
});
|
||||||
|
if (!response.ok) {
|
||||||
|
const err = await response.json().catch(() => ({}));
|
||||||
|
throw new Error(err.detail || "Upload failed");
|
||||||
|
}
|
||||||
|
|
||||||
|
setShowUpload(false);
|
||||||
|
setUploadVersion("");
|
||||||
|
setUploadNotes("");
|
||||||
|
setUploadFile(null);
|
||||||
|
if (fileInputRef.current) fileInputRef.current.value = "";
|
||||||
|
await fetchFirmware();
|
||||||
|
} catch (err) {
|
||||||
|
setUploadError(err.message);
|
||||||
|
} finally {
|
||||||
|
setUploading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDelete = async () => {
|
||||||
|
if (!deleteTarget) return;
|
||||||
|
setDeleting(true);
|
||||||
|
try {
|
||||||
|
await api.delete(`/firmware/${deleteTarget.id}`);
|
||||||
|
setDeleteTarget(null);
|
||||||
|
await fetchFirmware();
|
||||||
|
} catch (err) {
|
||||||
|
setError(err.message);
|
||||||
|
} finally {
|
||||||
|
setDeleting(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const BOARD_TYPE_LABELS = { vs: "Vesper", vp: "Vesper+", vx: "VesperPro" };
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<div className="flex items-center justify-between mb-6">
|
||||||
|
<div>
|
||||||
|
<h1 className="text-2xl font-bold" style={{ color: "var(--text-heading)" }}>
|
||||||
|
Firmware
|
||||||
|
</h1>
|
||||||
|
<p className="text-sm mt-0.5" style={{ color: "var(--text-muted)" }}>
|
||||||
|
{firmware.length} version{firmware.length !== 1 ? "s" : ""}
|
||||||
|
{hwTypeFilter || channelFilter ? " (filtered)" : ""}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
{canAdd && (
|
||||||
|
<button
|
||||||
|
onClick={() => setShowUpload(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)" }}
|
||||||
|
>
|
||||||
|
+ Upload Firmware
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Upload form */}
|
||||||
|
{showUpload && (
|
||||||
|
<div
|
||||||
|
className="rounded-lg border p-5 mb-5"
|
||||||
|
style={{ backgroundColor: "var(--bg-card)", borderColor: "var(--border-primary)" }}
|
||||||
|
>
|
||||||
|
<h2 className="text-base font-semibold mb-4" style={{ color: "var(--text-heading)" }}>
|
||||||
|
Upload New Firmware
|
||||||
|
</h2>
|
||||||
|
{uploadError && (
|
||||||
|
<div
|
||||||
|
className="text-sm rounded-md p-3 mb-3 border"
|
||||||
|
style={{
|
||||||
|
backgroundColor: "var(--danger-bg)",
|
||||||
|
borderColor: "var(--danger)",
|
||||||
|
color: "var(--danger-text)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{uploadError}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<form onSubmit={handleUpload} className="space-y-3">
|
||||||
|
<div className="grid grid-cols-3 gap-3">
|
||||||
|
<div>
|
||||||
|
<label className="block text-xs font-medium mb-1" style={{ color: "var(--text-muted)" }}>
|
||||||
|
Board Type
|
||||||
|
</label>
|
||||||
|
<select
|
||||||
|
value={uploadHwType}
|
||||||
|
onChange={(e) => setUploadHwType(e.target.value)}
|
||||||
|
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>
|
||||||
|
<label className="block text-xs font-medium mb-1" style={{ color: "var(--text-muted)" }}>
|
||||||
|
Channel
|
||||||
|
</label>
|
||||||
|
<select
|
||||||
|
value={uploadChannel}
|
||||||
|
onChange={(e) => setUploadChannel(e.target.value)}
|
||||||
|
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)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{CHANNELS.map((c) => (
|
||||||
|
<option key={c} value={c}>{c}</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-xs font-medium mb-1" style={{ color: "var(--text-muted)" }}>
|
||||||
|
Version
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={uploadVersion}
|
||||||
|
onChange={(e) => setUploadVersion(e.target.value)}
|
||||||
|
placeholder="1.4.2"
|
||||||
|
required
|
||||||
|
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)",
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-xs font-medium mb-1" style={{ color: "var(--text-muted)" }}>
|
||||||
|
firmware.bin
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
ref={fileInputRef}
|
||||||
|
type="file"
|
||||||
|
accept=".bin"
|
||||||
|
required
|
||||||
|
onChange={(e) => setUploadFile(e.target.files[0] || null)}
|
||||||
|
className="w-full text-sm"
|
||||||
|
style={{ color: "var(--text-primary)" }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-xs font-medium mb-1" style={{ color: "var(--text-muted)" }}>
|
||||||
|
Release Notes (optional)
|
||||||
|
</label>
|
||||||
|
<textarea
|
||||||
|
value={uploadNotes}
|
||||||
|
onChange={(e) => setUploadNotes(e.target.value)}
|
||||||
|
rows={2}
|
||||||
|
placeholder="What changed in this version?"
|
||||||
|
className="w-full px-3 py-2 rounded-md text-sm border resize-none"
|
||||||
|
style={{
|
||||||
|
backgroundColor: "var(--bg-input)",
|
||||||
|
borderColor: "var(--border-input)",
|
||||||
|
color: "var(--text-primary)",
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-3 pt-1">
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
disabled={uploading}
|
||||||
|
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)" }}
|
||||||
|
>
|
||||||
|
{uploading ? "Uploading…" : "Upload"}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => { setShowUpload(false); setUploadError(""); }}
|
||||||
|
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>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Filters */}
|
||||||
|
<div className="flex gap-3 mb-4">
|
||||||
|
<select
|
||||||
|
value={hwTypeFilter}
|
||||||
|
onChange={(e) => setHwTypeFilter(e.target.value)}
|
||||||
|
className="px-3 py-2 rounded-md text-sm border"
|
||||||
|
style={{
|
||||||
|
backgroundColor: "var(--bg-input)",
|
||||||
|
borderColor: "var(--border-input)",
|
||||||
|
color: "var(--text-primary)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<option value="">All Types</option>
|
||||||
|
{BOARD_TYPES.map((bt) => (
|
||||||
|
<option key={bt.value} value={bt.value}>{bt.label}</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
<select
|
||||||
|
value={channelFilter}
|
||||||
|
onChange={(e) => setChannelFilter(e.target.value)}
|
||||||
|
className="px-3 py-2 rounded-md text-sm border"
|
||||||
|
style={{
|
||||||
|
backgroundColor: "var(--bg-input)",
|
||||||
|
borderColor: "var(--border-input)",
|
||||||
|
color: "var(--text-primary)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<option value="">All Channels</option>
|
||||||
|
{CHANNELS.map((c) => (
|
||||||
|
<option key={c} value={c}>{c}</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{error && (
|
||||||
|
<div
|
||||||
|
className="text-sm rounded-md p-3 mb-4 border"
|
||||||
|
style={{
|
||||||
|
backgroundColor: "var(--danger-bg)",
|
||||||
|
borderColor: "var(--danger)",
|
||||||
|
color: "var(--danger-text)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{error}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Table */}
|
||||||
|
<div
|
||||||
|
className="rounded-lg border overflow-hidden"
|
||||||
|
style={{ borderColor: "var(--border-primary)" }}
|
||||||
|
>
|
||||||
|
<div className="overflow-x-auto">
|
||||||
|
<table className="w-full text-sm">
|
||||||
|
<thead>
|
||||||
|
<tr style={{ backgroundColor: "var(--bg-secondary)", borderBottom: "1px solid var(--border-primary)" }}>
|
||||||
|
<th className="px-4 py-3 text-left font-medium" style={{ color: "var(--text-muted)" }}>Type</th>
|
||||||
|
<th className="px-4 py-3 text-left font-medium" style={{ color: "var(--text-muted)" }}>Channel</th>
|
||||||
|
<th className="px-4 py-3 text-left font-medium" style={{ color: "var(--text-muted)" }}>Version</th>
|
||||||
|
<th className="px-4 py-3 text-left font-medium" style={{ color: "var(--text-muted)" }}>Size</th>
|
||||||
|
<th className="px-4 py-3 text-left font-medium" style={{ color: "var(--text-muted)" }}>SHA-256</th>
|
||||||
|
<th className="px-4 py-3 text-left font-medium" style={{ color: "var(--text-muted)" }}>Uploaded</th>
|
||||||
|
<th className="px-4 py-3 text-left font-medium" style={{ color: "var(--text-muted)" }}>Latest</th>
|
||||||
|
{canDelete && (
|
||||||
|
<th className="px-4 py-3" style={{ color: "var(--text-muted)" }} />
|
||||||
|
)}
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{loading ? (
|
||||||
|
<tr>
|
||||||
|
<td colSpan={canDelete ? 8 : 7} className="px-4 py-8 text-center text-sm" style={{ color: "var(--text-muted)" }}>
|
||||||
|
Loading…
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
) : firmware.length === 0 ? (
|
||||||
|
<tr>
|
||||||
|
<td colSpan={canDelete ? 8 : 7} className="px-4 py-8 text-center text-sm" style={{ color: "var(--text-muted)" }}>
|
||||||
|
No firmware versions found.{" "}
|
||||||
|
{canAdd && (
|
||||||
|
<button
|
||||||
|
onClick={() => setShowUpload(true)}
|
||||||
|
className="underline cursor-pointer"
|
||||||
|
style={{ color: "var(--text-link)" }}
|
||||||
|
>
|
||||||
|
Upload the first one.
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
) : (
|
||||||
|
firmware.map((fw) => (
|
||||||
|
<tr
|
||||||
|
key={fw.id}
|
||||||
|
style={{ borderBottom: "1px solid var(--border-secondary)" }}
|
||||||
|
>
|
||||||
|
<td className="px-4 py-3" style={{ color: "var(--text-secondary)" }}>
|
||||||
|
{BOARD_TYPE_LABELS[fw.hw_type] || fw.hw_type}
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-3">
|
||||||
|
<ChannelBadge channel={fw.channel} />
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-3 font-mono text-xs" style={{ color: "var(--text-primary)" }}>
|
||||||
|
{fw.version}
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-3 text-xs" style={{ color: "var(--text-muted)" }}>
|
||||||
|
{formatBytes(fw.size_bytes)}
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-3 font-mono text-xs" style={{ color: "var(--text-muted)" }} title={fw.sha256}>
|
||||||
|
{fw.sha256 ? fw.sha256.slice(0, 12) + "…" : "—"}
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-3 text-xs" style={{ color: "var(--text-muted)" }}>
|
||||||
|
{formatDate(fw.uploaded_at)}
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-3">
|
||||||
|
{fw.is_latest && (
|
||||||
|
<span
|
||||||
|
className="px-2 py-0.5 text-xs rounded-full font-medium"
|
||||||
|
style={{ backgroundColor: "var(--success-bg)", color: "var(--success-text)" }}
|
||||||
|
>
|
||||||
|
latest
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
{canDelete && (
|
||||||
|
<td className="px-4 py-3 text-right">
|
||||||
|
<button
|
||||||
|
onClick={() => setDeleteTarget(fw)}
|
||||||
|
className="text-xs hover:opacity-80 cursor-pointer"
|
||||||
|
style={{ color: "var(--danger)" }}
|
||||||
|
>
|
||||||
|
Delete
|
||||||
|
</button>
|
||||||
|
</td>
|
||||||
|
)}
|
||||||
|
</tr>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Delete confirmation */}
|
||||||
|
{deleteTarget && (
|
||||||
|
<div className="fixed inset-0 flex items-center justify-center z-50" style={{ backgroundColor: "rgba(0,0,0,0.6)" }}>
|
||||||
|
<div
|
||||||
|
className="rounded-lg border p-6 max-w-sm w-full mx-4"
|
||||||
|
style={{ backgroundColor: "var(--bg-card)", borderColor: "var(--border-primary)" }}
|
||||||
|
>
|
||||||
|
<h3 className="text-base font-semibold mb-2" style={{ color: "var(--text-heading)" }}>
|
||||||
|
Delete Firmware
|
||||||
|
</h3>
|
||||||
|
<p className="text-sm mb-5" style={{ color: "var(--text-secondary)" }}>
|
||||||
|
Delete{" "}
|
||||||
|
<span className="font-mono" style={{ color: "var(--text-primary)" }}>
|
||||||
|
{BOARD_TYPE_LABELS[deleteTarget.hw_type] || deleteTarget.hw_type} v{deleteTarget.version} ({deleteTarget.channel})
|
||||||
|
</span>
|
||||||
|
? This cannot be undone.
|
||||||
|
</p>
|
||||||
|
<div className="flex gap-3">
|
||||||
|
<button
|
||||||
|
onClick={handleDelete}
|
||||||
|
disabled={deleting}
|
||||||
|
className="px-4 py-2 text-sm rounded-md hover:opacity-90 transition-opacity cursor-pointer disabled:opacity-50"
|
||||||
|
style={{ backgroundColor: "var(--danger-btn)", color: "var(--text-white)" }}
|
||||||
|
>
|
||||||
|
{deleting ? "Deleting…" : "Delete"}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => setDeleteTarget(null)}
|
||||||
|
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>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -26,6 +26,15 @@ const navItems = [
|
|||||||
],
|
],
|
||||||
},
|
},
|
||||||
{ to: "/equipment/notes", label: "Issues and Notes", permission: "equipment" },
|
{ to: "/equipment/notes", label: "Issues and Notes", permission: "equipment" },
|
||||||
|
{
|
||||||
|
label: "Manufacturing",
|
||||||
|
permission: "manufacturing",
|
||||||
|
children: [
|
||||||
|
{ to: "/manufacturing", label: "Device Inventory" },
|
||||||
|
{ to: "/manufacturing/batch/new", label: "New Batch" },
|
||||||
|
{ to: "/firmware", label: "Firmware" },
|
||||||
|
],
|
||||||
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
const linkClass = (isActive, locked) =>
|
const linkClass = (isActive, locked) =>
|
||||||
|
|||||||
237
frontend/src/manufacturing/BatchCreator.jsx
Normal file
237
frontend/src/manufacturing/BatchCreator.jsx
Normal file
@@ -0,0 +1,237 @@
|
|||||||
|
import { useState } from "react";
|
||||||
|
import { useNavigate } from "react-router-dom";
|
||||||
|
import api from "../api/client";
|
||||||
|
|
||||||
|
const BOARD_TYPES = [
|
||||||
|
{ value: "vs", label: "Vesper (VS)" },
|
||||||
|
{ value: "vp", label: "Vesper+ (VP)" },
|
||||||
|
{ value: "vx", label: "VesperPro (VX)" },
|
||||||
|
];
|
||||||
|
|
||||||
|
export default function BatchCreator() {
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const [boardType, setBoardType] = useState("vs");
|
||||||
|
const [boardVersion, setBoardVersion] = useState("01");
|
||||||
|
const [quantity, setQuantity] = useState(1);
|
||||||
|
const [saving, setSaving] = useState(false);
|
||||||
|
const [error, setError] = useState("");
|
||||||
|
const [result, setResult] = useState(null);
|
||||||
|
const [copied, setCopied] = useState(false);
|
||||||
|
|
||||||
|
const handleSubmit = async (e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
setError("");
|
||||||
|
setResult(null);
|
||||||
|
setSaving(true);
|
||||||
|
try {
|
||||||
|
const data = await api.post("/manufacturing/batch", {
|
||||||
|
board_type: boardType,
|
||||||
|
board_version: boardVersion,
|
||||||
|
quantity: Number(quantity),
|
||||||
|
});
|
||||||
|
setResult(data);
|
||||||
|
} catch (err) {
|
||||||
|
setError(err.message);
|
||||||
|
} finally {
|
||||||
|
setSaving(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const copyAll = () => {
|
||||||
|
if (!result) return;
|
||||||
|
navigator.clipboard.writeText(result.serial_numbers.join("\n"));
|
||||||
|
setCopied(true);
|
||||||
|
setTimeout(() => setCopied(false), 2000);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="max-w-2xl">
|
||||||
|
<div className="flex items-center gap-3 mb-6">
|
||||||
|
<button
|
||||||
|
onClick={() => navigate("/manufacturing")}
|
||||||
|
className="text-sm hover:opacity-80 transition-opacity cursor-pointer"
|
||||||
|
style={{ color: "var(--text-muted)" }}
|
||||||
|
>
|
||||||
|
← Device Inventory
|
||||||
|
</button>
|
||||||
|
<span style={{ color: "var(--text-muted)" }}>/</span>
|
||||||
|
<h1 className="text-xl font-bold" style={{ color: "var(--text-heading)" }}>
|
||||||
|
New Batch
|
||||||
|
</h1>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{!result ? (
|
||||||
|
<div
|
||||||
|
className="rounded-lg border p-6"
|
||||||
|
style={{ backgroundColor: "var(--bg-card)", borderColor: "var(--border-primary)" }}
|
||||||
|
>
|
||||||
|
<h2 className="text-base font-semibold mb-5" style={{ color: "var(--text-heading)" }}>
|
||||||
|
Batch Parameters
|
||||||
|
</h2>
|
||||||
|
|
||||||
|
{error && (
|
||||||
|
<div
|
||||||
|
className="text-sm rounded-md p-3 mb-4 border"
|
||||||
|
style={{
|
||||||
|
backgroundColor: "var(--danger-bg)",
|
||||||
|
borderColor: "var(--danger)",
|
||||||
|
color: "var(--danger-text)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{error}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<form onSubmit={handleSubmit} className="space-y-4">
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium mb-1.5" style={{ color: "var(--text-secondary)" }}>
|
||||||
|
Board Type
|
||||||
|
</label>
|
||||||
|
<select
|
||||||
|
value={boardType}
|
||||||
|
onChange={(e) => setBoardType(e.target.value)}
|
||||||
|
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>
|
||||||
|
<label className="block text-sm font-medium mb-1.5" style={{ color: "var(--text-secondary)" }}>
|
||||||
|
Board Version
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={boardVersion}
|
||||||
|
onChange={(e) => setBoardVersion(e.target.value)}
|
||||||
|
placeholder="01"
|
||||||
|
maxLength={2}
|
||||||
|
pattern="\d{2}"
|
||||||
|
required
|
||||||
|
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)",
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<p className="text-xs mt-1" style={{ color: "var(--text-muted)" }}>
|
||||||
|
2-digit zero-padded version number (e.g. 01, 02)
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium mb-1.5" style={{ color: "var(--text-secondary)" }}>
|
||||||
|
Quantity
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
value={quantity}
|
||||||
|
onChange={(e) => setQuantity(e.target.value)}
|
||||||
|
min={1}
|
||||||
|
max={100}
|
||||||
|
required
|
||||||
|
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)",
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex gap-3 pt-2">
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
disabled={saving}
|
||||||
|
className="px-5 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)" }}
|
||||||
|
>
|
||||||
|
{saving ? "Generating…" : `Generate ${quantity} Serial Number${quantity > 1 ? "s" : ""}`}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => navigate("/manufacturing")}
|
||||||
|
className="px-4 py-2 text-sm rounded-md hover:opacity-80 transition-opacity cursor-pointer"
|
||||||
|
style={{ backgroundColor: "var(--bg-card-hover)", color: "var(--text-secondary)" }}
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div
|
||||||
|
className="rounded-lg border p-6"
|
||||||
|
style={{ backgroundColor: "var(--bg-card)", borderColor: "var(--border-primary)" }}
|
||||||
|
>
|
||||||
|
<div className="flex items-start justify-between mb-4">
|
||||||
|
<div>
|
||||||
|
<h2 className="text-base font-semibold" style={{ color: "var(--text-heading)" }}>
|
||||||
|
Batch Created
|
||||||
|
</h2>
|
||||||
|
<p className="text-sm mt-0.5" style={{ color: "var(--text-muted)" }}>
|
||||||
|
{result.batch_id} · {result.serial_numbers.length} device{result.serial_numbers.length !== 1 ? "s" : ""}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<span
|
||||||
|
className="px-2 py-0.5 text-xs rounded-full font-medium"
|
||||||
|
style={{ backgroundColor: "var(--badge-blue-bg)", color: "var(--badge-blue-text)" }}
|
||||||
|
>
|
||||||
|
{BOARD_TYPES.find((b) => b.value === result.board_type)?.label || result.board_type} v{result.board_version}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
className="rounded-md border p-3 mb-4 font-mono text-xs overflow-y-auto"
|
||||||
|
style={{
|
||||||
|
backgroundColor: "var(--bg-primary)",
|
||||||
|
borderColor: "var(--border-secondary)",
|
||||||
|
color: "var(--text-primary)",
|
||||||
|
maxHeight: "280px",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{result.serial_numbers.map((sn) => (
|
||||||
|
<div key={sn} className="py-0.5">
|
||||||
|
{sn}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex gap-3">
|
||||||
|
<button
|
||||||
|
onClick={copyAll}
|
||||||
|
className="px-4 py-2 text-sm rounded-md hover:opacity-90 transition-opacity cursor-pointer"
|
||||||
|
style={{ backgroundColor: "var(--btn-primary)", color: "var(--text-white)" }}
|
||||||
|
>
|
||||||
|
{copied ? "Copied!" : "Copy All"}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => navigate("/manufacturing")}
|
||||||
|
className="px-4 py-2 text-sm rounded-md hover:opacity-80 transition-opacity cursor-pointer"
|
||||||
|
style={{ backgroundColor: "var(--bg-card-hover)", color: "var(--text-secondary)" }}
|
||||||
|
>
|
||||||
|
View Inventory
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => { setResult(null); setQuantity(1); }}
|
||||||
|
className="px-4 py-2 text-sm rounded-md hover:opacity-80 transition-opacity cursor-pointer"
|
||||||
|
style={{ backgroundColor: "var(--bg-card-hover)", color: "var(--text-secondary)" }}
|
||||||
|
>
|
||||||
|
New Batch
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
244
frontend/src/manufacturing/DeviceInventory.jsx
Normal file
244
frontend/src/manufacturing/DeviceInventory.jsx
Normal file
@@ -0,0 +1,244 @@
|
|||||||
|
import { useState, useEffect } from "react";
|
||||||
|
import { useNavigate } from "react-router-dom";
|
||||||
|
import { useAuth } from "../auth/AuthContext";
|
||||||
|
import api from "../api/client";
|
||||||
|
|
||||||
|
const BOARD_TYPE_LABELS = { vs: "Vesper", vp: "Vesper+", vx: "VesperPro" };
|
||||||
|
|
||||||
|
const STATUS_STYLES = {
|
||||||
|
manufactured: { bg: "var(--bg-card-hover)", color: "var(--text-muted)" },
|
||||||
|
flashed: { bg: "var(--badge-blue-bg)", color: "var(--badge-blue-text)" },
|
||||||
|
provisioned: { bg: "#0a2e2a", color: "#4dd6c8" },
|
||||||
|
sold: { bg: "#1e1036", color: "#c084fc" },
|
||||||
|
claimed: { bg: "#2e1a00", color: "#fb923c" },
|
||||||
|
decommissioned:{ bg: "var(--danger-bg)", color: "var(--danger-text)" },
|
||||||
|
};
|
||||||
|
|
||||||
|
function StatusBadge({ status }) {
|
||||||
|
const style = STATUS_STYLES[status] || STATUS_STYLES.manufactured;
|
||||||
|
return (
|
||||||
|
<span
|
||||||
|
className="px-2 py-0.5 text-xs rounded-full capitalize font-medium"
|
||||||
|
style={{ backgroundColor: style.bg, color: style.color }}
|
||||||
|
>
|
||||||
|
{status}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function DeviceInventory() {
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const { hasPermission } = useAuth();
|
||||||
|
const canAdd = hasPermission("manufacturing", "add");
|
||||||
|
|
||||||
|
const [devices, setDevices] = useState([]);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [error, setError] = useState("");
|
||||||
|
|
||||||
|
const [search, setSearch] = useState("");
|
||||||
|
const [statusFilter, setStatusFilter] = useState("");
|
||||||
|
const [hwTypeFilter, setHwTypeFilter] = useState("");
|
||||||
|
|
||||||
|
const fetchDevices = async () => {
|
||||||
|
setLoading(true);
|
||||||
|
setError("");
|
||||||
|
try {
|
||||||
|
const params = new URLSearchParams();
|
||||||
|
if (search) params.set("search", search);
|
||||||
|
if (statusFilter) params.set("status", statusFilter);
|
||||||
|
if (hwTypeFilter) params.set("hw_type", hwTypeFilter);
|
||||||
|
params.set("limit", "200");
|
||||||
|
const qs = params.toString();
|
||||||
|
const data = await api.get(`/manufacturing/devices${qs ? `?${qs}` : ""}`);
|
||||||
|
setDevices(data.devices);
|
||||||
|
} catch (err) {
|
||||||
|
setError(err.message);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
fetchDevices();
|
||||||
|
}, [search, statusFilter, hwTypeFilter]);
|
||||||
|
|
||||||
|
const formatDate = (iso) => {
|
||||||
|
if (!iso) return "—";
|
||||||
|
try {
|
||||||
|
return new Date(iso).toLocaleDateString("en-US", {
|
||||||
|
year: "numeric", month: "short", day: "numeric",
|
||||||
|
});
|
||||||
|
} catch {
|
||||||
|
return iso;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<div className="flex items-center justify-between mb-6">
|
||||||
|
<div>
|
||||||
|
<h1 className="text-2xl font-bold" style={{ color: "var(--text-heading)" }}>
|
||||||
|
Device Inventory
|
||||||
|
</h1>
|
||||||
|
<p className="text-sm mt-0.5" style={{ color: "var(--text-muted)" }}>
|
||||||
|
{devices.length} device{devices.length !== 1 ? "s" : ""}
|
||||||
|
{statusFilter || hwTypeFilter || search ? " (filtered)" : ""}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
{canAdd && (
|
||||||
|
<button
|
||||||
|
onClick={() => navigate("/manufacturing/batch/new")}
|
||||||
|
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)" }}
|
||||||
|
>
|
||||||
|
+ New Batch
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Filters */}
|
||||||
|
<div className="flex flex-wrap gap-3 mb-4">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
placeholder="Search serial number, batch, owner…"
|
||||||
|
value={search}
|
||||||
|
onChange={(e) => setSearch(e.target.value)}
|
||||||
|
className="px-3 py-2 rounded-md text-sm border flex-1 min-w-48"
|
||||||
|
style={{
|
||||||
|
backgroundColor: "var(--bg-input)",
|
||||||
|
borderColor: "var(--border-input)",
|
||||||
|
color: "var(--text-primary)",
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<select
|
||||||
|
value={statusFilter}
|
||||||
|
onChange={(e) => setStatusFilter(e.target.value)}
|
||||||
|
className="px-3 py-2 rounded-md text-sm border"
|
||||||
|
style={{
|
||||||
|
backgroundColor: "var(--bg-input)",
|
||||||
|
borderColor: "var(--border-input)",
|
||||||
|
color: "var(--text-primary)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<option value="">All Statuses</option>
|
||||||
|
<option value="manufactured">Manufactured</option>
|
||||||
|
<option value="flashed">Flashed</option>
|
||||||
|
<option value="provisioned">Provisioned</option>
|
||||||
|
<option value="sold">Sold</option>
|
||||||
|
<option value="claimed">Claimed</option>
|
||||||
|
<option value="decommissioned">Decommissioned</option>
|
||||||
|
</select>
|
||||||
|
<select
|
||||||
|
value={hwTypeFilter}
|
||||||
|
onChange={(e) => setHwTypeFilter(e.target.value)}
|
||||||
|
className="px-3 py-2 rounded-md text-sm border"
|
||||||
|
style={{
|
||||||
|
backgroundColor: "var(--bg-input)",
|
||||||
|
borderColor: "var(--border-input)",
|
||||||
|
color: "var(--text-primary)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<option value="">All Types</option>
|
||||||
|
<option value="vs">Vesper (VS)</option>
|
||||||
|
<option value="vp">Vesper+ (VP)</option>
|
||||||
|
<option value="vx">VesperPro (VX)</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{error && (
|
||||||
|
<div
|
||||||
|
className="text-sm rounded-md p-3 mb-4 border"
|
||||||
|
style={{
|
||||||
|
backgroundColor: "var(--danger-bg)",
|
||||||
|
borderColor: "var(--danger)",
|
||||||
|
color: "var(--danger-text)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{error}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div
|
||||||
|
className="rounded-lg border overflow-hidden"
|
||||||
|
style={{ borderColor: "var(--border-primary)" }}
|
||||||
|
>
|
||||||
|
<div className="overflow-x-auto">
|
||||||
|
<table className="w-full text-sm">
|
||||||
|
<thead>
|
||||||
|
<tr style={{ backgroundColor: "var(--bg-secondary)", borderBottom: "1px solid var(--border-primary)" }}>
|
||||||
|
<th className="px-4 py-3 text-left font-medium" style={{ color: "var(--text-muted)" }}>Serial Number</th>
|
||||||
|
<th className="px-4 py-3 text-left font-medium" style={{ color: "var(--text-muted)" }}>Type</th>
|
||||||
|
<th className="px-4 py-3 text-left font-medium" style={{ color: "var(--text-muted)" }}>Version</th>
|
||||||
|
<th className="px-4 py-3 text-left font-medium" style={{ color: "var(--text-muted)" }}>Status</th>
|
||||||
|
<th className="px-4 py-3 text-left font-medium" style={{ color: "var(--text-muted)" }}>Batch</th>
|
||||||
|
<th className="px-4 py-3 text-left font-medium" style={{ color: "var(--text-muted)" }}>Created</th>
|
||||||
|
<th className="px-4 py-3 text-left font-medium" style={{ color: "var(--text-muted)" }}>Owner</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{loading ? (
|
||||||
|
<tr>
|
||||||
|
<td colSpan={7} className="px-4 py-8 text-center text-sm" style={{ color: "var(--text-muted)" }}>
|
||||||
|
Loading…
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
) : devices.length === 0 ? (
|
||||||
|
<tr>
|
||||||
|
<td colSpan={7} className="px-4 py-8 text-center text-sm" style={{ color: "var(--text-muted)" }}>
|
||||||
|
No devices found.
|
||||||
|
{canAdd && (
|
||||||
|
<span>
|
||||||
|
{" "}
|
||||||
|
<button
|
||||||
|
onClick={() => navigate("/manufacturing/batch/new")}
|
||||||
|
className="underline cursor-pointer"
|
||||||
|
style={{ color: "var(--text-link)" }}
|
||||||
|
>
|
||||||
|
Create a batch
|
||||||
|
</button>{" "}
|
||||||
|
to get started.
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
) : (
|
||||||
|
devices.map((device) => (
|
||||||
|
<tr
|
||||||
|
key={device.id}
|
||||||
|
onClick={() => navigate(`/manufacturing/devices/${device.serial_number}`)}
|
||||||
|
className="cursor-pointer transition-colors"
|
||||||
|
style={{ borderBottom: "1px solid var(--border-secondary)" }}
|
||||||
|
onMouseEnter={(e) => (e.currentTarget.style.backgroundColor = "var(--bg-card-hover)")}
|
||||||
|
onMouseLeave={(e) => (e.currentTarget.style.backgroundColor = "")}
|
||||||
|
>
|
||||||
|
<td className="px-4 py-3 font-mono text-xs" style={{ color: "var(--text-primary)" }}>
|
||||||
|
{device.serial_number}
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-3" style={{ color: "var(--text-secondary)" }}>
|
||||||
|
{BOARD_TYPE_LABELS[device.hw_type] || device.hw_type}
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-3" style={{ color: "var(--text-muted)" }}>
|
||||||
|
v{device.hw_version}
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-3">
|
||||||
|
<StatusBadge status={device.mfg_status} />
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-3 font-mono text-xs" style={{ color: "var(--text-muted)" }}>
|
||||||
|
{device.mfg_batch_id || "—"}
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-3 text-xs" style={{ color: "var(--text-muted)" }}>
|
||||||
|
{formatDate(device.created_at)}
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-3 text-xs" style={{ color: "var(--text-muted)" }}>
|
||||||
|
{device.owner || "—"}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
331
frontend/src/manufacturing/DeviceInventoryDetail.jsx
Normal file
331
frontend/src/manufacturing/DeviceInventoryDetail.jsx
Normal file
@@ -0,0 +1,331 @@
|
|||||||
|
import { useState, useEffect } from "react";
|
||||||
|
import { useParams, useNavigate } from "react-router-dom";
|
||||||
|
import { useAuth } from "../auth/AuthContext";
|
||||||
|
import api from "../api/client";
|
||||||
|
|
||||||
|
const BOARD_TYPE_LABELS = { vs: "Vesper", vp: "Vesper+", vx: "VesperPro" };
|
||||||
|
|
||||||
|
const STATUS_STYLES = {
|
||||||
|
manufactured: { bg: "var(--bg-card-hover)", color: "var(--text-muted)" },
|
||||||
|
flashed: { bg: "var(--badge-blue-bg)", color: "var(--badge-blue-text)" },
|
||||||
|
provisioned: { bg: "#0a2e2a", color: "#4dd6c8" },
|
||||||
|
sold: { bg: "#1e1036", color: "#c084fc" },
|
||||||
|
claimed: { bg: "#2e1a00", color: "#fb923c" },
|
||||||
|
decommissioned:{ bg: "var(--danger-bg)", color: "var(--danger-text)" },
|
||||||
|
};
|
||||||
|
|
||||||
|
const STATUS_OPTIONS = [
|
||||||
|
"manufactured", "flashed", "provisioned", "sold", "claimed", "decommissioned",
|
||||||
|
];
|
||||||
|
|
||||||
|
function StatusBadge({ status }) {
|
||||||
|
const style = STATUS_STYLES[status] || STATUS_STYLES.manufactured;
|
||||||
|
return (
|
||||||
|
<span
|
||||||
|
className="px-2.5 py-1 text-sm rounded-full capitalize font-medium"
|
||||||
|
style={{ backgroundColor: style.bg, color: style.color }}
|
||||||
|
>
|
||||||
|
{status}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function Field({ label, value, mono = false }) {
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<p className="text-xs font-medium uppercase tracking-wide mb-0.5" style={{ color: "var(--text-muted)" }}>
|
||||||
|
{label}
|
||||||
|
</p>
|
||||||
|
<p
|
||||||
|
className={`text-sm ${mono ? "font-mono" : ""}`}
|
||||||
|
style={{ color: "var(--text-primary)" }}
|
||||||
|
>
|
||||||
|
{value || "—"}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function DeviceInventoryDetail() {
|
||||||
|
const { sn } = useParams();
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const { hasPermission } = useAuth();
|
||||||
|
const canEdit = hasPermission("manufacturing", "edit");
|
||||||
|
|
||||||
|
const [device, setDevice] = useState(null);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [error, setError] = useState("");
|
||||||
|
|
||||||
|
const [editingStatus, setEditingStatus] = useState(false);
|
||||||
|
const [newStatus, setNewStatus] = useState("");
|
||||||
|
const [statusNote, setStatusNote] = useState("");
|
||||||
|
const [statusSaving, setStatusSaving] = useState(false);
|
||||||
|
const [statusError, setStatusError] = useState("");
|
||||||
|
|
||||||
|
const [nvsDownloading, setNvsDownloading] = useState(false);
|
||||||
|
|
||||||
|
const loadDevice = async () => {
|
||||||
|
setLoading(true);
|
||||||
|
setError("");
|
||||||
|
try {
|
||||||
|
const data = await api.get(`/manufacturing/devices/${sn}`);
|
||||||
|
setDevice(data);
|
||||||
|
} catch (err) {
|
||||||
|
setError(err.message);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
loadDevice();
|
||||||
|
}, [sn]);
|
||||||
|
|
||||||
|
const handleStatusSave = async () => {
|
||||||
|
setStatusError("");
|
||||||
|
setStatusSaving(true);
|
||||||
|
try {
|
||||||
|
const updated = await api.request(`/manufacturing/devices/${sn}/status`, {
|
||||||
|
method: "PATCH",
|
||||||
|
body: JSON.stringify({ status: newStatus, note: statusNote || null }),
|
||||||
|
});
|
||||||
|
setDevice(updated);
|
||||||
|
setEditingStatus(false);
|
||||||
|
setStatusNote("");
|
||||||
|
} catch (err) {
|
||||||
|
setStatusError(err.message);
|
||||||
|
} finally {
|
||||||
|
setStatusSaving(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const downloadNvs = async () => {
|
||||||
|
setNvsDownloading(true);
|
||||||
|
try {
|
||||||
|
const token = localStorage.getItem("access_token");
|
||||||
|
const response = await fetch(`/api/manufacturing/devices/${sn}/nvs.bin`, {
|
||||||
|
headers: { Authorization: `Bearer ${token}` },
|
||||||
|
});
|
||||||
|
if (!response.ok) {
|
||||||
|
const err = await response.json().catch(() => ({}));
|
||||||
|
throw new Error(err.detail || "Download failed");
|
||||||
|
}
|
||||||
|
const blob = await response.blob();
|
||||||
|
const url = URL.createObjectURL(blob);
|
||||||
|
const a = document.createElement("a");
|
||||||
|
a.href = url;
|
||||||
|
a.download = `${sn}_nvs.bin`;
|
||||||
|
a.click();
|
||||||
|
URL.revokeObjectURL(url);
|
||||||
|
} catch (err) {
|
||||||
|
setError(err.message);
|
||||||
|
} finally {
|
||||||
|
setNvsDownloading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const formatDate = (iso) => {
|
||||||
|
if (!iso) return "—";
|
||||||
|
try {
|
||||||
|
return new Date(iso).toLocaleString("en-US", {
|
||||||
|
year: "numeric", month: "short", day: "numeric",
|
||||||
|
hour: "2-digit", minute: "2-digit",
|
||||||
|
});
|
||||||
|
} catch {
|
||||||
|
return iso;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return (
|
||||||
|
<div className="flex items-center justify-center py-20">
|
||||||
|
<p className="text-sm" style={{ color: "var(--text-muted)" }}>Loading…</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (error && !device) {
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<button
|
||||||
|
onClick={() => navigate("/manufacturing")}
|
||||||
|
className="text-sm mb-4 hover:opacity-80 cursor-pointer"
|
||||||
|
style={{ color: "var(--text-muted)" }}
|
||||||
|
>
|
||||||
|
← Device Inventory
|
||||||
|
</button>
|
||||||
|
<div
|
||||||
|
className="text-sm rounded-md p-4 border"
|
||||||
|
style={{
|
||||||
|
backgroundColor: "var(--danger-bg)",
|
||||||
|
borderColor: "var(--danger)",
|
||||||
|
color: "var(--danger-text)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{error}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="max-w-2xl">
|
||||||
|
<div className="flex items-center gap-3 mb-6">
|
||||||
|
<button
|
||||||
|
onClick={() => navigate("/manufacturing")}
|
||||||
|
className="text-sm hover:opacity-80 transition-opacity cursor-pointer"
|
||||||
|
style={{ color: "var(--text-muted)" }}
|
||||||
|
>
|
||||||
|
← Device Inventory
|
||||||
|
</button>
|
||||||
|
<span style={{ color: "var(--text-muted)" }}>/</span>
|
||||||
|
<h1 className="text-xl font-bold font-mono" style={{ color: "var(--text-heading)" }}>
|
||||||
|
{device?.serial_number}
|
||||||
|
</h1>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{error && (
|
||||||
|
<div
|
||||||
|
className="text-sm rounded-md p-3 mb-4 border"
|
||||||
|
style={{
|
||||||
|
backgroundColor: "var(--danger-bg)",
|
||||||
|
borderColor: "var(--danger)",
|
||||||
|
color: "var(--danger-text)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{error}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Identity card */}
|
||||||
|
<div
|
||||||
|
className="rounded-lg border p-5 mb-4"
|
||||||
|
style={{ backgroundColor: "var(--bg-card)", borderColor: "var(--border-primary)" }}
|
||||||
|
>
|
||||||
|
<h2 className="text-sm font-semibold uppercase tracking-wide mb-4" style={{ color: "var(--text-muted)" }}>
|
||||||
|
Device Identity
|
||||||
|
</h2>
|
||||||
|
<div className="grid grid-cols-2 gap-4">
|
||||||
|
<Field label="Serial Number" value={device?.serial_number} mono />
|
||||||
|
<Field label="Board Type" value={BOARD_TYPE_LABELS[device?.hw_type] || device?.hw_type} />
|
||||||
|
<Field label="HW Version" value={device?.hw_version ? `v${device.hw_version}` : null} />
|
||||||
|
<Field label="Batch ID" value={device?.mfg_batch_id} mono />
|
||||||
|
<Field label="Created At" value={formatDate(device?.created_at)} />
|
||||||
|
<Field label="Owner" value={device?.owner} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Status card */}
|
||||||
|
<div
|
||||||
|
className="rounded-lg border p-5 mb-4"
|
||||||
|
style={{ backgroundColor: "var(--bg-card)", borderColor: "var(--border-primary)" }}
|
||||||
|
>
|
||||||
|
<div className="flex items-center justify-between mb-3">
|
||||||
|
<h2 className="text-sm font-semibold uppercase tracking-wide" style={{ color: "var(--text-muted)" }}>
|
||||||
|
Status
|
||||||
|
</h2>
|
||||||
|
{canEdit && !editingStatus && (
|
||||||
|
<button
|
||||||
|
onClick={() => {
|
||||||
|
setNewStatus(device.mfg_status);
|
||||||
|
setEditingStatus(true);
|
||||||
|
}}
|
||||||
|
className="text-xs hover:opacity-80 cursor-pointer"
|
||||||
|
style={{ color: "var(--text-link)" }}
|
||||||
|
>
|
||||||
|
Change
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{!editingStatus ? (
|
||||||
|
<StatusBadge status={device?.mfg_status} />
|
||||||
|
) : (
|
||||||
|
<div className="space-y-3">
|
||||||
|
{statusError && (
|
||||||
|
<div
|
||||||
|
className="text-xs rounded p-2 border"
|
||||||
|
style={{
|
||||||
|
backgroundColor: "var(--danger-bg)",
|
||||||
|
borderColor: "var(--danger)",
|
||||||
|
color: "var(--danger-text)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{statusError}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<select
|
||||||
|
value={newStatus}
|
||||||
|
onChange={(e) => setNewStatus(e.target.value)}
|
||||||
|
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)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{STATUS_OPTIONS.map((s) => (
|
||||||
|
<option key={s} value={s}>{s}</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
placeholder="Optional note…"
|
||||||
|
value={statusNote}
|
||||||
|
onChange={(e) => setStatusNote(e.target.value)}
|
||||||
|
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)",
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<button
|
||||||
|
onClick={handleStatusSave}
|
||||||
|
disabled={statusSaving}
|
||||||
|
className="px-4 py-1.5 text-sm rounded-md hover:opacity-90 transition-opacity cursor-pointer disabled:opacity-50"
|
||||||
|
style={{ backgroundColor: "var(--btn-primary)", color: "var(--text-white)" }}
|
||||||
|
>
|
||||||
|
{statusSaving ? "Saving…" : "Save"}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => { setEditingStatus(false); setStatusError(""); }}
|
||||||
|
className="px-4 py-1.5 text-sm rounded-md hover:opacity-80 cursor-pointer"
|
||||||
|
style={{ backgroundColor: "var(--bg-card-hover)", color: "var(--text-secondary)" }}
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Actions card */}
|
||||||
|
<div
|
||||||
|
className="rounded-lg border p-5"
|
||||||
|
style={{ backgroundColor: "var(--bg-card)", borderColor: "var(--border-primary)" }}
|
||||||
|
>
|
||||||
|
<h2 className="text-sm font-semibold uppercase tracking-wide mb-3" style={{ color: "var(--text-muted)" }}>
|
||||||
|
Actions
|
||||||
|
</h2>
|
||||||
|
<div className="flex flex-wrap gap-3">
|
||||||
|
<button
|
||||||
|
onClick={downloadNvs}
|
||||||
|
disabled={nvsDownloading}
|
||||||
|
className="px-4 py-2 text-sm rounded-md hover:opacity-90 transition-opacity cursor-pointer disabled:opacity-50 flex items-center gap-2"
|
||||||
|
style={{ backgroundColor: "var(--badge-blue-bg)", color: "var(--badge-blue-text)" }}
|
||||||
|
>
|
||||||
|
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4 4m0 0l-4-4m4 4V4" />
|
||||||
|
</svg>
|
||||||
|
{nvsDownloading ? "Generating…" : "Download NVS Binary"}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<p className="text-xs mt-2" style={{ color: "var(--text-muted)" }}>
|
||||||
|
NVS binary encodes device_uid, hw_type, hw_version. Flash at 0x9000.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user