Files
LoraMapTester/server/fastapi_app.py
T

207 lines
5.3 KiB
Python

"""Alternate LoraTester entry point — same API as Flask."""
import time
from pathlib import Path
from typing import Any, Optional
from fastapi import FastAPI, Header, HTTPException, Query
from fastapi.middleware.cors import CORSMiddleware
from fastapi.responses import FileResponse
from pydantic import BaseModel, Field
from core.auth import ANDROID_CLIENT_HEADER, ANDROID_CLIENT_VALUE
from core.config import HOST, PORT
from core.models import ChatIn, TelemetryIn
from core import storage
from core.telemetry_body import telemetry_from_body
STATIC_DIR = Path(__file__).resolve().parent / "static"
app = FastAPI(title="LoraTester")
app.add_middleware(
CORSMiddleware,
allow_origins=["*"],
allow_methods=["*"],
allow_headers=["*"],
)
storage.init_db()
class TelemetryBody(BaseModel):
device_id: str
lat: Optional[float] = None
lon: Optional[float] = None
rssi: Optional[float] = None
range_m: Optional[float] = None
raw_frame: Optional[str] = None
meta: Optional[Any] = None
fields: Optional[dict[str, Any]] = None
role: Optional[str] = None
ts: Optional[float] = None
class ChatBody(BaseModel):
device_id: str
text: str
ts: Optional[float] = None
class TrackStartBody(BaseModel):
device_id: str
label: Optional[str] = None
class TrackPoint(BaseModel):
ts: Optional[float] = None
lat: float
lon: float
altitude_gps: Optional[float] = None
rssi: Optional[float] = None
role: Optional[str] = None
meta: Optional[Any] = None
class TrackPointsBody(BaseModel):
points: list[TrackPoint] = Field(default_factory=list)
@app.get("/")
def index():
return FileResponse(
STATIC_DIR / "index.html",
headers={"Cache-Control": "no-store"},
)
@app.post("/api/telemetry")
def post_telemetry(
body: TelemetryBody,
x_lora_client: Optional[str] = Header(None, alias=ANDROID_CLIENT_HEADER),
):
if (x_lora_client or "").strip().lower() != ANDROID_CLIENT_VALUE:
raise HTTPException(
403,
detail=f"telemetry only from Android app (header {ANDROID_CLIENT_HEADER})",
)
try:
data = telemetry_from_body(body.model_dump(exclude_none=True))
return storage.record_telemetry(data)
except ValueError as e:
raise HTTPException(400, detail=str(e)) from e
@app.get("/api/devices")
def get_devices():
return storage.list_devices()
@app.get("/api/telemetry")
def get_telemetry_history(
device_id: Optional[str] = None,
limit: int = Query(100, ge=1, le=500),
since: Optional[float] = None,
until: Optional[float] = None,
role: Optional[str] = None,
):
return storage.get_telemetry(device_id, limit, since, until, role)
@app.get("/api/stats/history")
def get_stats_history(
device_id: str = Query(...),
limit: int = Query(50, ge=1, le=500),
since: Optional[float] = None,
until: Optional[float] = None,
role: Optional[str] = None,
):
return storage.get_telemetry(device_id, limit, since, until, role)
@app.post("/api/tracks/start")
def tracks_start(
body: TrackStartBody,
x_lora_client: Optional[str] = Header(None, alias=ANDROID_CLIENT_HEADER),
):
_require_android(x_lora_client)
try:
return storage.start_track(body.device_id, body.label)
except ValueError as e:
raise HTTPException(400, detail=str(e)) from e
@app.post("/api/tracks/{track_id}/points")
def tracks_points(
track_id: int,
body: TrackPointsBody,
x_lora_client: Optional[str] = Header(None, alias=ANDROID_CLIENT_HEADER),
):
_require_android(x_lora_client)
try:
points = [p.model_dump(exclude_none=True) for p in body.points]
return storage.add_track_points(track_id, points)
except ValueError as e:
raise HTTPException(400, detail=str(e)) from e
@app.post("/api/tracks/{track_id}/finish")
def tracks_finish(
track_id: int,
x_lora_client: Optional[str] = Header(None, alias=ANDROID_CLIENT_HEADER),
):
_require_android(x_lora_client)
try:
return storage.finish_track(track_id)
except ValueError as e:
raise HTTPException(400, detail=str(e)) from e
@app.get("/api/tracks")
def tracks_list(
device_id: Optional[str] = None,
limit: int = Query(50, ge=1, le=200),
):
return storage.list_tracks(device_id, limit)
@app.get("/api/tracks/{track_id}")
def tracks_get(track_id: int):
try:
return storage.get_track(track_id)
except ValueError as e:
raise HTTPException(404, detail=str(e)) from e
def _require_android(x_lora_client: Optional[str]) -> None:
if (x_lora_client or "").strip().lower() != ANDROID_CLIENT_VALUE:
raise HTTPException(
403,
detail=f"Android header {ANDROID_CLIENT_HEADER} required",
)
@app.post("/api/chat")
def post_chat(body: ChatBody):
text = body.text.strip()
if not text:
raise HTTPException(400, "text required")
data = ChatIn(device_id=body.device_id, text=text, ts=body.ts)
return storage.add_chat(data)
@app.get("/api/chat")
def get_chat(since: float = 0, limit: int = Query(200, ge=1, le=500)):
return storage.get_chat(since, limit)
@app.get("/api/health")
def health():
status = storage.db_status()
return {"ok": status["db_ok"], "ts": time.time(), **status}
if __name__ == "__main__":
import uvicorn
uvicorn.run("fastapi_app:app", host=HOST, port=PORT, reload=True)