830 lines
28 KiB
Python
830 lines
28 KiB
Python
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,
|
||
)
|