feat: add UE5 LED display client — backend API, C++ source, stereo framework
Backend: - Add /api/v1/ue/* endpoints (compute-points, cables, landing-points, satellites, status) returning flat JSON optimised for UE5 C++ parsing UE5 client (ue_client/): - PlanetDataManager: HTTP fetch + local mock JSON loader, spawns ComputePointActors - ComputePointActor / InteractiveObjectBase: hover/select state, material switching - GlobeInteractionComponent: drag-to-rotate via CesiumGeoreference origin shift, inertia, zoom - StereoRenderingManager: runtime SbS/TbB stereo toggle, IPD control (format TBD) - MotionCaptureInterface: protocol-agnostic gesture/rotate/zoom delegate interface (impl TBD) - PlanetPlayerController: unified mouse + motion-capture input routing - PlanetGameMode, Build.cs, Config, mock data Docs: - ue5_mvp_fused_plan.md updated to v3.0 for LED display context - ue_client_setup_guide.md: step-by-step editor setup guide - ue_todo.md: pending items blocked on vendor answers (stereo format + mocap protocol) Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -15,6 +15,7 @@ from app.api.v1 import (
|
||||
bgp,
|
||||
system_control,
|
||||
tv,
|
||||
ue_data,
|
||||
)
|
||||
|
||||
api_router = APIRouter()
|
||||
@@ -35,3 +36,4 @@ api_router.include_router(system_control.router, prefix="/system", tags=["system
|
||||
api_router.include_router(visualization.router, prefix="/visualization", tags=["visualization"])
|
||||
api_router.include_router(bgp.router, prefix="/bgp", tags=["bgp"])
|
||||
api_router.include_router(tv.router, prefix="/tv", tags=["tv"])
|
||||
api_router.include_router(ue_data.router, prefix="/ue", tags=["ue-client"])
|
||||
|
||||
351
backend/app/api/v1/ue_data.py
Normal file
351
backend/app/api/v1/ue_data.py
Normal file
@@ -0,0 +1,351 @@
|
||||
"""UE Client Data API
|
||||
|
||||
Flat JSON endpoints designed for easy parsing in Unreal Engine C++/Blueprint.
|
||||
Avoids GeoJSON nesting — every field is at the top level of each item.
|
||||
"""
|
||||
|
||||
from typing import Any, Dict, List, Optional
|
||||
from fastapi import APIRouter, Depends, Query
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy import select, func
|
||||
|
||||
from app.core.collected_data_fields import get_record_field
|
||||
from app.core.satellite_tle import build_tle_lines_from_elements
|
||||
from app.core.time import to_iso8601_utc
|
||||
from app.db.session import get_db
|
||||
from app.models.collected_data import CollectedData
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def _current_stmt(source: str, limit: Optional[int] = None):
|
||||
stmt = (
|
||||
select(CollectedData)
|
||||
.where(CollectedData.source == source)
|
||||
.where(CollectedData.is_current.is_(True))
|
||||
.order_by(CollectedData.id.desc())
|
||||
)
|
||||
if limit:
|
||||
stmt = stmt.limit(limit)
|
||||
return stmt
|
||||
|
||||
|
||||
async def _fetch(db: AsyncSession, source: str, limit: Optional[int] = None) -> List[CollectedData]:
|
||||
result = await db.execute(_current_stmt(source, limit))
|
||||
return list(result.scalars().all())
|
||||
|
||||
|
||||
def _safe_float(value: Any) -> Optional[float]:
|
||||
try:
|
||||
v = float(value)
|
||||
return v if v == v else None # reject NaN
|
||||
except (TypeError, ValueError):
|
||||
return None
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Status endpoint
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@router.get("/status")
|
||||
async def ue_status(db: AsyncSession = Depends(get_db)):
|
||||
"""Quick health-check + data counts for the UE client."""
|
||||
from datetime import UTC, datetime
|
||||
|
||||
async def count_source(source: str) -> int:
|
||||
result = await db.execute(
|
||||
select(func.count())
|
||||
.select_from(CollectedData)
|
||||
.where(CollectedData.source == source)
|
||||
.where(CollectedData.is_current.is_(True))
|
||||
)
|
||||
return result.scalar() or 0
|
||||
|
||||
return {
|
||||
"ok": True,
|
||||
"server_time": to_iso8601_utc(datetime.now(UTC)),
|
||||
"compute_points_count": await count_source("top500"),
|
||||
"cables_count": await count_source("telegeography_cables"),
|
||||
"landing_points_count": await count_source("arcgis_landing"),
|
||||
"satellites_count": await count_source("celestrak"),
|
||||
}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Compute points (TOP500 supercomputers)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@router.get("/compute-points")
|
||||
async def ue_compute_points(
|
||||
limit: int = Query(default=500, ge=1, le=2000),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""
|
||||
Returns TOP500 supercomputer data as a flat JSON array.
|
||||
|
||||
Response shape:
|
||||
{
|
||||
"count": 500,
|
||||
"items": [
|
||||
{
|
||||
"id": "top500_1",
|
||||
"name": "Frontier",
|
||||
"latitude": 36.01,
|
||||
"longitude": -84.26,
|
||||
"country": "United States",
|
||||
"city": "Oak Ridge",
|
||||
"rank": 1,
|
||||
"rmax_tflops": 1194000.0,
|
||||
"rpeak_tflops": 1679616.0,
|
||||
"cores": 8730112,
|
||||
"power_kw": 22703.0
|
||||
}
|
||||
]
|
||||
}
|
||||
"""
|
||||
records = await _fetch(db, "top500", limit)
|
||||
items = []
|
||||
for record in records:
|
||||
meta = record.extra_data or {}
|
||||
lat = _safe_float(get_record_field(record, "latitude"))
|
||||
lon = _safe_float(get_record_field(record, "longitude"))
|
||||
if lat is None or lon is None:
|
||||
continue
|
||||
items.append({
|
||||
"id": f"top500_{record.id}",
|
||||
"name": record.name or "Unknown",
|
||||
"latitude": lat,
|
||||
"longitude": lon,
|
||||
"country": get_record_field(record, "country") or "",
|
||||
"city": get_record_field(record, "city") or "",
|
||||
"rank": meta.get("rank"),
|
||||
"rmax_tflops": _safe_float(get_record_field(record, "rmax")),
|
||||
"rpeak_tflops": _safe_float(get_record_field(record, "rpeak")),
|
||||
"cores": meta.get("cores"),
|
||||
"power_kw": _safe_float(get_record_field(record, "power")),
|
||||
})
|
||||
return {"count": len(items), "items": items}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Cable landing points
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@router.get("/landing-points")
|
||||
async def ue_landing_points(db: AsyncSession = Depends(get_db)):
|
||||
"""
|
||||
Returns cable landing points as a flat JSON array.
|
||||
|
||||
Response shape:
|
||||
{
|
||||
"count": 1200,
|
||||
"items": [
|
||||
{
|
||||
"id": "lp_42",
|
||||
"name": "Shoreham",
|
||||
"latitude": 50.83,
|
||||
"longitude": -0.28,
|
||||
"country": "United Kingdom",
|
||||
"city": "Shoreham-by-Sea",
|
||||
"cable_names": ["FLAG", "TAT-14"]
|
||||
}
|
||||
]
|
||||
}
|
||||
"""
|
||||
# Load landing points
|
||||
lp_records = await _fetch(db, "arcgis_landing")
|
||||
|
||||
# Load relation + cable data for cable_names mapping
|
||||
rel_result = await db.execute(
|
||||
select(CollectedData)
|
||||
.where(CollectedData.source == "arcgis_relation")
|
||||
.where(CollectedData.is_current.is_(True))
|
||||
)
|
||||
rel_records = list(rel_result.scalars().all())
|
||||
|
||||
cable_result = await db.execute(
|
||||
select(CollectedData)
|
||||
.where(CollectedData.source == "telegeography_cables")
|
||||
.where(CollectedData.is_current.is_(True))
|
||||
)
|
||||
cable_records = list(cable_result.scalars().all())
|
||||
|
||||
# Build mapping: city_id → list of cable names
|
||||
city_to_cable_ids: Dict[int, List[int]] = {}
|
||||
for r in rel_records:
|
||||
meta = r.extra_data or {}
|
||||
city_id = meta.get("city_id")
|
||||
cable_id = meta.get("cable_id")
|
||||
if city_id is not None and cable_id is not None:
|
||||
city_to_cable_ids.setdefault(city_id, [])
|
||||
if cable_id not in city_to_cable_ids[city_id]:
|
||||
city_to_cable_ids[city_id].append(cable_id)
|
||||
|
||||
cable_id_to_name: Dict[int, str] = {}
|
||||
for r in cable_records:
|
||||
meta = r.extra_data or {}
|
||||
cable_id = meta.get("cable_id")
|
||||
if cable_id and r.name:
|
||||
cable_id_to_name[cable_id] = r.name
|
||||
|
||||
items = []
|
||||
for record in lp_records:
|
||||
lat = _safe_float(get_record_field(record, "latitude"))
|
||||
lon = _safe_float(get_record_field(record, "longitude"))
|
||||
if lat is None or lon is None:
|
||||
continue
|
||||
meta = record.extra_data or {}
|
||||
city_id = meta.get("city_id")
|
||||
cable_names = []
|
||||
if city_id in city_to_cable_ids:
|
||||
cable_names = [
|
||||
cable_id_to_name[cid]
|
||||
for cid in city_to_cable_ids[city_id]
|
||||
if cid in cable_id_to_name
|
||||
]
|
||||
items.append({
|
||||
"id": f"lp_{record.id}",
|
||||
"name": record.name or "Unknown",
|
||||
"latitude": lat,
|
||||
"longitude": lon,
|
||||
"country": get_record_field(record, "country") or "",
|
||||
"city": get_record_field(record, "city") or "",
|
||||
"cable_names": cable_names,
|
||||
})
|
||||
return {"count": len(items), "items": items}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Cables (route geometry)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@router.get("/cables")
|
||||
async def ue_cables(db: AsyncSession = Depends(get_db)):
|
||||
"""
|
||||
Returns cable route geometry.
|
||||
|
||||
Each segment is a flat array of [lon, lat] pairs.
|
||||
|
||||
Response shape:
|
||||
{
|
||||
"count": 100,
|
||||
"items": [
|
||||
{
|
||||
"id": "cable_42",
|
||||
"cable_id": "flag",
|
||||
"name": "FLAG",
|
||||
"status": "active",
|
||||
"length_km": 28000,
|
||||
"segments": [
|
||||
[[lon, lat], [lon, lat], ...]
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
"""
|
||||
records = await _fetch(db, "telegeography_cables")
|
||||
items = []
|
||||
for record in records:
|
||||
meta = record.extra_data or {}
|
||||
route_coords = meta.get("route_coordinates", [])
|
||||
segments: List[List[List[float]]] = []
|
||||
|
||||
if route_coords:
|
||||
# Support both flat [lon,lat] array and array-of-arrays
|
||||
if route_coords and isinstance(route_coords[0][0], list):
|
||||
raw_lines = route_coords
|
||||
else:
|
||||
raw_lines = [route_coords]
|
||||
|
||||
for raw_line in raw_lines:
|
||||
line = []
|
||||
for pt in raw_line:
|
||||
try:
|
||||
line.append([float(pt[0]), float(pt[1])])
|
||||
except (TypeError, ValueError, IndexError):
|
||||
continue
|
||||
if len(line) >= 2:
|
||||
segments.append(line)
|
||||
|
||||
if not segments:
|
||||
continue
|
||||
|
||||
items.append({
|
||||
"id": f"cable_{record.id}",
|
||||
"cable_id": record.source_id or record.name or "",
|
||||
"name": record.name or "Unknown",
|
||||
"status": meta.get("status", "active"),
|
||||
"length_km": _safe_float(get_record_field(record, "value")),
|
||||
"owners": meta.get("owners") or [],
|
||||
"rfs": meta.get("rfs"),
|
||||
"color": meta.get("color"),
|
||||
"segments": segments,
|
||||
})
|
||||
return {"count": len(items), "items": items}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Satellites (TLE data)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@router.get("/satellites")
|
||||
async def ue_satellites(
|
||||
limit: int = Query(default=200, ge=1, le=5000),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""
|
||||
Returns satellite TLE data for orbit propagation in UE.
|
||||
|
||||
Response shape:
|
||||
{
|
||||
"count": 200,
|
||||
"items": [
|
||||
{
|
||||
"id": "sat_42",
|
||||
"norad_id": "25544",
|
||||
"name": "ISS (ZARYA)",
|
||||
"tle_line1": "1 25544U ...",
|
||||
"tle_line2": "2 25544 ...",
|
||||
"epoch": "2026-04-14T00:00:00Z",
|
||||
"inclination": 51.6,
|
||||
"mean_motion": 15.5
|
||||
}
|
||||
]
|
||||
}
|
||||
"""
|
||||
records = await _fetch(db, "celestrak", limit)
|
||||
items = []
|
||||
for record in records:
|
||||
meta = record.extra_data or {}
|
||||
norad_id = meta.get("norad_cat_id")
|
||||
if not norad_id:
|
||||
continue
|
||||
tle1 = meta.get("tle_line1")
|
||||
tle2 = meta.get("tle_line2")
|
||||
if not tle1 or not tle2:
|
||||
tle1, tle2 = build_tle_lines_from_elements(
|
||||
norad_cat_id=norad_id,
|
||||
epoch=meta.get("epoch"),
|
||||
inclination=meta.get("inclination"),
|
||||
raan=meta.get("raan"),
|
||||
eccentricity=meta.get("eccentricity"),
|
||||
arg_of_perigee=meta.get("arg_of_perigee"),
|
||||
mean_anomaly=meta.get("mean_anomaly"),
|
||||
mean_motion=meta.get("mean_motion"),
|
||||
)
|
||||
items.append({
|
||||
"id": f"sat_{record.id}",
|
||||
"norad_id": str(norad_id),
|
||||
"name": record.name or "Unknown",
|
||||
"tle_line1": tle1 or "",
|
||||
"tle_line2": tle2 or "",
|
||||
"epoch": meta.get("epoch") or "",
|
||||
"inclination": _safe_float(meta.get("inclination")),
|
||||
"raan": _safe_float(meta.get("raan")),
|
||||
"eccentricity": _safe_float(meta.get("eccentricity")),
|
||||
"mean_motion": _safe_float(meta.get("mean_motion")),
|
||||
})
|
||||
return {"count": len(items), "items": items}
|
||||
Reference in New Issue
Block a user