feat: Phase 6, Device provisioning and deployment of updates on git-pull

This commit is contained in:
2026-02-27 04:42:41 +02:00
parent 32a2634739
commit 57259c2c2f
19 changed files with 1670 additions and 26 deletions

View File

64
backend/admin/router.py Normal file
View File

@@ -0,0 +1,64 @@
import asyncio
import hashlib
import hmac
import logging
from fastapi import APIRouter, HTTPException, Request
from config import settings
logger = logging.getLogger("admin.deploy")
router = APIRouter(prefix="/api/admin", tags=["admin"])
@router.post("/deploy")
async def deploy(request: Request):
"""Gitea webhook endpoint — pulls latest code and rebuilds Docker containers.
Gitea webhook configuration:
URL: https://<your-domain>/api/admin/deploy
Secret token: value of DEPLOY_SECRET env var
Content-Type: application/json
Trigger: Push events only (branch: main)
Add to VPS .env:
DEPLOY_SECRET=<random-strong-token>
DEPLOY_PROJECT_PATH=/home/bellsystems/bellsystems-cp
"""
if not settings.deploy_secret:
raise HTTPException(status_code=503, detail="Deploy secret not configured on server")
# Gitea sends the HMAC-SHA256 of the request body in X-Gitea-Signature
sig_header = request.headers.get("X-Gitea-Signature", "")
body = await request.body()
expected_sig = hmac.new(
key=settings.deploy_secret.encode(),
msg=body,
digestmod=hashlib.sha256,
).hexdigest()
if not hmac.compare_digest(sig_header, expected_sig):
raise HTTPException(status_code=403, detail="Invalid webhook signature")
logger.info("Auto-deploy triggered via Gitea webhook")
project_path = settings.deploy_project_path
cmd = f"cd {project_path} && git pull origin main && docker compose up -d --build"
try:
proc = await asyncio.create_subprocess_shell(
cmd,
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.STDOUT,
)
stdout, _ = await asyncio.wait_for(proc.communicate(), timeout=300)
output = stdout.decode(errors="replace") if stdout else ""
if proc.returncode != 0:
logger.error(f"Deploy failed (exit {proc.returncode}):\n{output}")
raise HTTPException(status_code=500, detail=f"Deploy script failed:\n{output[-500:]}")
logger.info(f"Deploy succeeded:\n{output[-300:]}")
return {"ok": True, "output": output[-1000:]}
except asyncio.TimeoutError:
raise HTTPException(status_code=504, detail="Deploy timed out after 300 seconds")

View File

@@ -29,10 +29,18 @@ class Settings(BaseSettings):
built_melodies_storage_path: str = "./storage/built_melodies" built_melodies_storage_path: str = "./storage/built_melodies"
firmware_storage_path: str = "./storage/firmware" firmware_storage_path: str = "./storage/firmware"
# Email (Resend)
resend_api_key: str = "re_placeholder_change_me"
email_from: str = "noreply@yourdomain.com"
# App # App
backend_cors_origins: str = '["http://localhost:5173"]' backend_cors_origins: str = '["http://localhost:5173"]'
debug: bool = True debug: bool = True
# Auto-deploy (Gitea webhook)
deploy_secret: str = ""
deploy_project_path: str = "/app"
@property @property
def cors_origins(self) -> List[str]: def cors_origins(self) -> List[str]:
return json.loads(self.backend_cors_origins) return json.loads(self.backend_cors_origins)

View File

@@ -16,6 +16,7 @@ from helpdesk.router import router as helpdesk_router
from builder.router import router as builder_router from builder.router import router as builder_router
from manufacturing.router import router as manufacturing_router from manufacturing.router import router as manufacturing_router
from firmware.router import router as firmware_router from firmware.router import router as firmware_router
from admin.router import router as admin_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
@@ -48,6 +49,7 @@ app.include_router(staff_router)
app.include_router(builder_router) app.include_router(builder_router)
app.include_router(manufacturing_router) app.include_router(manufacturing_router)
app.include_router(firmware_router) app.include_router(firmware_router)
app.include_router(admin_router)
@app.on_event("startup") @app.on_event("startup")

View File

@@ -0,0 +1,41 @@
import json
import logging
from mqtt.database import get_db
logger = logging.getLogger("manufacturing.audit")
async def log_action(
admin_user: str,
action: str,
serial_number: str | None = None,
detail: dict | None = None,
):
"""Write a manufacturing audit entry to SQLite.
action examples: batch_created, device_flashed, device_assigned, status_updated
"""
try:
db = await get_db()
await db.execute(
"""INSERT INTO mfg_audit_log (admin_user, action, serial_number, detail)
VALUES (?, ?, ?, ?)""",
(
admin_user,
action,
serial_number,
json.dumps(detail) if detail else None,
),
)
await db.commit()
except Exception as e:
logger.error(f"Failed to write audit log: {e}")
async def get_recent(limit: int = 20) -> list[dict]:
db = await get_db()
rows = await db.execute_fetchall(
"SELECT * FROM mfg_audit_log ORDER BY timestamp DESC LIMIT ?",
(limit,),
)
return [dict(r) for r in rows]

View File

@@ -59,3 +59,21 @@ class DeviceInventoryListResponse(BaseModel):
class DeviceStatusUpdate(BaseModel): class DeviceStatusUpdate(BaseModel):
status: MfgStatus status: MfgStatus
note: Optional[str] = None note: Optional[str] = None
class DeviceAssign(BaseModel):
customer_email: str
customer_name: Optional[str] = None
class RecentActivityItem(BaseModel):
serial_number: str
hw_type: str
mfg_status: str
owner: Optional[str] = None
updated_at: Optional[str] = None
class ManufacturingStats(BaseModel):
counts: dict
recent_activity: List[RecentActivityItem]

View File

@@ -1,5 +1,6 @@
from fastapi import APIRouter, Depends, Query from fastapi import APIRouter, Depends, Query
from fastapi.responses import Response from fastapi.responses import Response
from fastapi.responses import RedirectResponse
from typing import Optional from typing import Optional
from auth.models import TokenPayload from auth.models import TokenPayload
@@ -7,19 +8,48 @@ from auth.dependencies import require_permission
from manufacturing.models import ( from manufacturing.models import (
BatchCreate, BatchResponse, BatchCreate, BatchResponse,
DeviceInventoryItem, DeviceInventoryListResponse, DeviceInventoryItem, DeviceInventoryListResponse,
DeviceStatusUpdate, DeviceStatusUpdate, DeviceAssign,
ManufacturingStats,
) )
from manufacturing import service from manufacturing import service
from manufacturing import audit
router = APIRouter(prefix="/api/manufacturing", tags=["manufacturing"]) router = APIRouter(prefix="/api/manufacturing", tags=["manufacturing"])
@router.post("/batch", response_model=BatchResponse, status_code=201) @router.get("/stats", response_model=ManufacturingStats)
def create_batch( def get_stats(
body: BatchCreate, _user: TokenPayload = Depends(require_permission("manufacturing", "view")),
_user: TokenPayload = Depends(require_permission("manufacturing", "add")),
): ):
return service.create_batch(body) return service.get_stats()
@router.get("/audit-log")
async def get_audit_log(
limit: int = Query(20, ge=1, le=100),
_user: TokenPayload = Depends(require_permission("manufacturing", "view")),
):
entries = await audit.get_recent(limit=limit)
return {"entries": entries}
@router.post("/batch", response_model=BatchResponse, status_code=201)
async def create_batch(
body: BatchCreate,
user: TokenPayload = Depends(require_permission("manufacturing", "add")),
):
result = service.create_batch(body)
await audit.log_action(
admin_user=user.email,
action="batch_created",
detail={
"batch_id": result.batch_id,
"board_type": result.board_type,
"board_version": result.board_version,
"quantity": len(result.serial_numbers),
},
)
return result
@router.get("/devices", response_model=DeviceInventoryListResponse) @router.get("/devices", response_model=DeviceInventoryListResponse)
@@ -50,22 +80,62 @@ def get_device(
@router.patch("/devices/{sn}/status", response_model=DeviceInventoryItem) @router.patch("/devices/{sn}/status", response_model=DeviceInventoryItem)
def update_status( async def update_status(
sn: str, sn: str,
body: DeviceStatusUpdate, body: DeviceStatusUpdate,
_user: TokenPayload = Depends(require_permission("manufacturing", "edit")), user: TokenPayload = Depends(require_permission("manufacturing", "edit")),
): ):
return service.update_device_status(sn, body) result = service.update_device_status(sn, body)
await audit.log_action(
admin_user=user.email,
action="status_updated",
serial_number=sn,
detail={"status": body.status.value, "note": body.note},
)
return result
@router.get("/devices/{sn}/nvs.bin") @router.get("/devices/{sn}/nvs.bin")
def download_nvs( async def download_nvs(
sn: str, sn: str,
_user: TokenPayload = Depends(require_permission("manufacturing", "view")), user: TokenPayload = Depends(require_permission("manufacturing", "view")),
): ):
binary = service.get_nvs_binary(sn) binary = service.get_nvs_binary(sn)
await audit.log_action(
admin_user=user.email,
action="device_flashed",
serial_number=sn,
)
return Response( return Response(
content=binary, content=binary,
media_type="application/octet-stream", media_type="application/octet-stream",
headers={"Content-Disposition": f'attachment; filename="{sn}_nvs.bin"'}, headers={"Content-Disposition": f'attachment; filename="{sn}_nvs.bin"'},
) )
@router.post("/devices/{sn}/assign", response_model=DeviceInventoryItem)
async def assign_device(
sn: str,
body: DeviceAssign,
user: TokenPayload = Depends(require_permission("manufacturing", "edit")),
):
result = service.assign_device(sn, body)
await audit.log_action(
admin_user=user.email,
action="device_assigned",
serial_number=sn,
detail={"customer_email": body.customer_email, "customer_name": body.customer_name},
)
return result
@router.get("/devices/{sn}/firmware.bin")
def redirect_firmware(
sn: str,
_user: TokenPayload = Depends(require_permission("manufacturing", "view")),
):
"""Redirect to the latest stable firmware binary for this device's hw_type.
Resolves to GET /api/firmware/{hw_type}/stable/{version}/firmware.bin.
"""
url = service.get_firmware_url(sn)
return RedirectResponse(url=url, status_code=302)

View File

@@ -6,7 +6,7 @@ from shared.firebase import get_db
from shared.exceptions import NotFoundError from shared.exceptions import NotFoundError
from utils.serial_number import generate_serial from utils.serial_number import generate_serial
from utils.nvs_generator import generate as generate_nvs_binary from utils.nvs_generator import generate as generate_nvs_binary
from manufacturing.models import BatchCreate, BatchResponse, DeviceInventoryItem, DeviceStatusUpdate from manufacturing.models import BatchCreate, BatchResponse, DeviceInventoryItem, DeviceStatusUpdate, DeviceAssign, ManufacturingStats, RecentActivityItem
COLLECTION = "devices" COLLECTION = "devices"
_BATCH_ID_CHARS = string.ascii_uppercase + string.digits _BATCH_ID_CHARS = string.ascii_uppercase + string.digits
@@ -151,3 +151,77 @@ def get_nvs_binary(sn: str) -> bytes:
hw_type=item.hw_type, hw_type=item.hw_type,
hw_version=item.hw_version, hw_version=item.hw_version,
) )
def assign_device(sn: str, data: DeviceAssign) -> DeviceInventoryItem:
from utils.email import send_device_assignment_invite
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
doc_ref.update({
"owner": data.customer_email,
"assigned_to": data.customer_email,
"mfg_status": "sold",
})
send_device_assignment_invite(
customer_email=data.customer_email,
serial_number=sn,
customer_name=data.customer_name,
)
return _doc_to_inventory_item(doc_ref.get())
def get_stats() -> ManufacturingStats:
db = get_db()
docs = list(db.collection(COLLECTION).stream())
all_statuses = ["manufactured", "flashed", "provisioned", "sold", "claimed", "decommissioned"]
counts = {s: 0 for s in all_statuses}
activity_candidates = []
for doc in docs:
data = doc.to_dict() or {}
status = data.get("mfg_status", "manufactured")
if status in counts:
counts[status] += 1
if status in ("provisioned", "sold", "claimed"):
# Use created_at as a proxy timestamp; Firestore DatetimeWithNanoseconds or plain datetime
ts = data.get("created_at")
if isinstance(ts, datetime):
ts_str = ts.strftime("%Y-%m-%dT%H:%M:%SZ")
else:
ts_str = str(ts) if ts else None
activity_candidates.append(RecentActivityItem(
serial_number=data.get("serial_number", ""),
hw_type=data.get("hw_type", ""),
mfg_status=status,
owner=data.get("owner"),
updated_at=ts_str,
))
# Sort by updated_at descending, take latest 10
activity_candidates.sort(
key=lambda x: x.updated_at or "",
reverse=True,
)
recent = activity_candidates[:10]
return ManufacturingStats(counts=counts, recent_activity=recent)
def get_firmware_url(sn: str) -> str:
"""Return the FastAPI download URL for the latest stable firmware for this device's hw_type."""
from firmware.service import get_latest
item = get_device_by_sn(sn)
hw_type = item.hw_type.lower()
latest = get_latest(hw_type, "stable")
# download_url is a relative path like /api/firmware/vs/stable/1.4.2/firmware.bin
return latest.download_url

View File

@@ -65,6 +65,17 @@ SCHEMA_STATEMENTS = [
created_at TEXT NOT NULL DEFAULT (datetime('now')), created_at TEXT NOT NULL DEFAULT (datetime('now')),
updated_at TEXT NOT NULL DEFAULT (datetime('now')) updated_at TEXT NOT NULL DEFAULT (datetime('now'))
)""", )""",
# Manufacturing audit log
"""CREATE TABLE IF NOT EXISTS mfg_audit_log (
id INTEGER PRIMARY KEY AUTOINCREMENT,
timestamp TEXT NOT NULL DEFAULT (datetime('now')),
admin_user TEXT NOT NULL,
action TEXT NOT NULL,
serial_number TEXT,
detail TEXT
)""",
"CREATE INDEX IF NOT EXISTS idx_mfg_audit_time ON mfg_audit_log(timestamp)",
"CREATE INDEX IF NOT EXISTS idx_mfg_audit_action ON mfg_audit_log(action)",
] ]

View File

@@ -9,3 +9,4 @@ passlib[bcrypt]==1.7.4
python-multipart==0.0.20 python-multipart==0.0.20
bcrypt==4.0.1 bcrypt==4.0.1
aiosqlite==0.20.0 aiosqlite==0.20.0
resend==2.10.0

87
backend/utils/email.py Normal file
View File

@@ -0,0 +1,87 @@
import logging
import resend
from config import settings
logger = logging.getLogger(__name__)
def _get_client() -> resend.Resend:
return resend.Resend(api_key=settings.resend_api_key)
def send_email(to: str, subject: str, html: str) -> None:
"""Send a transactional email via Resend. Logs errors but does not raise."""
try:
client = _get_client()
client.emails.send({
"from": settings.email_from,
"to": to,
"subject": subject,
"html": html,
})
logger.info("Email sent to %s — subject: %s", to, subject)
except Exception as exc:
logger.error("Failed to send email to %s: %s", to, exc)
raise
def send_device_assignment_invite(
customer_email: str,
serial_number: str,
customer_name: str | None = None,
) -> None:
"""Notify a customer that a Vesper device has been assigned to them."""
greeting = f"Hi {customer_name}," if customer_name else "Hello,"
html = f"""
<div style="font-family: sans-serif; max-width: 600px; margin: 0 auto;">
<h2 style="color: #111827;">Your Vesper device is ready</h2>
<p>{greeting}</p>
<p>A Vesper bell automation device has been registered and assigned to you.</p>
<p>
<strong>Serial Number:</strong>
<code style="background: #f3f4f6; padding: 2px 6px; border-radius: 4px; font-size: 14px;">{serial_number}</code>
</p>
<p>Open the Vesper app and enter this serial number to get started.</p>
<hr style="border: none; border-top: 1px solid #e5e7eb; margin: 24px 0;" />
<p style="color: #6b7280; font-size: 12px;">
If you did not expect this email, please contact your system administrator.
</p>
</div>
"""
send_email(
to=customer_email,
subject=f"Your Vesper device is ready — {serial_number}",
html=html,
)
def send_device_provisioned_alert(
admin_email: str,
serial_number: str,
hw_type: str,
) -> None:
"""Internal alert sent to an admin when a device reaches provisioned status."""
html = f"""
<div style="font-family: sans-serif; max-width: 600px; margin: 0 auto;">
<h2 style="color: #111827;">Device Provisioned</h2>
<p>A Vesper device has successfully provisioned and is ready to ship.</p>
<table style="border-collapse: collapse; width: 100%; margin-top: 16px;">
<tr>
<td style="padding: 6px 12px; font-weight: bold; background: #f9fafb;">Serial Number</td>
<td style="padding: 6px 12px; font-family: monospace;">{serial_number}</td>
</tr>
<tr>
<td style="padding: 6px 12px; font-weight: bold; background: #f9fafb;">Board Type</td>
<td style="padding: 6px 12px;">{hw_type.upper()}</td>
</tr>
</table>
<p style="margin-top: 24px;">
<a href="#" style="color: #2563eb;">View in Admin Console</a>
</p>
</div>
"""
send_email(
to=admin_email,
subject=f"[Vesper] Device provisioned — {serial_number}",
html=html,
)

View File

@@ -8,6 +8,7 @@
"name": "frontend", "name": "frontend",
"version": "0.0.0", "version": "0.0.0",
"dependencies": { "dependencies": {
"esptool-js": "^0.5.7",
"leaflet": "^1.9.4", "leaflet": "^1.9.4",
"react": "^19.2.0", "react": "^19.2.0",
"react-dom": "^19.2.0", "react-dom": "^19.2.0",
@@ -1814,6 +1815,12 @@
"dev": true, "dev": true,
"license": "Python-2.0" "license": "Python-2.0"
}, },
"node_modules/atob-lite": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/atob-lite/-/atob-lite-2.0.0.tgz",
"integrity": "sha512-LEeSAWeh2Gfa2FtlQE1shxQ8zi5F9GHarrGKz08TMdODD5T4eH6BMsvtnhbWZ+XQn+Gb6om/917ucvRu7l7ukw==",
"license": "MIT"
},
"node_modules/balanced-match": { "node_modules/balanced-match": {
"version": "1.0.2", "version": "1.0.2",
"resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz",
@@ -2252,6 +2259,17 @@
"url": "https://opencollective.com/eslint" "url": "https://opencollective.com/eslint"
} }
}, },
"node_modules/esptool-js": {
"version": "0.5.7",
"resolved": "https://registry.npmjs.org/esptool-js/-/esptool-js-0.5.7.tgz",
"integrity": "sha512-k3pkXU9OTySCd58OUDjuJWNnFjM+QpPWAghxyWPm3zNfaLiP4ex2jNd7Rj0jWPu3/fgvwau236tetsTZrh4x5g==",
"license": "Apache-2.0",
"dependencies": {
"atob-lite": "^2.0.0",
"pako": "^2.1.0",
"tslib": "^2.4.1"
}
},
"node_modules/esquery": { "node_modules/esquery": {
"version": "1.7.0", "version": "1.7.0",
"resolved": "https://registry.npmjs.org/esquery/-/esquery-1.7.0.tgz", "resolved": "https://registry.npmjs.org/esquery/-/esquery-1.7.0.tgz",
@@ -3054,6 +3072,12 @@
"url": "https://github.com/sponsors/sindresorhus" "url": "https://github.com/sponsors/sindresorhus"
} }
}, },
"node_modules/pako": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/pako/-/pako-2.1.0.tgz",
"integrity": "sha512-w+eufiZ1WuJYgPXbV/PO3NCMEc3xqylkKHzp8bxp1uW4qaSNQUkwmLLEc3kKsfz8lpV1F8Ht3U1Cm+9Srog2ug==",
"license": "(MIT AND Zlib)"
},
"node_modules/parent-module": { "node_modules/parent-module": {
"version": "1.0.1", "version": "1.0.1",
"resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz", "resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz",
@@ -3413,6 +3437,12 @@
"url": "https://github.com/sponsors/SuperchupuDev" "url": "https://github.com/sponsors/SuperchupuDev"
} }
}, },
"node_modules/tslib": {
"version": "2.8.1",
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz",
"integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==",
"license": "0BSD"
},
"node_modules/type-check": { "node_modules/type-check": {
"version": "0.4.0", "version": "0.4.0",
"resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz", "resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz",

View File

@@ -10,6 +10,7 @@
"preview": "vite preview" "preview": "vite preview"
}, },
"dependencies": { "dependencies": {
"esptool-js": "^0.5.7",
"leaflet": "^1.9.4", "leaflet": "^1.9.4",
"react": "^19.2.0", "react": "^19.2.0",
"react-dom": "^19.2.0", "react-dom": "^19.2.0",

View File

@@ -27,7 +27,9 @@ import StaffForm from "./settings/StaffForm";
import DeviceInventory from "./manufacturing/DeviceInventory"; import DeviceInventory from "./manufacturing/DeviceInventory";
import BatchCreator from "./manufacturing/BatchCreator"; import BatchCreator from "./manufacturing/BatchCreator";
import DeviceInventoryDetail from "./manufacturing/DeviceInventoryDetail"; import DeviceInventoryDetail from "./manufacturing/DeviceInventoryDetail";
import ProvisioningWizard from "./manufacturing/ProvisioningWizard";
import FirmwareManager from "./firmware/FirmwareManager"; import FirmwareManager from "./firmware/FirmwareManager";
import DashboardPage from "./dashboard/DashboardPage";
function ProtectedRoute({ children }) { function ProtectedRoute({ children }) {
const { user, loading } = useAuth(); const { user, loading } = useAuth();
@@ -93,18 +95,6 @@ function RoleGate({ roles, children }) {
return children; return children;
} }
function DashboardPage() {
const { user } = useAuth();
return (
<div>
<h1 className="text-2xl font-bold mb-4" style={{ color: "var(--text-heading)" }}>Dashboard</h1>
<p style={{ color: "var(--text-secondary)" }}>
Welcome, {user?.name}. You are logged in as{" "}
<span className="font-medium" style={{ color: "var(--accent)" }}>{user?.role}</span>.
</p>
</div>
);
}
export default function App() { export default function App() {
return ( return (
@@ -156,6 +146,7 @@ export default function App() {
{/* Manufacturing */} {/* Manufacturing */}
<Route path="manufacturing" element={<PermissionGate section="manufacturing"><DeviceInventory /></PermissionGate>} /> <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/batch/new" element={<PermissionGate section="manufacturing" action="add"><BatchCreator /></PermissionGate>} />
<Route path="manufacturing/provision" element={<PermissionGate section="manufacturing" action="edit"><ProvisioningWizard /></PermissionGate>} />
<Route path="manufacturing/devices/:sn" element={<PermissionGate section="manufacturing"><DeviceInventoryDetail /></PermissionGate>} /> <Route path="manufacturing/devices/:sn" element={<PermissionGate section="manufacturing"><DeviceInventoryDetail /></PermissionGate>} />
<Route path="firmware" element={<PermissionGate section="manufacturing"><FirmwareManager /></PermissionGate>} /> <Route path="firmware" element={<PermissionGate section="manufacturing"><FirmwareManager /></PermissionGate>} />

View File

@@ -0,0 +1,266 @@
import { useState, useEffect } from "react";
import { useNavigate } from "react-router-dom";
import { useAuth } from "../auth/AuthContext";
import api from "../api/client";
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_ORDER = ["manufactured", "flashed", "provisioned", "sold", "claimed", "decommissioned"];
const ACTION_LABELS = {
batch_created: "Batch created",
device_flashed: "NVS downloaded",
device_assigned: "Device assigned",
status_updated: "Status updated",
};
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>
);
}
function StatCard({ label, count, status, onClick }) {
const style = STATUS_STYLES[status] || STATUS_STYLES.manufactured;
return (
<button
onClick={onClick}
className="rounded-lg border p-4 text-left transition-colors cursor-pointer w-full"
style={{ backgroundColor: "var(--bg-card)", borderColor: "var(--border-primary)" }}
onMouseEnter={(e) => (e.currentTarget.style.backgroundColor = "var(--bg-card-hover)")}
onMouseLeave={(e) => (e.currentTarget.style.backgroundColor = "var(--bg-card)")}
>
<div
className="text-3xl font-bold mb-1"
style={{ color: style.color }}
>
{count}
</div>
<div className="text-xs capitalize font-medium" style={{ color: "var(--text-muted)" }}>
{label}
</div>
</button>
);
}
export default function DashboardPage() {
const { user, hasPermission } = useAuth();
const navigate = useNavigate();
const canViewMfg = hasPermission("manufacturing", "view");
const [stats, setStats] = useState(null);
const [auditLog, setAuditLog] = useState([]);
const [loadingStats, setLoadingStats] = useState(false);
const [loadingAudit, setLoadingAudit] = useState(false);
useEffect(() => {
if (!canViewMfg) return;
setLoadingStats(true);
api.get("/manufacturing/stats")
.then(setStats)
.catch(() => {})
.finally(() => setLoadingStats(false));
setLoadingAudit(true);
api.get("/manufacturing/audit-log?limit=20")
.then((data) => setAuditLog(data.entries || []))
.catch(() => {})
.finally(() => setLoadingAudit(false));
}, [canViewMfg]);
const formatTs = (ts) => {
if (!ts) return "—";
try {
return new Date(ts).toLocaleString("en-US", {
month: "short", day: "numeric",
hour: "2-digit", minute: "2-digit",
});
} catch {
return ts;
}
};
return (
<div>
<h1 className="text-2xl font-bold mb-1" style={{ color: "var(--text-heading)" }}>
Dashboard
</h1>
<p className="text-sm mb-6" style={{ color: "var(--text-secondary)" }}>
Welcome, {user?.name}.{" "}
<span className="font-medium" style={{ color: "var(--accent)" }}>{user?.role}</span>
</p>
{canViewMfg && (
<>
{/* Device Status Summary */}
<div className="mb-2 flex items-center justify-between">
<h2 className="text-sm font-semibold uppercase tracking-wide" style={{ color: "var(--text-muted)" }}>
Device Inventory
</h2>
<button
onClick={() => navigate("/manufacturing")}
className="text-xs underline"
style={{ color: "var(--text-link)" }}
>
View all
</button>
</div>
{loadingStats ? (
<div className="text-sm mb-6" style={{ color: "var(--text-muted)" }}>Loading</div>
) : stats ? (
<div className="grid grid-cols-3 sm:grid-cols-6 gap-3 mb-8">
{STATUS_ORDER.map((s) => (
<StatCard
key={s}
label={s}
count={stats.counts[s] ?? 0}
status={s}
onClick={() => navigate(`/manufacturing?status=${s}`)}
/>
))}
</div>
) : null}
{/* Recent Activity */}
{stats?.recent_activity?.length > 0 && (
<div className="mb-8">
<h2 className="text-sm font-semibold uppercase tracking-wide mb-2" style={{ color: "var(--text-muted)" }}>
Recent Activity
</h2>
<div className="rounded-lg border overflow-hidden" style={{ borderColor: "var(--border-primary)" }}>
<table className="w-full text-sm">
<thead>
<tr style={{ backgroundColor: "var(--bg-secondary)", borderBottom: "1px solid var(--border-primary)" }}>
<th className="px-4 py-2 text-left font-medium text-xs" style={{ color: "var(--text-muted)" }}>Serial Number</th>
<th className="px-4 py-2 text-left font-medium text-xs" style={{ color: "var(--text-muted)" }}>Status</th>
<th className="px-4 py-2 text-left font-medium text-xs" style={{ color: "var(--text-muted)" }}>Owner</th>
<th className="px-4 py-2 text-left font-medium text-xs" style={{ color: "var(--text-muted)" }}>Date</th>
</tr>
</thead>
<tbody>
{stats.recent_activity.map((item, i) => (
<tr
key={i}
className="cursor-pointer"
style={{ borderBottom: "1px solid var(--border-secondary)" }}
onClick={() => navigate(`/manufacturing/devices/${item.serial_number}`)}
onMouseEnter={(e) => (e.currentTarget.style.backgroundColor = "var(--bg-card-hover)")}
onMouseLeave={(e) => (e.currentTarget.style.backgroundColor = "")}
>
<td className="px-4 py-2 font-mono text-xs" style={{ color: "var(--text-primary)" }}>
{item.serial_number}
</td>
<td className="px-4 py-2">
<StatusBadge status={item.mfg_status} />
</td>
<td className="px-4 py-2 text-xs" style={{ color: "var(--text-muted)" }}>
{item.owner || "—"}
</td>
<td className="px-4 py-2 text-xs" style={{ color: "var(--text-muted)" }}>
{formatTs(item.updated_at)}
</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
)}
{/* Audit Log */}
<div>
<h2 className="text-sm font-semibold uppercase tracking-wide mb-2" style={{ color: "var(--text-muted)" }}>
Audit Log
</h2>
{loadingAudit ? (
<div className="text-sm" style={{ color: "var(--text-muted)" }}>Loading</div>
) : auditLog.length === 0 ? (
<div className="text-sm" style={{ color: "var(--text-muted)" }}>No audit entries yet.</div>
) : (
<div className="rounded-lg border overflow-hidden" style={{ borderColor: "var(--border-primary)" }}>
<table className="w-full text-sm">
<thead>
<tr style={{ backgroundColor: "var(--bg-secondary)", borderBottom: "1px solid var(--border-primary)" }}>
<th className="px-4 py-2 text-left font-medium text-xs" style={{ color: "var(--text-muted)" }}>Time</th>
<th className="px-4 py-2 text-left font-medium text-xs" style={{ color: "var(--text-muted)" }}>Admin</th>
<th className="px-4 py-2 text-left font-medium text-xs" style={{ color: "var(--text-muted)" }}>Action</th>
<th className="px-4 py-2 text-left font-medium text-xs" style={{ color: "var(--text-muted)" }}>Device</th>
<th className="px-4 py-2 text-left font-medium text-xs" style={{ color: "var(--text-muted)" }}>Detail</th>
</tr>
</thead>
<tbody>
{auditLog.map((entry) => (
<tr
key={entry.id}
style={{ borderBottom: "1px solid var(--border-secondary)" }}
>
<td className="px-4 py-2 text-xs whitespace-nowrap" style={{ color: "var(--text-muted)" }}>
{formatTs(entry.timestamp)}
</td>
<td className="px-4 py-2 text-xs" style={{ color: "var(--text-secondary)" }}>
{entry.admin_user}
</td>
<td className="px-4 py-2 text-xs font-medium" style={{ color: "var(--text-primary)" }}>
{ACTION_LABELS[entry.action] || entry.action}
</td>
<td className="px-4 py-2 font-mono text-xs" style={{ color: "var(--text-muted)" }}>
{entry.serial_number
? (
<button
className="underline"
style={{ color: "var(--text-link)" }}
onClick={() => navigate(`/manufacturing/devices/${entry.serial_number}`)}
>
{entry.serial_number}
</button>
)
: "—"}
</td>
<td className="px-4 py-2 text-xs" style={{ color: "var(--text-muted)" }}>
{entry.detail
? (() => {
try {
const d = JSON.parse(entry.detail);
return Object.entries(d)
.filter(([, v]) => v !== null && v !== undefined)
.map(([k, v]) => `${k}: ${v}`)
.join(", ");
} catch {
return entry.detail;
}
})()
: "—"}
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</div>
</>
)}
{!canViewMfg && (
<p className="text-sm" style={{ color: "var(--text-muted)" }}>
Select a section from the sidebar to get started.
</p>
)}
</div>
);
}

View File

@@ -32,6 +32,7 @@ const navItems = [
children: [ children: [
{ to: "/manufacturing", label: "Device Inventory" }, { to: "/manufacturing", label: "Device Inventory" },
{ to: "/manufacturing/batch/new", label: "New Batch" }, { to: "/manufacturing/batch/new", label: "New Batch" },
{ to: "/manufacturing/provision", label: "Provision Device" },
{ to: "/firmware", label: "Firmware" }, { to: "/firmware", label: "Firmware" },
], ],
}, },

View File

@@ -64,6 +64,12 @@ export default function DeviceInventoryDetail() {
const [nvsDownloading, setNvsDownloading] = useState(false); const [nvsDownloading, setNvsDownloading] = useState(false);
const [assignEmail, setAssignEmail] = useState("");
const [assignName, setAssignName] = useState("");
const [assignSaving, setAssignSaving] = useState(false);
const [assignError, setAssignError] = useState("");
const [assignSuccess, setAssignSuccess] = useState(false);
const loadDevice = async () => { const loadDevice = async () => {
setLoading(true); setLoading(true);
setError(""); setError("");
@@ -99,6 +105,28 @@ export default function DeviceInventoryDetail() {
} }
}; };
const handleAssign = async () => {
setAssignError("");
setAssignSaving(true);
try {
const updated = await api.request(`/manufacturing/devices/${sn}/assign`, {
method: "POST",
body: JSON.stringify({
customer_email: assignEmail,
customer_name: assignName || null,
}),
});
setDevice(updated);
setAssignSuccess(true);
setAssignEmail("");
setAssignName("");
} catch (err) {
setAssignError(err.message);
} finally {
setAssignSaving(false);
}
};
const downloadNvs = async () => { const downloadNvs = async () => {
setNvsDownloading(true); setNvsDownloading(true);
try { try {
@@ -303,7 +331,7 @@ export default function DeviceInventoryDetail() {
{/* Actions card */} {/* Actions card */}
<div <div
className="rounded-lg border p-5" className="rounded-lg border p-5 mb-4"
style={{ backgroundColor: "var(--bg-card)", borderColor: "var(--border-primary)" }} 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)" }}> <h2 className="text-sm font-semibold uppercase tracking-wide mb-3" style={{ color: "var(--text-muted)" }}>
@@ -326,6 +354,77 @@ export default function DeviceInventoryDetail() {
NVS binary encodes device_uid, hw_type, hw_version. Flash at 0x9000. NVS binary encodes device_uid, hw_type, hw_version. Flash at 0x9000.
</p> </p>
</div> </div>
{/* Assign to Customer card */}
{canEdit && ["provisioned", "flashed"].includes(device?.mfg_status) && (
<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)" }}>
Assign to Customer
</h2>
{assignSuccess ? (
<div
className="text-sm rounded-md p-3 border"
style={{ backgroundColor: "#0a2e2a", borderColor: "#4dd6c8", color: "#4dd6c8" }}
>
Device assigned and invitation email sent to <strong>{device?.owner}</strong>.
</div>
) : (
<div className="space-y-3">
{assignError && (
<div
className="text-xs rounded p-2 border"
style={{
backgroundColor: "var(--danger-bg)",
borderColor: "var(--danger)",
color: "var(--danger-text)",
}}
>
{assignError}
</div>
)}
<input
type="email"
placeholder="Customer email address"
value={assignEmail}
onChange={(e) => setAssignEmail(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)",
}}
/>
<input
type="text"
placeholder="Customer name (optional)"
value={assignName}
onChange={(e) => setAssignName(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)",
}}
/>
<button
onClick={handleAssign}
disabled={assignSaving || !assignEmail.trim()}
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)" }}
>
{assignSaving ? "Sending…" : "Assign & Send Invite"}
</button>
<p className="text-xs" style={{ color: "var(--text-muted)" }}>
Sets device status to <em>sold</em> and emails the customer their serial number.
</p>
</div>
)}
</div>
)}
</div> </div>
); );
} }

View File

@@ -0,0 +1,868 @@
import { useState, useRef, useCallback } from "react";
import { useNavigate } from "react-router-dom";
import { ESPLoader, Transport } from "esptool-js";
import api from "../api/client";
// ─── constants ───────────────────────────────────────────────────────────────
const BOARD_TYPES = [
{ value: "vs", label: "Vesper (VS)" },
{ value: "vp", label: "Vesper+ (VP)" },
{ value: "vx", label: "VesperPro (VX)" },
];
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 FLASH_BAUD = 460800;
const NVS_ADDRESS = 0x9000;
const FW_ADDRESS = 0x10000;
const VERIFY_POLL_MS = 5000;
const VERIFY_TIMEOUT_MS = 120_000;
// ─── small helpers ────────────────────────────────────────────────────────────
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 StepIndicator({ current }) {
const steps = ["Select Device", "Flash", "Verify", "Done"];
return (
<div className="flex items-center gap-0 mb-8">
{steps.map((label, i) => {
const idx = i + 1;
const done = idx < current;
const active = idx === current;
const pending = idx > current;
return (
<div key={label} className="flex items-center">
<div className="flex flex-col items-center">
<div
className="w-8 h-8 rounded-full flex items-center justify-center text-sm font-semibold transition-colors"
style={{
backgroundColor: done
? "var(--accent)"
: active
? "var(--accent)"
: "var(--bg-card-hover)",
color: done || active ? "var(--bg-primary)" : "var(--text-muted)",
opacity: pending ? 0.5 : 1,
}}
>
{done ? (
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2.5} d="M5 13l4 4L19 7" />
</svg>
) : idx}
</div>
<span
className="text-xs mt-1 whitespace-nowrap"
style={{ color: active ? "var(--text-primary)" : "var(--text-muted)", opacity: pending ? 0.5 : 1 }}
>
{label}
</span>
</div>
{i < steps.length - 1 && (
<div
className="h-px w-12 mx-1 mb-5 flex-shrink-0"
style={{ backgroundColor: done ? "var(--accent)" : "var(--border-primary)" }}
/>
)}
</div>
);
})}
</div>
);
}
function ProgressBar({ label, percent }) {
return (
<div className="space-y-1.5">
<div className="flex justify-between text-xs" style={{ color: "var(--text-secondary)" }}>
<span>{label}</span>
<span>{Math.round(percent)}%</span>
</div>
<div
className="h-2 rounded-full overflow-hidden"
style={{ backgroundColor: "var(--bg-card-hover)" }}
>
<div
className="h-full rounded-full transition-all duration-200"
style={{ width: `${percent}%`, backgroundColor: "var(--accent)" }}
/>
</div>
</div>
);
}
function ErrorBox({ msg }) {
if (!msg) return null;
return (
<div
className="text-sm rounded-md p-3 border"
style={{
backgroundColor: "var(--danger-bg)",
borderColor: "var(--danger)",
color: "var(--danger-text)",
}}
>
{msg}
</div>
);
}
function inputCls() {
return "w-full px-3 py-2 rounded-md text-sm border";
}
function inputStyle() {
return {
backgroundColor: "var(--bg-input)",
borderColor: "var(--border-input)",
color: "var(--text-primary)",
};
}
// ─── Step 1 — Select or create device ─────────────────────────────────────────
function StepSelectDevice({ onSelected }) {
const [mode, setMode] = useState("search"); // "search" | "create"
const [searchSn, setSearchSn] = useState("");
const [searching, setSearching] = useState(false);
const [searchError, setSearchError] = useState("");
const [found, setFound] = useState(null);
// Create-device fields
const [boardType, setBoardType] = useState("vs");
const [boardVersion, setBoardVersion] = useState("01");
const [creating, setCreating] = useState(false);
const [createError, setCreateError] = useState("");
const handleSearch = async (e) => {
e.preventDefault();
setSearchError("");
setFound(null);
setSearching(true);
try {
const data = await api.get(`/manufacturing/devices/${searchSn.trim().toUpperCase()}`);
if (data.mfg_status === "flashed" || data.mfg_status === "provisioned") {
setSearchError(
`Device is already ${data.mfg_status}. Only unprovisioned devices can be re-flashed here.`
);
} else {
setFound(data);
}
} catch (err) {
setSearchError(err.message);
} finally {
setSearching(false);
}
};
const handleCreate = async (e) => {
e.preventDefault();
setCreateError("");
setCreating(true);
try {
const batch = await api.post("/manufacturing/batch", {
board_type: boardType,
board_version: boardVersion,
quantity: 1,
});
const sn = batch.serial_numbers[0];
const device = await api.get(`/manufacturing/devices/${sn}`);
setFound(device);
} catch (err) {
setCreateError(err.message);
} finally {
setCreating(false);
}
};
if (found) {
return (
<div className="space-y-4">
<div
className="rounded-lg border p-5"
style={{ backgroundColor: "var(--bg-card)", borderColor: "var(--border-primary)" }}
>
<h3 className="text-sm font-semibold uppercase tracking-wide mb-4" style={{ color: "var(--text-muted)" }}>
Device Selected
</h3>
<div className="grid grid-cols-2 gap-4 mb-4">
<div>
<p className="text-xs uppercase tracking-wide mb-0.5" style={{ color: "var(--text-muted)" }}>Serial Number</p>
<p className="text-sm font-mono" style={{ color: "var(--text-primary)" }}>{found.serial_number}</p>
</div>
<div>
<p className="text-xs uppercase tracking-wide mb-0.5" style={{ color: "var(--text-muted)" }}>Board Type</p>
<p className="text-sm" style={{ color: "var(--text-primary)" }}>{BOARD_TYPE_LABELS[found.hw_type] || found.hw_type}</p>
</div>
<div>
<p className="text-xs uppercase tracking-wide mb-0.5" style={{ color: "var(--text-muted)" }}>HW Version</p>
<p className="text-sm" style={{ color: "var(--text-primary)" }}>v{found.hw_version}</p>
</div>
<div>
<p className="text-xs uppercase tracking-wide mb-0.5" style={{ color: "var(--text-muted)" }}>Status</p>
<StatusBadge status={found.mfg_status} />
</div>
</div>
<div className="flex gap-3">
<button
onClick={() => onSelected(found)}
className="px-5 py-2 text-sm rounded-md font-medium hover:opacity-90 transition-opacity cursor-pointer"
style={{ backgroundColor: "var(--btn-primary)", color: "var(--text-white)" }}
>
Continue
</button>
<button
onClick={() => setFound(null)}
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)" }}
>
Change Device
</button>
</div>
</div>
</div>
);
}
return (
<div className="space-y-4">
{/* Mode toggle */}
<div className="flex rounded-md overflow-hidden border" style={{ borderColor: "var(--border-primary)" }}>
{[["search", "Search Existing"], ["create", "Quick Create"]].map(([val, lbl]) => (
<button
key={val}
onClick={() => { setMode(val); setSearchError(""); setCreateError(""); }}
className="flex-1 py-2 text-sm font-medium transition-colors cursor-pointer"
style={{
backgroundColor: mode === val ? "var(--accent)" : "var(--bg-card-hover)",
color: mode === val ? "var(--bg-primary)" : "var(--text-secondary)",
}}
>
{lbl}
</button>
))}
</div>
{/* Search */}
{mode === "search" && (
<div
className="rounded-lg border p-5"
style={{ backgroundColor: "var(--bg-card)", borderColor: "var(--border-primary)" }}
>
<h3 className="text-sm font-semibold mb-4" style={{ color: "var(--text-heading)" }}>
Find Unprovisioned Device
</h3>
<ErrorBox msg={searchError} />
{searchError && <div className="h-3" />}
<form onSubmit={handleSearch} className="space-y-3">
<input
type="text"
placeholder="e.g. PV-26B27-VS01R-X7KQA"
value={searchSn}
onChange={(e) => setSearchSn(e.target.value)}
required
className={inputCls()}
style={inputStyle()}
/>
<button
type="submit"
disabled={searching}
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)" }}
>
{searching ? "Searching…" : "Search"}
</button>
</form>
</div>
)}
{/* Create */}
{mode === "create" && (
<div
className="rounded-lg border p-5"
style={{ backgroundColor: "var(--bg-card)", borderColor: "var(--border-primary)" }}
>
<h3 className="text-sm font-semibold mb-4" style={{ color: "var(--text-heading)" }}>
Create Single Device
</h3>
<ErrorBox msg={createError} />
{createError && <div className="h-3" />}
<form onSubmit={handleCreate} 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={inputCls()}
style={inputStyle()}
>
{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={inputCls()}
style={inputStyle()}
/>
</div>
<button
type="submit"
disabled={creating}
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)" }}
>
{creating ? "Creating…" : "Create & Continue"}
</button>
</form>
</div>
)}
</div>
);
}
// ─── Step 2 — Flash ────────────────────────────────────────────────────────────
function StepFlash({ device, onFlashed }) {
const [connecting, setConnecting] = useState(false);
const [flashing, setFlashing] = useState(false);
const [nvsProgress, setNvsProgress] = useState(0);
const [fwProgress, setFwProgress] = useState(0);
const [log, setLog] = useState([]);
const [error, setError] = useState("");
const loaderRef = useRef(null);
const appendLog = (msg) => setLog((prev) => [...prev, msg]);
const fetchBinary = async (url) => {
const token = localStorage.getItem("access_token");
const resp = await fetch(url, { headers: { Authorization: `Bearer ${token}` } });
if (!resp.ok) {
const err = await resp.json().catch(() => ({}));
throw new Error(err.detail || `Failed to fetch ${url}: ${resp.status}`);
}
return resp.arrayBuffer();
};
// esptool-js wants binary data as a plain string of char codes
const arrayBufferToString = (buf) => {
const bytes = new Uint8Array(buf);
let str = "";
for (let i = 0; i < bytes.length; i++) {
str += String.fromCharCode(bytes[i]);
}
return str;
};
const handleFlash = async () => {
setError("");
setLog([]);
setNvsProgress(0);
setFwProgress(0);
// 1. Open Web Serial port
let port;
try {
setConnecting(true);
appendLog("Opening port picker…");
port = await navigator.serial.requestPort();
} catch (err) {
setError(err.message || "Port selection cancelled.");
setConnecting(false);
return;
}
try {
// 2. Fetch binaries from backend
appendLog("Fetching NVS binary…");
const nvsBuffer = await fetchBinary(`/api/manufacturing/devices/${device.serial_number}/nvs.bin`);
appendLog(`NVS binary: ${nvsBuffer.byteLength} bytes`);
appendLog("Fetching firmware binary…");
const fwBuffer = await fetchBinary(`/api/manufacturing/devices/${device.serial_number}/firmware.bin`);
appendLog(`Firmware binary: ${fwBuffer.byteLength} bytes`);
// 3. Connect ESPLoader
setConnecting(false);
setFlashing(true);
appendLog("Connecting to ESP32…");
const transport = new Transport(port, true);
loaderRef.current = new ESPLoader({
transport,
baudrate: FLASH_BAUD,
terminal: {
clean() {},
writeLine: (line) => appendLog(line),
write: (msg) => appendLog(msg),
},
});
await loaderRef.current.main();
appendLog("ESP32 connected.");
// 4. Flash NVS + firmware with progress callbacks
const nvsData = arrayBufferToString(nvsBuffer);
const fwData = arrayBufferToString(fwBuffer);
// Track progress by watching the two images in sequence.
// esptool-js reports progress as { index, fileIndex, written, total }
const totalBytes = nvsBuffer.byteLength + fwBuffer.byteLength;
let writtenSoFar = 0;
await loaderRef.current.writeFlash({
fileArray: [
{ data: nvsData, address: NVS_ADDRESS },
{ data: fwData, address: FW_ADDRESS },
],
flashSize: "keep",
flashMode: "keep",
flashFreq: "keep",
eraseAll: false,
compress: true,
reportProgress(fileIndex, written, total) {
if (fileIndex === 0) {
setNvsProgress((written / total) * 100);
} else {
setNvsProgress(100);
setFwProgress((written / total) * 100);
}
writtenSoFar = written;
},
calculateMD5Hash: (image) => {
// MD5 is optional for progress verification; returning empty disables it
return "";
},
});
setNvsProgress(100);
setFwProgress(100);
appendLog("Flash complete. Disconnecting…");
await transport.disconnect();
appendLog("Done.");
// 5. Update device status → flashed
await api.request(`/manufacturing/devices/${device.serial_number}/status`, {
method: "PATCH",
body: JSON.stringify({ status: "flashed", note: "Flashed via browser provisioning wizard" }),
});
onFlashed();
} catch (err) {
setError(err.message || String(err));
setFlashing(false);
setConnecting(false);
try {
if (loaderRef.current) await loaderRef.current.transport?.disconnect();
} catch (_) {}
}
};
const webSerialAvailable = "serial" in navigator;
return (
<div className="space-y-4">
<div
className="rounded-lg border p-5"
style={{ backgroundColor: "var(--bg-card)", borderColor: "var(--border-primary)" }}
>
<h3 className="text-sm font-semibold uppercase tracking-wide mb-4" style={{ color: "var(--text-muted)" }}>
Device to Flash
</h3>
<div className="grid grid-cols-2 gap-3 mb-5">
<div>
<p className="text-xs uppercase tracking-wide mb-0.5" style={{ color: "var(--text-muted)" }}>Serial Number</p>
<p className="text-sm font-mono" style={{ color: "var(--text-primary)" }}>{device.serial_number}</p>
</div>
<div>
<p className="text-xs uppercase tracking-wide mb-0.5" style={{ color: "var(--text-muted)" }}>Board</p>
<p className="text-sm" style={{ color: "var(--text-primary)" }}>
{BOARD_TYPE_LABELS[device.hw_type] || device.hw_type} v{device.hw_version}
</p>
</div>
</div>
{!webSerialAvailable && (
<div
className="text-sm rounded-md p-3 mb-4 border"
style={{ backgroundColor: "#2e1a00", borderColor: "#fb923c", color: "#fb923c" }}
>
Web Serial API not available. Use Chrome or Edge on a desktop system.
</div>
)}
<ErrorBox msg={error} />
{error && <div className="h-3" />}
{(flashing || nvsProgress > 0) && (
<div className="space-y-3 mb-5">
<ProgressBar label="NVS Partition (0x9000)" percent={nvsProgress} />
<ProgressBar label="Firmware (0x10000)" percent={fwProgress} />
</div>
)}
{log.length > 0 && (
<div
className="rounded-md border p-3 mb-4 font-mono text-xs overflow-y-auto max-h-36 space-y-0.5"
style={{
backgroundColor: "var(--bg-primary)",
borderColor: "var(--border-secondary)",
color: "var(--text-muted)",
}}
>
{log.map((line, i) => (
<div key={i}>{line}</div>
))}
</div>
)}
{!flashing && (
<button
onClick={handleFlash}
disabled={!webSerialAvailable || connecting}
className="flex items-center gap-2 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)" }}
>
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M13 10V3L4 14h7v7l9-11h-7z" />
</svg>
{connecting ? "Connecting…" : "Connect & Flash Device"}
</button>
)}
{flashing && nvsProgress < 100 && fwProgress < 100 && (
<p className="text-sm" style={{ color: "var(--text-muted)" }}>
Flashing in progress do not disconnect
</p>
)}
</div>
<p className="text-xs" style={{ color: "var(--text-muted)" }}>
Flash addresses: NVS at 0x9000 · Firmware at 0x10000 · Baud: {FLASH_BAUD}
</p>
</div>
);
}
// ─── Step 3 — Verify ──────────────────────────────────────────────────────────
function StepVerify({ device, onVerified }) {
const [polling, setPolling] = useState(false);
const [timedOut, setTimedOut] = useState(false);
const [error, setError] = useState("");
const intervalRef = useRef(null);
const timeoutRef = useRef(null);
const startPolling = useCallback(() => {
if (polling) return;
setPolling(true);
setTimedOut(false);
setError("");
const startTime = Date.now();
intervalRef.current = setInterval(async () => {
try {
const data = await api.get(`/manufacturing/devices/${device.serial_number}`);
if (data.mfg_status === "provisioned") {
clearInterval(intervalRef.current);
clearTimeout(timeoutRef.current);
onVerified(data);
return;
}
// Also accept any last_seen update (heartbeat) as evidence of life
if (data.last_seen) {
const ts = new Date(data.last_seen).getTime();
if (ts > startTime) {
clearInterval(intervalRef.current);
clearTimeout(timeoutRef.current);
// Promote to provisioned
try {
await api.request(`/manufacturing/devices/${device.serial_number}/status`, {
method: "PATCH",
body: JSON.stringify({ status: "provisioned", note: "Auto-verified via wizard" }),
});
} catch (_) {}
onVerified({ ...data, mfg_status: "provisioned" });
return;
}
}
} catch (err) {
// Non-fatal; keep polling
setError(err.message);
}
}, VERIFY_POLL_MS);
timeoutRef.current = setTimeout(() => {
clearInterval(intervalRef.current);
setPolling(false);
setTimedOut(true);
}, VERIFY_TIMEOUT_MS);
}, [polling, device.serial_number, onVerified]);
const stopPolling = () => {
clearInterval(intervalRef.current);
clearTimeout(timeoutRef.current);
setPolling(false);
};
return (
<div className="space-y-4">
<div
className="rounded-lg border p-5"
style={{ backgroundColor: "var(--bg-card)", borderColor: "var(--border-primary)" }}
>
<h3 className="text-sm font-semibold uppercase tracking-wide mb-4" style={{ color: "var(--text-muted)" }}>
Waiting for Device
</h3>
<div className="flex flex-col items-center py-6 gap-5">
{polling && (
<>
<svg
className="w-12 h-12 animate-spin"
style={{ color: "var(--accent)" }}
fill="none"
viewBox="0 0 24 24"
>
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" />
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z" />
</svg>
<p className="text-sm text-center" style={{ color: "var(--text-secondary)" }}>
Waiting for device to connect
<br />
<span className="text-xs" style={{ color: "var(--text-muted)" }}>
Power cycle the device and ensure it can reach the MQTT broker.
</span>
</p>
</>
)}
{!polling && !timedOut && (
<p className="text-sm" style={{ color: "var(--text-secondary)" }}>
Power cycle the device, then click Start Verification.
</p>
)}
{timedOut && (
<div
className="text-sm rounded-md p-3 border text-center"
style={{ backgroundColor: "#2e1a00", borderColor: "#fb923c", color: "#fb923c" }}
>
Timed out after {VERIFY_TIMEOUT_MS / 1000}s. Check WiFi credentials and MQTT broker connectivity.
</div>
)}
</div>
{error && !timedOut && (
<ErrorBox msg={`Poll error (will retry): ${error}`} />
)}
<div className="flex gap-3 mt-2">
{!polling && (
<button
onClick={startPolling}
className="px-5 py-2 text-sm rounded-md font-medium hover:opacity-90 transition-opacity cursor-pointer"
style={{ backgroundColor: "var(--btn-primary)", color: "var(--text-white)" }}
>
{timedOut ? "Retry Verification" : "Start Verification"}
</button>
)}
{polling && (
<button
onClick={stopPolling}
className="px-5 py-2 text-sm rounded-md hover:opacity-80 transition-opacity cursor-pointer"
style={{ backgroundColor: "var(--bg-card-hover)", color: "var(--text-secondary)" }}
>
Stop
</button>
)}
</div>
</div>
<p className="text-xs" style={{ color: "var(--text-muted)" }}>
Polling every {VERIFY_POLL_MS / 1000}s · timeout {VERIFY_TIMEOUT_MS / 1000}s
</p>
</div>
);
}
// ─── Step 4 — Done ────────────────────────────────────────────────────────────
function StepDone({ device, startedAt, onProvisionNext }) {
const navigate = useNavigate();
const elapsed = startedAt ? Math.round((Date.now() - startedAt) / 1000) : null;
const formatElapsed = (sec) => {
if (sec < 60) return `${sec}s`;
return `${Math.floor(sec / 60)}m ${sec % 60}s`;
};
return (
<div className="space-y-4">
<div
className="rounded-lg border p-6"
style={{ backgroundColor: "var(--bg-card)", borderColor: "var(--border-primary)" }}
>
<div className="flex flex-col items-center py-4 gap-4">
<div
className="w-16 h-16 rounded-full flex items-center justify-center"
style={{ backgroundColor: "#0a2e2a" }}
>
<svg className="w-8 h-8" style={{ color: "#4dd6c8" }} fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2.5} d="M5 13l4 4L19 7" />
</svg>
</div>
<div className="text-center">
<h3 className="text-lg font-bold" style={{ color: "var(--text-heading)" }}>Device Provisioned</h3>
<p className="text-sm mt-1" style={{ color: "var(--text-muted)" }}>
{device.serial_number} is live.
</p>
</div>
</div>
<div
className="grid grid-cols-2 gap-4 rounded-md p-4 mb-5"
style={{ backgroundColor: "var(--bg-primary)" }}
>
<div>
<p className="text-xs uppercase tracking-wide mb-0.5" style={{ color: "var(--text-muted)" }}>Serial Number</p>
<p className="text-sm font-mono" style={{ color: "var(--text-primary)" }}>{device.serial_number}</p>
</div>
<div>
<p className="text-xs uppercase tracking-wide mb-0.5" style={{ color: "var(--text-muted)" }}>Board Type</p>
<p className="text-sm" style={{ color: "var(--text-primary)" }}>
{BOARD_TYPE_LABELS[device.hw_type] || device.hw_type} v{device.hw_version}
</p>
</div>
<div>
<p className="text-xs uppercase tracking-wide mb-0.5" style={{ color: "var(--text-muted)" }}>Status</p>
<StatusBadge status={device.mfg_status} />
</div>
{elapsed !== null && (
<div>
<p className="text-xs uppercase tracking-wide mb-0.5" style={{ color: "var(--text-muted)" }}>Time Taken</p>
<p className="text-sm" style={{ color: "var(--text-primary)" }}>{formatElapsed(elapsed)}</p>
</div>
)}
</div>
<div className="flex flex-wrap gap-3">
<button
onClick={onProvisionNext}
className="px-5 py-2 text-sm rounded-md font-medium hover:opacity-90 transition-opacity cursor-pointer"
style={{ backgroundColor: "var(--btn-primary)", color: "var(--text-white)" }}
>
Provision Next Device
</button>
<button
onClick={() => navigate(`/manufacturing/devices/${device.serial_number}`)}
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 in Inventory
</button>
</div>
</div>
</div>
);
}
// ─── Main Wizard ──────────────────────────────────────────────────────────────
export default function ProvisioningWizard() {
const navigate = useNavigate();
const [step, setStep] = useState(1);
const [device, setDevice] = useState(null);
const [startedAt, setStartedAt] = useState(null);
const handleDeviceSelected = (dev) => {
setDevice(dev);
setStartedAt(Date.now());
setStep(2);
};
const handleFlashed = () => {
setStep(3);
};
const handleVerified = (updatedDevice) => {
setDevice(updatedDevice);
setStep(4);
};
const handleProvisionNext = () => {
setDevice(null);
setStartedAt(null);
setStep(1);
};
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)" }}>
Provisioning Wizard
</h1>
</div>
<StepIndicator current={step} />
{step === 1 && <StepSelectDevice onSelected={handleDeviceSelected} />}
{step === 2 && device && (
<StepFlash device={device} onFlashed={handleFlashed} />
)}
{step === 3 && device && (
<StepVerify device={device} onVerified={handleVerified} />
)}
{step === 4 && device && (
<StepDone
device={device}
startedAt={startedAt}
onProvisionNext={handleProvisionNext}
/>
)}
</div>
);
}

View File

@@ -15,6 +15,18 @@ http {
listen 80; listen 80;
server_name localhost; server_name localhost;
# OTA firmware files — allow browser (esptool-js) to fetch .bin files directly
location /ota/ {
root /srv;
add_header Access-Control-Allow-Origin "*";
add_header Access-Control-Allow-Methods "GET, OPTIONS";
add_header Access-Control-Allow-Headers "Authorization, Content-Type";
if ($request_method = OPTIONS) {
add_header Access-Control-Max-Age 3600;
return 204;
}
}
# API requests → FastAPI backend # API requests → FastAPI backend
location /api/ { location /api/ {
proxy_pass http://backend; proxy_pass http://backend;