diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index d389fdb..0da23fe 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -32,6 +32,10 @@ export default function App() { const [track, setTrack] = useState([]); const wsRef = useRef(null); const [mapMode, setMapMode] = useState("raster"); + const [gnssMode, setGnssMode] = useState<"simulated" | "serial" | "unknown">("unknown"); + const [simCenterLat, setSimCenterLat] = useState(""); + const [simCenterLon, setSimCenterLon] = useState(""); + const [vectorSource, setVectorSource] = useState<"mbtiles" | "tileset">("mbtiles"); const [tilesets, setTilesets] = useState([]); const [activeTileset, setActiveTileset] = useState(""); const [selectedFileName, setSelectedFileName] = useState(""); @@ -70,6 +74,21 @@ export default function App() { }); }, [sendFrontLog]); + const refreshGnssState = useCallback(async () => { + const url = config?.gnss_state_url ? `${backendBase}${config.gnss_state_url}` : `${backendBase}/gnss/state`; + const res = await fetch(url, { cache: "no-store" }); + const data = await res.json(); + if (!res.ok) throw new Error(data?.detail || `status ${res.status}`); + const mode = (data?.gnss?.mode as string | undefined) || "unknown"; + if (mode === "simulated" || mode === "serial") { + setGnssMode(mode); + } else { + setGnssMode("unknown"); + } + if (typeof data?.gnss?.lat === "number") setSimCenterLat(String(data.gnss.lat)); + if (typeof data?.gnss?.lon === "number") setSimCenterLon(String(data.gnss.lon)); + }, [config]); + const refreshTilesets = useCallback(async () => { const url = config?.tilesets_url ? `${backendBase}${config.tilesets_url}` : `${backendBase}/tilesets`; const res = await fetch(url, { cache: "no-store" }); @@ -82,7 +101,54 @@ export default function App() { useEffect(() => { if (!config) return; refreshTilesets().catch((err) => sendFrontLog(`tilesets refresh failed: ${err}`, "warn")); - }, [config, refreshTilesets, sendFrontLog]); + refreshGnssState().catch((err) => sendFrontLog(`gnss refresh failed: ${err}`, "warn")); + }, [config, refreshGnssState, refreshTilesets, sendFrontLog]); + + useEffect(() => { + if (!config?.gnss_source) return; + if (config.gnss_source === "simulated" || config.gnss_source === "serial") setGnssMode(config.gnss_source); + }, [config]); + + const setGnssModeRequest = useCallback( + async (mode: "simulated" | "serial") => { + const url = config?.gnss_mode_url ? `${backendBase}${config.gnss_mode_url}` : `${backendBase}/gnss/mode`; + const res = await fetch(url, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ mode }), + }); + const data = await res.json(); + if (!res.ok) throw new Error(data?.detail || `status ${res.status}`); + sendFrontLog(`gnss mode set: ${mode}`, "info"); + setGnssMode(mode); + setConfig((prev) => (prev ? { ...prev, gnss_source: mode } : prev)); + if (mode === "simulated") { + if (typeof data?.gnss?.lat === "number") setSimCenterLat(String(data.gnss.lat)); + if (typeof data?.gnss?.lon === "number") setSimCenterLon(String(data.gnss.lon)); + } + }, + [config, sendFrontLog], + ); + + const applySimCenter = useCallback(async () => { + const lat = Number(simCenterLat); + const lon = Number(simCenterLon); + if (!Number.isFinite(lat) || !Number.isFinite(lon)) { + sendFrontLog("invalid simulated center coordinates", "warn"); + return; + } + const url = config?.gnss_sim_center_url + ? `${backendBase}${config.gnss_sim_center_url}` + : `${backendBase}/gnss/sim/center`; + const res = await fetch(url, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ lat, lon }), + }); + const data = await res.json(); + if (!res.ok) throw new Error(data?.detail || `status ${res.status}`); + sendFrontLog(`sim center updated: ${lat}, ${lon}`, "info"); + }, [config, sendFrontLog, simCenterLat, simCenterLon]); const activateTileset = useCallback( async (name: string) => { @@ -171,25 +237,45 @@ export default function App() { }`; const vectorTileUrl = - config && `${backendBase.replace(/\/+$/, "")}${config.vector_tiles_url || "/tiles/vector/{z}/{x}/{y}.pbf"}`; + config && + `${backendBase.replace(/\/+$/, "")}${ + vectorSource === "tileset" + ? config.vector_tileset_url || "/tilesets/active/vector/{z}/{x}/{y}.pbf" + : config.vector_tiles_url || "/tiles/vector/{z}/{x}/{y}.pbf" + }`; const vectorMetaUrl = - config && `${backendBase.replace(/\/+$/, "")}${config.vector_metadata_url || "/tiles/vector/metadata"}`; + config && + `${backendBase.replace(/\/+$/, "")}${ + vectorSource === "tileset" + ? config.vector_tileset_metadata_url || "/tilesets/active/vector/metadata" + : config.vector_metadata_url || "/tiles/vector/metadata" + }`; - const activateVectorMbtiles = useCallback( + const ingestMap = useCallback( async (path: string) => { - const res = await fetch(`${backendBase}/tiles/vector/activate`, { + const res = await fetch(`${backendBase}/maps/ingest`, { method: "POST", headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ path }), + body: JSON.stringify({ + path, + tileset: sliceParams.tileset || undefined, + min_zoom: Number(sliceParams.min_zoom), + max_zoom: Number(sliceParams.max_zoom), + }), }); const data = await res.json(); if (!res.ok) throw new Error(data?.detail || `status ${res.status}`); - sendFrontLog(`vector mbtiles activated: ${path}`, "info"); - setMapMode("vector"); + if (data.kind === "vector") { + setVectorSource("mbtiles"); + setMapMode("vector"); + } else if (data.kind === "raster") { + setMapMode("raster"); + } + sendFrontLog(`map ingested: kind=${data.kind} driver=${data.driver}`, "info"); return data; }, - [sendFrontLog], + [sendFrontLog, sliceParams.max_zoom, sliceParams.min_zoom, sliceParams.tileset], ); const uploadFileBlob = useCallback( @@ -218,9 +304,7 @@ export default function App() { setSliceStatus("idle"); sendFrontLog(`uploaded source ${res.filename}`, "info"); if (res.filename?.toLowerCase?.().endsWith(".mbtiles")) { - activateVectorMbtiles(res.path).catch((err) => { - sendFrontLog(`vector activate failed: ${err}`, "warn"); - }); + ingestMap(res.path).catch((err) => sendFrontLog(`ingest failed: ${err}`, "warn")); } }) .catch((err) => { @@ -228,7 +312,7 @@ export default function App() { sendFrontLog(`upload error: ${err}`, "error"); }); }, - [activateVectorMbtiles, sendFrontLog], + [ingestMap, sendFrontLog], ); const triggerFileSelect = useCallback(async () => { @@ -345,6 +429,16 @@ export default function App() { status={status} theme={theme} onToggleTheme={() => setTheme((t) => (t === "dark" ? "light" : "dark"))} + gnssMode={gnssMode} + simCenterLat={simCenterLat} + simCenterLon={simCenterLon} + onGnssRefresh={() => refreshGnssState().catch((err) => sendFrontLog(`${err}`, "warn"))} + onGnssModeChange={(mode) => setGnssModeRequest(mode).catch((err) => sendFrontLog(`${err}`, "error"))} + onSimCenterLatChange={setSimCenterLat} + onSimCenterLonChange={setSimCenterLon} + onSimCenterApply={() => applySimCenter().catch((err) => sendFrontLog(`${err}`, "error"))} + vectorSource={vectorSource} + onVectorSourceChange={setVectorSource} tilesets={tilesets} activeTileset={activeTileset} onActivateTileset={(name) => activateTileset(name).catch((err) => sendFrontLog(`${err}`, "error"))} diff --git a/frontend/src/components/SettingsPanel.tsx b/frontend/src/components/SettingsPanel.tsx index 24852df..d8ab148 100644 --- a/frontend/src/components/SettingsPanel.tsx +++ b/frontend/src/components/SettingsPanel.tsx @@ -9,6 +9,16 @@ type SettingsPanelProps = { status: string; theme: "dark" | "light"; onToggleTheme: () => void; + gnssMode: "simulated" | "serial" | "unknown"; + simCenterLat: string; + simCenterLon: string; + onGnssRefresh: () => void; + onGnssModeChange: (mode: "simulated" | "serial") => void; + onSimCenterLatChange: (value: string) => void; + onSimCenterLonChange: (value: string) => void; + onSimCenterApply: () => void; + vectorSource: "mbtiles" | "tileset"; + onVectorSourceChange: (source: "mbtiles" | "tileset") => void; tilesets: string[]; activeTileset: string; onActivateTileset: (name: string) => void; @@ -32,6 +42,16 @@ export const SettingsPanel = ({ status, theme, onToggleTheme, + gnssMode, + simCenterLat, + simCenterLon, + onGnssRefresh, + onGnssModeChange, + onSimCenterLatChange, + onSimCenterLonChange, + onSimCenterApply, + vectorSource, + onVectorSourceChange, tilesets, activeTileset, onActivateTileset, @@ -74,6 +94,44 @@ export const SettingsPanel = ({ +
+
+
GNSS
+
Режим и точка симуляции
+
+ +
+ + + + {gnssMode === "simulated" && ( +
+ + + +
+ )} +
Тема
@@ -103,6 +161,14 @@ export const SettingsPanel = ({
+ +
Нарезка тайлов
diff --git a/frontend/src/types.ts b/frontend/src/types.ts index 9d1bcc2..587aa5f 100644 --- a/frontend/src/types.ts +++ b/frontend/src/types.ts @@ -27,10 +27,15 @@ export type AppConfig = { tiles_url_tms?: string; vector_tiles_url?: string; vector_metadata_url?: string; + vector_tileset_url?: string; + vector_tileset_metadata_url?: string; tilesets_url?: string; tilesets_activate_url?: string; ws_url: string; gnss_source: string; + gnss_state_url?: string; + gnss_mode_url?: string; + gnss_sim_center_url?: string; }; export type TrackPoint = { diff --git a/gnss/api/routes/config_route.py b/gnss/api/routes/config_route.py index 37cfcb6..ee1c653 100644 --- a/gnss/api/routes/config_route.py +++ b/gnss/api/routes/config_route.py @@ -1,12 +1,17 @@ from fastapi import APIRouter from ... import config +from .. import state router = APIRouter(tags=["config"]) @router.get("/config") async def get_config() -> dict: + try: + gnss_source = state.get_gnss_service().source_kind() + except Exception: + gnss_source = config.GNSS_SOURCE return { "tiles_url": "/tiles/{z}/{x}/{y}.png", "tiles_url_tms": "/tiles/tms/{z}/{x}/{y}.png", @@ -17,5 +22,8 @@ async def get_config() -> dict: "tilesets_url": "/tilesets", "tilesets_activate_url": "/tilesets/activate", "ws_url": "/ws/track", - "gnss_source": config.GNSS_SOURCE, + "gnss_source": gnss_source, + "gnss_state_url": "/gnss/state", + "gnss_mode_url": "/gnss/mode", + "gnss_sim_center_url": "/gnss/sim/center", } diff --git a/gnss/api/routes/gnss.py b/gnss/api/routes/gnss.py new file mode 100644 index 0000000..0517c8c --- /dev/null +++ b/gnss/api/routes/gnss.py @@ -0,0 +1,61 @@ +from fastapi import APIRouter, Depends, HTTPException +from pydantic import BaseModel, Field + +from ..dependencies import get_gnss_service +from ... import config +from ...services.gnss import GNSSService, RectangularPathSource, SerialNMEASource, SimulatedPathSource + + +router = APIRouter(tags=["gnss"]) + + +@router.get("/gnss/state") +async def gnss_state(gnss: GNSSService = Depends(get_gnss_service)) -> dict: + return {"status": "ok", "gnss": gnss.describe()} + + +class SetGNSSModeRequest(BaseModel): + mode: str = Field(pattern="^(simulated|serial)$") + + +@router.post("/gnss/mode") +async def set_gnss_mode(payload: SetGNSSModeRequest, gnss: GNSSService = Depends(get_gnss_service)) -> dict: + mode = payload.mode.lower() + if mode == "serial": + source = SerialNMEASource(port=config.GNSS_PORT, baudrate=config.GNSS_BAUD, timeout=config.GNSS_TIMEOUT) + await gnss.set_source(source) + return {"status": "ok", "gnss": gnss.describe()} + + start_lat, start_lon = config.SIM_START_POINT + path = (getattr(config, "SIM_PATH", "circle") or "circle").lower() + if path == "rectangle": + source = RectangularPathSource( + center_lat=start_lat, + center_lon=start_lon, + width_m=config.SIM_RECT_WIDTH_METERS, + height_m=config.SIM_RECT_HEIGHT_METERS, + step_m=config.SIM_STEP_METERS, + alt_m=config.SIM_ALT_METERS, + ) + else: + source = SimulatedPathSource(center_lat=start_lat, center_lon=start_lon, radius_m=config.SIM_RADIUS_METERS) + await gnss.set_source(source) + return {"status": "ok", "gnss": gnss.describe()} + + +class UpdateSimulatedCenterRequest(BaseModel): + lat: float = Field(ge=-90, le=90) + lon: float = Field(ge=-180, le=180) + + +@router.post("/gnss/sim/center") +async def update_simulated_center( + payload: UpdateSimulatedCenterRequest, + gnss: GNSSService = Depends(get_gnss_service), +) -> dict: + try: + await gnss.update_simulated_center(lat=payload.lat, lon=payload.lon) + except TypeError as exc: + raise HTTPException(status_code=400, detail=str(exc)) + return {"status": "ok", "gnss": gnss.describe()} + diff --git a/gnss/main.py b/gnss/main.py index da70d61..8db31e8 100644 --- a/gnss/main.py +++ b/gnss/main.py @@ -6,7 +6,7 @@ from fastapi.middleware.cors import CORSMiddleware from . import config from .api import state -from .api.routes import config_route, health, logs, maps, raster_mbtiles, tiles, tilesets, vector_tiles, ws +from .api.routes import config_route, gnss, health, logs, maps, raster_mbtiles, tiles, tilesets, vector_tiles, ws from .services.gnss import GNSSService, RectangularPathSource, SerialNMEASource, SimulatedPathSource from .services.logs import LogService from .services.tiles import TileService @@ -84,6 +84,7 @@ app.include_router(config_route.router) app.include_router(tiles.router) app.include_router(ws.router) app.include_router(logs.router) +app.include_router(gnss.router) app.include_router(maps.router) app.include_router(raster_mbtiles.router) app.include_router(vector_tiles.router) diff --git a/gnss/services/gnss.py b/gnss/services/gnss.py index 824c8e7..2e056d9 100644 --- a/gnss/services/gnss.py +++ b/gnss/services/gnss.py @@ -180,6 +180,43 @@ class GNSSService: async def stop(self) -> None: await self.source.stop() + def source_kind(self) -> str: + if isinstance(self.source, SerialNMEASource): + return "serial" + if isinstance(self.source, (SimulatedPathSource, RectangularPathSource)): + return "simulated" + return "unknown" + + def describe(self) -> dict: + kind = self.source_kind() + payload: dict = {"mode": kind} + if kind == "simulated": + payload["lat"] = getattr(self.source, "center_lat", None) + payload["lon"] = getattr(self.source, "center_lon", None) + payload["path"] = "rectangle" if isinstance(self.source, RectangularPathSource) else "circle" + if kind == "serial": + payload["port"] = getattr(self.source, "port", None) + payload["baudrate"] = getattr(self.source, "baudrate", None) + return payload + + async def set_source(self, source: BaseGNSSSource) -> None: + async with self._lock: + try: + await self.source.stop() + except Exception: + pass + self.source = source + self._last_fix = None + await self.source.start() + + async def update_simulated_center(self, lat: float, lon: float) -> None: + async with self._lock: + if not isinstance(self.source, (SimulatedPathSource, RectangularPathSource)): + raise TypeError("current GNSS source is not simulated") + self.source.center_lat = lat + self.source.center_lon = lon + self._last_fix = None + async def _fetch_fix(self) -> Optional[GNSSPoint]: async with self._lock: fix = await self.source.next_point()