diff --git a/backend/admin/__init__.py b/backend/admin/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/admin/router.py b/backend/admin/router.py new file mode 100644 index 0000000..6e0a3d1 --- /dev/null +++ b/backend/admin/router.py @@ -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:///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= + 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") diff --git a/backend/config.py b/backend/config.py index a2dc037..007646c 100644 --- a/backend/config.py +++ b/backend/config.py @@ -29,10 +29,18 @@ class Settings(BaseSettings): built_melodies_storage_path: str = "./storage/built_melodies" firmware_storage_path: str = "./storage/firmware" + # Email (Resend) + resend_api_key: str = "re_placeholder_change_me" + email_from: str = "noreply@yourdomain.com" + # App backend_cors_origins: str = '["http://localhost:5173"]' debug: bool = True + # Auto-deploy (Gitea webhook) + deploy_secret: str = "" + deploy_project_path: str = "/app" + @property def cors_origins(self) -> List[str]: return json.loads(self.backend_cors_origins) diff --git a/backend/main.py b/backend/main.py index 9560f27..4fe19a5 100644 --- a/backend/main.py +++ b/backend/main.py @@ -16,6 +16,7 @@ from helpdesk.router import router as helpdesk_router from builder.router import router as builder_router from manufacturing.router import router as manufacturing_router from firmware.router import router as firmware_router +from admin.router import router as admin_router from mqtt.client import mqtt_manager from mqtt import database as mqtt_db from melodies import service as melody_service @@ -48,6 +49,7 @@ app.include_router(staff_router) app.include_router(builder_router) app.include_router(manufacturing_router) app.include_router(firmware_router) +app.include_router(admin_router) @app.on_event("startup") diff --git a/backend/manufacturing/audit.py b/backend/manufacturing/audit.py new file mode 100644 index 0000000..1173c2c --- /dev/null +++ b/backend/manufacturing/audit.py @@ -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] diff --git a/backend/manufacturing/models.py b/backend/manufacturing/models.py index bf64cfc..d9698aa 100644 --- a/backend/manufacturing/models.py +++ b/backend/manufacturing/models.py @@ -59,3 +59,21 @@ class DeviceInventoryListResponse(BaseModel): class DeviceStatusUpdate(BaseModel): status: MfgStatus 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] diff --git a/backend/manufacturing/router.py b/backend/manufacturing/router.py index d4fe58f..9777398 100644 --- a/backend/manufacturing/router.py +++ b/backend/manufacturing/router.py @@ -1,5 +1,6 @@ from fastapi import APIRouter, Depends, Query from fastapi.responses import Response +from fastapi.responses import RedirectResponse from typing import Optional from auth.models import TokenPayload @@ -7,19 +8,48 @@ from auth.dependencies import require_permission from manufacturing.models import ( BatchCreate, BatchResponse, DeviceInventoryItem, DeviceInventoryListResponse, - DeviceStatusUpdate, + DeviceStatusUpdate, DeviceAssign, + ManufacturingStats, ) from manufacturing import service +from manufacturing import audit 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")), +@router.get("/stats", response_model=ManufacturingStats) +def get_stats( + _user: TokenPayload = Depends(require_permission("manufacturing", "view")), ): - 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) @@ -50,22 +80,62 @@ def get_device( @router.patch("/devices/{sn}/status", response_model=DeviceInventoryItem) -def update_status( +async def update_status( sn: str, 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") -def download_nvs( +async def download_nvs( sn: str, - _user: TokenPayload = Depends(require_permission("manufacturing", "view")), + user: TokenPayload = Depends(require_permission("manufacturing", "view")), ): binary = service.get_nvs_binary(sn) + await audit.log_action( + admin_user=user.email, + action="device_flashed", + serial_number=sn, + ) return Response( content=binary, media_type="application/octet-stream", 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) diff --git a/backend/manufacturing/service.py b/backend/manufacturing/service.py index dc757ff..84bf68e 100644 --- a/backend/manufacturing/service.py +++ b/backend/manufacturing/service.py @@ -6,7 +6,7 @@ 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 +from manufacturing.models import BatchCreate, BatchResponse, DeviceInventoryItem, DeviceStatusUpdate, DeviceAssign, ManufacturingStats, RecentActivityItem COLLECTION = "devices" _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_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 diff --git a/backend/mqtt/database.py b/backend/mqtt/database.py index e17e196..bd09439 100644 --- a/backend/mqtt/database.py +++ b/backend/mqtt/database.py @@ -65,6 +65,17 @@ SCHEMA_STATEMENTS = [ created_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)", ] diff --git a/backend/requirements.txt b/backend/requirements.txt index 7ea60ec..9ab2c38 100644 --- a/backend/requirements.txt +++ b/backend/requirements.txt @@ -8,4 +8,5 @@ python-jose[cryptography]==3.3.0 passlib[bcrypt]==1.7.4 python-multipart==0.0.20 bcrypt==4.0.1 -aiosqlite==0.20.0 \ No newline at end of file +aiosqlite==0.20.0 +resend==2.10.0 \ No newline at end of file diff --git a/backend/utils/email.py b/backend/utils/email.py new file mode 100644 index 0000000..7e3256d --- /dev/null +++ b/backend/utils/email.py @@ -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""" +
+

Your Vesper device is ready

+

{greeting}

+

A Vesper bell automation device has been registered and assigned to you.

+

+ Serial Number: + {serial_number} +

+

Open the Vesper app and enter this serial number to get started.

+
+

+ If you did not expect this email, please contact your system administrator. +

+
+ """ + 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""" +
+

Device Provisioned

+

A Vesper device has successfully provisioned and is ready to ship.

+ + + + + + + + + +
Serial Number{serial_number}
Board Type{hw_type.upper()}
+

+ View in Admin Console +

+
+ """ + send_email( + to=admin_email, + subject=f"[Vesper] Device provisioned — {serial_number}", + html=html, + ) diff --git a/frontend/package-lock.json b/frontend/package-lock.json index ab26280..494b33b 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -8,6 +8,7 @@ "name": "frontend", "version": "0.0.0", "dependencies": { + "esptool-js": "^0.5.7", "leaflet": "^1.9.4", "react": "^19.2.0", "react-dom": "^19.2.0", @@ -1814,6 +1815,12 @@ "dev": true, "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": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", @@ -2252,6 +2259,17 @@ "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": { "version": "1.7.0", "resolved": "https://registry.npmjs.org/esquery/-/esquery-1.7.0.tgz", @@ -3054,6 +3072,12 @@ "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": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz", @@ -3413,6 +3437,12 @@ "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": { "version": "0.4.0", "resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz", diff --git a/frontend/package.json b/frontend/package.json index 8b6d23b..1978053 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -10,6 +10,7 @@ "preview": "vite preview" }, "dependencies": { + "esptool-js": "^0.5.7", "leaflet": "^1.9.4", "react": "^19.2.0", "react-dom": "^19.2.0", diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx index b884dd9..f7b40c6 100644 --- a/frontend/src/App.jsx +++ b/frontend/src/App.jsx @@ -27,7 +27,9 @@ import StaffForm from "./settings/StaffForm"; import DeviceInventory from "./manufacturing/DeviceInventory"; import BatchCreator from "./manufacturing/BatchCreator"; import DeviceInventoryDetail from "./manufacturing/DeviceInventoryDetail"; +import ProvisioningWizard from "./manufacturing/ProvisioningWizard"; import FirmwareManager from "./firmware/FirmwareManager"; +import DashboardPage from "./dashboard/DashboardPage"; function ProtectedRoute({ children }) { const { user, loading } = useAuth(); @@ -93,18 +95,6 @@ function RoleGate({ roles, children }) { return children; } -function DashboardPage() { - const { user } = useAuth(); - return ( -
-

Dashboard

-

- Welcome, {user?.name}. You are logged in as{" "} - {user?.role}. -

-
- ); -} export default function App() { return ( @@ -156,6 +146,7 @@ export default function App() { {/* Manufacturing */} } /> } /> + } /> } /> } /> diff --git a/frontend/src/dashboard/DashboardPage.jsx b/frontend/src/dashboard/DashboardPage.jsx new file mode 100644 index 0000000..e8e4bc1 --- /dev/null +++ b/frontend/src/dashboard/DashboardPage.jsx @@ -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 ( + + {status} + + ); +} + +function StatCard({ label, count, status, onClick }) { + const style = STATUS_STYLES[status] || STATUS_STYLES.manufactured; + return ( + + ); +} + +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 ( +
+

+ Dashboard +

+

+ Welcome, {user?.name}.{" "} + {user?.role} +

+ + {canViewMfg && ( + <> + {/* Device Status Summary */} +
+

+ Device Inventory +

+ +
+ + {loadingStats ? ( +
Loading…
+ ) : stats ? ( +
+ {STATUS_ORDER.map((s) => ( + navigate(`/manufacturing?status=${s}`)} + /> + ))} +
+ ) : null} + + {/* Recent Activity */} + {stats?.recent_activity?.length > 0 && ( +
+

+ Recent Activity +

+
+ + + + + + + + + + + {stats.recent_activity.map((item, i) => ( + navigate(`/manufacturing/devices/${item.serial_number}`)} + onMouseEnter={(e) => (e.currentTarget.style.backgroundColor = "var(--bg-card-hover)")} + onMouseLeave={(e) => (e.currentTarget.style.backgroundColor = "")} + > + + + + + + ))} + +
Serial NumberStatusOwnerDate
+ {item.serial_number} + + + + {item.owner || "—"} + + {formatTs(item.updated_at)} +
+
+
+ )} + + {/* Audit Log */} +
+

+ Audit Log +

+ {loadingAudit ? ( +
Loading…
+ ) : auditLog.length === 0 ? ( +
No audit entries yet.
+ ) : ( +
+ + + + + + + + + + + + {auditLog.map((entry) => ( + + + + + + + + ))} + +
TimeAdminActionDeviceDetail
+ {formatTs(entry.timestamp)} + + {entry.admin_user} + + {ACTION_LABELS[entry.action] || entry.action} + + {entry.serial_number + ? ( + + ) + : "—"} + + {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; + } + })() + : "—"} +
+
+ )} +
+ + )} + + {!canViewMfg && ( +

+ Select a section from the sidebar to get started. +

+ )} +
+ ); +} diff --git a/frontend/src/layout/Sidebar.jsx b/frontend/src/layout/Sidebar.jsx index f86a582..2910be0 100644 --- a/frontend/src/layout/Sidebar.jsx +++ b/frontend/src/layout/Sidebar.jsx @@ -32,6 +32,7 @@ const navItems = [ children: [ { to: "/manufacturing", label: "Device Inventory" }, { to: "/manufacturing/batch/new", label: "New Batch" }, + { to: "/manufacturing/provision", label: "Provision Device" }, { to: "/firmware", label: "Firmware" }, ], }, diff --git a/frontend/src/manufacturing/DeviceInventoryDetail.jsx b/frontend/src/manufacturing/DeviceInventoryDetail.jsx index 67abe13..702d56f 100644 --- a/frontend/src/manufacturing/DeviceInventoryDetail.jsx +++ b/frontend/src/manufacturing/DeviceInventoryDetail.jsx @@ -64,6 +64,12 @@ export default function DeviceInventoryDetail() { 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 () => { setLoading(true); 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 () => { setNvsDownloading(true); try { @@ -303,7 +331,7 @@ export default function DeviceInventoryDetail() { {/* Actions card */}

@@ -326,6 +354,77 @@ export default function DeviceInventoryDetail() { NVS binary encodes device_uid, hw_type, hw_version. Flash at 0x9000.

+ + {/* Assign to Customer card */} + {canEdit && ["provisioned", "flashed"].includes(device?.mfg_status) && ( +
+

+ Assign to Customer +

+ + {assignSuccess ? ( +
+ Device assigned and invitation email sent to {device?.owner}. +
+ ) : ( +
+ {assignError && ( +
+ {assignError} +
+ )} + 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)", + }} + /> + 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)", + }} + /> + +

+ Sets device status to sold and emails the customer their serial number. +

+
+ )} +
+ )} ); } diff --git a/frontend/src/manufacturing/ProvisioningWizard.jsx b/frontend/src/manufacturing/ProvisioningWizard.jsx new file mode 100644 index 0000000..3ae1e12 --- /dev/null +++ b/frontend/src/manufacturing/ProvisioningWizard.jsx @@ -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 ( + + {status} + + ); +} + +function StepIndicator({ current }) { + const steps = ["Select Device", "Flash", "Verify", "Done"]; + return ( +
+ {steps.map((label, i) => { + const idx = i + 1; + const done = idx < current; + const active = idx === current; + const pending = idx > current; + return ( +
+
+
+ {done ? ( + + + + ) : idx} +
+ + {label} + +
+ {i < steps.length - 1 && ( +
+ )} +
+ ); + })} +
+ ); +} + +function ProgressBar({ label, percent }) { + return ( +
+
+ {label} + {Math.round(percent)}% +
+
+
+
+
+ ); +} + +function ErrorBox({ msg }) { + if (!msg) return null; + return ( +
+ {msg} +
+ ); +} + +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 ( +
+
+

+ Device Selected +

+
+
+

Serial Number

+

{found.serial_number}

+
+
+

Board Type

+

{BOARD_TYPE_LABELS[found.hw_type] || found.hw_type}

+
+
+

HW Version

+

v{found.hw_version}

+
+
+

Status

+ +
+
+
+ + +
+
+
+ ); + } + + return ( +
+ {/* Mode toggle */} +
+ {[["search", "Search Existing"], ["create", "Quick Create"]].map(([val, lbl]) => ( + + ))} +
+ + {/* Search */} + {mode === "search" && ( +
+

+ Find Unprovisioned Device +

+ + {searchError &&
} +
+ setSearchSn(e.target.value)} + required + className={inputCls()} + style={inputStyle()} + /> + +
+
+ )} + + {/* Create */} + {mode === "create" && ( +
+

+ Create Single Device +

+ + {createError &&
} +
+
+ + +
+
+ + setBoardVersion(e.target.value)} + placeholder="01" + maxLength={2} + pattern="\d{2}" + required + className={inputCls()} + style={inputStyle()} + /> +
+ +
+
+ )} +
+ ); +} + +// ─── 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 ( +
+
+

+ Device to Flash +

+
+
+

Serial Number

+

{device.serial_number}

+
+
+

Board

+

+ {BOARD_TYPE_LABELS[device.hw_type] || device.hw_type} v{device.hw_version} +

+
+
+ + {!webSerialAvailable && ( +
+ Web Serial API not available. Use Chrome or Edge on a desktop system. +
+ )} + + + {error &&
} + + {(flashing || nvsProgress > 0) && ( +
+ + +
+ )} + + {log.length > 0 && ( +
+ {log.map((line, i) => ( +
{line}
+ ))} +
+ )} + + {!flashing && ( + + )} + + {flashing && nvsProgress < 100 && fwProgress < 100 && ( +

+ Flashing in progress — do not disconnect… +

+ )} +
+ +

+ Flash addresses: NVS at 0x9000 · Firmware at 0x10000 · Baud: {FLASH_BAUD} +

+
+ ); +} + +// ─── 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 ( +
+
+

+ Waiting for Device +

+ +
+ {polling && ( + <> + + + + +

+ Waiting for device to connect… +
+ + Power cycle the device and ensure it can reach the MQTT broker. + +

+ + )} + + {!polling && !timedOut && ( +

+ Power cycle the device, then click Start Verification. +

+ )} + + {timedOut && ( +
+ Timed out after {VERIFY_TIMEOUT_MS / 1000}s. Check WiFi credentials and MQTT broker connectivity. +
+ )} +
+ + {error && !timedOut && ( + + )} + +
+ {!polling && ( + + )} + {polling && ( + + )} +
+
+ +

+ Polling every {VERIFY_POLL_MS / 1000}s · timeout {VERIFY_TIMEOUT_MS / 1000}s +

+
+ ); +} + +// ─── 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 ( +
+
+
+
+ + + +
+
+

Device Provisioned

+

+ {device.serial_number} is live. +

+
+
+ +
+
+

Serial Number

+

{device.serial_number}

+
+
+

Board Type

+

+ {BOARD_TYPE_LABELS[device.hw_type] || device.hw_type} v{device.hw_version} +

+
+
+

Status

+ +
+ {elapsed !== null && ( +
+

Time Taken

+

{formatElapsed(elapsed)}

+
+ )} +
+ +
+ + +
+
+
+ ); +} + +// ─── 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 ( +
+
+ + / +

+ Provisioning Wizard +

+
+ + + + {step === 1 && } + {step === 2 && device && ( + + )} + {step === 3 && device && ( + + )} + {step === 4 && device && ( + + )} +
+ ); +} diff --git a/nginx/nginx.conf b/nginx/nginx.conf index b515e36..2ffdddd 100644 --- a/nginx/nginx.conf +++ b/nginx/nginx.conf @@ -15,6 +15,18 @@ http { listen 80; 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 location /api/ { proxy_pass http://backend;