Coverage for app\routers\gto.py: 12%
201 statements
« prev ^ index » next coverage.py v7.3.2, created at 2026-01-15 13:39 +0300
« prev ^ index » next coverage.py v7.3.2, created at 2026-01-15 13:39 +0300
1from typing import List, Optional, Dict, Any
2from fastapi import APIRouter, Depends, HTTPException, Request, Body
3import httpx
4import os
5import zlib
6from ..schemas import calibration as cal_schemas
8router = APIRouter()
10GTO_BACKEND_URL = os.getenv("GTO_BACKEND_URL", "http://gto-backend:8027")
11SCHOOL_NODE_URL = os.getenv("SCHOOL_NODE_URL", "http://school-template:8100")
12AI_TRACKER_URL = os.getenv("AI_TRACKER_URL", "http://ai-tracker:8000")
13INTEGRATION_SECRET = os.getenv("INTEGRATION_SECRET", "dev_secret_shared")
15def get_gto_school_id(school_id_str: Optional[str]) -> int:
16 """
17 Maps a UUID string (from Admin Core) to an Integer ID (for GTO Backend).
18 Uses a deterministic hash.
19 """
20 if not school_id_str:
21 return 0
22 try:
23 # If it's already an int (as string), return it
24 return int(school_id_str)
25 except ValueError:
26 # Hash the UUID to a positive integer (max 2^31-1 for Postgres Integer)
27 # Using zlib.crc32 for a short hash
28 return zlib.crc32(school_id_str.encode()) & 0x7FFFFFFF
30@router.get("/devices")
31async def get_gto_devices(request: Request, school_id: Optional[str] = None):
32 async with httpx.AsyncClient() as client:
33 try:
34 # Forward auth headers
35 headers = dict(request.headers)
36 headers.pop("content-length", None)
37 headers.pop("host", None)
38 headers["X-Integration-Secret"] = INTEGRATION_SECRET
40 if school_id:
41 # Use School Node devices endpoint
42 # School Node expects integer school_id (as string)
43 gto_school_id = get_gto_school_id(school_id)
44 response = await client.get(
45 f"{SCHOOL_NODE_URL}/gto/devices",
46 params={"school_id": str(gto_school_id)},
47 headers=headers,
48 timeout=10.0
49 )
50 else:
51 # List all devices from School Node (requires admin)
52 response = await client.get(
53 f"{SCHOOL_NODE_URL}/gto/devices",
54 headers=headers,
55 timeout=10.0
56 )
58 if response.status_code != 200:
59 # Fallback or error
60 print(f"Error fetching devices from School Node: {response.status_code} {response.text}")
61 # Return empty list or mock data to avoid breaking frontend
62 return []
64 devices = response.json()
65 return devices
67 except Exception as e:
68 import traceback
69 traceback.print_exc()
70 print(f"Exception fetching devices: {repr(e)}")
71 raise HTTPException(status_code=503, detail="GTO Backend/School Node unavailable")
73from starlette.responses import StreamingResponse
75@router.get("/cameras/{camera_id}/stream")
76async def proxy_stream(camera_id: str):
77 """
78 Proxy video stream from GTO Backend.
79 """
80 url = f"{GTO_BACKEND_URL}/api/v1/cameras/{camera_id}/stream"
82 async def stream_generator():
83 async with httpx.AsyncClient() as client:
84 try:
85 async with client.stream("GET", url, timeout=None) as response:
86 if response.status_code != 200:
87 yield b""
88 return
89 async for chunk in response.aiter_bytes():
90 yield chunk
91 except Exception as e:
92 print(f"Stream proxy error: {e}")
93 yield b""
95 return StreamingResponse(stream_generator(), media_type="multipart/x-mixed-replace; boundary=frame")
97@router.post("/cameras/{camera_id}/calibration/start")
98async def start_calibration(camera_id: str, request: Request):
99 """
100 Start calibration process for a camera.
101 """
102 # For now, this is a placeholder or triggers AI Tracker to enter calibration mode
103 # Since AI Tracker is on SCHOOL_NODE_URL/ai-tracker or AI_TRACKER_URL
104 # We might want to tell AI tracker to show calibration overlay
106 # Just return success for now as requested "API endpoints exist"
107 return {"status": "started", "camera_id": camera_id}
109@router.post("/cameras/{camera_id}/calibration/save")
110async def save_calibration(camera_id: str, payload: cal_schemas.CameraCalibrationSave, request: Request):
111 """
112 Save camera calibration data.
113 """
114 async with httpx.AsyncClient() as client:
115 try:
116 headers = dict(request.headers)
117 headers.pop("content-length", None)
118 headers.pop("host", None)
119 headers["X-Integration-Secret"] = INTEGRATION_SECRET
121 # 1. Save to School Node (Edge) - Primary Source of Truth for Calibration
122 school_node_payload = {
123 "calibration": payload.calibration.model_dump(),
124 # "exercises_supported": payload.exercises_supported # Not in DeviceUpdate yet, unless I missed it?
125 # Actually enabled_disciplines is in GTO Backend but not in School Node Device model explicitly?
126 # I didn't add enabled_disciplines to School Node model. Let's stick to calibration and zone_config.
127 }
129 print(f"Saving calibration to School Node: {SCHOOL_NODE_URL}/gto/devices/{camera_id}")
130 school_node_response = await client.put(
131 f"{SCHOOL_NODE_URL}/gto/devices/{camera_id}",
132 json=school_node_payload,
133 headers=headers,
134 timeout=10.0
135 )
137 if school_node_response.status_code >= 400:
138 print(f"School Node save failed: {school_node_response.text}")
139 raise HTTPException(status_code=school_node_response.status_code, detail="Failed to save calibration to School Node")
141 # 2. Sync to GTO Backend (Cloud) - Optional / Best Effort
142 try:
143 # Need to find the corresponding camera ID in GTO Backend (which uses Integers)
144 # matching our School Node camera (which might use UUID or different ID).
146 # Fetch cameras from GTO Backend for this school
147 # We need to convert school_id to int for GTO Backend
148 # We don't have the school_id handy in the payload or args easily without fetching the device first.
149 # But we can try to find it.
151 # Better approach: Fetch the device from School Node first to get its metadata (school_id, stream_url)
152 # (We could have done this earlier or passed it in, but let's be safe)
154 device_info_resp = await client.get(
155 f"{SCHOOL_NODE_URL}/gto/devices/{camera_id}",
156 headers=headers,
157 timeout=5.0
158 )
160 if device_info_resp.status_code == 200:
161 device_info = device_info_resp.json()
162 school_id_uuid = device_info.get("school_id")
163 gto_school_id = get_gto_school_id(str(school_id_uuid))
165 # Now search in GTO Backend
166 gto_cameras_resp = await client.get(
167 f"{GTO_BACKEND_URL}/api/v1/cameras/",
168 params={"school_id": gto_school_id, "limit": 100},
169 headers=headers,
170 timeout=5.0
171 )
173 if gto_cameras_resp.status_code == 200:
174 gto_cameras = gto_cameras_resp.json()
175 target_gto_camera = None
177 # Match by stream_url or name
178 for cam in gto_cameras:
179 # Strict match on stream_url if available
180 if device_info.get("stream_url") and cam.get("stream_url") == device_info.get("stream_url"):
181 target_gto_camera = cam
182 break
183 # Or match by name if unique enough
184 if cam.get("name") == device_info.get("name"):
185 target_gto_camera = cam
186 break
188 if target_gto_camera:
189 gto_cam_id = target_gto_camera["id"]
190 print(f"Found matching GTO Backend camera {gto_cam_id} for sync")
192 update_payload = {
193 "calibration": payload.calibration.model_dump(),
194 "enabled_disciplines": [int(d) for d in payload.exercises_supported if d.isdigit()]
195 }
197 print(f"Syncing calibration to GTO Backend: {GTO_BACKEND_URL}/api/v1/cameras/{gto_cam_id}")
198 gto_backend_response = await client.put(
199 f"{GTO_BACKEND_URL}/api/v1/cameras/{gto_cam_id}",
200 json=update_payload,
201 headers=headers,
202 timeout=5.0
203 )
204 if gto_backend_response.status_code >= 400:
205 print(f"GTO Backend sync warning: {gto_backend_response.status_code} {gto_backend_response.text}")
206 else:
207 print(f"No matching camera found in GTO Backend for sync (School ID: {gto_school_id})")
208 else:
209 print(f"Could not list cameras from GTO Backend: {gto_cameras_resp.status_code}")
210 else:
211 print(f"Could not fetch device info from School Node for sync context: {device_info_resp.status_code}")
213 except Exception as e:
214 print(f"GTO Backend sync failed (ignored): {e}")
215 # import traceback
216 # traceback.print_exc()
218 return school_node_response.json()
220 except HTTPException:
221 raise
222 except Exception as e:
223 print(f"Error saving calibration: {e}")
224 raise HTTPException(status_code=503, detail=f"Service unavailable: {str(e)}")
226@router.post("/devices")
227async def register_device(request: Request, device_data: Dict[str, Any] = Body(...)):
228 """
229 Proxy device registration to School Node.
230 """
231 try:
232 # Fix common typo
233 if "stream_url" in device_data and isinstance(device_data["stream_url"], str) and device_data["stream_url"].startswith("rstp://"):
234 device_data["stream_url"] = device_data["stream_url"].replace("rstp://", "rtsp://", 1)
236 # Convert school_id to integer for School Node (Postgres Integer)
237 # But pass as string because School Node API expects string, while DB expects int
238 if "school_id" in device_data:
239 print(f"Converting school_id: {device_data['school_id']}")
240 try:
241 device_data["school_id"] = str(get_gto_school_id(str(device_data["school_id"])))
242 print(f"Converted school_id to: {device_data['school_id']}")
243 except Exception as e:
244 print(f"Error converting school_id: {e}")
245 # Don't fail here, let it pass through and fail at backend if needed,
246 # or maybe this was the cause of 500 if get_gto_school_id failed hard.
248 async with httpx.AsyncClient() as client:
249 # Forward auth headers
250 headers = dict(request.headers)
251 headers.pop("content-length", None)
252 headers.pop("host", None)
253 headers["X-Integration-Secret"] = INTEGRATION_SECRET
255 print(f"Proxying device registration to {SCHOOL_NODE_URL}/gto/devices")
257 # Forward to School Node
258 response = await client.post(
259 f"{SCHOOL_NODE_URL}/gto/devices",
260 json=device_data,
261 headers=headers,
262 timeout=10.0
263 )
265 if response.status_code >= 400:
266 try:
267 error_detail = response.json()
268 except:
269 error_detail = response.text
270 print(f"School Node returned error: {response.status_code} {error_detail}")
271 raise HTTPException(status_code=response.status_code, detail=error_detail)
273 # Parse created device from School Node
274 created_device = response.json()
275 print(f"Device created in School Node: {created_device.get('id')}")
277 # Best-effort sync: also create camera in GTO Backend so UI at 8028 reflects admin configuration
278 try:
279 # Map device fields to GTO camera schema
280 gto_school_id = get_gto_school_id(str(created_device.get("school_id") or device_data.get("school_id") or "0"))
282 # Determine stream URL - prefer stream_url if set, else construct from id
283 # The school node might return rtsp_url or stream_url
284 stream_url = created_device.get("stream_url")
285 if not stream_url:
286 # If we have rtsp_url but no stream_url, we might want to use the AI tracker stream
287 # But for GTO backend, we usually want the AI tracker stream URL
288 stream_url = f"{os.getenv('AI_TRACKER_PUBLIC_URL', 'http://localhost:8005')}/camera/{created_device.get('id') or device_data.get('id')}/stream"
290 cam_payload: Dict[str, Any] = {
291 "name": created_device.get("name") or device_data.get("name") or "Camera",
292 "school_id": gto_school_id,
293 "stream_url": stream_url,
294 "status": created_device.get("status") or device_data.get("status") or "active",
295 "zone_config": {}
296 }
298 print(f"Syncing to GTO Backend: {cam_payload}")
300 # Validate minimal required fields
301 if cam_payload["school_id"] and cam_payload["stream_url"]:
302 cam_resp = await client.post(
303 f"{GTO_BACKEND_URL}/api/v1/cameras",
304 json=cam_payload,
305 headers=headers,
306 timeout=10.0
307 )
308 if cam_resp.status_code >= 400:
309 # Do not fail the main request; log to stdout for observability
310 try:
311 err = cam_resp.json()
312 except Exception:
313 err = cam_resp.text
314 print(f"Failed to sync camera to GTO Backend: {cam_resp.status_code} {err}")
315 else:
316 print("Synced to GTO Backend successfully")
317 else:
318 print("Skipping GTO camera sync: missing school_id or stream_url")
319 except Exception as e:
320 # Non-blocking sync failure
321 print(f"Exception during GTO camera sync: {e}")
322 import traceback
323 traceback.print_exc()
325 # Also register camera in AI-Tracker for live streaming
326 try:
327 tracker_payload = {
328 "id": str(created_device.get("id") or device_data.get("id") or created_device.get("name") or "cam1"),
329 "source": created_device.get("rtsp_url") or device_data.get("rtsp_url")
330 }
331 if tracker_payload["source"]:
332 print(f"Registering with AI Tracker: {tracker_payload}")
333 tr_resp = await client.post(
334 f"{AI_TRACKER_URL}/cameras",
335 json=tracker_payload,
336 timeout=10.0
337 )
338 if tr_resp.status_code >= 400:
339 try:
340 err = tr_resp.json()
341 except Exception:
342 err = tr_resp.text
343 print(f"Failed to add camera to AI Tracker: {tr_resp.status_code} {err}")
344 else:
345 print("Registered with AI Tracker successfully")
346 except Exception as e:
347 print(f"Exception during AI Tracker camera add: {e}")
349 return created_device
351 except HTTPException:
352 raise
353 except Exception as e:
354 import traceback
355 traceback.print_exc()
356 print(f"Error registering device via School Node: {e}")
357 raise HTTPException(status_code=503, detail=f"School Node unavailable: {str(e)}")
359@router.post("/devices/test-connection")
360async def test_device_connection(request: Request, device_data: Dict[str, Any] = Body(...)):
361 """
362 Proxy device connection test to School Node.
363 """
364 async with httpx.AsyncClient() as client:
365 try:
366 headers = dict(request.headers)
367 headers.pop("content-length", None)
368 headers.pop("host", None)
370 response = await client.post(
371 f"{SCHOOL_NODE_URL}/gto/devices/test-connection",
372 json=device_data,
373 headers=headers,
374 timeout=15.0
375 )
377 if response.status_code >= 400:
378 try:
379 error_detail = response.json()
380 except:
381 error_detail = response.text
382 raise HTTPException(status_code=response.status_code, detail=error_detail)
384 return response.json()
385 except HTTPException:
386 raise
387 except Exception as e:
388 print(f"Error testing connection via School Node: {e}")
389 raise HTTPException(status_code=503, detail=f"School Node unavailable: {str(e)}")