Phase 5 Complete by Claude Code
This commit is contained in:
@@ -1 +1,151 @@
|
||||
# TODO: Command endpoints + WebSocket for live data
|
||||
from fastapi import APIRouter, Depends, Query, WebSocket, WebSocketDisconnect
|
||||
from typing import Optional
|
||||
from auth.models import TokenPayload
|
||||
from auth.dependencies import require_device_access, require_viewer
|
||||
from mqtt.models import (
|
||||
MqttCommandRequest, CommandSendResponse, MqttStatusResponse,
|
||||
DeviceMqttStatus, LogListResponse, HeartbeatListResponse,
|
||||
CommandListResponse, HeartbeatEntry,
|
||||
)
|
||||
from mqtt.client import mqtt_manager
|
||||
from mqtt import database as db
|
||||
from datetime import datetime, timezone
|
||||
|
||||
router = APIRouter(prefix="/api/mqtt", tags=["mqtt"])
|
||||
|
||||
|
||||
@router.get("/status", response_model=MqttStatusResponse)
|
||||
async def get_all_device_status(
|
||||
_user: TokenPayload = Depends(require_viewer),
|
||||
):
|
||||
heartbeats = await db.get_latest_heartbeats()
|
||||
now = datetime.now(timezone.utc)
|
||||
devices = []
|
||||
for hb in heartbeats:
|
||||
received_str = hb["received_at"]
|
||||
try:
|
||||
received = datetime.fromisoformat(received_str)
|
||||
if received.tzinfo is None:
|
||||
received = received.replace(tzinfo=timezone.utc)
|
||||
seconds_ago = int((now - received).total_seconds())
|
||||
except (ValueError, TypeError):
|
||||
seconds_ago = 9999
|
||||
|
||||
devices.append(DeviceMqttStatus(
|
||||
device_serial=hb["device_serial"],
|
||||
online=seconds_ago < 90,
|
||||
last_heartbeat=HeartbeatEntry(**hb),
|
||||
seconds_since_heartbeat=seconds_ago,
|
||||
))
|
||||
return MqttStatusResponse(
|
||||
devices=devices,
|
||||
broker_connected=mqtt_manager.connected,
|
||||
)
|
||||
|
||||
|
||||
@router.post("/command/{device_serial}", response_model=CommandSendResponse)
|
||||
async def send_command(
|
||||
device_serial: str,
|
||||
body: MqttCommandRequest,
|
||||
_user: TokenPayload = Depends(require_device_access),
|
||||
):
|
||||
command_id = await db.insert_command(
|
||||
device_serial=device_serial,
|
||||
command_name=body.cmd,
|
||||
command_payload={"cmd": body.cmd, "contents": body.contents},
|
||||
)
|
||||
|
||||
success = mqtt_manager.publish_command(
|
||||
device_serial=device_serial,
|
||||
cmd=body.cmd,
|
||||
contents=body.contents,
|
||||
)
|
||||
|
||||
if not success:
|
||||
await db.update_command_response(
|
||||
command_id, "error",
|
||||
{"error": "MQTT broker not connected"},
|
||||
)
|
||||
return CommandSendResponse(
|
||||
success=False, command_id=command_id,
|
||||
message="MQTT broker not connected",
|
||||
)
|
||||
|
||||
return CommandSendResponse(
|
||||
success=True, command_id=command_id,
|
||||
message=f"Command '{body.cmd}' sent to {device_serial}",
|
||||
)
|
||||
|
||||
|
||||
@router.get("/logs/{device_serial}", response_model=LogListResponse)
|
||||
async def get_device_logs(
|
||||
device_serial: str,
|
||||
level: Optional[str] = Query(None, description="Filter: INFO, WARN, ERROR"),
|
||||
search: Optional[str] = Query(None),
|
||||
limit: int = Query(100, ge=1, le=1000),
|
||||
offset: int = Query(0, ge=0),
|
||||
_user: TokenPayload = Depends(require_viewer),
|
||||
):
|
||||
logs, total = await db.get_logs(
|
||||
device_serial, level=level, search=search,
|
||||
limit=limit, offset=offset,
|
||||
)
|
||||
return LogListResponse(logs=logs, total=total)
|
||||
|
||||
|
||||
@router.get("/heartbeats/{device_serial}", response_model=HeartbeatListResponse)
|
||||
async def get_device_heartbeats(
|
||||
device_serial: str,
|
||||
limit: int = Query(100, ge=1, le=1000),
|
||||
offset: int = Query(0, ge=0),
|
||||
_user: TokenPayload = Depends(require_viewer),
|
||||
):
|
||||
heartbeats, total = await db.get_heartbeats(
|
||||
device_serial, limit=limit, offset=offset,
|
||||
)
|
||||
return HeartbeatListResponse(heartbeats=heartbeats, total=total)
|
||||
|
||||
|
||||
@router.get("/commands/{device_serial}", response_model=CommandListResponse)
|
||||
async def get_device_commands(
|
||||
device_serial: str,
|
||||
limit: int = Query(100, ge=1, le=1000),
|
||||
offset: int = Query(0, ge=0),
|
||||
_user: TokenPayload = Depends(require_viewer),
|
||||
):
|
||||
commands, total = await db.get_commands(
|
||||
device_serial, limit=limit, offset=offset,
|
||||
)
|
||||
return CommandListResponse(commands=commands, total=total)
|
||||
|
||||
|
||||
@router.websocket("/ws")
|
||||
async def mqtt_websocket(websocket: WebSocket):
|
||||
"""Live MQTT data stream. Auth via query param: ?token=JWT"""
|
||||
token = websocket.query_params.get("token")
|
||||
if not token:
|
||||
await websocket.close(code=4001, reason="Missing token")
|
||||
return
|
||||
|
||||
try:
|
||||
from auth.utils import decode_access_token
|
||||
payload = decode_access_token(token)
|
||||
role = payload.get("role", "")
|
||||
allowed = {"superadmin", "device_manager", "viewer"}
|
||||
if role not in allowed:
|
||||
await websocket.close(code=4003, reason="Insufficient permissions")
|
||||
return
|
||||
except Exception:
|
||||
await websocket.close(code=4001, reason="Invalid token")
|
||||
return
|
||||
|
||||
await websocket.accept()
|
||||
mqtt_manager.add_ws_subscriber(websocket)
|
||||
|
||||
try:
|
||||
while True:
|
||||
await websocket.receive_text()
|
||||
except WebSocketDisconnect:
|
||||
pass
|
||||
finally:
|
||||
mqtt_manager.remove_ws_subscriber(websocket)
|
||||
|
||||
Reference in New Issue
Block a user