feat: Phase 3 manufacturing + firmware management

This commit is contained in:
2026-02-27 02:47:08 +02:00
parent 2f610633c4
commit 32a2634739
25 changed files with 2266 additions and 52 deletions

View File

@@ -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,
}

View File

@@ -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"]'

View File

@@ -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

View File

View File

@@ -0,0 +1,31 @@
from pydantic import BaseModel
from typing import Optional, List
class FirmwareVersion(BaseModel):
id: str
hw_type: str # "vs", "vp", "vx"
channel: str # "stable", "beta", "alpha", "testing"
version: str # semver e.g. "1.4.2"
filename: str
size_bytes: int
sha256: str
uploaded_at: str
notes: Optional[str] = None
is_latest: bool = False
class FirmwareListResponse(BaseModel):
firmware: List[FirmwareVersion]
total: int
class FirmwareLatestResponse(BaseModel):
hw_type: str
channel: str
version: str
size_bytes: int
sha256: str
download_url: str
uploaded_at: str
notes: Optional[str] = None

View File

@@ -0,0 +1,66 @@
from fastapi import APIRouter, Depends, Query, UploadFile, File, Form
from fastapi.responses import FileResponse
from typing import Optional
from auth.models import TokenPayload
from auth.dependencies import require_permission
from firmware.models import FirmwareVersion, FirmwareListResponse, FirmwareLatestResponse
from firmware import service
router = APIRouter(prefix="/api/firmware", tags=["firmware"])
@router.post("/upload", response_model=FirmwareVersion, status_code=201)
async def upload_firmware(
hw_type: str = Form(...),
channel: str = Form(...),
version: str = Form(...),
notes: Optional[str] = Form(None),
file: UploadFile = File(...),
_user: TokenPayload = Depends(require_permission("manufacturing", "add")),
):
file_bytes = await file.read()
return service.upload_firmware(
hw_type=hw_type,
channel=channel,
version=version,
file_bytes=file_bytes,
notes=notes,
)
@router.get("", response_model=FirmwareListResponse)
def list_firmware(
hw_type: Optional[str] = Query(None),
channel: Optional[str] = Query(None),
_user: TokenPayload = Depends(require_permission("manufacturing", "view")),
):
items = service.list_firmware(hw_type=hw_type, channel=channel)
return FirmwareListResponse(firmware=items, total=len(items))
@router.get("/{hw_type}/{channel}/latest", response_model=FirmwareLatestResponse)
def get_latest_firmware(hw_type: str, channel: str):
"""Returns metadata for the latest firmware for a given hw_type + channel.
No auth required — devices call this endpoint to check for updates.
"""
return service.get_latest(hw_type, channel)
@router.get("/{hw_type}/{channel}/{version}/firmware.bin")
def download_firmware(hw_type: str, channel: str, version: str):
"""Download the firmware binary. No auth required — devices call this directly."""
path = service.get_firmware_path(hw_type, channel, version)
return FileResponse(
path=str(path),
media_type="application/octet-stream",
filename="firmware.bin",
)
@router.delete("/{firmware_id}", status_code=204)
def delete_firmware(
firmware_id: str,
_user: TokenPayload = Depends(require_permission("manufacturing", "delete")),
):
service.delete_firmware(firmware_id)

186
backend/firmware/service.py Normal file
View File

@@ -0,0 +1,186 @@
import hashlib
import uuid
from datetime import datetime, timezone
from pathlib import Path
from fastapi import HTTPException
from config import settings
from shared.firebase import get_db
from shared.exceptions import NotFoundError
from firmware.models import FirmwareVersion, FirmwareLatestResponse
COLLECTION = "firmware_versions"
VALID_HW_TYPES = {"vs", "vp", "vx"}
VALID_CHANNELS = {"stable", "beta", "alpha", "testing"}
def _storage_path(hw_type: str, channel: str, version: str) -> Path:
return Path(settings.firmware_storage_path) / hw_type / channel / version / "firmware.bin"
def _doc_to_firmware_version(doc) -> FirmwareVersion:
data = doc.to_dict() or {}
uploaded_raw = data.get("uploaded_at")
if isinstance(uploaded_raw, datetime):
uploaded_str = uploaded_raw.strftime("%Y-%m-%dT%H:%M:%SZ")
else:
uploaded_str = str(uploaded_raw) if uploaded_raw else ""
return FirmwareVersion(
id=doc.id,
hw_type=data.get("hw_type", ""),
channel=data.get("channel", ""),
version=data.get("version", ""),
filename=data.get("filename", "firmware.bin"),
size_bytes=data.get("size_bytes", 0),
sha256=data.get("sha256", ""),
uploaded_at=uploaded_str,
notes=data.get("notes"),
is_latest=data.get("is_latest", False),
)
def upload_firmware(
hw_type: str,
channel: str,
version: str,
file_bytes: bytes,
notes: str | None = None,
) -> FirmwareVersion:
if hw_type not in VALID_HW_TYPES:
raise HTTPException(status_code=400, detail=f"Invalid hw_type. Must be one of: {', '.join(VALID_HW_TYPES)}")
if channel not in VALID_CHANNELS:
raise HTTPException(status_code=400, detail=f"Invalid channel. Must be one of: {', '.join(VALID_CHANNELS)}")
dest = _storage_path(hw_type, channel, version)
dest.parent.mkdir(parents=True, exist_ok=True)
dest.write_bytes(file_bytes)
sha256 = hashlib.sha256(file_bytes).hexdigest()
now = datetime.now(timezone.utc)
doc_id = str(uuid.uuid4())
db = get_db()
# Mark previous latest for this hw_type+channel as no longer latest
prev_docs = (
db.collection(COLLECTION)
.where("hw_type", "==", hw_type)
.where("channel", "==", channel)
.where("is_latest", "==", True)
.stream()
)
for prev in prev_docs:
prev.reference.update({"is_latest": False})
doc_ref = db.collection(COLLECTION).document(doc_id)
doc_ref.set({
"hw_type": hw_type,
"channel": channel,
"version": version,
"filename": "firmware.bin",
"size_bytes": len(file_bytes),
"sha256": sha256,
"uploaded_at": now,
"notes": notes,
"is_latest": True,
})
return _doc_to_firmware_version(doc_ref.get())
def list_firmware(
hw_type: str | None = None,
channel: str | None = None,
) -> list[FirmwareVersion]:
db = get_db()
query = db.collection(COLLECTION)
if hw_type:
query = query.where("hw_type", "==", hw_type)
if channel:
query = query.where("channel", "==", channel)
docs = list(query.stream())
items = [_doc_to_firmware_version(doc) for doc in docs]
items.sort(key=lambda x: x.uploaded_at, reverse=True)
return items
def get_latest(hw_type: str, channel: str) -> FirmwareLatestResponse:
if hw_type not in VALID_HW_TYPES:
raise HTTPException(status_code=400, detail=f"Invalid hw_type '{hw_type}'")
if channel not in VALID_CHANNELS:
raise HTTPException(status_code=400, detail=f"Invalid channel '{channel}'")
db = get_db()
docs = list(
db.collection(COLLECTION)
.where("hw_type", "==", hw_type)
.where("channel", "==", channel)
.where("is_latest", "==", True)
.limit(1)
.stream()
)
if not docs:
raise NotFoundError("Firmware")
fw = _doc_to_firmware_version(docs[0])
download_url = f"/api/firmware/{hw_type}/{channel}/{fw.version}/firmware.bin"
return FirmwareLatestResponse(
hw_type=fw.hw_type,
channel=fw.channel,
version=fw.version,
size_bytes=fw.size_bytes,
sha256=fw.sha256,
download_url=download_url,
uploaded_at=fw.uploaded_at,
notes=fw.notes,
)
def get_firmware_path(hw_type: str, channel: str, version: str) -> Path:
path = _storage_path(hw_type, channel, version)
if not path.exists():
raise NotFoundError("Firmware binary")
return path
def delete_firmware(doc_id: str) -> None:
db = get_db()
doc_ref = db.collection(COLLECTION).document(doc_id)
doc = doc_ref.get()
if not doc.exists:
raise NotFoundError("Firmware")
data = doc.to_dict()
hw_type = data.get("hw_type", "")
channel = data.get("channel", "")
version = data.get("version", "")
was_latest = data.get("is_latest", False)
# Delete the binary file
path = _storage_path(hw_type, channel, version)
if path.exists():
path.unlink()
# Remove the version directory if empty
try:
path.parent.rmdir()
except OSError:
pass
doc_ref.delete()
# If we deleted the latest, promote the next most recent as latest
if was_latest:
remaining = list(
db.collection(COLLECTION)
.where("hw_type", "==", hw_type)
.where("channel", "==", channel)
.order_by("uploaded_at", direction="DESCENDING")
.limit(1)
.stream()
)
if remaining:
remaining[0].reference.update({"is_latest": True})

View File

@@ -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")

View File

View File

@@ -0,0 +1,61 @@
from pydantic import BaseModel, Field
from typing import Optional, List
from enum import Enum
class BoardType(str, Enum):
vs = "vs" # Vesper
vp = "vp" # Vesper+
vx = "vx" # VesperPro
BOARD_TYPE_LABELS = {
"vs": "Vesper",
"vp": "Vesper+",
"vx": "VesperPro",
}
class MfgStatus(str, Enum):
manufactured = "manufactured"
flashed = "flashed"
provisioned = "provisioned"
sold = "sold"
claimed = "claimed"
decommissioned = "decommissioned"
class BatchCreate(BaseModel):
board_type: BoardType
board_version: str = Field(..., pattern=r"^\d{2}$", description="2-digit zero-padded version, e.g. '01'")
quantity: int = Field(..., ge=1, le=100)
class BatchResponse(BaseModel):
batch_id: str
serial_numbers: List[str]
board_type: str
board_version: str
created_at: str
class DeviceInventoryItem(BaseModel):
id: str
serial_number: str
hw_type: str
hw_version: str
mfg_status: str
mfg_batch_id: Optional[str] = None
created_at: Optional[str] = None
owner: Optional[str] = None
assigned_to: Optional[str] = None
class DeviceInventoryListResponse(BaseModel):
devices: List[DeviceInventoryItem]
total: int
class DeviceStatusUpdate(BaseModel):
status: MfgStatus
note: Optional[str] = None

View File

@@ -0,0 +1,71 @@
from fastapi import APIRouter, Depends, Query
from fastapi.responses import Response
from typing import Optional
from auth.models import TokenPayload
from auth.dependencies import require_permission
from manufacturing.models import (
BatchCreate, BatchResponse,
DeviceInventoryItem, DeviceInventoryListResponse,
DeviceStatusUpdate,
)
from manufacturing import service
router = APIRouter(prefix="/api/manufacturing", tags=["manufacturing"])
@router.post("/batch", response_model=BatchResponse, status_code=201)
def create_batch(
body: BatchCreate,
_user: TokenPayload = Depends(require_permission("manufacturing", "add")),
):
return service.create_batch(body)
@router.get("/devices", response_model=DeviceInventoryListResponse)
def list_devices(
status: Optional[str] = Query(None),
hw_type: Optional[str] = Query(None),
search: Optional[str] = Query(None),
limit: int = Query(100, ge=1, le=500),
offset: int = Query(0, ge=0),
_user: TokenPayload = Depends(require_permission("manufacturing", "view")),
):
items = service.list_devices(
status=status,
hw_type=hw_type,
search=search,
limit=limit,
offset=offset,
)
return DeviceInventoryListResponse(devices=items, total=len(items))
@router.get("/devices/{sn}", response_model=DeviceInventoryItem)
def get_device(
sn: str,
_user: TokenPayload = Depends(require_permission("manufacturing", "view")),
):
return service.get_device_by_sn(sn)
@router.patch("/devices/{sn}/status", response_model=DeviceInventoryItem)
def update_status(
sn: str,
body: DeviceStatusUpdate,
_user: TokenPayload = Depends(require_permission("manufacturing", "edit")),
):
return service.update_device_status(sn, body)
@router.get("/devices/{sn}/nvs.bin")
def download_nvs(
sn: str,
_user: TokenPayload = Depends(require_permission("manufacturing", "view")),
):
binary = service.get_nvs_binary(sn)
return Response(
content=binary,
media_type="application/octet-stream",
headers={"Content-Disposition": f'attachment; filename="{sn}_nvs.bin"'},
)

View File

@@ -0,0 +1,153 @@
import random
import string
from datetime import datetime, timezone
from shared.firebase import get_db
from shared.exceptions import NotFoundError
from utils.serial_number import generate_serial
from utils.nvs_generator import generate as generate_nvs_binary
from manufacturing.models import BatchCreate, BatchResponse, DeviceInventoryItem, DeviceStatusUpdate
COLLECTION = "devices"
_BATCH_ID_CHARS = string.ascii_uppercase + string.digits
def _make_batch_id() -> str:
today = datetime.utcnow().strftime("%y%m%d")
suffix = "".join(random.choices(_BATCH_ID_CHARS, k=4))
return f"BATCH-{today}-{suffix}"
def _get_existing_sns(db) -> set:
existing = set()
for doc in db.collection(COLLECTION).select(["serial_number"]).stream():
data = doc.to_dict()
sn = data.get("serial_number")
if sn:
existing.add(sn)
return existing
def _doc_to_inventory_item(doc) -> DeviceInventoryItem:
data = doc.to_dict() or {}
created_raw = data.get("created_at")
if isinstance(created_raw, datetime):
created_str = created_raw.strftime("%Y-%m-%dT%H:%M:%SZ")
else:
created_str = str(created_raw) if created_raw else None
return DeviceInventoryItem(
id=doc.id,
serial_number=data.get("serial_number", ""),
hw_type=data.get("hw_type", ""),
hw_version=data.get("hw_version", ""),
mfg_status=data.get("mfg_status", "manufactured"),
mfg_batch_id=data.get("mfg_batch_id"),
created_at=created_str,
owner=data.get("owner"),
assigned_to=data.get("assigned_to"),
)
def create_batch(data: BatchCreate) -> BatchResponse:
db = get_db()
existing_sns = _get_existing_sns(db)
batch_id = _make_batch_id()
now = datetime.now(timezone.utc)
serial_numbers = []
for _ in range(data.quantity):
for attempt in range(200):
sn = generate_serial(data.board_type.value, data.board_version)
if sn not in existing_sns:
existing_sns.add(sn)
break
else:
raise RuntimeError("Could not generate unique serial numbers — collision limit hit")
db.collection(COLLECTION).add({
"serial_number": sn,
"hw_type": data.board_type.value,
"hw_version": data.board_version,
"mfg_status": "manufactured",
"mfg_batch_id": batch_id,
"created_at": now,
"owner": None,
"assigned_to": None,
"users_list": [],
# Legacy fields left empty so existing device views don't break
"device_name": "",
"device_location": "",
"is_Online": False,
})
serial_numbers.append(sn)
return BatchResponse(
batch_id=batch_id,
serial_numbers=serial_numbers,
board_type=data.board_type.value,
board_version=data.board_version,
created_at=now.strftime("%Y-%m-%dT%H:%M:%SZ"),
)
def list_devices(
status: str | None = None,
hw_type: str | None = None,
search: str | None = None,
limit: int = 100,
offset: int = 0,
) -> list[DeviceInventoryItem]:
db = get_db()
query = db.collection(COLLECTION)
if status:
query = query.where("mfg_status", "==", status)
if hw_type:
query = query.where("hw_type", "==", hw_type)
docs = list(query.stream())
items = [_doc_to_inventory_item(doc) for doc in docs]
if search:
search_lower = search.lower()
items = [
item for item in items
if search_lower in (item.serial_number or "").lower()
or search_lower in (item.owner or "").lower()
or search_lower in (item.mfg_batch_id or "").lower()
]
return items[offset: offset + limit]
def get_device_by_sn(sn: str) -> DeviceInventoryItem:
db = get_db()
docs = list(db.collection(COLLECTION).where("serial_number", "==", sn).limit(1).stream())
if not docs:
raise NotFoundError("Device")
return _doc_to_inventory_item(docs[0])
def update_device_status(sn: str, data: DeviceStatusUpdate) -> DeviceInventoryItem:
db = get_db()
docs = list(db.collection(COLLECTION).where("serial_number", "==", sn).limit(1).stream())
if not docs:
raise NotFoundError("Device")
doc_ref = docs[0].reference
update = {"mfg_status": data.status.value}
if data.note:
update["mfg_status_note"] = data.note
doc_ref.update(update)
return _doc_to_inventory_item(doc_ref.get())
def get_nvs_binary(sn: str) -> bytes:
item = get_device_by_sn(sn)
return generate_nvs_binary(
serial_number=item.serial_number,
hw_type=item.hw_type,
hw_version=item.hw_version,
)

126
backend/mqtt/auth.py Normal file
View File

@@ -0,0 +1,126 @@
"""
MQTT authentication and ACL endpoints for mosquitto-go-auth HTTP backend.
Mosquitto calls these on every CONNECT, SUBSCRIBE, and PUBLISH.
- POST /mqtt/auth/user → validate device credentials
- POST /mqtt/auth/acl → enforce per-device topic isolation
Password strategy: HMAC-SHA256(MQTT_SECRET, username)[:32]
- Deterministic: no storage needed, re-derive on every auth check
- Rotating MQTT_SECRET invalidates all passwords at once if needed
Transition support: during rollout, the legacy password "vesper" is also
accepted so that devices still on old firmware stay connected.
User types handled:
- Device users (e.g. "PV25L22BP01R01", "PV-26A18-BC02R-X7KQA"):
Authenticated via HMAC. ACL restricted to their own vesper/{sn}/... topics.
- Kiosk users (e.g. "PV25L22BP01R01-kiosk"):
Same HMAC auth derived from the full kiosk username.
ACL: allowed to access topics of their base device (suffix stripped).
- bonamin, NodeRED, and other non-device users:
These connect via the passwd file backend (go-auth file backend).
They never reach this HTTP backend — go-auth resolves them first.
The ACL endpoint below handles them defensively anyway (superuser list).
"""
import hmac
import hashlib
from fastapi import APIRouter, Form, Response
from config import settings
router = APIRouter(prefix="/mqtt/auth", tags=["mqtt-auth"])
LEGACY_PASSWORD = "vesper"
# Users authenticated via passwd file (go-auth file backend).
# If they somehow reach the HTTP ACL endpoint, grant full access.
SUPERUSERS = {"bonamin", "NodeRED"}
def _derive_password(username: str) -> str:
"""Derive the expected MQTT password for a given username."""
return hmac.new(
settings.mqtt_secret.encode(),
username.encode(),
hashlib.sha256,
).hexdigest()[:32]
def _is_valid_password(username: str, password: str) -> bool:
"""
Accept the password if it matches either:
- The HMAC-derived password (new firmware)
- The legacy hardcoded "vesper" password (old firmware, transition period)
Remove the legacy check in Stage 7 once all devices are on new firmware.
"""
expected = _derive_password(username)
hmac_ok = hmac.compare_digest(expected, password)
legacy_ok = hmac.compare_digest(LEGACY_PASSWORD, password)
return hmac_ok or legacy_ok
def _base_sn(username: str) -> str:
"""
Strip the -kiosk suffix if present, returning the base serial number.
e.g. "PV25L22BP01R01-kiosk" -> "PV25L22BP01R01"
"PV25L22BP01R01" -> "PV25L22BP01R01"
"""
if username.endswith("-kiosk"):
return username[: -len("-kiosk")]
return username
@router.post("/user")
async def mqtt_auth_user(
username: str = Form(...),
password: str = Form(...),
clientid: str = Form(default=""),
):
"""
Called by Mosquitto on every CONNECT.
Returns 200 to allow, 403 to deny.
Username = device SN (new format: "PV-26A18-BC02R-X7KQA", old format: "PV25L22BP01R01")
or kiosk variant: "PV25L22BP01R01-kiosk"
Password = HMAC-derived (new firmware) or "vesper" (legacy firmware)
Note: bonamin and NodeRED authenticate via the go-auth passwd file backend
and never reach this endpoint.
"""
if _is_valid_password(username, password):
return Response(status_code=200)
return Response(status_code=403)
@router.post("/acl")
async def mqtt_auth_acl(
username: str = Form(...),
topic: str = Form(...),
clientid: str = Form(default=""),
acc: int = Form(...), # 1 = subscribe, 2 = publish, 3 = subscribe+publish
):
"""
Called by Mosquitto on every SUBSCRIBE and PUBLISH.
Returns 200 to allow, 403 to deny.
Topic pattern: vesper/{sn}/...
- Device users: may only access their own SN segment
- Kiosk users: stripped of -kiosk suffix, then same rule applies
- Superusers (bonamin, NodeRED): full access
"""
# Superusers get full access (shouldn't reach here but handled defensively)
if username in SUPERUSERS:
return Response(status_code=200)
# Derive the base SN (handles -kiosk suffix)
base = _base_sn(username)
# Topic must be vesper/{base_sn}/...
parts = topic.split("/")
if len(parts) >= 2 and parts[0] == "vesper" and parts[1] == base:
return Response(status_code=200)
return Response(status_code=403)

View File

@@ -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

View File

View File

@@ -0,0 +1,205 @@
"""
Pure-Python ESP32 NVS partition binary generator.
Generates a binary-compatible NVS partition for a Vesper device identity.
No ESP-IDF toolchain required on the server.
NVS partition layout (ESP32 NVS format v2):
- Partition size: 0x5000 (20480 bytes) = 5 pages
- Page size: 4096 bytes
- Page structure:
Offset 0x000 - 0x01F : Page header (32 bytes)
Offset 0x020 - 0x03F : Entry state bitmap (32 bytes, 2 bits per slot)
Offset 0x040 - 0xFFF : Entry storage (120 slots × 32 bytes each)
Entry state bitmap: 2 bits per entry
11 = empty
10 = written (active)
00 = erased
Page header (32 bytes):
uint32 page_state (0xFFFFFFFE = active)
uint32 sequence_number
uint8 version (0xFE = v2)
uint8 reserved[19]
uint32 crc32 (of bytes 4..27)
Entry (32 bytes):
uint8 ns_index (namespace index, 0 = namespace entry itself)
uint8 type (0x01=uint8, 0x02=uint16, 0x04=uint32, 0x08=uint64, 0x21=string, 0x41=blob)
uint8 span (number of 32-byte slots this entry occupies)
uint8 chunk_index (0xFF for non-blob)
uint32 crc32 (of the entry header bytes 0..3 and data, excluding the crc field itself)
char key[16] (null-terminated, max 15 chars + null)
<data> [8 bytes for primitives, or inline for short strings]
For strings:
- If len <= 8 bytes (incl. null): fits in the data field of the same entry (span=1)
- Longer strings: data follows in subsequent 32-byte "data entries" (span = 1 + ceil(strlen+1, 32))
- The entry header data field contains: uint16 data_size, uint16 reserved=0xFFFF, uint32 crc32_of_data
"""
import struct
import binascii
from typing import List, Tuple
NVS_PAGE_SIZE = 4096
NVS_PARTITION_SIZE = 0x5000 # 20480 bytes = 5 pages
NVS_ENTRY_SIZE = 32
NVS_ENTRY_COUNT = 126 # entries per page (first 3 slots are header + bitmap)
NVS_PAGE_STATE_ACTIVE = 0xFFFFFFFE
NVS_PAGE_VERSION = 0xFE
ENTRY_STATE_WRITTEN = 0b10 # 2 bits
ENTRY_STATE_EMPTY = 0b11 # 2 bits (erased flash)
ENTRY_TYPE_NAMESPACE = 0x01 # used for namespace entries (uint8)
ENTRY_TYPE_STRING = 0x21
def _crc32(data: bytes) -> int:
return binascii.crc32(data) & 0xFFFFFFFF
def _page_header_crc(seq: int, version: int) -> int:
"""CRC covers bytes 4..27 of the page header (seq + version + reserved)."""
buf = struct.pack("<IB", seq, version) + b"\xFF" * 19
return _crc32(buf)
def _entry_crc(ns_index: int, entry_type: int, span: int, chunk_index: int,
key: bytes, data: bytes) -> int:
"""CRC covers the entry minus the 4-byte crc field at offset 4..7."""
header_no_crc = struct.pack("BBBB", ns_index, entry_type, span, chunk_index)
return _crc32(header_no_crc + key + data)
def _pack_entry(ns_index: int, entry_type: int, span: int, chunk_index: int,
key: str, data: bytes) -> bytes:
key_bytes = key.encode("ascii").ljust(16, b"\x00")[:16]
data_bytes = data.ljust(8, b"\xFF")[:8]
crc = _entry_crc(ns_index, entry_type, span, chunk_index, key_bytes, data_bytes)
return struct.pack("BBBBI", ns_index, entry_type, span, chunk_index, crc) + key_bytes + data_bytes
def _bitmap_set_written(bitmap: bytearray, slot_index: int) -> None:
"""Mark a slot as written (10) in the entry state bitmap."""
bit_pos = slot_index * 2
byte_idx = bit_pos // 8
bit_off = bit_pos % 8
# Clear both bits for this slot (set to 00 then OR in 10)
bitmap[byte_idx] &= ~(0b11 << bit_off)
bitmap[byte_idx] |= (ENTRY_STATE_WRITTEN << bit_off)
def _build_namespace_entry(ns_name: str, ns_index: int) -> Tuple[bytes, int]:
"""Build the namespace declaration entry. ns_index is the assigned namespace id (1-based)."""
data = struct.pack("<B", ns_index) + b"\xFF" * 7
entry = _pack_entry(
ns_index=0,
entry_type=ENTRY_TYPE_NAMESPACE,
span=1,
chunk_index=0xFF,
key=ns_name,
data=data,
)
return entry, 1 # consumes 1 slot
def _build_string_entry(ns_index: int, key: str, value: str) -> Tuple[bytes, int]:
"""Build a string entry. May span multiple 32-byte slots for long strings."""
value_bytes = value.encode("utf-8") + b"\x00" # null-terminated
value_len = len(value_bytes)
# Pad to multiple of 32
padded_len = ((value_len + 31) // 32) * 32
value_padded = value_bytes.ljust(padded_len, b"\xFF")
span = 1 + (padded_len // 32)
# Data field in the header entry: uint16 data_size, uint16 0xFFFF, uint32 crc_of_data
data_crc = _crc32(value_bytes)
header_data = struct.pack("<HHI", value_len, 0xFFFF, data_crc)
entry = _pack_entry(
ns_index=ns_index,
entry_type=ENTRY_TYPE_STRING,
span=span,
chunk_index=0xFF,
key=key,
data=header_data,
)
# Append data chunks (each 32 bytes)
full_entry = entry + value_padded
return full_entry, span
def _build_page(entries: List[bytes], slot_counts: List[int], seq: int = 0) -> bytes:
"""Assemble a full 4096-byte NVS page."""
# Build entry storage area
storage = bytearray(NVS_ENTRY_COUNT * NVS_ENTRY_SIZE) # all 0xFF (erased)
storage[:] = b"\xFF" * len(storage)
bitmap = bytearray(b"\xFF" * 32) # all slots empty (11 bits)
slot = 0
for entry_bytes, span in zip(entries, slot_counts):
entry_offset = slot * NVS_ENTRY_SIZE
storage[entry_offset:entry_offset + len(entry_bytes)] = entry_bytes
for s in range(span):
_bitmap_set_written(bitmap, slot + s)
slot += span
# Page header
header_crc = _page_header_crc(seq, NVS_PAGE_VERSION)
header = struct.pack(
"<IIBI19sI",
NVS_PAGE_STATE_ACTIVE,
seq,
NVS_PAGE_VERSION,
0, # padding
b"\xFF" * 19,
header_crc,
)
# Trim header to exactly 32 bytes
header = struct.pack("<I", NVS_PAGE_STATE_ACTIVE)
header += struct.pack("<I", seq)
header += struct.pack("<B", NVS_PAGE_VERSION)
header += b"\xFF" * 19
header += struct.pack("<I", header_crc)
assert len(header) == 32, f"Header size mismatch: {len(header)}"
page = header + bytes(bitmap) + bytes(storage)
assert len(page) == NVS_PAGE_SIZE, f"Page size mismatch: {len(page)}"
return page
def generate(serial_number: str, hw_type: str, hw_version: str) -> bytes:
"""Generate a 0x5000-byte NVS partition binary for a Vesper device.
serial_number: full SN string e.g. 'PV-26B27-VS01R-X7KQA'
hw_type: lowercase board type e.g. 'vs', 'vp', 'vx'
hw_version: zero-padded version e.g. '01'
Returns raw bytes ready to flash at 0x9000.
"""
ns_index = 1 # first (and only) namespace
# Build entries for namespace "device_id"
ns_entry, ns_span = _build_namespace_entry("device_id", ns_index)
uid_entry, uid_span = _build_string_entry(ns_index, "device_uid", serial_number)
hwt_entry, hwt_span = _build_string_entry(ns_index, "hw_type", hw_type.lower())
hwv_entry, hwv_span = _build_string_entry(ns_index, "hw_version", hw_version)
entries = [ns_entry, uid_entry, hwt_entry, hwv_entry]
spans = [ns_span, uid_span, hwt_span, hwv_span]
page0 = _build_page(entries, spans, seq=0)
# Remaining pages are blank (erased flash = 0xFF)
blank_page = b"\xFF" * NVS_PAGE_SIZE
remaining_pages = (NVS_PARTITION_SIZE // NVS_PAGE_SIZE) - 1
return page0 + blank_page * remaining_pages

View File

@@ -0,0 +1,19 @@
import random
from datetime import datetime
MONTH_CODES = "ABCDEFGHIJKL"
SAFE_CHARS = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789" # No 0, O, 1, I — avoids label confusion
def generate_serial(board_type: str, board_version: str) -> str:
"""Generate a serial number in the format PV-YYMMM-BBTTR-XXXXX.
board_type: 2-char uppercase code, e.g. 'VS', 'VP', 'VX'
board_version: 2-char zero-padded version, e.g. '01'
"""
now = datetime.utcnow()
year = now.strftime("%y")
month = MONTH_CODES[now.month - 1]
day = now.strftime("%d")
suffix = "".join(random.choices(SAFE_CHARS, k=5))
return f"PV-{year}{month}{day}-{board_type.upper()}{board_version}R-{suffix}"