Files
banban/talkingq-url/banban/routers/devices.py
2026-06-08 14:10:34 +08:00

830 lines
28 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import logging
from collections.abc import Mapping
from datetime import datetime, time, timezone
from fastapi import APIRouter, Depends, HTTPException, Query, Request
from pydantic import BaseModel, Field
from sqlalchemy import text
import asyncio
from datetime import timedelta
from handlers.mqtt_handler import TalkingQMQTTService
from config import settings
from services.connection_manager import connection_manager
try:
from banban.security import get_current_user_id
from banban.schemas.location import (
DeviceLocationCurrentResponse,
DeviceLocationTrajectoryItem,
DeviceLocationTrajectoryResponse,
)
from banban.service.device_alarm import device_alarm_service
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.device_alarm import device_alarm_service
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 DeviceAiConversationItem(BaseModel):
conversation_id: int
role_key: str
role_name: str
role_description: str | None = None
message_count: int
last_message_preview: str | None = None
last_message_at: datetime | None = None
created_at: datetime
updated_at: datetime
class DeviceAiConversationListResponse(BaseModel):
items: list[DeviceAiConversationItem]
total: int
next_cursor: int | None = None
class DeviceStatusResponse(BaseModel):
device_id: str
child_id: int | None = None
child_name: str | None = None
sleep_mode: int | None = None
manual_sleep_mode: int | None = None
schedule_suppressed_until: datetime | None = None
disable_time_start: str | None = None
disable_time_end: str | None = None
timezone: 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 DeviceOnlineStatusResponse(BaseModel):
device_id: str
online: bool
reason: str
message: str
last_location_at: datetime | None = None
checked_at: datetime
class DeviceAlarmItem(BaseModel):
alarm_id: int
device_id: str
child_id: int | None = None
child_name: str | None = None
source_msg_id: str
coord_type: str | None = None
lat: float | None = None
lng: float | None = None
location_updated_at: datetime | None = None
location_stale: bool = False
address: str | None = None
address_resolved_at: datetime | None = None
address_resolve_status: int | None = None
created_at: datetime
class DeviceAlarmListResponse(BaseModel):
items: list[DeviceAlarmItem]
total: int
class DeviceVolumeUpdateRequest(BaseModel):
level: int = Field(ge=0, le=100)
class DeviceVolumeUpdateResponse(BaseModel):
device_id: str
level: int
msg_id: str
time: int
status: str
class DeviceVolumeCommandStatusResponse(BaseModel):
device_id: str
level: int
msg_id: str
time: int
status: str
current_level: int | None = None
response_status: str | None = None
error: str | None = None
class DeviceSleepScheduleUpdateRequest(BaseModel):
start: str = Field(..., pattern=r"^\d{2}:\d{2}$")
end: str = Field(..., pattern=r"^\d{2}:\d{2}$")
timezone: str = Field(default="Asia/Shanghai", min_length=1, max_length=32)
class DeviceSleepScheduleResponse(BaseModel):
device_id: str
sleep_mode: int
start: str | None = None
end: str | None = None
timezone: str
msg_id: str | None = None
class DeviceRemoteSleepWakeRequest(BaseModel):
switch: str = Field(..., pattern=r"^(on|off)$")
class DeviceRemoteSleepWakeResponse(BaseModel):
device_id: str
switch: str
msg_id: str
class DeviceFirmwareStatusResponse(BaseModel):
device_id: str
current_version: str | None = None
latest_version: str | None = None
update_available: bool
can_update: bool
update_status: str
progress: float
target_version: str | None = None
updated_at: datetime | None = None
class DeviceFirmwareUpdateResponse(DeviceFirmwareStatusResponse):
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_ai_conversation_item(row: Mapping) -> DeviceAiConversationItem:
return DeviceAiConversationItem(
conversation_id=int(row["conversation_id"]),
role_key=str(row["role_key"]),
role_name=str(row["role_name"] or row["role_key"]),
role_description=row.get("role_description"),
message_count=int(row["message_count"] or 0),
last_message_preview=row.get("last_message_preview"),
last_message_at=row.get("last_message_at"),
created_at=row["created_at"],
updated_at=row["updated_at"],
)
def _row_to_current_location_response(
row: Mapping,
*,
stale: bool = False,
realtime: bool = True,
) -> 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"],
address=row.get("address"),
address_resolved_at=row.get("address_resolved_at"),
address_resolve_status=int(row["address_resolve_status"]) if row.get("address_resolve_status") is not None else None,
stale=stale,
realtime=realtime,
)
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 _format_time_value(value: object) -> str | None:
if value is None:
return None
if isinstance(value, time):
return value.strftime("%H:%M")
text_value = str(value)
return text_value[:5] if len(text_value) >= 5 else text_value
def _parse_time_value(value: str, field_name: str) -> time:
try:
return datetime.strptime(value, "%H:%M").time()
except ValueError as exc:
raise HTTPException(status_code=422, detail=f"{field_name} must use HH:MM format") from exc
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"),
sleep_mode=row.get("sleep_mode"),
manual_sleep_mode=row.get("manual_sleep_mode"),
schedule_suppressed_until=row.get("schedule_suppressed_until"),
disable_time_start=_format_time_value(row.get("disable_time_start")),
disable_time_end=_format_time_value(row.get("disable_time_end")),
timezone=row.get("timezone"),
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"],
)
def _seconds_since(value: datetime | None, now: datetime) -> float | None:
if value is None:
return None
normalized_value = value
normalized_now = now
if normalized_value.tzinfo is not None and normalized_now.tzinfo is None:
normalized_now = normalized_now.replace(tzinfo=timezone.utc)
elif normalized_value.tzinfo is None and normalized_now.tzinfo is not None:
normalized_value = normalized_value.replace(tzinfo=normalized_now.tzinfo)
return (normalized_now - normalized_value).total_seconds()
def _row_to_alarm_item(row: Mapping) -> DeviceAlarmItem:
location_updated_at = row.get("location_updated_at")
created_at = row["created_at"]
location_stale = False
if location_updated_at is not None:
location_age_seconds = _seconds_between(created_at, location_updated_at)
location_stale = (
location_age_seconds is None
or abs(location_age_seconds) > settings.alarm_location_stale_seconds
)
return DeviceAlarmItem(
alarm_id=int(row["alarm_id"]),
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"),
source_msg_id=str(row["source_msg_id"]),
coord_type=row.get("coord_type"),
lat=float(row["lat"]) if row.get("lat") is not None else None,
lng=float(row["lng"]) if row.get("lng") is not None else None,
location_updated_at=location_updated_at,
location_stale=location_stale,
address=row.get("address"),
address_resolved_at=row.get("address_resolved_at"),
address_resolve_status=int(row["address_resolve_status"]) if row.get("address_resolve_status") is not None else None,
created_at=created_at,
)
def _seconds_between(later: datetime | None, earlier: datetime | None) -> float | None:
if later is None or earlier is None:
return None
normalized_later = later
normalized_earlier = earlier
if normalized_later.tzinfo is not None and normalized_earlier.tzinfo is None:
normalized_earlier = normalized_earlier.replace(tzinfo=normalized_later.tzinfo)
elif normalized_later.tzinfo is None and normalized_earlier.tzinfo is not None:
normalized_later = normalized_later.replace(tzinfo=normalized_earlier.tzinfo)
return (normalized_later - normalized_earlier).total_seconds()
@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}/ai-conversations", response_model=DeviceAiConversationListResponse)
async def list_device_ai_conversations(
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),
) -> DeviceAiConversationListResponse:
rows = await device_service.list_device_ai_conversations(
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]["conversation_id"]) if has_more and rows else None
logger.info(
"listed device ai conversations",
extra={
"event": "device_ai_conversations",
"request_id": getattr(request.state, "request_id", None),
"user_id": current_user_id,
"device_id": device_id,
"returned_count": len(rows),
},
)
return DeviceAiConversationListResponse(
items=[_row_to_ai_conversation_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.get("/{device_id}/online-status", response_model=DeviceOnlineStatusResponse)
async def get_device_online_status(
device_id: str,
request: Request,
current_user_id: int = Depends(get_current_user_id),
) -> DeviceOnlineStatusResponse:
row = await device_service.get_device_status(
device_id=device_id,
user_id=current_user_id,
)
checked_at = datetime.now()
websocket_online = await connection_manager.is_connected(device_id)
last_location_at = row.get("location_updated_at")
location_age_seconds = _seconds_since(last_location_at, checked_at)
recent_location_online = location_age_seconds is not None and location_age_seconds <= 300
online = bool(websocket_online or recent_location_online)
if websocket_online:
reason = "websocket_connected"
message = "设备在线"
elif recent_location_online:
reason = "recent_location"
message = "设备最近有位置上报"
elif last_location_at is None:
reason = "no_location"
message = "设备不在线或暂时无法上报位置"
else:
reason = "location_stale"
message = "设备不在线或暂时无法上报位置"
logger.info(
"device online status fetched",
extra={
"event": "device_online_status",
"request_id": getattr(request.state, "request_id", None),
"user_id": current_user_id,
"device_id": device_id,
"online": online,
"reason": reason,
"last_location_at": last_location_at,
},
)
return DeviceOnlineStatusResponse(
device_id=device_id,
online=online,
reason=reason,
message=message,
last_location_at=last_location_at,
checked_at=checked_at,
)
@router.get("/{device_id}/alarms", response_model=DeviceAlarmListResponse)
async def list_device_alarms(
device_id: str,
request: Request,
limit: int = Query(default=20, ge=1, le=100),
current_user_id: int = Depends(get_current_user_id),
) -> DeviceAlarmListResponse:
await device_service.ensure_device_access(device_id=device_id, user_id=current_user_id)
rows = await device_alarm_service.list_device_alarms(
device_id=device_id,
limit=limit,
)
logger.info(
"device alarms fetched",
extra={
"event": "device_alarm_list",
"request_id": getattr(request.state, "request_id", None),
"user_id": current_user_id,
"device_id": device_id,
"returned_count": len(rows),
},
)
return DeviceAlarmListResponse(
items=[_row_to_alarm_item(row) for row in rows],
total=len(rows),
)
@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:
command_state = 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": command_state["msg_id"],
"time": command_state["time"],
"status": command_state["status"],
},
)
return DeviceVolumeUpdateResponse(
device_id=device_id,
level=payload.level,
msg_id=command_state["msg_id"],
time=command_state["time"],
status=command_state["status"],
)
@router.get("/{device_id}/volume/{command_time}", response_model=DeviceVolumeCommandStatusResponse)
async def get_device_volume_command_status(
device_id: str,
command_time: int,
current_user_id: int = Depends(get_current_user_id),
) -> DeviceVolumeCommandStatusResponse:
command_state = await device_service.get_device_volume_command_status(
device_id=device_id,
user_id=current_user_id,
command_time=command_time,
)
if not command_state:
raise HTTPException(status_code=404, detail="volume command not found")
return DeviceVolumeCommandStatusResponse(**command_state)
@router.post("/{device_id}/sleep-schedule", response_model=DeviceSleepScheduleResponse)
async def set_device_sleep_schedule(
device_id: str,
payload: DeviceSleepScheduleUpdateRequest,
request: Request,
current_user_id: int = Depends(get_current_user_id),
) -> DeviceSleepScheduleResponse:
start_time = _parse_time_value(payload.start, "start")
end_time = _parse_time_value(payload.end, "end")
msg_id = await device_service.set_sleep_schedule(
device_id=device_id,
user_id=current_user_id,
start=start_time,
end=end_time,
timezone=payload.timezone,
)
logger.info(
"device sleep schedule command sent",
extra={
"event": "device_sleep_schedule_set",
"request_id": getattr(request.state, "request_id", None),
"user_id": current_user_id,
"device_id": device_id,
"start": payload.start,
"end": payload.end,
"timezone": payload.timezone,
"msg_id": msg_id,
},
)
return DeviceSleepScheduleResponse(
device_id=device_id,
sleep_mode=1,
start=payload.start,
end=payload.end,
timezone=payload.timezone,
msg_id=msg_id,
)
@router.post("/{device_id}/sleep-wake", response_model=DeviceRemoteSleepWakeResponse)
async def set_device_remote_sleep_wake(
device_id: str,
payload: DeviceRemoteSleepWakeRequest,
request: Request,
current_user_id: int = Depends(get_current_user_id),
) -> DeviceRemoteSleepWakeResponse:
msg_id = await device_service.set_remote_sleep_wake(
device_id=device_id,
user_id=current_user_id,
switch=payload.switch,
)
logger.info(
"device remote sleep wake command sent",
extra={
"event": "device_remote_sleep_wake_set",
"request_id": getattr(request.state, "request_id", None),
"user_id": current_user_id,
"device_id": device_id,
"switch": payload.switch,
"msg_id": msg_id,
},
)
return DeviceRemoteSleepWakeResponse(
device_id=device_id,
switch=payload.switch,
msg_id=msg_id,
)
@router.get("/{device_id}/firmware", response_model=DeviceFirmwareStatusResponse)
async def get_device_firmware_status(
device_id: str,
request: Request,
current_user_id: int = Depends(get_current_user_id),
) -> DeviceFirmwareStatusResponse:
result = await device_service.get_firmware_status(
device_id=device_id,
user_id=current_user_id,
)
logger.info(
"device firmware status fetched",
extra={
"event": "device_firmware_status",
"request_id": getattr(request.state, "request_id", None),
"user_id": current_user_id,
"device_id": device_id,
"update_available": result["update_available"],
"update_status": result["update_status"],
},
)
return DeviceFirmwareStatusResponse(**result)
@router.post("/{device_id}/firmware/update", response_model=DeviceFirmwareUpdateResponse)
async def start_device_firmware_update(
device_id: str,
request: Request,
current_user_id: int = Depends(get_current_user_id),
) -> DeviceFirmwareUpdateResponse:
result = await device_service.start_firmware_update(
device_id=device_id,
user_id=current_user_id,
)
logger.info(
"device firmware update command sent",
extra={
"event": "device_firmware_update",
"request_id": getattr(request.state, "request_id", None),
"user_id": current_user_id,
"device_id": device_id,
"target_version": result["target_version"],
"msg_id": result["msg_id"],
},
)
return DeviceFirmwareUpdateResponse(**result)
@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:
# 每次进入小程序地图页面获取最新位置坐标等待设备上报GPS数据再查询数据库坐标
service = await TalkingQMQTTService.get_instance()
if service is None:
raise HTTPException(status_code=503, detail="MQTT 服务未初始化")
query_started_at = datetime.now()
await service.send_gps_query(device_id)
try:
# MQTT 设备没有 WebSocket 连接时,也可以通过 GPS 回包刷新定位。
row = None
for _ in range(3):
try:
row = await location_service.get_device_current_location(
device_id=device_id,
user_id=current_user_id,
)
except Exception as e:
await asyncio.sleep(1)
continue
if row is not None and row["updated_at"] is not None:
updated_at = row["updated_at"]
if updated_at >= query_started_at - timedelta(seconds=1):
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)
await asyncio.sleep(1)
if row is not None:
logger.info(
"device stale location returned",
extra={
"event": "device_stale_location",
"request_id": getattr(request.state, "request_id", None),
"user_id": current_user_id,
"device_id": device_id,
"child_id": int(row["child_id"]),
"location_updated_at": row.get("updated_at"),
},
)
return _row_to_current_location_response(row, stale=True, realtime=False)
raise HTTPException(status_code=408, detail="GPS数据上报超时")
except Exception as e:
raise HTTPException(status_code=408, detail=f"GPS数据上报失败: {e}")
@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,
)