""" Commands Router - 指令管理接口 API endpoints for sending commands / messages to devices and viewing command history. """ import logging import math from app.config import now_cst from fastapi import APIRouter, Depends, HTTPException, Query, Request from pydantic import BaseModel, Field from sqlalchemy.ext.asyncio import AsyncSession from app.database import get_db from app.config import settings from app.extensions import limiter from app.schemas import ( APIResponse, BatchCommandRequest, BatchCommandResponse, BatchCommandResult, CommandResponse, PaginatedList, ) from app.dependencies import require_write from app.services import command_service, device_service from app.services import tcp_command_service router = APIRouter(prefix="/api/commands", tags=["Commands / 指令管理"]) # --------------------------------------------------------------------------- # Request schemas specific to this router # --------------------------------------------------------------------------- class SendCommandRequest(BaseModel): """Request body for sending a command to a device.""" device_id: int | None = Field(None, description="设备ID / Device ID (provide device_id or imei)") imei: str | None = Field(None, description="IMEI号 / IMEI number (provide device_id or imei)") command_type: str = Field(..., max_length=30, description="指令类型 / Command type (e.g. online_cmd)") command_content: str = Field(..., max_length=500, description="指令内容 / Command content") class SendMessageRequest(BaseModel): """Request body for sending a message (0x82) to a device.""" device_id: int | None = Field(None, description="设备ID / Device ID (provide device_id or imei)") imei: str | None = Field(None, description="IMEI号 / IMEI number (provide device_id or imei)") message: str = Field(..., max_length=500, description="消息内容 / Message content") class SendTTSRequest(BaseModel): """Request body for sending a TTS voice broadcast to a device.""" device_id: int | None = Field(None, description="设备ID / Device ID (provide device_id or imei)") imei: str | None = Field(None, description="IMEI号 / IMEI number (provide device_id or imei)") text: str = Field(..., min_length=1, max_length=200, description="语音播报文本 / TTS text content") # --------------------------------------------------------------------------- # Helpers # --------------------------------------------------------------------------- async def _resolve_device( db: AsyncSession, device_id: int | None, imei: str | None, ): """Resolve a device from either device_id or imei. Returns the Device ORM instance.""" if device_id is None and imei is None: raise HTTPException( status_code=400, detail="Either device_id or imei must be provided / 必须提供 device_id 或 imei", ) if device_id is not None: device = await device_service.get_device(db, device_id) else: device = await device_service.get_device_by_imei(db, imei) if device is None: identifier = f"ID={device_id}" if device_id else f"IMEI={imei}" raise HTTPException( status_code=404, detail=f"Device {identifier} not found / 未找到设备 {identifier}", ) return device async def _send_to_device( db: AsyncSession, device, command_type: str, command_content: str, executor, success_msg: str, fail_msg: str, ): """Common logic for sending command/message/tts to a device via TCP. Parameters ---------- executor : async callable The actual send function, e.g. tcp_command_service.send_command(...) """ command_log = await command_service.create_command( db, device_id=device.id, command_type=command_type, command_content=command_content, ) try: result = await executor() except Exception as e: logging.getLogger(__name__).error("Command send failed: %s", e) command_log.status = "failed" await db.flush() await db.refresh(command_log) raise HTTPException( status_code=500, detail=fail_msg, ) if result is False: command_log.status = "failed" await db.flush() await db.refresh(command_log) raise HTTPException( status_code=400, detail=f"Device {device.imei} TCP not connected / 设备 {device.imei} TCP未连接,请等待设备重连", ) command_log.status = "sent" command_log.sent_at = now_cst() await db.flush() await db.refresh(command_log) return APIResponse( message=success_msg, data=CommandResponse.model_validate(command_log), ) # --------------------------------------------------------------------------- # Endpoints # --------------------------------------------------------------------------- @router.get( "", response_model=APIResponse[PaginatedList[CommandResponse]], summary="获取指令历史 / List command history", ) async def list_commands( device_id: int | None = Query(default=None, description="设备ID / Device ID"), command_type: str | None = Query(default=None, description="指令类型 / Command type (online_cmd/message/tts)"), status: str | None = Query(default=None, description="指令状态 / Command status (pending/sent/success/failed)"), page: int = Query(default=1, ge=1, description="页码 / Page number"), page_size: int = Query(default=20, ge=1, le=100, description="每页数量 / Items per page"), db: AsyncSession = Depends(get_db), ): """ 获取指令历史记录,支持按设备、指令类型和状态过滤。 List command history with optional device, command type and status filters. """ commands, total = await command_service.get_commands( db, device_id=device_id, command_type=command_type, status=status, page=page, page_size=page_size ) return APIResponse( data=PaginatedList( items=[CommandResponse.model_validate(c) for c in commands], total=total, page=page, page_size=page_size, total_pages=math.ceil(total / page_size) if total else 0, ) ) @router.post( "/send", response_model=APIResponse[CommandResponse], status_code=201, summary="发送指令 / Send command to device", dependencies=[Depends(require_write)], ) async def send_command(body: SendCommandRequest, db: AsyncSession = Depends(get_db)): """ 向设备发送指令(通过TCP连接下发)。 Send a command to a device via the TCP connection. Requires the device to be online. """ device = await _resolve_device(db, body.device_id, body.imei) return await _send_to_device( db, device, command_type=body.command_type, command_content=body.command_content, executor=lambda: tcp_command_service.send_command( device.imei, body.command_type, body.command_content ), success_msg="Command sent successfully / 指令发送成功", fail_msg="Failed to send command / 指令发送失败", ) @router.post( "/message", response_model=APIResponse[CommandResponse], status_code=201, summary="发送留言 / Send message to device (0x82)", dependencies=[Depends(require_write)], ) async def send_message(body: SendMessageRequest, db: AsyncSession = Depends(get_db)): """ 向设备发送留言消息(协议号 0x82)。 Send a text message to a device using protocol 0x82. """ device = await _resolve_device(db, body.device_id, body.imei) return await _send_to_device( db, device, command_type="message", command_content=body.message, executor=lambda: tcp_command_service.send_message(device.imei, body.message), success_msg="Message sent successfully / 留言发送成功", fail_msg="Failed to send message / 留言发送失败", ) @router.post( "/tts", response_model=APIResponse[CommandResponse], status_code=201, summary="语音下发 / Send TTS voice broadcast to device", dependencies=[Depends(require_write)], ) async def send_tts(body: SendTTSRequest, db: AsyncSession = Depends(get_db)): """ 向设备发送 TTS 语音播报(通过 0x80 在线指令,TTS 命令格式)。 Send a TTS voice broadcast to a device via online command (0x80). The device will use its built-in TTS engine to speak the text aloud. """ device = await _resolve_device(db, body.device_id, body.imei) tts_command = f"TTS,{body.text}" return await _send_to_device( db, device, command_type="tts", command_content=tts_command, executor=lambda: tcp_command_service.send_command( device.imei, "tts", tts_command ), success_msg="TTS sent successfully / 语音下发成功", fail_msg="Failed to send TTS / 语音下发失败", ) @router.post( "/batch", response_model=APIResponse[BatchCommandResponse], status_code=201, summary="批量发送指令 / Batch send command to multiple devices", dependencies=[Depends(require_write)], ) @limiter.limit(settings.RATE_LIMIT_WRITE) async def batch_send_command(request: Request, body: BatchCommandRequest, db: AsyncSession = Depends(get_db)): """ 向多台设备批量发送同一指令,最多100台。 Send the same command to multiple devices (up to 100). Skips offline devices. """ # Resolve devices in single query (mutual exclusion validated by schema) if body.device_ids: devices = await device_service.get_devices_by_ids(db, body.device_ids) else: devices = await device_service.get_devices_by_imeis(db, body.imeis) results = [] for device in devices: if not tcp_command_service.is_device_online(device.imei): results.append(BatchCommandResult( device_id=device.id, imei=device.imei, success=False, error="Device offline", )) continue try: cmd_log = await command_service.create_command( db, device_id=device.id, command_type=body.command_type, command_content=body.command_content, ) await tcp_command_service.send_command( device.imei, body.command_type, body.command_content ) cmd_log.status = "sent" cmd_log.sent_at = now_cst() await db.flush() await db.refresh(cmd_log) results.append(BatchCommandResult( device_id=device.id, imei=device.imei, success=True, command_id=cmd_log.id, )) except Exception as e: logging.getLogger(__name__).error("Batch cmd failed for %s: %s", device.imei, e) results.append(BatchCommandResult( device_id=device.id, imei=device.imei, success=False, error="Send failed", )) sent = sum(1 for r in results if r.success) failed = len(results) - sent return APIResponse( message=f"Batch command: {sent} sent, {failed} failed", data=BatchCommandResponse( total=len(results), sent=sent, failed=failed, results=results, ), ) class BroadcastCommandRequest(BaseModel): """Request body for broadcasting a command to all devices.""" command_type: str = Field(default="online_cmd", max_length=30, description="指令类型") command_content: str = Field(..., max_length=500, description="指令内容") @router.post( "/broadcast", response_model=APIResponse[BatchCommandResponse], status_code=201, summary="广播指令给所有设备 / Broadcast command to all devices", dependencies=[Depends(require_write)], ) @limiter.limit(settings.RATE_LIMIT_WRITE) async def broadcast_command(request: Request, body: BroadcastCommandRequest, db: AsyncSession = Depends(get_db)): """ 向所有设备广播同一指令。尝试通过 TCP 发送给每台设备,TCP 未连接的自动跳过。 Broadcast the same command to all devices. Attempts TCP send for each, skips those without active TCP connection. """ from sqlalchemy import select from app.models import Device result = await db.execute(select(Device)) devices = list(result.scalars().all()) if not devices: return APIResponse( message="No devices / 没有设备", data=BatchCommandResponse(total=0, sent=0, failed=0, results=[]), ) results = [] for device in devices: if not tcp_command_service.is_device_online(device.imei): results.append(BatchCommandResult( device_id=device.id, imei=device.imei, success=False, error="TCP not connected", )) continue try: cmd_log = await command_service.create_command( db, device_id=device.id, command_type=body.command_type, command_content=body.command_content, ) await tcp_command_service.send_command( device.imei, body.command_type, body.command_content ) cmd_log.status = "sent" cmd_log.sent_at = now_cst() await db.flush() await db.refresh(cmd_log) results.append(BatchCommandResult( device_id=device.id, imei=device.imei, success=True, command_id=cmd_log.id, )) except Exception as e: logging.getLogger(__name__).error("Broadcast cmd failed for %s: %s", device.imei, e) results.append(BatchCommandResult( device_id=device.id, imei=device.imei, success=False, error="Send failed", )) sent = sum(1 for r in results if r.success) failed = len(results) - sent return APIResponse( message=f"Broadcast: {sent} sent, {failed} skipped (total: {len(devices)})", data=BatchCommandResponse( total=len(results), sent=sent, failed=failed, results=results, ), ) @router.get( "/{command_id}", response_model=APIResponse[CommandResponse], summary="获取指令详情 / Get command details", ) async def get_command(command_id: int, db: AsyncSession = Depends(get_db)): """ 按ID获取指令详情。 Get command log details by ID. """ command = await command_service.get_command(db, command_id) if command is None: raise HTTPException(status_code=404, detail=f"Command {command_id} not found / 未找到指令{command_id}") return APIResponse(data=CommandResponse.model_validate(command))