316 lines
10 KiB
Python
316 lines
10 KiB
Python
import logging
|
|
from collections.abc import Mapping
|
|
from datetime import datetime
|
|
|
|
from fastapi import APIRouter, Depends, HTTPException, Query, Request
|
|
from pydantic import BaseModel, Field
|
|
from sqlalchemy import text
|
|
|
|
try:
|
|
from banban.security import get_current_user_id
|
|
from banban.schemas.location import (
|
|
DeviceLocationCurrentResponse,
|
|
DeviceLocationTrajectoryItem,
|
|
DeviceLocationTrajectoryResponse,
|
|
)
|
|
from banban.service.location import location_service
|
|
from banban.service.device import device_service
|
|
except ModuleNotFoundError:
|
|
from banban.security import get_current_user_id
|
|
from banban.schemas.location import (
|
|
DeviceLocationCurrentResponse,
|
|
DeviceLocationTrajectoryItem,
|
|
DeviceLocationTrajectoryResponse,
|
|
)
|
|
from banban.service.location import location_service
|
|
from banban.service.device import device_service
|
|
|
|
|
|
router = APIRouter(prefix="/devices", tags=["devices"])
|
|
logger = logging.getLogger("app.devices")
|
|
|
|
|
|
class DeviceMessageItem(BaseModel):
|
|
id: int
|
|
conversation_id: int
|
|
role_key: str
|
|
is_user: bool
|
|
speaker: str
|
|
content: str
|
|
timestamp: float
|
|
created_at: datetime
|
|
|
|
|
|
class DeviceMessageListResponse(BaseModel):
|
|
items: list[DeviceMessageItem]
|
|
total: int
|
|
next_cursor: int | None = None
|
|
|
|
|
|
class DeviceStatusResponse(BaseModel):
|
|
device_id: str
|
|
child_id: int | None = None
|
|
child_name: str | None = None
|
|
power: int | None = None
|
|
volume: int | None = None
|
|
signal: int | None = None
|
|
version: str | None = None
|
|
settings_updated_at: datetime | None = None
|
|
coord_type: str | None = None
|
|
lat: float | None = None
|
|
lng: float | None = None
|
|
accuracy_m: int | None = None
|
|
altitude_m: float | None = None
|
|
speed_mps: float | None = None
|
|
heading_deg: int | None = None
|
|
source: int | None = None
|
|
battery_pct: int | None = None
|
|
device_time: datetime | None = None
|
|
server_time: datetime | None = None
|
|
location_updated_at: datetime | None = None
|
|
|
|
|
|
class DeviceVolumeUpdateRequest(BaseModel):
|
|
level: int = Field(ge=0, le=100)
|
|
|
|
|
|
class DeviceVolumeUpdateResponse(BaseModel):
|
|
device_id: str
|
|
level: int
|
|
msg_id: str
|
|
|
|
|
|
|
|
|
|
|
|
def _row_to_message_item(row: Mapping) -> DeviceMessageItem:
|
|
is_user = bool(row["is_user"])
|
|
return DeviceMessageItem(
|
|
id=int(row["id"]),
|
|
conversation_id=int(row["conversation_id"]),
|
|
role_key=str(row["role_key"]),
|
|
is_user=is_user,
|
|
speaker="user" if is_user else "assistant",
|
|
content=str(row["content"]),
|
|
timestamp=float(row["timestamp"]),
|
|
created_at=row["created_at"],
|
|
)
|
|
|
|
|
|
def _row_to_current_location_response(row: Mapping) -> DeviceLocationCurrentResponse:
|
|
return DeviceLocationCurrentResponse(
|
|
child_id=int(row["child_id"]),
|
|
child_name=row.get("child_name"),
|
|
device_id=str(row["device_id"]),
|
|
coord_type=str(row["coord_type"]),
|
|
lat=float(row["lat"]),
|
|
lng=float(row["lng"]),
|
|
accuracy_m=row["accuracy_m"],
|
|
altitude_m=float(row["altitude_m"]) if row["altitude_m"] is not None else None,
|
|
speed_mps=float(row["speed_mps"]) if row["speed_mps"] is not None else None,
|
|
heading_deg=row["heading_deg"],
|
|
source=int(row["source"]),
|
|
battery_pct=row["battery_pct"],
|
|
device_time=row["device_time"],
|
|
server_time=row["server_time"],
|
|
updated_at=row["updated_at"],
|
|
)
|
|
|
|
|
|
def _row_to_trajectory_item(row: Mapping, *, child_name: str | None) -> DeviceLocationTrajectoryItem:
|
|
return DeviceLocationTrajectoryItem(
|
|
id=int(row["id"]),
|
|
child_id=int(row["child_id"]),
|
|
child_name=child_name,
|
|
device_id=str(row["device_id"]),
|
|
coord_type=str(row["coord_type"]),
|
|
lat=float(row["lat"]),
|
|
lng=float(row["lng"]),
|
|
accuracy_m=row["accuracy_m"],
|
|
altitude_m=float(row["altitude_m"]) if row["altitude_m"] is not None else None,
|
|
speed_mps=float(row["speed_mps"]) if row["speed_mps"] is not None else None,
|
|
heading_deg=row["heading_deg"],
|
|
source=int(row["source"]),
|
|
battery_pct=row["battery_pct"],
|
|
device_time=row["device_time"],
|
|
server_time=row["server_time"],
|
|
created_at=row["created_at"],
|
|
)
|
|
|
|
|
|
def _row_to_device_status_response(row: Mapping) -> DeviceStatusResponse:
|
|
return DeviceStatusResponse(
|
|
device_id=str(row["device_id"]),
|
|
child_id=int(row["child_id"]) if row["child_id"] is not None else None,
|
|
child_name=row.get("child_name"),
|
|
power=row["power"],
|
|
volume=row["volume"],
|
|
signal=row["signal_strength"],
|
|
version=row["version"],
|
|
settings_updated_at=row["settings_updated_at"],
|
|
coord_type=row["coord_type"],
|
|
lat=float(row["lat"]) if row["lat"] is not None else None,
|
|
lng=float(row["lng"]) if row["lng"] is not None else None,
|
|
accuracy_m=row["accuracy_m"],
|
|
altitude_m=float(row["altitude_m"]) if row["altitude_m"] is not None else None,
|
|
speed_mps=float(row["speed_mps"]) if row["speed_mps"] is not None else None,
|
|
heading_deg=row["heading_deg"],
|
|
source=int(row["source"]) if row["source"] is not None else None,
|
|
battery_pct=row["battery_pct"],
|
|
device_time=row["device_time"],
|
|
server_time=row["server_time"],
|
|
location_updated_at=row["location_updated_at"],
|
|
)
|
|
|
|
|
|
@router.get("/{device_id}/messages", response_model=DeviceMessageListResponse)
|
|
async def list_device_messages(
|
|
device_id: str,
|
|
request: Request,
|
|
cursor: int | None = Query(default=None, ge=1),
|
|
limit: int = Query(default=20, ge=1, le=100),
|
|
current_user_id: int = Depends(get_current_user_id),
|
|
) -> DeviceMessageListResponse:
|
|
rows = await device_service.list_device_messages(
|
|
device_id=device_id,
|
|
user_id=current_user_id,
|
|
cursor=cursor,
|
|
limit=limit,
|
|
)
|
|
|
|
has_more = len(rows) > limit
|
|
rows = rows[:limit]
|
|
next_cursor = int(rows[-1]["id"]) if has_more and rows else None
|
|
|
|
logger.info(
|
|
"listed device ai messages",
|
|
extra={
|
|
"event": "device_messages",
|
|
"request_id": getattr(request.state, "request_id", None),
|
|
"user_id": current_user_id,
|
|
"device_id": device_id,
|
|
"returned_count": len(rows),
|
|
},
|
|
)
|
|
|
|
return DeviceMessageListResponse(
|
|
items=[_row_to_message_item(row) for row in rows],
|
|
total=len(rows),
|
|
next_cursor=next_cursor,
|
|
)
|
|
|
|
|
|
@router.get("/{device_id}/status", response_model=DeviceStatusResponse)
|
|
async def get_device_status(
|
|
device_id: str,
|
|
request: Request,
|
|
current_user_id: int = Depends(get_current_user_id),
|
|
) -> DeviceStatusResponse:
|
|
row = await device_service.get_device_status(
|
|
device_id=device_id,
|
|
user_id=current_user_id,
|
|
)
|
|
|
|
logger.info(
|
|
"device status fetched",
|
|
extra={
|
|
"event": "device_status",
|
|
"request_id": getattr(request.state, "request_id", None),
|
|
"user_id": current_user_id,
|
|
"device_id": device_id,
|
|
"child_id": row["child_id"],
|
|
},
|
|
)
|
|
return _row_to_device_status_response(row)
|
|
|
|
|
|
@router.post("/{device_id}/volume", response_model=DeviceVolumeUpdateResponse)
|
|
async def set_device_volume(
|
|
device_id: str,
|
|
payload: DeviceVolumeUpdateRequest,
|
|
request: Request,
|
|
current_user_id: int = Depends(get_current_user_id),
|
|
) -> DeviceVolumeUpdateResponse:
|
|
msg_id = await device_service.set_device_volume(
|
|
device_id=device_id,
|
|
user_id=current_user_id,
|
|
level=payload.level,
|
|
)
|
|
|
|
logger.info(
|
|
"device volume command sent",
|
|
extra={
|
|
"event": "device_volume_set",
|
|
"request_id": getattr(request.state, "request_id", None),
|
|
"user_id": current_user_id,
|
|
"device_id": device_id,
|
|
"level": payload.level,
|
|
"msg_id": msg_id,
|
|
},
|
|
)
|
|
return DeviceVolumeUpdateResponse(device_id=device_id, level=payload.level, msg_id=msg_id)
|
|
|
|
|
|
@router.get("/{device_id}/location", response_model=DeviceLocationCurrentResponse)
|
|
async def get_current_device_location(
|
|
device_id: str,
|
|
request: Request,
|
|
current_user_id: int = Depends(get_current_user_id),
|
|
) -> DeviceLocationCurrentResponse:
|
|
row = await location_service.get_device_current_location(
|
|
device_id=device_id,
|
|
user_id=current_user_id,
|
|
)
|
|
|
|
logger.info(
|
|
"device current location fetched",
|
|
extra={
|
|
"event": "device_current_location",
|
|
"request_id": getattr(request.state, "request_id", None),
|
|
"user_id": current_user_id,
|
|
"device_id": device_id,
|
|
"child_id": int(row["child_id"]),
|
|
},
|
|
)
|
|
return _row_to_current_location_response(row)
|
|
|
|
|
|
@router.get("/{device_id}/trajectory", response_model=DeviceLocationTrajectoryResponse)
|
|
async def get_device_location_trajectory(
|
|
device_id: str,
|
|
request: Request,
|
|
start_at: datetime | None = Query(default=None),
|
|
end_at: datetime | None = Query(default=None),
|
|
limit: int = Query(default=200, ge=1, le=1000),
|
|
current_user_id: int = Depends(get_current_user_id),
|
|
) -> DeviceLocationTrajectoryResponse:
|
|
if start_at and end_at and start_at > end_at:
|
|
raise HTTPException(status_code=422, detail="start_at must be earlier than end_at")
|
|
|
|
access, rows = await location_service.get_device_trajectory(
|
|
device_id=device_id,
|
|
user_id=current_user_id,
|
|
start_at=start_at,
|
|
end_at=end_at,
|
|
limit=limit,
|
|
)
|
|
|
|
logger.info(
|
|
"device trajectory fetched",
|
|
extra={
|
|
"event": "device_trajectory",
|
|
"request_id": getattr(request.state, "request_id", None),
|
|
"user_id": current_user_id,
|
|
"device_id": device_id,
|
|
"child_id": access.child_id,
|
|
"count": len(rows),
|
|
},
|
|
)
|
|
|
|
return DeviceLocationTrajectoryResponse(
|
|
items=[_row_to_trajectory_item(row, child_name=access.child_name) for row in rows],
|
|
total=len(rows),
|
|
start_at=start_at,
|
|
end_at=end_at,
|
|
)
|