Mic: confirmed via its pinout (L/R, WS, SCK, SD, VCC, GND) that the third target module is a standard I2S digital MEMS mic (INMP441-family). Added mems_mic.c/h using ESP-IDF's current driver/i2s_std.h API — reports RMS audio level in dBFS as sensor_type "evp" rather than attempting on-device voice-band FFT (the browser EVP mode's approach); the backend's existing statistical anomaly detector handles spike detection from the raw level, same as it already does for temperature/pressure/presence. Also fixes a real gap Workstream B's report flagged: User.essence (a live model column used throughout merged code — /auth/me, inventory purchases, summon trickle) had no migration line in main.py's lifespan, which would have broken on the actual production Postgres database.
129 lines
4.7 KiB
Python
129 lines
4.7 KiB
Python
import asyncio
|
|
import contextlib
|
|
from contextlib import asynccontextmanager
|
|
from pathlib import Path
|
|
|
|
from fastapi import FastAPI, HTTPException
|
|
from fastapi.responses import FileResponse
|
|
from fastapi.staticfiles import StaticFiles
|
|
from sqlalchemy import text
|
|
|
|
import app.models # noqa: F401 — registers models on Base.metadata before create_all
|
|
from app.config import settings
|
|
from app.db import Base, async_session_maker, engine
|
|
from app.routes.auth import router as auth_router
|
|
from app.routes.codex import router as codex_router
|
|
from app.routes.device import router as device_router
|
|
from app.routes.inventory import router as inventory_router
|
|
from app.routes.shop import router as shop_router
|
|
from app.session_cleanup import delete_expired_sessions
|
|
from app.ws import AUDIO_DIR
|
|
from app.ws import router as ws_router
|
|
|
|
FRONTEND_DIST = Path(__file__).resolve().parent.parent.parent / "frontend" / "dist"
|
|
|
|
SESSION_CLEANUP_INTERVAL_SECONDS = 30 * 60
|
|
|
|
|
|
async def _session_cleanup_loop() -> None:
|
|
"""Periodically sweeps expired auth_sessions rows so the table doesn't
|
|
grow forever — get_current_user already rejects expired sessions on
|
|
read, this just deletes the rows themselves."""
|
|
try:
|
|
while True:
|
|
await asyncio.sleep(SESSION_CLEANUP_INTERVAL_SECONDS)
|
|
try:
|
|
async with async_session_maker() as db:
|
|
await delete_expired_sessions(db)
|
|
except Exception:
|
|
# A transient DB hiccup shouldn't kill the sweep loop —
|
|
# just try again next interval.
|
|
pass
|
|
except asyncio.CancelledError:
|
|
pass
|
|
|
|
|
|
@asynccontextmanager
|
|
async def lifespan(app: FastAPI):
|
|
AUDIO_DIR.mkdir(parents=True, exist_ok=True)
|
|
async with engine.begin() as conn:
|
|
await conn.run_sync(Base.metadata.create_all)
|
|
# No Alembic in this repo — `create_all` never alters existing
|
|
# tables, so columns added to live models need a manual, idempotent
|
|
# migration here. Safe to run on every startup.
|
|
await conn.execute(text(
|
|
"ALTER TABLE entities ADD COLUMN IF NOT EXISTS traits JSONB NOT NULL DEFAULT '{}'::jsonb"
|
|
))
|
|
# Workstream C (character-depth-ghost-log spec) — missing from C's
|
|
# own commit, added by the integrator after Workstream B's report
|
|
# flagged that User.essence had a live model column and application
|
|
# code (auth/me, inventory purchases, summon trickle) but no
|
|
# migration, which would have broken on the real production DB.
|
|
await conn.execute(text(
|
|
"ALTER TABLE users ADD COLUMN IF NOT EXISTS essence INTEGER NOT NULL DEFAULT 0"
|
|
))
|
|
# Workstream B (character-depth-ghost-log spec).
|
|
await conn.execute(text(
|
|
"ALTER TABLE users ADD COLUMN IF NOT EXISTS favor DOUBLE PRECISION NOT NULL DEFAULT 0.0"
|
|
))
|
|
await conn.execute(text(
|
|
"ALTER TABLE entities ADD COLUMN IF NOT EXISTS at_peace BOOLEAN NOT NULL DEFAULT false"
|
|
))
|
|
cleanup_task = asyncio.create_task(_session_cleanup_loop())
|
|
try:
|
|
yield
|
|
finally:
|
|
cleanup_task.cancel()
|
|
with contextlib.suppress(asyncio.CancelledError):
|
|
await cleanup_task
|
|
|
|
|
|
app = FastAPI(title="Quantumancy", lifespan=lifespan)
|
|
app.include_router(auth_router)
|
|
app.include_router(codex_router)
|
|
app.include_router(device_router)
|
|
app.include_router(inventory_router)
|
|
app.include_router(shop_router)
|
|
app.include_router(ws_router)
|
|
|
|
|
|
@app.get("/healthz")
|
|
async def healthz():
|
|
return {"status": "ok"}
|
|
|
|
|
|
app.mount(
|
|
"/assets",
|
|
StaticFiles(directory=FRONTEND_DIST / "assets", check_dir=False),
|
|
name="frontend-assets",
|
|
)
|
|
app.mount(
|
|
"/audio",
|
|
StaticFiles(directory=AUDIO_DIR, check_dir=False),
|
|
name="spirit-audio",
|
|
)
|
|
|
|
|
|
@app.get("/{full_path:path}")
|
|
async def serve_spa(full_path: str):
|
|
index_file = FRONTEND_DIST / "index.html"
|
|
if not index_file.exists():
|
|
raise HTTPException(
|
|
status_code=503,
|
|
detail="Frontend not built. Run `npm run build` in frontend/ and restart.",
|
|
)
|
|
|
|
# Vite emits root-level static files (favicon.ico, favicon.svg,
|
|
# apple-touch-icon.png, og-image.png, …) straight into dist/ rather than
|
|
# dist/assets/ — the only mounted static dir. Without this, requests for
|
|
# them fall through to the SPA fallback below and get index.html back
|
|
# instead of the actual file (browsers silently ignore it; social-media
|
|
# link-preview crawlers fetching og:image get an HTML page).
|
|
if full_path:
|
|
dist_root = FRONTEND_DIST.resolve()
|
|
candidate = (dist_root / full_path).resolve()
|
|
if candidate.is_file() and dist_root in candidate.parents:
|
|
return FileResponse(candidate)
|
|
|
|
return FileResponse(index_file)
|