From 32a2634739b23993e57ade86a3201aadebf9b314 Mon Sep 17 00:00:00 2001 From: bonamin Date: Fri, 27 Feb 2026 02:47:08 +0200 Subject: [PATCH] feat: Phase 3 manufacturing + firmware management --- .env.example | 5 + backend/auth/models.py | 3 + backend/config.py | 2 + backend/devices/service.py | 5 - backend/firmware/__init__.py | 0 backend/firmware/models.py | 31 ++ backend/firmware/router.py | 66 +++ backend/firmware/service.py | 186 +++++++ backend/main.py | 6 + backend/manufacturing/__init__.py | 0 backend/manufacturing/models.py | 61 +++ backend/manufacturing/router.py | 71 +++ backend/manufacturing/service.py | 153 ++++++ backend/mqtt/auth.py | 126 +++++ backend/mqtt/mosquitto.py | 57 +- backend/utils/__init__.py | 0 backend/utils/nvs_generator.py | 205 ++++++++ backend/utils/serial_number.py | 19 + docker-compose.yml | 3 +- frontend/src/App.jsx | 10 + frontend/src/firmware/FirmwareManager.jsx | 488 ++++++++++++++++++ frontend/src/layout/Sidebar.jsx | 9 + frontend/src/manufacturing/BatchCreator.jsx | 237 +++++++++ .../src/manufacturing/DeviceInventory.jsx | 244 +++++++++ .../manufacturing/DeviceInventoryDetail.jsx | 331 ++++++++++++ 25 files changed, 2266 insertions(+), 52 deletions(-) create mode 100644 backend/firmware/__init__.py create mode 100644 backend/firmware/models.py create mode 100644 backend/firmware/router.py create mode 100644 backend/firmware/service.py create mode 100644 backend/manufacturing/__init__.py create mode 100644 backend/manufacturing/models.py create mode 100644 backend/manufacturing/router.py create mode 100644 backend/manufacturing/service.py create mode 100644 backend/mqtt/auth.py create mode 100644 backend/utils/__init__.py create mode 100644 backend/utils/nvs_generator.py create mode 100644 backend/utils/serial_number.py create mode 100644 frontend/src/firmware/FirmwareManager.jsx create mode 100644 frontend/src/manufacturing/BatchCreator.jsx create mode 100644 frontend/src/manufacturing/DeviceInventory.jsx create mode 100644 frontend/src/manufacturing/DeviceInventoryDetail.jsx diff --git a/.env.example b/.env.example index 1e0af00..e28a76f 100644 --- a/.env.example +++ b/.env.example @@ -13,11 +13,16 @@ MQTT_BROKER_PORT=1883 MQTT_ADMIN_USERNAME=admin MQTT_ADMIN_PASSWORD=your-mqtt-admin-password MOSQUITTO_PASSWORD_FILE=/etc/mosquitto/passwd +# HMAC secret used to derive per-device MQTT passwords (must match firmware) +MQTT_SECRET=change-me-in-production # App BACKEND_CORS_ORIGINS=["http://localhost:5173"] DEBUG=true +# Port nginx binds on the host (use 90 on VPS if 80 is taken) +NGINX_PORT=80 # Local file storage (override if you want to store data elsewhere) SQLITE_DB_PATH=./mqtt_data.db BUILT_MELODIES_STORAGE_PATH=./storage/built_melodies +FIRMWARE_STORAGE_PATH=./storage/firmware diff --git a/backend/auth/models.py b/backend/auth/models.py index b2c6855..79a56a4 100644 --- a/backend/auth/models.py +++ b/backend/auth/models.py @@ -22,6 +22,7 @@ class StaffPermissions(BaseModel): devices: SectionPermissions = SectionPermissions() app_users: SectionPermissions = SectionPermissions() equipment: SectionPermissions = SectionPermissions() + manufacturing: SectionPermissions = SectionPermissions() mqtt: bool = False @@ -37,6 +38,7 @@ def default_permissions_for_role(role: str) -> Optional[dict]: "devices": full, "app_users": full, "equipment": full, + "manufacturing": view_only, "mqtt": True, } # user role - view only @@ -45,6 +47,7 @@ def default_permissions_for_role(role: str) -> Optional[dict]: "devices": view_only, "app_users": view_only, "equipment": view_only, + "manufacturing": {"view": False, "add": False, "edit": False, "delete": False}, "mqtt": False, } diff --git a/backend/config.py b/backend/config.py index ce59aa4..a2dc037 100644 --- a/backend/config.py +++ b/backend/config.py @@ -18,6 +18,7 @@ class Settings(BaseSettings): mqtt_broker_port: int = 1883 mqtt_admin_username: str = "admin" mqtt_admin_password: str = "" + mqtt_secret: str = "change-me-in-production" mosquitto_password_file: str = "/etc/mosquitto/passwd" # SQLite (MQTT data storage) @@ -26,6 +27,7 @@ class Settings(BaseSettings): # Local file storage built_melodies_storage_path: str = "./storage/built_melodies" + firmware_storage_path: str = "./storage/firmware" # App backend_cors_origins: str = '["http://localhost:5173"]' diff --git a/backend/devices/service.py b/backend/devices/service.py index 4e58f86..f091600 100644 --- a/backend/devices/service.py +++ b/backend/devices/service.py @@ -7,7 +7,6 @@ from google.cloud.firestore_v1 import GeoPoint, DocumentReference from shared.firebase import get_db from shared.exceptions import NotFoundError from devices.models import DeviceCreate, DeviceUpdate, DeviceInDB -from mqtt.mosquitto import register_device_password COLLECTION = "devices" @@ -154,10 +153,6 @@ def create_device(data: DeviceCreate) -> DeviceInDB: # Generate unique serial number serial_number = _ensure_unique_serial(db) - # Generate MQTT password and register with Mosquitto - mqtt_password = secrets.token_urlsafe(24) - register_device_password(serial_number, mqtt_password) - doc_data = data.model_dump() doc_data["device_id"] = serial_number diff --git a/backend/firmware/__init__.py b/backend/firmware/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/firmware/models.py b/backend/firmware/models.py new file mode 100644 index 0000000..b99eb99 --- /dev/null +++ b/backend/firmware/models.py @@ -0,0 +1,31 @@ +from pydantic import BaseModel +from typing import Optional, List + + +class FirmwareVersion(BaseModel): + id: str + hw_type: str # "vs", "vp", "vx" + channel: str # "stable", "beta", "alpha", "testing" + version: str # semver e.g. "1.4.2" + filename: str + size_bytes: int + sha256: str + uploaded_at: str + notes: Optional[str] = None + is_latest: bool = False + + +class FirmwareListResponse(BaseModel): + firmware: List[FirmwareVersion] + total: int + + +class FirmwareLatestResponse(BaseModel): + hw_type: str + channel: str + version: str + size_bytes: int + sha256: str + download_url: str + uploaded_at: str + notes: Optional[str] = None diff --git a/backend/firmware/router.py b/backend/firmware/router.py new file mode 100644 index 0000000..dcebbeb --- /dev/null +++ b/backend/firmware/router.py @@ -0,0 +1,66 @@ +from fastapi import APIRouter, Depends, Query, UploadFile, File, Form +from fastapi.responses import FileResponse +from typing import Optional + +from auth.models import TokenPayload +from auth.dependencies import require_permission +from firmware.models import FirmwareVersion, FirmwareListResponse, FirmwareLatestResponse +from firmware import service + +router = APIRouter(prefix="/api/firmware", tags=["firmware"]) + + +@router.post("/upload", response_model=FirmwareVersion, status_code=201) +async def upload_firmware( + hw_type: str = Form(...), + channel: str = Form(...), + version: str = Form(...), + notes: Optional[str] = Form(None), + file: UploadFile = File(...), + _user: TokenPayload = Depends(require_permission("manufacturing", "add")), +): + file_bytes = await file.read() + return service.upload_firmware( + hw_type=hw_type, + channel=channel, + version=version, + file_bytes=file_bytes, + notes=notes, + ) + + +@router.get("", response_model=FirmwareListResponse) +def list_firmware( + hw_type: Optional[str] = Query(None), + channel: Optional[str] = Query(None), + _user: TokenPayload = Depends(require_permission("manufacturing", "view")), +): + items = service.list_firmware(hw_type=hw_type, channel=channel) + return FirmwareListResponse(firmware=items, total=len(items)) + + +@router.get("/{hw_type}/{channel}/latest", response_model=FirmwareLatestResponse) +def get_latest_firmware(hw_type: str, channel: str): + """Returns metadata for the latest firmware for a given hw_type + channel. + No auth required — devices call this endpoint to check for updates. + """ + return service.get_latest(hw_type, channel) + + +@router.get("/{hw_type}/{channel}/{version}/firmware.bin") +def download_firmware(hw_type: str, channel: str, version: str): + """Download the firmware binary. No auth required — devices call this directly.""" + path = service.get_firmware_path(hw_type, channel, version) + return FileResponse( + path=str(path), + media_type="application/octet-stream", + filename="firmware.bin", + ) + + +@router.delete("/{firmware_id}", status_code=204) +def delete_firmware( + firmware_id: str, + _user: TokenPayload = Depends(require_permission("manufacturing", "delete")), +): + service.delete_firmware(firmware_id) diff --git a/backend/firmware/service.py b/backend/firmware/service.py new file mode 100644 index 0000000..c65a77f --- /dev/null +++ b/backend/firmware/service.py @@ -0,0 +1,186 @@ +import hashlib +import uuid +from datetime import datetime, timezone +from pathlib import Path + +from fastapi import HTTPException + +from config import settings +from shared.firebase import get_db +from shared.exceptions import NotFoundError +from firmware.models import FirmwareVersion, FirmwareLatestResponse + +COLLECTION = "firmware_versions" + +VALID_HW_TYPES = {"vs", "vp", "vx"} +VALID_CHANNELS = {"stable", "beta", "alpha", "testing"} + + +def _storage_path(hw_type: str, channel: str, version: str) -> Path: + return Path(settings.firmware_storage_path) / hw_type / channel / version / "firmware.bin" + + +def _doc_to_firmware_version(doc) -> FirmwareVersion: + data = doc.to_dict() or {} + uploaded_raw = data.get("uploaded_at") + if isinstance(uploaded_raw, datetime): + uploaded_str = uploaded_raw.strftime("%Y-%m-%dT%H:%M:%SZ") + else: + uploaded_str = str(uploaded_raw) if uploaded_raw else "" + + return FirmwareVersion( + id=doc.id, + hw_type=data.get("hw_type", ""), + channel=data.get("channel", ""), + version=data.get("version", ""), + filename=data.get("filename", "firmware.bin"), + size_bytes=data.get("size_bytes", 0), + sha256=data.get("sha256", ""), + uploaded_at=uploaded_str, + notes=data.get("notes"), + is_latest=data.get("is_latest", False), + ) + + +def upload_firmware( + hw_type: str, + channel: str, + version: str, + file_bytes: bytes, + notes: str | None = None, +) -> FirmwareVersion: + if hw_type not in VALID_HW_TYPES: + raise HTTPException(status_code=400, detail=f"Invalid hw_type. Must be one of: {', '.join(VALID_HW_TYPES)}") + if channel not in VALID_CHANNELS: + raise HTTPException(status_code=400, detail=f"Invalid channel. Must be one of: {', '.join(VALID_CHANNELS)}") + + dest = _storage_path(hw_type, channel, version) + dest.parent.mkdir(parents=True, exist_ok=True) + dest.write_bytes(file_bytes) + + sha256 = hashlib.sha256(file_bytes).hexdigest() + now = datetime.now(timezone.utc) + doc_id = str(uuid.uuid4()) + + db = get_db() + + # Mark previous latest for this hw_type+channel as no longer latest + prev_docs = ( + db.collection(COLLECTION) + .where("hw_type", "==", hw_type) + .where("channel", "==", channel) + .where("is_latest", "==", True) + .stream() + ) + for prev in prev_docs: + prev.reference.update({"is_latest": False}) + + doc_ref = db.collection(COLLECTION).document(doc_id) + doc_ref.set({ + "hw_type": hw_type, + "channel": channel, + "version": version, + "filename": "firmware.bin", + "size_bytes": len(file_bytes), + "sha256": sha256, + "uploaded_at": now, + "notes": notes, + "is_latest": True, + }) + + return _doc_to_firmware_version(doc_ref.get()) + + +def list_firmware( + hw_type: str | None = None, + channel: str | None = None, +) -> list[FirmwareVersion]: + db = get_db() + query = db.collection(COLLECTION) + if hw_type: + query = query.where("hw_type", "==", hw_type) + if channel: + query = query.where("channel", "==", channel) + + docs = list(query.stream()) + items = [_doc_to_firmware_version(doc) for doc in docs] + items.sort(key=lambda x: x.uploaded_at, reverse=True) + return items + + +def get_latest(hw_type: str, channel: str) -> FirmwareLatestResponse: + if hw_type not in VALID_HW_TYPES: + raise HTTPException(status_code=400, detail=f"Invalid hw_type '{hw_type}'") + if channel not in VALID_CHANNELS: + raise HTTPException(status_code=400, detail=f"Invalid channel '{channel}'") + + db = get_db() + docs = list( + db.collection(COLLECTION) + .where("hw_type", "==", hw_type) + .where("channel", "==", channel) + .where("is_latest", "==", True) + .limit(1) + .stream() + ) + if not docs: + raise NotFoundError("Firmware") + + fw = _doc_to_firmware_version(docs[0]) + download_url = f"/api/firmware/{hw_type}/{channel}/{fw.version}/firmware.bin" + return FirmwareLatestResponse( + hw_type=fw.hw_type, + channel=fw.channel, + version=fw.version, + size_bytes=fw.size_bytes, + sha256=fw.sha256, + download_url=download_url, + uploaded_at=fw.uploaded_at, + notes=fw.notes, + ) + + +def get_firmware_path(hw_type: str, channel: str, version: str) -> Path: + path = _storage_path(hw_type, channel, version) + if not path.exists(): + raise NotFoundError("Firmware binary") + return path + + +def delete_firmware(doc_id: str) -> None: + db = get_db() + doc_ref = db.collection(COLLECTION).document(doc_id) + doc = doc_ref.get() + if not doc.exists: + raise NotFoundError("Firmware") + + data = doc.to_dict() + hw_type = data.get("hw_type", "") + channel = data.get("channel", "") + version = data.get("version", "") + was_latest = data.get("is_latest", False) + + # Delete the binary file + path = _storage_path(hw_type, channel, version) + if path.exists(): + path.unlink() + # Remove the version directory if empty + try: + path.parent.rmdir() + except OSError: + pass + + doc_ref.delete() + + # If we deleted the latest, promote the next most recent as latest + if was_latest: + remaining = list( + db.collection(COLLECTION) + .where("hw_type", "==", hw_type) + .where("channel", "==", channel) + .order_by("uploaded_at", direction="DESCENDING") + .limit(1) + .stream() + ) + if remaining: + remaining[0].reference.update({"is_latest": True}) diff --git a/backend/main.py b/backend/main.py index c51358e..9560f27 100644 --- a/backend/main.py +++ b/backend/main.py @@ -9,10 +9,13 @@ from devices.router import router as devices_router from settings.router import router as settings_router from users.router import router as users_router from mqtt.router import router as mqtt_router +from mqtt.auth import router as mqtt_auth_router from equipment.router import router as equipment_router from staff.router import router as staff_router from helpdesk.router import router as helpdesk_router from builder.router import router as builder_router +from manufacturing.router import router as manufacturing_router +from firmware.router import router as firmware_router from mqtt.client import mqtt_manager from mqtt import database as mqtt_db from melodies import service as melody_service @@ -38,10 +41,13 @@ app.include_router(devices_router) app.include_router(settings_router) app.include_router(users_router) app.include_router(mqtt_router) +app.include_router(mqtt_auth_router) app.include_router(equipment_router) app.include_router(helpdesk_router) app.include_router(staff_router) app.include_router(builder_router) +app.include_router(manufacturing_router) +app.include_router(firmware_router) @app.on_event("startup") diff --git a/backend/manufacturing/__init__.py b/backend/manufacturing/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/manufacturing/models.py b/backend/manufacturing/models.py new file mode 100644 index 0000000..bf64cfc --- /dev/null +++ b/backend/manufacturing/models.py @@ -0,0 +1,61 @@ +from pydantic import BaseModel, Field +from typing import Optional, List +from enum import Enum + + +class BoardType(str, Enum): + vs = "vs" # Vesper + vp = "vp" # Vesper+ + vx = "vx" # VesperPro + + +BOARD_TYPE_LABELS = { + "vs": "Vesper", + "vp": "Vesper+", + "vx": "VesperPro", +} + + +class MfgStatus(str, Enum): + manufactured = "manufactured" + flashed = "flashed" + provisioned = "provisioned" + sold = "sold" + claimed = "claimed" + decommissioned = "decommissioned" + + +class BatchCreate(BaseModel): + board_type: BoardType + board_version: str = Field(..., pattern=r"^\d{2}$", description="2-digit zero-padded version, e.g. '01'") + quantity: int = Field(..., ge=1, le=100) + + +class BatchResponse(BaseModel): + batch_id: str + serial_numbers: List[str] + board_type: str + board_version: str + created_at: str + + +class DeviceInventoryItem(BaseModel): + id: str + serial_number: str + hw_type: str + hw_version: str + mfg_status: str + mfg_batch_id: Optional[str] = None + created_at: Optional[str] = None + owner: Optional[str] = None + assigned_to: Optional[str] = None + + +class DeviceInventoryListResponse(BaseModel): + devices: List[DeviceInventoryItem] + total: int + + +class DeviceStatusUpdate(BaseModel): + status: MfgStatus + note: Optional[str] = None diff --git a/backend/manufacturing/router.py b/backend/manufacturing/router.py new file mode 100644 index 0000000..d4fe58f --- /dev/null +++ b/backend/manufacturing/router.py @@ -0,0 +1,71 @@ +from fastapi import APIRouter, Depends, Query +from fastapi.responses import Response +from typing import Optional + +from auth.models import TokenPayload +from auth.dependencies import require_permission +from manufacturing.models import ( + BatchCreate, BatchResponse, + DeviceInventoryItem, DeviceInventoryListResponse, + DeviceStatusUpdate, +) +from manufacturing import service + +router = APIRouter(prefix="/api/manufacturing", tags=["manufacturing"]) + + +@router.post("/batch", response_model=BatchResponse, status_code=201) +def create_batch( + body: BatchCreate, + _user: TokenPayload = Depends(require_permission("manufacturing", "add")), +): + return service.create_batch(body) + + +@router.get("/devices", response_model=DeviceInventoryListResponse) +def list_devices( + status: Optional[str] = Query(None), + hw_type: Optional[str] = Query(None), + search: Optional[str] = Query(None), + limit: int = Query(100, ge=1, le=500), + offset: int = Query(0, ge=0), + _user: TokenPayload = Depends(require_permission("manufacturing", "view")), +): + items = service.list_devices( + status=status, + hw_type=hw_type, + search=search, + limit=limit, + offset=offset, + ) + return DeviceInventoryListResponse(devices=items, total=len(items)) + + +@router.get("/devices/{sn}", response_model=DeviceInventoryItem) +def get_device( + sn: str, + _user: TokenPayload = Depends(require_permission("manufacturing", "view")), +): + return service.get_device_by_sn(sn) + + +@router.patch("/devices/{sn}/status", response_model=DeviceInventoryItem) +def update_status( + sn: str, + body: DeviceStatusUpdate, + _user: TokenPayload = Depends(require_permission("manufacturing", "edit")), +): + return service.update_device_status(sn, body) + + +@router.get("/devices/{sn}/nvs.bin") +def download_nvs( + sn: str, + _user: TokenPayload = Depends(require_permission("manufacturing", "view")), +): + binary = service.get_nvs_binary(sn) + return Response( + content=binary, + media_type="application/octet-stream", + headers={"Content-Disposition": f'attachment; filename="{sn}_nvs.bin"'}, + ) diff --git a/backend/manufacturing/service.py b/backend/manufacturing/service.py new file mode 100644 index 0000000..dc757ff --- /dev/null +++ b/backend/manufacturing/service.py @@ -0,0 +1,153 @@ +import random +import string +from datetime import datetime, timezone + +from shared.firebase import get_db +from shared.exceptions import NotFoundError +from utils.serial_number import generate_serial +from utils.nvs_generator import generate as generate_nvs_binary +from manufacturing.models import BatchCreate, BatchResponse, DeviceInventoryItem, DeviceStatusUpdate + +COLLECTION = "devices" +_BATCH_ID_CHARS = string.ascii_uppercase + string.digits + + +def _make_batch_id() -> str: + today = datetime.utcnow().strftime("%y%m%d") + suffix = "".join(random.choices(_BATCH_ID_CHARS, k=4)) + return f"BATCH-{today}-{suffix}" + + +def _get_existing_sns(db) -> set: + existing = set() + for doc in db.collection(COLLECTION).select(["serial_number"]).stream(): + data = doc.to_dict() + sn = data.get("serial_number") + if sn: + existing.add(sn) + return existing + + +def _doc_to_inventory_item(doc) -> DeviceInventoryItem: + data = doc.to_dict() or {} + created_raw = data.get("created_at") + if isinstance(created_raw, datetime): + created_str = created_raw.strftime("%Y-%m-%dT%H:%M:%SZ") + else: + created_str = str(created_raw) if created_raw else None + + return DeviceInventoryItem( + id=doc.id, + serial_number=data.get("serial_number", ""), + hw_type=data.get("hw_type", ""), + hw_version=data.get("hw_version", ""), + mfg_status=data.get("mfg_status", "manufactured"), + mfg_batch_id=data.get("mfg_batch_id"), + created_at=created_str, + owner=data.get("owner"), + assigned_to=data.get("assigned_to"), + ) + + +def create_batch(data: BatchCreate) -> BatchResponse: + db = get_db() + existing_sns = _get_existing_sns(db) + batch_id = _make_batch_id() + now = datetime.now(timezone.utc) + serial_numbers = [] + + for _ in range(data.quantity): + for attempt in range(200): + sn = generate_serial(data.board_type.value, data.board_version) + if sn not in existing_sns: + existing_sns.add(sn) + break + else: + raise RuntimeError("Could not generate unique serial numbers — collision limit hit") + + db.collection(COLLECTION).add({ + "serial_number": sn, + "hw_type": data.board_type.value, + "hw_version": data.board_version, + "mfg_status": "manufactured", + "mfg_batch_id": batch_id, + "created_at": now, + "owner": None, + "assigned_to": None, + "users_list": [], + # Legacy fields left empty so existing device views don't break + "device_name": "", + "device_location": "", + "is_Online": False, + }) + serial_numbers.append(sn) + + return BatchResponse( + batch_id=batch_id, + serial_numbers=serial_numbers, + board_type=data.board_type.value, + board_version=data.board_version, + created_at=now.strftime("%Y-%m-%dT%H:%M:%SZ"), + ) + + +def list_devices( + status: str | None = None, + hw_type: str | None = None, + search: str | None = None, + limit: int = 100, + offset: int = 0, +) -> list[DeviceInventoryItem]: + db = get_db() + query = db.collection(COLLECTION) + + if status: + query = query.where("mfg_status", "==", status) + if hw_type: + query = query.where("hw_type", "==", hw_type) + + docs = list(query.stream()) + items = [_doc_to_inventory_item(doc) for doc in docs] + + if search: + search_lower = search.lower() + items = [ + item for item in items + if search_lower in (item.serial_number or "").lower() + or search_lower in (item.owner or "").lower() + or search_lower in (item.mfg_batch_id or "").lower() + ] + + return items[offset: offset + limit] + + +def get_device_by_sn(sn: str) -> DeviceInventoryItem: + db = get_db() + docs = list(db.collection(COLLECTION).where("serial_number", "==", sn).limit(1).stream()) + if not docs: + raise NotFoundError("Device") + return _doc_to_inventory_item(docs[0]) + + +def update_device_status(sn: str, data: DeviceStatusUpdate) -> DeviceInventoryItem: + db = get_db() + docs = list(db.collection(COLLECTION).where("serial_number", "==", sn).limit(1).stream()) + if not docs: + raise NotFoundError("Device") + + doc_ref = docs[0].reference + update = {"mfg_status": data.status.value} + if data.note: + update["mfg_status_note"] = data.note + doc_ref.update(update) + + return _doc_to_inventory_item(doc_ref.get()) + + +def get_nvs_binary(sn: str) -> bytes: + item = get_device_by_sn(sn) + return generate_nvs_binary( + serial_number=item.serial_number, + hw_type=item.hw_type, + hw_version=item.hw_version, + ) diff --git a/backend/mqtt/auth.py b/backend/mqtt/auth.py new file mode 100644 index 0000000..6701a30 --- /dev/null +++ b/backend/mqtt/auth.py @@ -0,0 +1,126 @@ +""" +MQTT authentication and ACL endpoints for mosquitto-go-auth HTTP backend. + +Mosquitto calls these on every CONNECT, SUBSCRIBE, and PUBLISH. +- POST /mqtt/auth/user → validate device credentials +- POST /mqtt/auth/acl → enforce per-device topic isolation + +Password strategy: HMAC-SHA256(MQTT_SECRET, username)[:32] +- Deterministic: no storage needed, re-derive on every auth check +- Rotating MQTT_SECRET invalidates all passwords at once if needed + +Transition support: during rollout, the legacy password "vesper" is also +accepted so that devices still on old firmware stay connected. + +User types handled: +- Device users (e.g. "PV25L22BP01R01", "PV-26A18-BC02R-X7KQA"): + Authenticated via HMAC. ACL restricted to their own vesper/{sn}/... topics. +- Kiosk users (e.g. "PV25L22BP01R01-kiosk"): + Same HMAC auth derived from the full kiosk username. + ACL: allowed to access topics of their base device (suffix stripped). +- bonamin, NodeRED, and other non-device users: + These connect via the passwd file backend (go-auth file backend). + They never reach this HTTP backend — go-auth resolves them first. + The ACL endpoint below handles them defensively anyway (superuser list). +""" + +import hmac +import hashlib +from fastapi import APIRouter, Form, Response +from config import settings + +router = APIRouter(prefix="/mqtt/auth", tags=["mqtt-auth"]) + +LEGACY_PASSWORD = "vesper" + +# Users authenticated via passwd file (go-auth file backend). +# If they somehow reach the HTTP ACL endpoint, grant full access. +SUPERUSERS = {"bonamin", "NodeRED"} + + +def _derive_password(username: str) -> str: + """Derive the expected MQTT password for a given username.""" + return hmac.new( + settings.mqtt_secret.encode(), + username.encode(), + hashlib.sha256, + ).hexdigest()[:32] + + +def _is_valid_password(username: str, password: str) -> bool: + """ + Accept the password if it matches either: + - The HMAC-derived password (new firmware) + - The legacy hardcoded "vesper" password (old firmware, transition period) + + Remove the legacy check in Stage 7 once all devices are on new firmware. + """ + expected = _derive_password(username) + hmac_ok = hmac.compare_digest(expected, password) + legacy_ok = hmac.compare_digest(LEGACY_PASSWORD, password) + return hmac_ok or legacy_ok + + +def _base_sn(username: str) -> str: + """ + Strip the -kiosk suffix if present, returning the base serial number. + e.g. "PV25L22BP01R01-kiosk" -> "PV25L22BP01R01" + "PV25L22BP01R01" -> "PV25L22BP01R01" + """ + if username.endswith("-kiosk"): + return username[: -len("-kiosk")] + return username + + +@router.post("/user") +async def mqtt_auth_user( + username: str = Form(...), + password: str = Form(...), + clientid: str = Form(default=""), +): + """ + Called by Mosquitto on every CONNECT. + Returns 200 to allow, 403 to deny. + + Username = device SN (new format: "PV-26A18-BC02R-X7KQA", old format: "PV25L22BP01R01") + or kiosk variant: "PV25L22BP01R01-kiosk" + Password = HMAC-derived (new firmware) or "vesper" (legacy firmware) + + Note: bonamin and NodeRED authenticate via the go-auth passwd file backend + and never reach this endpoint. + """ + if _is_valid_password(username, password): + return Response(status_code=200) + + return Response(status_code=403) + + +@router.post("/acl") +async def mqtt_auth_acl( + username: str = Form(...), + topic: str = Form(...), + clientid: str = Form(default=""), + acc: int = Form(...), # 1 = subscribe, 2 = publish, 3 = subscribe+publish +): + """ + Called by Mosquitto on every SUBSCRIBE and PUBLISH. + Returns 200 to allow, 403 to deny. + + Topic pattern: vesper/{sn}/... + - Device users: may only access their own SN segment + - Kiosk users: stripped of -kiosk suffix, then same rule applies + - Superusers (bonamin, NodeRED): full access + """ + # Superusers get full access (shouldn't reach here but handled defensively) + if username in SUPERUSERS: + return Response(status_code=200) + + # Derive the base SN (handles -kiosk suffix) + base = _base_sn(username) + + # Topic must be vesper/{base_sn}/... + parts = topic.split("/") + if len(parts) >= 2 and parts[0] == "vesper" and parts[1] == base: + return Response(status_code=200) + + return Response(status_code=403) diff --git a/backend/mqtt/mosquitto.py b/backend/mqtt/mosquitto.py index 3a9e9f7..2719882 100644 --- a/backend/mqtt/mosquitto.py +++ b/backend/mqtt/mosquitto.py @@ -1,52 +1,17 @@ -import subprocess -import os -from config import settings +""" +mqtt/mosquitto.py — no-ops since Stage 5. + +Auth is now HMAC-based via the go-auth HTTP plugin. +These functions are kept as no-ops so existing call sites don't break. +They can be removed entirely in Phase 6 cleanup. +""" def register_device_password(serial_number: str, password: str) -> bool: - """Register a device in the Mosquitto password file. - - Uses mosquitto_passwd to add/update the device credentials. - The serial number is used as the MQTT username. - Returns True on success, False on failure. - """ - passwd_file = settings.mosquitto_password_file - - # Ensure the password file exists - if not os.path.exists(passwd_file): - # Create the file if it doesn't exist - os.makedirs(os.path.dirname(passwd_file), exist_ok=True) - open(passwd_file, "a").close() - - try: - # Use mosquitto_passwd with -b flag (batch mode) to set password - result = subprocess.run( - ["mosquitto_passwd", "-b", passwd_file, serial_number, password], - capture_output=True, - text=True, - timeout=10, - ) - return result.returncode == 0 - except (subprocess.TimeoutExpired, FileNotFoundError) as e: - print(f"[WARNING] Mosquitto password registration failed: {e}") - return False + """No-op. HMAC auth is derived on demand — no registration needed.""" + return True def remove_device_password(serial_number: str) -> bool: - """Remove a device from the Mosquitto password file.""" - passwd_file = settings.mosquitto_password_file - - if not os.path.exists(passwd_file): - return True - - try: - result = subprocess.run( - ["mosquitto_passwd", "-D", passwd_file, serial_number], - capture_output=True, - text=True, - timeout=10, - ) - return result.returncode == 0 - except (subprocess.TimeoutExpired, FileNotFoundError) as e: - print(f"[WARNING] Mosquitto password removal failed: {e}") - return False + """No-op. HMAC auth is derived on demand — no removal needed.""" + return True diff --git a/backend/utils/__init__.py b/backend/utils/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/utils/nvs_generator.py b/backend/utils/nvs_generator.py new file mode 100644 index 0000000..e363e1d --- /dev/null +++ b/backend/utils/nvs_generator.py @@ -0,0 +1,205 @@ +""" +Pure-Python ESP32 NVS partition binary generator. + +Generates a binary-compatible NVS partition for a Vesper device identity. +No ESP-IDF toolchain required on the server. + +NVS partition layout (ESP32 NVS format v2): +- Partition size: 0x5000 (20480 bytes) = 5 pages +- Page size: 4096 bytes +- Page structure: + Offset 0x000 - 0x01F : Page header (32 bytes) + Offset 0x020 - 0x03F : Entry state bitmap (32 bytes, 2 bits per slot) + Offset 0x040 - 0xFFF : Entry storage (120 slots × 32 bytes each) + +Entry state bitmap: 2 bits per entry + 11 = empty + 10 = written (active) + 00 = erased + +Page header (32 bytes): + uint32 page_state (0xFFFFFFFE = active) + uint32 sequence_number + uint8 version (0xFE = v2) + uint8 reserved[19] + uint32 crc32 (of bytes 4..27) + +Entry (32 bytes): + uint8 ns_index (namespace index, 0 = namespace entry itself) + uint8 type (0x01=uint8, 0x02=uint16, 0x04=uint32, 0x08=uint64, 0x21=string, 0x41=blob) + uint8 span (number of 32-byte slots this entry occupies) + uint8 chunk_index (0xFF for non-blob) + uint32 crc32 (of the entry header bytes 0..3 and data, excluding the crc field itself) + char key[16] (null-terminated, max 15 chars + null) + [8 bytes for primitives, or inline for short strings] + +For strings: + - If len <= 8 bytes (incl. null): fits in the data field of the same entry (span=1) + - Longer strings: data follows in subsequent 32-byte "data entries" (span = 1 + ceil(strlen+1, 32)) + - The entry header data field contains: uint16 data_size, uint16 reserved=0xFFFF, uint32 crc32_of_data +""" + +import struct +import binascii +from typing import List, Tuple + + +NVS_PAGE_SIZE = 4096 +NVS_PARTITION_SIZE = 0x5000 # 20480 bytes = 5 pages +NVS_ENTRY_SIZE = 32 +NVS_ENTRY_COUNT = 126 # entries per page (first 3 slots are header + bitmap) + +NVS_PAGE_STATE_ACTIVE = 0xFFFFFFFE +NVS_PAGE_VERSION = 0xFE + +ENTRY_STATE_WRITTEN = 0b10 # 2 bits +ENTRY_STATE_EMPTY = 0b11 # 2 bits (erased flash) + +ENTRY_TYPE_NAMESPACE = 0x01 # used for namespace entries (uint8) +ENTRY_TYPE_STRING = 0x21 + + +def _crc32(data: bytes) -> int: + return binascii.crc32(data) & 0xFFFFFFFF + + +def _page_header_crc(seq: int, version: int) -> int: + """CRC covers bytes 4..27 of the page header (seq + version + reserved).""" + buf = struct.pack(" int: + """CRC covers the entry minus the 4-byte crc field at offset 4..7.""" + header_no_crc = struct.pack("BBBB", ns_index, entry_type, span, chunk_index) + return _crc32(header_no_crc + key + data) + + +def _pack_entry(ns_index: int, entry_type: int, span: int, chunk_index: int, + key: str, data: bytes) -> bytes: + key_bytes = key.encode("ascii").ljust(16, b"\x00")[:16] + data_bytes = data.ljust(8, b"\xFF")[:8] + crc = _entry_crc(ns_index, entry_type, span, chunk_index, key_bytes, data_bytes) + return struct.pack("BBBBI", ns_index, entry_type, span, chunk_index, crc) + key_bytes + data_bytes + + +def _bitmap_set_written(bitmap: bytearray, slot_index: int) -> None: + """Mark a slot as written (10) in the entry state bitmap.""" + bit_pos = slot_index * 2 + byte_idx = bit_pos // 8 + bit_off = bit_pos % 8 + # Clear both bits for this slot (set to 00 then OR in 10) + bitmap[byte_idx] &= ~(0b11 << bit_off) + bitmap[byte_idx] |= (ENTRY_STATE_WRITTEN << bit_off) + + +def _build_namespace_entry(ns_name: str, ns_index: int) -> Tuple[bytes, int]: + """Build the namespace declaration entry. ns_index is the assigned namespace id (1-based).""" + data = struct.pack(" Tuple[bytes, int]: + """Build a string entry. May span multiple 32-byte slots for long strings.""" + value_bytes = value.encode("utf-8") + b"\x00" # null-terminated + value_len = len(value_bytes) + + # Pad to multiple of 32 + padded_len = ((value_len + 31) // 32) * 32 + value_padded = value_bytes.ljust(padded_len, b"\xFF") + + span = 1 + (padded_len // 32) + + # Data field in the header entry: uint16 data_size, uint16 0xFFFF, uint32 crc_of_data + data_crc = _crc32(value_bytes) + header_data = struct.pack(" bytes: + """Assemble a full 4096-byte NVS page.""" + # Build entry storage area + storage = bytearray(NVS_ENTRY_COUNT * NVS_ENTRY_SIZE) # all 0xFF (erased) + storage[:] = b"\xFF" * len(storage) + bitmap = bytearray(b"\xFF" * 32) # all slots empty (11 bits) + + slot = 0 + for entry_bytes, span in zip(entries, slot_counts): + entry_offset = slot * NVS_ENTRY_SIZE + storage[entry_offset:entry_offset + len(entry_bytes)] = entry_bytes + for s in range(span): + _bitmap_set_written(bitmap, slot + s) + slot += span + + # Page header + header_crc = _page_header_crc(seq, NVS_PAGE_VERSION) + header = struct.pack( + " bytes: + """Generate a 0x5000-byte NVS partition binary for a Vesper device. + + serial_number: full SN string e.g. 'PV-26B27-VS01R-X7KQA' + hw_type: lowercase board type e.g. 'vs', 'vp', 'vx' + hw_version: zero-padded version e.g. '01' + + Returns raw bytes ready to flash at 0x9000. + """ + ns_index = 1 # first (and only) namespace + + # Build entries for namespace "device_id" + ns_entry, ns_span = _build_namespace_entry("device_id", ns_index) + uid_entry, uid_span = _build_string_entry(ns_index, "device_uid", serial_number) + hwt_entry, hwt_span = _build_string_entry(ns_index, "hw_type", hw_type.lower()) + hwv_entry, hwv_span = _build_string_entry(ns_index, "hw_version", hw_version) + + entries = [ns_entry, uid_entry, hwt_entry, hwv_entry] + spans = [ns_span, uid_span, hwt_span, hwv_span] + + page0 = _build_page(entries, spans, seq=0) + + # Remaining pages are blank (erased flash = 0xFF) + blank_page = b"\xFF" * NVS_PAGE_SIZE + remaining_pages = (NVS_PARTITION_SIZE // NVS_PAGE_SIZE) - 1 + + return page0 + blank_page * remaining_pages diff --git a/backend/utils/serial_number.py b/backend/utils/serial_number.py new file mode 100644 index 0000000..b1e76aa --- /dev/null +++ b/backend/utils/serial_number.py @@ -0,0 +1,19 @@ +import random +from datetime import datetime + +MONTH_CODES = "ABCDEFGHIJKL" +SAFE_CHARS = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789" # No 0, O, 1, I — avoids label confusion + + +def generate_serial(board_type: str, board_version: str) -> str: + """Generate a serial number in the format PV-YYMMM-BBTTR-XXXXX. + + board_type: 2-char uppercase code, e.g. 'VS', 'VP', 'VX' + board_version: 2-char zero-padded version, e.g. '01' + """ + now = datetime.utcnow() + year = now.strftime("%y") + month = MONTH_CODES[now.month - 1] + day = now.strftime("%d") + suffix = "".join(random.choices(SAFE_CHARS, k=5)) + return f"PV-{year}{month}{day}-{board_type.upper()}{board_version}R-{suffix}" diff --git a/docker-compose.yml b/docker-compose.yml index 1083d9a..ab2ea22 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -8,6 +8,7 @@ services: # Persistent data - lives outside the container - ./data/mqtt_data.db:/app/mqtt_data.db - ./data/built_melodies:/app/storage/built_melodies + - ./data/firmware:/app/storage/firmware - ./data/firebase-service-account.json:/app/firebase-service-account.json:ro ports: - "8000:8000" @@ -26,7 +27,7 @@ services: image: nginx:alpine container_name: bellsystems-nginx ports: - - "80:80" + - "${NGINX_PORT:-80}:80" volumes: - ./nginx/nginx.conf:/etc/nginx/nginx.conf:ro depends_on: diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx index 368669c..b884dd9 100644 --- a/frontend/src/App.jsx +++ b/frontend/src/App.jsx @@ -24,6 +24,10 @@ import NoteForm from "./equipment/NoteForm"; import StaffList from "./settings/StaffList"; import StaffDetail from "./settings/StaffDetail"; import StaffForm from "./settings/StaffForm"; +import DeviceInventory from "./manufacturing/DeviceInventory"; +import BatchCreator from "./manufacturing/BatchCreator"; +import DeviceInventoryDetail from "./manufacturing/DeviceInventoryDetail"; +import FirmwareManager from "./firmware/FirmwareManager"; function ProtectedRoute({ children }) { const { user, loading } = useAuth(); @@ -149,6 +153,12 @@ export default function App() { } /> } /> + {/* Manufacturing */} + } /> + } /> + } /> + } /> + {/* Settings - Staff Management */} } /> } /> diff --git a/frontend/src/firmware/FirmwareManager.jsx b/frontend/src/firmware/FirmwareManager.jsx new file mode 100644 index 0000000..d72a64a --- /dev/null +++ b/frontend/src/firmware/FirmwareManager.jsx @@ -0,0 +1,488 @@ +import { useState, useEffect, useRef } from "react"; +import { useAuth } from "../auth/AuthContext"; +import api from "../api/client"; + +const BOARD_TYPES = [ + { value: "vs", label: "Vesper (VS)" }, + { value: "vp", label: "Vesper+ (VP)" }, + { value: "vx", label: "VesperPro (VX)" }, +]; + +const CHANNELS = ["stable", "beta", "alpha", "testing"]; + +const CHANNEL_STYLES = { + stable: { bg: "var(--success-bg)", color: "var(--success-text)" }, + beta: { bg: "var(--badge-blue-bg)", color: "var(--badge-blue-text)" }, + alpha: { bg: "#2e1a00", color: "#fb923c" }, + testing: { bg: "var(--bg-card-hover)", color: "var(--text-muted)" }, +}; + +function ChannelBadge({ channel }) { + const style = CHANNEL_STYLES[channel] || CHANNEL_STYLES.testing; + return ( + + {channel} + + ); +} + +function formatBytes(bytes) { + if (!bytes) return "—"; + if (bytes < 1024) return `${bytes} B`; + if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`; + return `${(bytes / (1024 * 1024)).toFixed(2)} MB`; +} + +function formatDate(iso) { + if (!iso) return "—"; + try { + return new Date(iso).toLocaleString("en-US", { + year: "numeric", month: "short", day: "numeric", + hour: "2-digit", minute: "2-digit", + }); + } catch { + return iso; + } +} + +export default function FirmwareManager() { + const { hasPermission } = useAuth(); + const canAdd = hasPermission("manufacturing", "add"); + const canDelete = hasPermission("manufacturing", "delete"); + + const [firmware, setFirmware] = useState([]); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(""); + + const [hwTypeFilter, setHwTypeFilter] = useState(""); + const [channelFilter, setChannelFilter] = useState(""); + + const [showUpload, setShowUpload] = useState(false); + const [uploadHwType, setUploadHwType] = useState("vs"); + const [uploadChannel, setUploadChannel] = useState("stable"); + const [uploadVersion, setUploadVersion] = useState(""); + const [uploadNotes, setUploadNotes] = useState(""); + const [uploadFile, setUploadFile] = useState(null); + const [uploading, setUploading] = useState(false); + const [uploadError, setUploadError] = useState(""); + const fileInputRef = useRef(null); + + const [deleteTarget, setDeleteTarget] = useState(null); + const [deleting, setDeleting] = useState(false); + + const fetchFirmware = async () => { + setLoading(true); + setError(""); + try { + const params = new URLSearchParams(); + if (hwTypeFilter) params.set("hw_type", hwTypeFilter); + if (channelFilter) params.set("channel", channelFilter); + const qs = params.toString(); + const data = await api.get(`/firmware${qs ? `?${qs}` : ""}`); + setFirmware(data.firmware); + } catch (err) { + setError(err.message); + } finally { + setLoading(false); + } + }; + + useEffect(() => { + fetchFirmware(); + }, [hwTypeFilter, channelFilter]); + + const handleUpload = async (e) => { + e.preventDefault(); + if (!uploadFile) return; + setUploadError(""); + setUploading(true); + try { + const formData = new FormData(); + formData.append("hw_type", uploadHwType); + formData.append("channel", uploadChannel); + formData.append("version", uploadVersion); + if (uploadNotes) formData.append("notes", uploadNotes); + formData.append("file", uploadFile); + + const token = localStorage.getItem("access_token"); + const response = await fetch("/api/firmware/upload", { + method: "POST", + headers: { Authorization: `Bearer ${token}` }, + body: formData, + }); + if (!response.ok) { + const err = await response.json().catch(() => ({})); + throw new Error(err.detail || "Upload failed"); + } + + setShowUpload(false); + setUploadVersion(""); + setUploadNotes(""); + setUploadFile(null); + if (fileInputRef.current) fileInputRef.current.value = ""; + await fetchFirmware(); + } catch (err) { + setUploadError(err.message); + } finally { + setUploading(false); + } + }; + + const handleDelete = async () => { + if (!deleteTarget) return; + setDeleting(true); + try { + await api.delete(`/firmware/${deleteTarget.id}`); + setDeleteTarget(null); + await fetchFirmware(); + } catch (err) { + setError(err.message); + } finally { + setDeleting(false); + } + }; + + const BOARD_TYPE_LABELS = { vs: "Vesper", vp: "Vesper+", vx: "VesperPro" }; + + return ( +
+
+
+

+ Firmware +

+

+ {firmware.length} version{firmware.length !== 1 ? "s" : ""} + {hwTypeFilter || channelFilter ? " (filtered)" : ""} +

+
+ {canAdd && ( + + )} +
+ + {/* Upload form */} + {showUpload && ( +
+

+ Upload New Firmware +

+ {uploadError && ( +
+ {uploadError} +
+ )} +
+
+
+ + +
+
+ + +
+
+ + setUploadVersion(e.target.value)} + placeholder="1.4.2" + required + className="w-full px-3 py-2 rounded-md text-sm border" + style={{ + backgroundColor: "var(--bg-input)", + borderColor: "var(--border-input)", + color: "var(--text-primary)", + }} + /> +
+
+
+ + setUploadFile(e.target.files[0] || null)} + className="w-full text-sm" + style={{ color: "var(--text-primary)" }} + /> +
+
+ +