512 lines
17 KiB
Python
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
|