routes.py 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313
  1. """FastAPI REST 路由."""
  2. import logging
  3. import mimetypes
  4. import os
  5. import threading
  6. import time
  7. from pathlib import Path
  8. from typing import Optional
  9. import cv2
  10. from fastapi import APIRouter, Depends, HTTPException
  11. from fastapi.responses import StreamingResponse, FileResponse, JSONResponse
  12. from web.auth import verify_api_key
  13. from pydantic import BaseModel, Field
  14. import web.state as _web_state_module
  15. from web.state import WebState
  16. from core.stream_manager import generate_mjpeg_stream
  17. from config.coordinator import COORDINATOR_CONFIG
  18. router = APIRouter()
  19. PANORAMA_BASE = Path(os.environ.get("PANORAMA_DIR", ".")).resolve()
  20. class AddPointPayload(BaseModel):
  21. pan: float = Field(..., ge=-360, le=360)
  22. tilt: float = Field(..., ge=-90, le=90)
  23. zoom: int = Field(1, ge=1)
  24. dwell_time: float = Field(3.0, gt=0)
  25. preview_image: Optional[str] = None
  26. class PreviewPayload(BaseModel):
  27. pan: float = Field(..., ge=-360, le=360)
  28. tilt: float = Field(..., ge=-90, le=90)
  29. zoom: int = Field(1, ge=1)
  30. point_id: Optional[int] = None
  31. def _get_state() -> WebState:
  32. state = _web_state_module.web_state
  33. if state is None:
  34. raise HTTPException(status_code=503, detail="System not initialized")
  35. return state
  36. def _resolve_panorama_path(raw_path: Optional[str]) -> Path:
  37. if not raw_path:
  38. raise HTTPException(status_code=404, detail="Panorama not found")
  39. resolved = (PANORAMA_BASE / raw_path).resolve()
  40. try:
  41. resolved.relative_to(PANORAMA_BASE)
  42. except ValueError as exc:
  43. raise HTTPException(status_code=400, detail="Invalid panorama path") from exc
  44. return resolved
  45. def _sanitize_group_state(state: dict) -> dict:
  46. safe = dict(state)
  47. safe.pop("panorama_rtsp", None)
  48. safe.pop("ptz_rtsp", None)
  49. safe.pop("ptz_config", None)
  50. return safe
  51. @router.get("/api/status")
  52. def api_status() -> dict:
  53. state = _get_state()
  54. groups = {}
  55. for gid in state.group_state.list_groups():
  56. groups[gid] = _sanitize_group_state(state.group_state.get(gid))
  57. return {"groups": groups}
  58. def _require_group(state, group_id: str) -> dict:
  59. current = state.group_state.get(group_id)
  60. if not current:
  61. raise HTTPException(status_code=404, detail="Group not found")
  62. return current
  63. @router.post("/api/scan/{group_id}", dependencies=[Depends(verify_api_key)])
  64. def api_start_scan(group_id: str) -> JSONResponse:
  65. state = _get_state()
  66. if not state.group_state.compare_and_update(
  67. group_id, "polling_state", "idle", "scanning"
  68. ):
  69. current = state.group_state.get(group_id)
  70. if not current:
  71. raise HTTPException(status_code=404, detail="Group not found")
  72. raise HTTPException(status_code=409, detail="Scan already in progress")
  73. scanner = state.scanners.get(group_id)
  74. if not scanner:
  75. state.group_state.update(group_id, "polling_state", "idle")
  76. raise HTTPException(status_code=404, detail="Scanner not found")
  77. def run() -> None:
  78. try:
  79. result = scanner.run(
  80. pan_range=(0.0, 360.0),
  81. tilt_layers=tuple(float(t) for t in range(-90, 91, 30)),
  82. pan_step=30.0,
  83. zoom=1,
  84. progress_callback=lambda p: state.group_state.update(
  85. group_id, "scan_progress", p
  86. ),
  87. )
  88. state.scan_store.set_samples(group_id, result["samples"])
  89. state.scan_store.set_panorama(
  90. group_id,
  91. {
  92. "equirectangular": result["panorama_path"],
  93. "width": 4096,
  94. "height": 2048,
  95. },
  96. )
  97. state.scan_store.set_scan_config(group_id, result["config"])
  98. state.group_state.update(
  99. group_id, "scan_progress", {"state": "completed"}
  100. )
  101. except Exception as exc: # noqa: BLE001
  102. logging.exception("Scan failed for group %s", group_id)
  103. state.group_state.update(
  104. group_id, "scan_progress", {"state": "failed", "error": str(exc)}
  105. )
  106. finally:
  107. state.group_state.update(group_id, "polling_state", "idle")
  108. state.group_state.update(group_id, "polling_state", "scanning")
  109. threading.Thread(target=run, daemon=True).start()
  110. return JSONResponse(
  111. {"message": "Scan started", "group_id": group_id},
  112. status_code=202,
  113. )
  114. @router.get("/api/scan/{group_id}/progress")
  115. def api_scan_progress(group_id: str) -> dict:
  116. state = _get_state()
  117. current = _require_group(state, group_id)
  118. return current.get("scan_progress", {})
  119. @router.get("/api/points/{group_id}")
  120. def api_list_points(group_id: str) -> dict:
  121. state = _get_state()
  122. _require_group(state, group_id)
  123. return {"points": state.scan_store.list_enabled_points(group_id)}
  124. @router.post("/api/points/{group_id}", dependencies=[Depends(verify_api_key)])
  125. def api_add_point(group_id: str, payload: AddPointPayload) -> dict:
  126. state = _get_state()
  127. _require_group(state, group_id)
  128. point = state.scan_store.add_enabled_point(
  129. group_id,
  130. pan=payload.pan,
  131. tilt=payload.tilt,
  132. zoom=payload.zoom,
  133. dwell_time=payload.dwell_time,
  134. preview_image=payload.preview_image,
  135. )
  136. return point
  137. @router.delete("/api/points/{group_id}/{point_id}", dependencies=[Depends(verify_api_key)])
  138. def api_delete_point(group_id: str, point_id: int) -> dict:
  139. state = _get_state()
  140. _require_group(state, group_id)
  141. ok = state.scan_store.delete_enabled_point(group_id, point_id)
  142. if not ok:
  143. raise HTTPException(status_code=404, detail="Point not found")
  144. return {"ok": True}
  145. @router.post("/api/poll/{group_id}/start", dependencies=[Depends(verify_api_key)])
  146. def api_poll_start(group_id: str) -> dict:
  147. state = _get_state()
  148. current = state.group_state.get(group_id)
  149. if not current:
  150. raise HTTPException(status_code=404, detail="Group not found")
  151. if not state.group_state.compare_and_update(
  152. group_id, "polling_state", "idle", "polling"
  153. ):
  154. current_state = current.get("polling_state")
  155. if current_state == "scanning":
  156. raise HTTPException(status_code=409, detail="Scan in progress")
  157. if current_state == "polling":
  158. raise HTTPException(status_code=409, detail="Polling already active")
  159. raise HTTPException(status_code=409, detail="Cannot start polling")
  160. scheduler = state.schedulers.get(group_id)
  161. if not scheduler:
  162. state.group_state.update(group_id, "polling_state", "idle")
  163. raise HTTPException(status_code=404, detail="Scheduler not found")
  164. scheduler.start()
  165. return {"message": "Polling started"}
  166. @router.post("/api/poll/{group_id}/stop", dependencies=[Depends(verify_api_key)])
  167. def api_poll_stop(group_id: str) -> dict:
  168. state = _get_state()
  169. _require_group(state, group_id)
  170. scheduler = state.schedulers.get(group_id)
  171. if scheduler:
  172. scheduler.stop()
  173. state.group_state.update(group_id, "polling_state", "idle")
  174. return {"message": "Polling stopped"}
  175. @router.post("/api/preview/{group_id}", dependencies=[Depends(verify_api_key)])
  176. def api_preview(group_id: str, payload: PreviewPayload) -> dict:
  177. state = _get_state()
  178. _require_group(state, group_id)
  179. ptz = state.ptz_cameras.get(group_id)
  180. if not ptz:
  181. raise HTTPException(status_code=404, detail="PTZ camera not connected")
  182. try:
  183. ptz.goto_exact_position(payload.pan, payload.tilt, payload.zoom)
  184. except Exception as exc:
  185. logging.exception("PTZ preview move failed for %s", group_id)
  186. raise HTTPException(status_code=500, detail=f"PTZ move failed: {exc}") from exc
  187. stabilize = COORDINATOR_CONFIG.get("ptz_stabilize_time", 1.5)
  188. time.sleep(stabilize)
  189. stream = state.stream_manager.get(f"{group_id}_ptz")
  190. frame = stream.get_frame() if stream else None
  191. snapshot_path = None
  192. if frame is not None:
  193. snapshot_dir = Path("data") / "previews" / group_id
  194. snapshot_dir.mkdir(parents=True, exist_ok=True)
  195. snapshot_path = str(snapshot_dir / f"preview_{int(time.time() * 1000)}.jpg")
  196. if not cv2.imwrite(snapshot_path, frame):
  197. raise HTTPException(status_code=500, detail="Failed to save preview snapshot")
  198. state.group_state.update(group_id, "ptz_position", {
  199. "pan": payload.pan, "tilt": payload.tilt, "zoom": payload.zoom
  200. })
  201. snapshot_url = f"/api/preview-image?path={snapshot_path}" if snapshot_path else None
  202. return {
  203. "message": "Preview done",
  204. "position": {"pan": payload.pan, "tilt": payload.tilt, "zoom": payload.zoom},
  205. "snapshot_path": snapshot_path,
  206. "snapshot_url": snapshot_url,
  207. }
  208. PREVIEW_BASE = Path("data/previews").resolve()
  209. @router.get("/api/preview-image")
  210. def api_preview_image(path: str) -> FileResponse:
  211. if not path:
  212. raise HTTPException(status_code=400, detail="Path required")
  213. raw = Path(path)
  214. if raw.is_absolute():
  215. raise HTTPException(status_code=400, detail="Invalid preview path")
  216. try:
  217. rel = raw.relative_to("data/previews")
  218. except ValueError:
  219. rel = raw
  220. resolved = (PREVIEW_BASE / rel).resolve()
  221. try:
  222. resolved.relative_to(PREVIEW_BASE)
  223. except ValueError as exc:
  224. raise HTTPException(status_code=400, detail="Invalid preview path") from exc
  225. if not resolved.exists():
  226. raise HTTPException(status_code=404, detail="Preview not found")
  227. media_type = mimetypes.guess_type(str(resolved))[0] or "image/jpeg"
  228. return FileResponse(resolved, media_type=media_type)
  229. @router.get("/api/live/{camera}/{group_id}")
  230. def api_live(
  231. camera: str, group_id: str, marked: bool = False
  232. ) -> StreamingResponse:
  233. if camera not in {"panorama", "ptz"}:
  234. raise HTTPException(status_code=400, detail="Invalid camera type")
  235. state = _get_state()
  236. stream_id = f"{group_id}_{camera}"
  237. stream = state.stream_manager.get(stream_id)
  238. if not stream:
  239. raise HTTPException(status_code=404, detail="Stream not found")
  240. getter = stream.get_marked_frame if marked else stream.get_frame
  241. return StreamingResponse(
  242. generate_mjpeg_stream(getter),
  243. media_type="multipart/x-mixed-replace; boundary=frame",
  244. )
  245. @router.get("/api/panorama/{group_id}")
  246. def api_panorama(
  247. group_id: str, raw_path: Optional[str] = None
  248. ) -> FileResponse:
  249. state = _get_state()
  250. group = state.scan_store.get_group(group_id)
  251. if not group:
  252. raise HTTPException(status_code=404, detail="Group not found")
  253. path = raw_path if raw_path is not None else group.get("panorama", {}).get("equirectangular")
  254. resolved = _resolve_panorama_path(path)
  255. if not resolved.exists():
  256. raise HTTPException(status_code=404, detail="Panorama not found")
  257. media_type = mimetypes.guess_type(str(resolved))[0] or "image/jpeg"
  258. return FileResponse(resolved, media_type=media_type)