Implements the backend half of the ESP32-P4 sensor node spec's pairing, ingestion, and live-broadcast contract: - New Device model (backend/app/models/device.py): id, user_id FK, name, token_hash (unique+indexed), created_at, last_seen_at. Reuses generate_session_token()/hash_token() from auth_session.py verbatim for the one-time raw pairing token / stored hash. - POST /api/device, GET /api/device (session-cookie authenticated REST pairing endpoints) and POST /api/device/telemetry (device bearer-token authenticated ingestion, per-device rate limited, 16KB body cap, 64 reading cap, strict shape validation — never a 500 on garbage input) in backend/app/routes/device.py. - /ws/device-feed live dashboard WS (qm_session cookie authenticated), fanning out ingested readings to the owning user's connected dashboard sockets via an in-process dict[user_id, connections] registry, each with its own send-queue + single sender task (mirrors app.ws's SeanceState/_sender convention). - last_seen_at updates on every successful ingestion. - _process_reading(device, reading) left as an explicit no-op handoff point for Workstream K's summon-pipeline integration. Backend suite: 102 passed. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
105 lines
3.4 KiB
Python
105 lines
3.4 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
|
|
|
|
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.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)
|
|
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(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)
|