Added SpeedCalc and MelodyBuilder. Evaluation Pending

This commit is contained in:
2026-02-22 13:17:54 +02:00
parent 8a8c665dfd
commit 8703c4fe26
27 changed files with 4075 additions and 3 deletions

View File

View File

@@ -0,0 +1,90 @@
import json
import logging
from mqtt.database import get_db
logger = logging.getLogger("builder.database")
async def insert_built_melody(melody_id: str, name: str, pid: str, steps: str) -> None:
db = await get_db()
await db.execute(
"""INSERT INTO built_melodies (id, name, pid, steps, assigned_melody_ids)
VALUES (?, ?, ?, ?, ?)""",
(melody_id, name, pid, steps, json.dumps([])),
)
await db.commit()
async def update_built_melody(melody_id: str, name: str, pid: str, steps: str) -> None:
db = await get_db()
await db.execute(
"""UPDATE built_melodies
SET name = ?, pid = ?, steps = ?, updated_at = datetime('now')
WHERE id = ?""",
(name, pid, steps, melody_id),
)
await db.commit()
async def update_binary_path(melody_id: str, binary_path: str) -> None:
db = await get_db()
await db.execute(
"""UPDATE built_melodies
SET binary_path = ?, updated_at = datetime('now')
WHERE id = ?""",
(binary_path, melody_id),
)
await db.commit()
async def update_progmem_code(melody_id: str, progmem_code: str) -> None:
db = await get_db()
await db.execute(
"""UPDATE built_melodies
SET progmem_code = ?, updated_at = datetime('now')
WHERE id = ?""",
(progmem_code, melody_id),
)
await db.commit()
async def update_assigned_melody_ids(melody_id: str, assigned_ids: list) -> None:
db = await get_db()
await db.execute(
"""UPDATE built_melodies
SET assigned_melody_ids = ?, updated_at = datetime('now')
WHERE id = ?""",
(json.dumps(assigned_ids), melody_id),
)
await db.commit()
async def get_built_melody(melody_id: str) -> dict | None:
db = await get_db()
rows = await db.execute_fetchall(
"SELECT * FROM built_melodies WHERE id = ?", (melody_id,)
)
if not rows:
return None
row = dict(rows[0])
row["assigned_melody_ids"] = json.loads(row["assigned_melody_ids"] or "[]")
return row
async def list_built_melodies() -> list[dict]:
db = await get_db()
rows = await db.execute_fetchall(
"SELECT * FROM built_melodies ORDER BY updated_at DESC"
)
results = []
for row in rows:
r = dict(row)
r["assigned_melody_ids"] = json.loads(r["assigned_melody_ids"] or "[]")
results.append(r)
return results
async def delete_built_melody(melody_id: str) -> None:
db = await get_db()
await db.execute("DELETE FROM built_melodies WHERE id = ?", (melody_id,))
await db.commit()

38
backend/builder/models.py Normal file
View File

@@ -0,0 +1,38 @@
from pydantic import BaseModel
from typing import List, Optional
class BuiltMelodyCreate(BaseModel):
name: str
pid: str
steps: str # raw step string e.g. "1,2,2+1,1,2,3+1"
class BuiltMelodyUpdate(BaseModel):
name: Optional[str] = None
pid: Optional[str] = None
steps: Optional[str] = None
class BuiltMelodyInDB(BaseModel):
id: str
name: str
pid: str
steps: str
binary_path: Optional[str] = None
binary_url: Optional[str] = None
progmem_code: Optional[str] = None
assigned_melody_ids: List[str] = []
created_at: str
updated_at: str
@property
def step_count(self) -> int:
if not self.steps:
return 0
return len(self.steps.split(","))
class BuiltMelodyListResponse(BaseModel):
melodies: List[BuiltMelodyInDB]
total: int

124
backend/builder/router.py Normal file
View File

@@ -0,0 +1,124 @@
from fastapi import APIRouter, Depends, HTTPException
from fastapi.responses import FileResponse
from auth.models import TokenPayload
from auth.dependencies import require_permission
from builder.models import (
BuiltMelodyCreate,
BuiltMelodyUpdate,
BuiltMelodyInDB,
BuiltMelodyListResponse,
)
from builder import service
router = APIRouter(prefix="/api/builder/melodies", tags=["builder"])
@router.get("", response_model=BuiltMelodyListResponse)
async def list_built_melodies(
_user: TokenPayload = Depends(require_permission("melodies", "view")),
):
melodies = await service.list_built_melodies()
return BuiltMelodyListResponse(melodies=melodies, total=len(melodies))
@router.get("/for-melody/{firestore_melody_id}")
async def get_for_firestore_melody(
firestore_melody_id: str,
_user: TokenPayload = Depends(require_permission("melodies", "view")),
):
"""Get the built melody assigned to a given Firestore melody ID. Returns null if none found."""
result = await service.get_built_melody_for_firestore_id(firestore_melody_id)
if result is None:
return None
return result.model_dump()
@router.get("/{melody_id}", response_model=BuiltMelodyInDB)
async def get_built_melody(
melody_id: str,
_user: TokenPayload = Depends(require_permission("melodies", "view")),
):
return await service.get_built_melody(melody_id)
@router.post("", response_model=BuiltMelodyInDB, status_code=201)
async def create_built_melody(
body: BuiltMelodyCreate,
_user: TokenPayload = Depends(require_permission("melodies", "edit")),
):
return await service.create_built_melody(body)
@router.put("/{melody_id}", response_model=BuiltMelodyInDB)
async def update_built_melody(
melody_id: str,
body: BuiltMelodyUpdate,
_user: TokenPayload = Depends(require_permission("melodies", "edit")),
):
return await service.update_built_melody(melody_id, body)
@router.delete("/{melody_id}", status_code=204)
async def delete_built_melody(
melody_id: str,
_user: TokenPayload = Depends(require_permission("melodies", "delete")),
):
await service.delete_built_melody(melody_id)
@router.post("/{melody_id}/build-binary", response_model=BuiltMelodyInDB)
async def build_binary(
melody_id: str,
_user: TokenPayload = Depends(require_permission("melodies", "edit")),
):
"""Build the .bsm binary file from the stored steps."""
return await service.build_binary(melody_id)
@router.post("/{melody_id}/build-builtin", response_model=BuiltMelodyInDB)
async def build_builtin_code(
melody_id: str,
_user: TokenPayload = Depends(require_permission("melodies", "edit")),
):
"""Generate PROGMEM C code and store it in the database."""
return await service.build_builtin_code(melody_id)
@router.get("/{melody_id}/download")
async def download_binary(
melody_id: str,
_user: TokenPayload = Depends(require_permission("melodies", "view")),
):
"""Download the .bsm binary file."""
path = await service.get_binary_path(melody_id)
if not path:
raise HTTPException(status_code=404, detail="Binary not built yet for this melody")
melody = await service.get_built_melody(melody_id)
filename = f"{melody.name}.bsm"
return FileResponse(
path=str(path),
media_type="application/octet-stream",
filename=filename,
)
@router.post("/{melody_id}/assign", response_model=BuiltMelodyInDB)
async def assign_to_melody(
melody_id: str,
firestore_melody_id: str,
_user: TokenPayload = Depends(require_permission("melodies", "edit")),
):
"""Mark this built melody as assigned to a Firestore melody."""
return await service.assign_to_melody(melody_id, firestore_melody_id)
@router.post("/{melody_id}/unassign", response_model=BuiltMelodyInDB)
async def unassign_from_melody(
melody_id: str,
firestore_melody_id: str,
_user: TokenPayload = Depends(require_permission("melodies", "edit")),
):
"""Remove a Firestore melody assignment from this built melody."""
return await service.unassign_from_melody(melody_id, firestore_melody_id)

257
backend/builder/service.py Normal file
View File

@@ -0,0 +1,257 @@
import logging
import uuid
from pathlib import Path
from datetime import datetime
from typing import List, Optional
from builder import database as db
from builder.models import BuiltMelodyCreate, BuiltMelodyUpdate, BuiltMelodyInDB
from fastapi import HTTPException
logger = logging.getLogger("builder.service")
# Storage directory for built .bsm files
STORAGE_DIR = Path("storage/built_melodies")
def _ensure_storage_dir():
STORAGE_DIR.mkdir(parents=True, exist_ok=True)
def _binary_url(melody_id: str) -> str:
"""Returns the API URL to download the binary for a given melody id."""
return f"/api/builder/melodies/{melody_id}/download"
def _row_to_built_melody(row: dict) -> BuiltMelodyInDB:
binary_path = row.get("binary_path")
binary_url = _binary_url(row["id"]) if binary_path else None
return BuiltMelodyInDB(
id=row["id"],
name=row["name"],
pid=row["pid"],
steps=row["steps"],
binary_path=binary_path,
binary_url=binary_url,
progmem_code=row.get("progmem_code"),
assigned_melody_ids=row.get("assigned_melody_ids", []),
created_at=row["created_at"],
updated_at=row["updated_at"],
)
# ============================================================================
# Conversion Logic (ported from SecondaryApps/MelodyBuilders/)
# ============================================================================
def parse_bell_notation(notation: str) -> int:
"""Convert human-readable bell notation to uint16 bit flag value.
Examples:
"2+8" → bells 2,8 → bits 1,7 → 0x0082 (130)
"4" → bell 4 → bit 3 → 0x0008 (8)
"0" → silence → 0x0000 (0)
"""
notation = notation.strip()
if notation == "0" or not notation:
return 0
value = 0
for bell_str in notation.split("+"):
bell_str = bell_str.strip()
try:
bell_num = int(bell_str)
if bell_num == 0:
continue
if bell_num < 1 or bell_num > 16:
logger.warning(f"Bell number {bell_num} out of range (1-16), skipping")
continue
value |= 1 << (bell_num - 1)
except ValueError:
logger.warning(f"Invalid bell token '{bell_str}', skipping")
return value
def steps_string_to_values(steps: str) -> List[int]:
"""Parse raw steps string (e.g. '1,2+3,0,4') into list of uint16 values."""
return [parse_bell_notation(s) for s in steps.split(",")]
def format_melody_array(name: str, values: List[int], values_per_line: int = 8) -> str:
"""Format values as C PROGMEM array declaration."""
array_name = f"melody_builtin_{name.lower()}"
lines = [f"const uint16_t PROGMEM {array_name}[] = {{"]
for i in range(0, len(values), values_per_line):
chunk = values[i : i + values_per_line]
hex_vals = [f"0x{v:04X}" for v in chunk]
suffix = "," if i + len(chunk) < len(values) else ""
lines.append(" " + ", ".join(hex_vals) + suffix)
lines.append("};")
return "\n".join(lines)
def generate_progmem_code(name: str, pid: str, values: List[int]) -> str:
"""Generate standalone PROGMEM C code for a single melody."""
array_name = f"melody_builtin_{name.lower()}"
id_name = pid if pid else f"builtin_{name.lower()}"
display_name = name.replace("_", " ").title()
timestamp = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
parts = [
f"// Generated: {timestamp}",
f"// Melody: {display_name} | PID: {id_name}",
"",
format_melody_array(name, values),
"",
"// --- Add this entry to your MELODY_LIBRARY[] array: ---",
"// {",
f'// "{display_name}",',
f'// "{id_name}",',
f"// {array_name},",
f"// sizeof({array_name}) / sizeof(uint16_t)",
"// }",
]
return "\n".join(parts)
# ============================================================================
# CRUD
# ============================================================================
async def list_built_melodies() -> List[BuiltMelodyInDB]:
rows = await db.list_built_melodies()
return [_row_to_built_melody(r) for r in rows]
async def get_built_melody(melody_id: str) -> BuiltMelodyInDB:
row = await db.get_built_melody(melody_id)
if not row:
raise HTTPException(status_code=404, detail=f"Built melody '{melody_id}' not found")
return _row_to_built_melody(row)
async def create_built_melody(data: BuiltMelodyCreate) -> BuiltMelodyInDB:
melody_id = str(uuid.uuid4())
await db.insert_built_melody(
melody_id=melody_id,
name=data.name,
pid=data.pid,
steps=data.steps,
)
return await get_built_melody(melody_id)
async def update_built_melody(melody_id: str, data: BuiltMelodyUpdate) -> BuiltMelodyInDB:
row = await db.get_built_melody(melody_id)
if not row:
raise HTTPException(status_code=404, detail=f"Built melody '{melody_id}' not found")
new_name = data.name if data.name is not None else row["name"]
new_pid = data.pid if data.pid is not None else row["pid"]
new_steps = data.steps if data.steps is not None else row["steps"]
await db.update_built_melody(melody_id, name=new_name, pid=new_pid, steps=new_steps)
return await get_built_melody(melody_id)
async def delete_built_melody(melody_id: str) -> None:
row = await db.get_built_melody(melody_id)
if not row:
raise HTTPException(status_code=404, detail=f"Built melody '{melody_id}' not found")
# Delete the .bsm file if it exists
if row.get("binary_path"):
bsm_path = Path(row["binary_path"])
if bsm_path.exists():
bsm_path.unlink()
await db.delete_built_melody(melody_id)
# ============================================================================
# Build Actions
# ============================================================================
async def build_binary(melody_id: str) -> BuiltMelodyInDB:
"""Parse steps and write a .bsm binary file to storage."""
row = await db.get_built_melody(melody_id)
if not row:
raise HTTPException(status_code=404, detail=f"Built melody '{melody_id}' not found")
_ensure_storage_dir()
values = steps_string_to_values(row["steps"])
bsm_path = STORAGE_DIR / f"{melody_id}.bsm"
with open(bsm_path, "wb") as f:
for value in values:
value = value & 0xFFFF
f.write(value.to_bytes(2, byteorder="big"))
await db.update_binary_path(melody_id, str(bsm_path))
logger.info(f"Built binary for '{row['name']}'{bsm_path} ({len(values)} steps)")
return await get_built_melody(melody_id)
async def build_builtin_code(melody_id: str) -> BuiltMelodyInDB:
"""Generate PROGMEM C code and store it in the database."""
row = await db.get_built_melody(melody_id)
if not row:
raise HTTPException(status_code=404, detail=f"Built melody '{melody_id}' not found")
values = steps_string_to_values(row["steps"])
code = generate_progmem_code(row["name"], row["pid"], values)
await db.update_progmem_code(melody_id, code)
logger.info(f"Built builtin code for '{row['name']}'")
return await get_built_melody(melody_id)
async def get_binary_path(melody_id: str) -> Optional[Path]:
"""Return the filesystem path to the .bsm file, or None if not built."""
row = await db.get_built_melody(melody_id)
if not row:
raise HTTPException(status_code=404, detail=f"Built melody '{melody_id}' not found")
if not row.get("binary_path"):
return None
path = Path(row["binary_path"])
if not path.exists():
return None
return path
# ============================================================================
# Assignment
# ============================================================================
async def assign_to_melody(built_id: str, firestore_melody_id: str) -> BuiltMelodyInDB:
"""Add a Firestore melody ID to this built melody's assignment list."""
row = await db.get_built_melody(built_id)
if not row:
raise HTTPException(status_code=404, detail=f"Built melody '{built_id}' not found")
assigned = row.get("assigned_melody_ids", [])
if firestore_melody_id not in assigned:
assigned.append(firestore_melody_id)
await db.update_assigned_melody_ids(built_id, assigned)
return await get_built_melody(built_id)
async def unassign_from_melody(built_id: str, firestore_melody_id: str) -> BuiltMelodyInDB:
"""Remove a Firestore melody ID from this built melody's assignment list."""
row = await db.get_built_melody(built_id)
if not row:
raise HTTPException(status_code=404, detail=f"Built melody '{built_id}' not found")
assigned = [mid for mid in row.get("assigned_melody_ids", []) if mid != firestore_melody_id]
await db.update_assigned_melody_ids(built_id, assigned)
return await get_built_melody(built_id)
async def get_built_melody_for_firestore_id(firestore_melody_id: str) -> Optional[BuiltMelodyInDB]:
"""Find the built melody assigned to a given Firestore melody ID (first match)."""
rows = await db.list_built_melodies()
for row in rows:
if firestore_melody_id in row.get("assigned_melody_ids", []):
return _row_to_built_melody(row)
return None

View File

@@ -12,6 +12,7 @@ from mqtt.router import router as mqtt_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 mqtt.client import mqtt_manager
from mqtt import database as mqtt_db
from melodies import service as melody_service
@@ -40,6 +41,7 @@ app.include_router(mqtt_router)
app.include_router(equipment_router)
app.include_router(helpdesk_router)
app.include_router(staff_router)
app.include_router(builder_router)
@app.on_event("startup")

View File

@@ -53,6 +53,18 @@ SCHEMA_STATEMENTS = [
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
)""",
"CREATE INDEX IF NOT EXISTS idx_melody_drafts_status ON melody_drafts(status)",
# Built melodies table (local melody builder)
"""CREATE TABLE IF NOT EXISTS built_melodies (
id TEXT PRIMARY KEY,
name TEXT NOT NULL,
pid TEXT NOT NULL,
steps TEXT NOT NULL,
binary_path TEXT,
progmem_code TEXT,
assigned_melody_ids TEXT NOT NULL DEFAULT '[]',
created_at TEXT NOT NULL DEFAULT (datetime('now')),
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
)""",
]