from fastapi import APIRouter, Depends, HTTPException, Query, Path, Body from typing import List from datetime import datetime from psycopg import AsyncConnection from pydantic import BaseModel, Field, field_validator from app.domain.schemas.scada import ScadaLatestReadingsResponse from app.infra.db.postgresql.scada import ScadaInfoRepository from app.infra.db.timescaledb.repositories.scada import ScadaRepository from .dependencies import get_postgres_connection, get_timescale_connection router = APIRouter() SCADA_BATCH_MAX_ITEMS = 10_000 class ScadaReadingBatchItem(BaseModel): time: datetime device_id: str = Field(min_length=1) monitored_value: float | None = None cleaned_value: float | None = None @field_validator("device_id") @classmethod def normalize_device_id(cls, value: str) -> str: normalized = value.strip() if not normalized: raise ValueError("device_id must not be blank") return normalized @router.post("/timeseries/scada-readings/batches", status_code=201, summary="批量插入SCADA监测数据") async def insert_scada_data( data: List[ScadaReadingBatchItem] = Body( ..., min_length=1, max_length=SCADA_BATCH_MAX_ITEMS, description="SCADA设备监测数据列表", ), conn: AsyncConnection = Depends(get_timescale_connection), postgres_conn: AsyncConnection = Depends(get_postgres_connection), ): """ 批量插入SCADA监测数据 将多个设备的实时监测数据批量插入时间序列数据库。 Args: data: SCADA设备监测数据列表,每项包含device_id、时间戳和监测值等信息 Returns: 插入成功的记录数 """ rows = [item.model_dump() for item in data] requested_ids = list(dict.fromkeys(item["device_id"] for item in rows)) existing_ids = await ScadaInfoRepository.get_existing_device_ids( postgres_conn, requested_ids ) missing_ids = [ device_id for device_id in requested_ids if device_id not in existing_ids ] if missing_ids: raise HTTPException( status_code=422, detail=f"SCADA devices do not exist in BizDB: {', '.join(missing_ids)}", ) await ScadaRepository.insert_scada_batch(conn, rows) return {"message": f"Inserted {len(data)} records"} @router.get("/timeseries/scada-readings", summary="按设备ID和时间范围查询SCADA数据") async def get_scada_by_ids_time_range( start_time: datetime = Query(..., description="查询开始时间"), end_time: datetime = Query(..., description="查询结束时间"), device_ids: str = Query( ..., description="设备ID列表,逗号分隔,如 'device1,device2,device3'" ), conn: AsyncConnection = Depends(get_timescale_connection), ): """ 按设备ID和时间范围查询SCADA监测数据 查询多个设备在指定时间范围内的所有监测数据。 Args: start_time: 查询开始时间 end_time: 查询结束时间 device_ids: 设备ID列表,用逗号分隔 Returns: SCADA监测数据列表 """ device_ids_list = ( [id.strip() for id in device_ids.split(",") if id.strip()] if device_ids else [] ) return await ScadaRepository.get_scada_by_ids_time_range( conn, device_ids_list, start_time, end_time ) @router.get( "/timeseries/scada-readings/latest", response_model=ScadaLatestReadingsResponse, summary="获取SCADA设备最新数据", ) async def get_latest_scada_readings( limit: int = Query(100, ge=1, le=500, description="返回设备数上限"), offset: int = Query(0, ge=0, description="设备目录偏移量"), conn: AsyncConnection = Depends(get_timescale_connection), postgres_conn: AsyncConnection = Depends(get_postgres_connection), ) -> ScadaLatestReadingsResponse: """Read one latest persisted row per project device without applying a time window.""" devices = await ScadaInfoRepository.get_scadas(postgres_conn) selected_devices = devices[offset : offset + limit] latest_by_device = await ScadaRepository.get_latest_scada_readings( conn, [device["device_id"] for device in selected_devices], ) items = [] for device in selected_devices: reading = latest_by_device.get(device["device_id"]) items.append( { "device_id": device["device_id"], "device_type": device["device_type"], "measurement_unit": device["measurement_unit"], "transmission_mode": device["transmission_mode"], "transmission_frequency": device["transmission_frequency"], "observed_at": reading["time"] if reading else None, "monitored_value": reading["monitored_value"] if reading else None, "cleaned_value": reading["cleaned_value"] if reading else None, } ) observed_times = [item["observed_at"] for item in items if item["observed_at"] is not None] return ScadaLatestReadingsResponse.model_validate( { "items": items, "total": len(devices), "limit": limit, "offset": offset, "observed_count": len(observed_times), "latest_observed_at": max(observed_times) if observed_times else None, } ) @router.get( "/timeseries/scada-readings/fields", summary="按设备ID、字段和时间范围查询SCADA数据" ) async def get_scada_field_by_ids_time_range( start_time: datetime = Query(..., description="查询开始时间"), end_time: datetime = Query(..., description="查询结束时间"), field: str = Query(..., description="要查询的字段名称"), device_ids: str = Query( ..., description="设备ID列表,逗号分隔,如 'device1,device2,device3'" ), conn: AsyncConnection = Depends(get_timescale_connection), ): """ 按设备ID、字段和时间范围查询特定SCADA数据 查询多个设备在指定时间范围内的特定字段监测数据。 Args: start_time: 查询开始时间 end_time: 查询结束时间 field: 字段名称 device_ids: 设备ID列表,用逗号分隔 Returns: SCADA字段数据列表 Raises: HTTPException: 当字段不存在或查询参数无效时返回400错误 """ try: device_ids_list = ( [id.strip() for id in device_ids.split(",") if id.strip()] if device_ids else [] ) return await ScadaRepository.get_scada_field_by_id_time_range( conn, device_ids_list, start_time, end_time, field ) except ValueError as e: raise HTTPException(status_code=400, detail=str(e)) @router.patch("/timeseries/scada-readings/{device_id}/field", summary="更新SCADA设备字段") async def update_scada_field( device_id: str = Path(..., description="设备ID"), time: datetime = Query(..., description="更新数据的时间戳"), field: str = Query(..., description="要更新的字段名称"), value: float = Query(..., description="更新的字段值"), conn: AsyncConnection = Depends(get_timescale_connection), ): """ 更新指定设备的字段值 更新SCADA设备在特定时间的某个字段监测数据。 Args: device_id: 设备ID time: 数据时间戳 field: 字段名称 value: 字段新值 Returns: 更新结果信息 Raises: HTTPException: 当字段不存在或更新失败时返回400错误 """ try: await ScadaRepository.update_scada_field(conn, time, device_id, field, value) return {"message": "Updated successfully"} except ValueError as e: raise HTTPException(status_code=400, detail=str(e)) @router.delete("/timeseries/scada-readings", summary="按设备ID和时间范围删除SCADA数据") async def delete_scada_data( device_id: str = Query(..., description="设备ID"), start_time: datetime = Query(..., description="删除开始时间"), end_time: datetime = Query(..., description="删除结束时间"), conn: AsyncConnection = Depends(get_timescale_connection), ): """ 删除指定设备和时间范围内的SCADA数据 删除在指定时间范围内的特定设备监测数据。 Args: device_id: 设备ID start_time: 删除开始时间 end_time: 删除结束时间 Returns: 删除结果信息 """ await ScadaRepository.delete_scada_by_id_time_range( conn, device_id, start_time, end_time ) return {"message": "Deleted successfully"}