Files
smart-city-digital-twin-mar…/smart-app-city/backend/app/routes/iot.py

512 lines
17 KiB
Python

"""IoT routes — sensors, zones, alerts, and dashboard statistics.
All endpoints require JWT authentication (Bearer token).
"""
from __future__ import annotations
from datetime import datetime, timedelta, timezone
from typing import Optional
from uuid import UUID
from fastapi import APIRouter, Depends, HTTPException, Query, status
from sqlalchemy import func, select
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import selectinload
from app.auth.jwt import get_current_user
from app.database import get_session
from app.models.models import Alert, Sensor, SensorReading, Zone
# ---------------------------------------------------------------------------
# Router
# ---------------------------------------------------------------------------
router = APIRouter(prefix="/iot", tags=["IoT"])
# ---------------------------------------------------------------------------
# Helper: UUID path validation
# ---------------------------------------------------------------------------
def _validate_uuid(param: str, value: str) -> UUID:
"""Raise 400 if *value* is not a valid UUID."""
try:
return UUID(value)
except (ValueError, AttributeError):
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f"{param} must be a valid UUID, got '{value}'",
)
# ===========================================================================
# SENSORS
# ===========================================================================
@router.get(
"/sensors",
summary="List all sensors (paginated)",
description="Return a paginated list of sensors with optional status filter.",
)
async def list_sensors(
page: int = Query(1, ge=1, description="Page number (1-indexed)"),
page_size: int = Query(20, ge=1, le=100, description="Items per page"),
status: Optional[str] = Query(None, description="Filter by sensor status (active, inactive, maintenance)"),
session: AsyncSession = Depends(get_session),
_current_user: dict = Depends(get_current_user),
) -> dict:
base_stmt = select(Sensor)
total_stmt = select(func.count(Sensor.id))
if status is not None:
base_stmt = base_stmt.where(Sensor.status == status)
total_stmt = total_stmt.where(Sensor.status == status)
# Count total
total_result = await session.execute(total_stmt)
total: int = total_result.scalar_one()
# Paginated fetch — include zone relationship
offset = (page - 1) * page_size
stmt = (
base_stmt
.options(selectinload(Sensor.zone))
.order_by(Sensor.created_at.desc())
.offset(offset)
.limit(page_size)
)
result = await session.execute(stmt)
sensors = result.scalars().all()
pages = max(1, -(-total // page_size)) # ceil division
return {
"items": [_sensor_to_dict(s) for s in sensors],
"total": total,
"page": page,
"page_size": page_size,
"pages": pages,
}
@router.get(
"/sensors/{sensor_id}",
summary="Get sensor detail",
description="Return full details for a single sensor, including its zone.",
)
async def get_sensor(
sensor_id: str,
session: AsyncSession = Depends(get_session),
_current_user: dict = Depends(get_current_user),
) -> dict:
sid = _validate_uuid("sensor_id", sensor_id)
stmt = (
select(Sensor)
.options(selectinload(Sensor.zone))
.where(Sensor.id == sid)
)
result = await session.execute(stmt)
sensor = result.scalar_one_or_none()
if sensor is None:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"Sensor {sensor_id} not found",
)
return _sensor_to_dict(sensor)
@router.get(
"/sensors/{sensor_id}/data",
summary="Historical sensor readings",
description="Return readings for a sensor within an optional time range.",
)
async def get_sensor_data(
sensor_id: str,
from_: Optional[datetime] = Query(
None,
alias="from",
description="Start of time range (ISO 8601). Defaults to 24 h ago.",
),
to: Optional[datetime] = Query(
None,
description="End of time range (ISO 8601). Defaults to now.",
),
limit: int = Query(100, ge=1, le=10_000, description="Max number of readings"),
session: AsyncSession = Depends(get_session),
_current_user: dict = Depends(get_current_user),
) -> dict:
sid = _validate_uuid("sensor_id", sensor_id)
# Ensure sensor exists
sensor_result = await session.execute(select(Sensor).where(Sensor.id == sid))
if sensor_result.scalar_one_or_none() is None:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"Sensor {sensor_id} not found",
)
now = datetime.now(timezone.utc)
from_dt = from_ or (now - timedelta(hours=24))
to_dt = to or now
stmt = (
select(SensorReading)
.where(
SensorReading.sensor_id == sid,
SensorReading.recorded_at >= from_dt,
SensorReading.recorded_at <= to_dt,
)
.order_by(SensorReading.recorded_at.desc())
.limit(limit)
)
result = await session.execute(stmt)
readings = result.scalars().all()
return {
"sensor_id": str(sid),
"from": from_dt.isoformat(),
"to": to_dt.isoformat(),
"limit": limit,
"total": len(readings),
"readings": [_reading_to_dict(r) for r in readings],
}
# ===========================================================================
# ZONES
# ===========================================================================
@router.get(
"/zones",
summary="List all zones",
description="Return all zones ordered by creation date (newest first).",
)
async def list_zones(
session: AsyncSession = Depends(get_session),
_current_user: dict = Depends(get_current_user),
) -> dict:
stmt = select(Zone).order_by(Zone.created_at.desc())
result = await session.execute(stmt)
zones = result.scalars().all()
return {"items": [_zone_to_dict(z) for z in zones], "total": len(zones)}
@router.get(
"/zones/{zone_id}/sensors",
summary="Sensors in a zone",
description="Return all sensors belonging to a specific zone.",
)
async def get_zone_sensors(
zone_id: str,
page: int = Query(1, ge=1),
page_size: int = Query(20, ge=1, le=100),
session: AsyncSession = Depends(get_session),
_current_user: dict = Depends(get_current_user),
) -> dict:
zid = _validate_uuid("zone_id", zone_id)
# Ensure zone exists
zone_result = await session.execute(select(Zone).where(Zone.id == zid))
if zone_result.scalar_one_or_none() is None:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"Zone {zone_id} not found",
)
total_result = await session.execute(
select(func.count(Sensor.id)).where(Sensor.zone_id == zid)
)
total: int = total_result.scalar_one()
offset = (page - 1) * page_size
stmt = (
select(Sensor)
.where(Sensor.zone_id == zid)
.order_by(Sensor.name)
.offset(offset)
.limit(page_size)
)
result = await session.execute(stmt)
sensors = result.scalars().all()
pages = max(1, -(-total // page_size))
return {
"zone_id": str(zid),
"items": [_sensor_to_dict(s) for s in sensors],
"total": total,
"page": page,
"page_size": page_size,
"pages": pages,
}
# ===========================================================================
# ALERTS
# ===========================================================================
@router.get(
"/alerts",
summary="List alerts (paginated)",
description="Return alerts with optional status and severity filters.",
)
async def list_alerts(
page: int = Query(1, ge=1),
page_size: int = Query(20, ge=1, le=100),
status: Optional[str] = Query(None, description="Filter by status (active, resolved, acknowledged)"),
severity: Optional[str] = Query(None, description="Filter by severity (low, medium, high, critical)"),
session: AsyncSession = Depends(get_session),
_current_user: dict = Depends(get_current_user),
) -> dict:
base_stmt = select(Alert)
total_stmt = select(func.count(Alert.id))
if status is not None:
base_stmt = base_stmt.where(Alert.status == status)
total_stmt = total_stmt.where(Alert.status == status)
if severity is not None:
base_stmt = base_stmt.where(Alert.severity == severity)
total_stmt = total_stmt.where(Alert.severity == severity)
total_result = await session.execute(total_stmt)
total: int = total_result.scalar_one()
offset = (page - 1) * page_size
stmt = (
base_stmt
.options(selectinload(Alert.sensor))
.order_by(Alert.created_at.desc())
.offset(offset)
.limit(page_size)
)
result = await session.execute(stmt)
alerts = result.scalars().all()
pages = max(1, -(-total // page_size))
return {
"items": [_alert_to_dict(a) for a in alerts],
"total": total,
"page": page,
"page_size": page_size,
"pages": pages,
}
# ===========================================================================
# DASHBOARD STATS
# ===========================================================================
@router.get(
"/stats",
summary="Dashboard statistics (last 24 h)",
description="Return aggregated IoT statistics for the last 24 hours.",
)
async def get_stats(
session: AsyncSession = Depends(get_session),
_current_user: dict = Depends(get_current_user),
) -> dict:
now = datetime.now(timezone.utc)
since = now - timedelta(hours=24)
# ── Sensor counts ─────────────────────────────────────────────────
total_sensors = await _scalar_count(session, func.count(Sensor.id))
active_sensors = await _scalar_count(
session, func.count(Sensor.id), Sensor.status == "active"
)
# ── Readings last 24 h ────────────────────────────────────────────
readings_24h = await _scalar_count(
session,
func.count(SensorReading.id),
SensorReading.recorded_at >= since,
)
# Average reading value over 24 h
avg_val_result = await session.execute(
select(func.avg(SensorReading.value)).where(
SensorReading.recorded_at >= since
)
)
avg_value: Optional[float] = avg_val_result.scalar_one()
# ── Alerts last 24 h ──────────────────────────────────────────────
total_alerts_24h = await _scalar_count(
session,
func.count(Alert.id),
Alert.created_at >= since,
)
active_alerts = await _scalar_count(
session,
func.count(Alert.id),
Alert.status == "active",
)
critical_alerts = await _scalar_count(
session,
func.count(Alert.id),
(Alert.status == "active") & (Alert.severity == "critical"),
)
# ── Zones ─────────────────────────────────────────────────────────
total_zones = await _scalar_count(session, func.count(Zone.id))
# ── Alerts by severity (last 24 h) ────────────────────────────────
severity_rows = await session.execute(
select(Alert.severity, func.count(Alert.id))
.where(Alert.created_at >= since)
.group_by(Alert.severity)
)
alerts_by_severity = {row[0]: row[1] for row in severity_rows.all()}
# ── Alerts by status ──────────────────────────────────────────────
status_rows = await session.execute(
select(Alert.status, func.count(Alert.id))
.where(Alert.created_at >= since)
.group_by(Alert.status)
)
alerts_by_status = {row[0]: row[1] for row in status_rows.all()}
# ── Sensors by type ───────────────────────────────────────────────
type_rows = await session.execute(
select(Sensor.type, func.count(Sensor.id)).group_by(Sensor.type)
)
sensors_by_type = {row[0]: row[1] for row in type_rows.all()}
# ── Sensors by status ─────────────────────────────────────────────
sensor_status_rows = await session.execute(
select(Sensor.status, func.count(Sensor.id)).group_by(Sensor.status)
)
sensors_by_status = {row[0]: row[1] for row in sensor_status_rows.all()}
# ── Avg battery level ─────────────────────────────────────────────
battery_result = await session.execute(
select(func.avg(Sensor.battery_level)).where(
Sensor.battery_level.is_not(None)
)
)
avg_battery: Optional[float] = battery_result.scalar_one()
return {
"period": "24h",
"generated_at": now.isoformat(),
"sensors": {
"total": total_sensors,
"active": active_sensors,
"by_type": sensors_by_type,
"by_status": sensors_by_status,
"avg_battery_level": round(avg_battery, 1) if avg_battery is not None else None,
},
"readings": {
"last_24h_count": readings_24h,
"avg_value": round(avg_value, 4) if avg_value is not None else None,
},
"zones": {
"total": total_zones,
},
"alerts": {
"last_24h_count": total_alerts_24h,
"active": active_alerts,
"critical": critical_alerts,
"by_severity": alerts_by_severity,
"by_status": alerts_by_status,
},
}
# ===========================================================================
# Internal helpers
# ===========================================================================
async def _scalar_count(
session: AsyncSession,
expression,
*filters,
) -> int:
"""Shorthand: execute a COUNT query and return the integer scalar."""
stmt = select(expression)
if filters:
for f in filters:
stmt = stmt.where(f)
result = await session.execute(stmt)
return result.scalar_one()
def _sensor_to_dict(s: Sensor) -> dict:
"""Serialize a Sensor model to a flat dictionary."""
d = {
"id": str(s.id),
"name": s.name,
"type": s.type,
"status": s.status,
"latitude": s.latitude,
"longitude": s.longitude,
"zone_id": str(s.zone_id) if s.zone_id else None,
"last_value": s.last_value,
"last_reading_at": s.last_reading_at.isoformat() if s.last_reading_at else None,
"battery_level": s.battery_level,
"created_at": s.created_at.isoformat() if s.created_at else None,
}
if s.zone is not None:
d["zone"] = {
"id": str(s.zone.id),
"name": s.zone.name,
}
return d
def _reading_to_dict(r: SensorReading) -> dict:
"""Serialize a SensorReading model to a dictionary."""
return {
"id": str(r.id),
"sensor_id": str(r.sensor_id),
"value": r.value,
"unit": r.unit,
"quality": r.quality,
"recorded_at": r.recorded_at.isoformat() if r.recorded_at else None,
"created_at": r.created_at.isoformat() if r.created_at else None,
}
def _zone_to_dict(z: Zone) -> dict:
"""Serialize a Zone model to a dictionary."""
return {
"id": str(z.id),
"name": z.name,
"description": z.description,
"color": z.color,
"geojson": z.geojson,
"created_at": z.created_at.isoformat() if z.created_at else None,
}
def _alert_to_dict(a: Alert) -> dict:
"""Serialize an Alert model to a dictionary."""
d = {
"id": str(a.id),
"sensor_id": str(a.sensor_id),
"type": a.type,
"severity": a.severity,
"message": a.message,
"value": a.value,
"threshold": a.threshold,
"status": a.status,
"created_at": a.created_at.isoformat() if a.created_at else None,
"resolved_at": a.resolved_at.isoformat() if a.resolved_at else None,
}
if a.sensor is not None:
d["sensor"] = {
"id": str(a.sensor.id),
"name": a.sensor.name,
"type": a.sensor.type,
}
return d