SmartEDT/backend/services/simulation_manager.py

145 lines
5.0 KiB
Python

from __future__ import annotations
import asyncio
import logging
import secrets
from dataclasses import dataclass
from typing import Any
from sqlalchemy import insert
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker
from backend.database.schema import Simulation, vehicle_signals
from backend.device.mock_vehicle import MockVehicleDevice
from backend.services.broadcaster import Broadcaster
from backend.utils import utc_now
logger = logging.getLogger("backend.simulation")
@dataclass
class SimulationRuntime:
simulation_id: str
status: str
task: asyncio.Task | None = None
class SimulationManager:
def __init__(
self,
session_factory: async_sessionmaker[AsyncSession],
broadcaster: Broadcaster,
) -> None:
self._session_factory = session_factory
self._broadcaster = broadcaster
self._runtime: SimulationRuntime | None = None
self._device = MockVehicleDevice()
self._seq = 0
def current(self) -> SimulationRuntime | None:
return self._runtime
async def register_device(self, device: MockVehicleDevice) -> None:
self._device = device
async def start(self, scenario_config: dict[str, Any]) -> str:
if self._runtime and self._runtime.status == "running":
return self._runtime.simulation_id
simulation_id = "SIM" + utc_now().strftime("%Y%m%d%H%M%S") + secrets.token_hex(2).upper()
started_at = utc_now()
scenario_name = scenario_config.get("scenario")
operator = scenario_config.get("driver") or scenario_config.get("operator")
config_created_at = started_at
async with self._session_factory() as session:
session.add(
Simulation(
simulation_id=simulation_id,
status="running",
started_at=started_at,
ended_at=None,
scenario_name=scenario_name,
scenario_config=scenario_config,
config_created_at=config_created_at,
operator=operator,
archived=False,
)
)
await session.commit()
await self._device.connect()
self._runtime = SimulationRuntime(simulation_id=simulation_id, status="running")
self._runtime.task = asyncio.create_task(self._run_loop(simulation_id))
await self._broadcaster.broadcast_json(
{"type": "simulation.status", "ts": started_at.timestamp(), "simulation_id": simulation_id, "payload": {"status": "running"}}
)
return simulation_id
async def stop(self, simulation_id: str) -> None:
runtime = self._runtime
if not runtime or runtime.simulation_id != simulation_id:
return
runtime.status = "stopping"
if runtime.task:
runtime.task.cancel()
try:
await runtime.task
except asyncio.CancelledError:
pass
await self._device.disconnect()
ended_at = utc_now()
async with self._session_factory() as session:
sim = await session.get(Simulation, simulation_id)
if sim:
sim.status = "stopped"
sim.ended_at = ended_at
await session.commit()
await self._broadcaster.broadcast_json(
{"type": "simulation.status", "ts": ended_at.timestamp(), "simulation_id": simulation_id, "payload": {"status": "stopped"}}
)
self._runtime = None
async def _run_loop(self, simulation_id: str) -> None:
try:
while True:
await asyncio.sleep(0.05)
if not await self._device.is_connected():
continue
self._seq += 1
ts = utc_now()
payload = self._device.sample().to_dict()
message = {
"type": "vehicle.signal",
"ts": ts.timestamp(),
"simulation_id": simulation_id,
"device_id": self._device.device_id,
"seq": self._seq,
"payload": payload,
}
await self._broadcaster.broadcast_json(message)
await self._persist_signal(ts, simulation_id, self._device.device_id, self._seq, payload)
except asyncio.CancelledError:
raise
except Exception:
logger.exception("simulation loop crashed")
async def _persist_signal(self, ts, simulation_id: str, device_id: str, seq: int, signals: dict[str, Any]) -> None:
async with self._session_factory() as session:
await session.execute(
insert(vehicle_signals).values(
ts=ts,
simulation_id=simulation_id,
device_id=device_id,
seq=seq,
signals=signals,
)
)
await session.commit()