From 6c77672ca7c4ca6e6dd757e9b6bdd4ffec88d067 Mon Sep 17 00:00:00 2001 From: stu2not Date: Mon, 25 May 2026 17:45:16 +0800 Subject: [PATCH] feat(devices): skip realtime requests when device offline --- banban-mini/src/pages/location/index.tsx | 20 +++++- banban-mini/src/pages/sleep/index.tsx | 10 +++ banban-mini/src/services/device.ts | 21 ++++++ talkingq-url/banban/routers/devices.py | 76 ++++++++++++++++++++- talkingq-url/handlers/websocket_handler.py | 2 +- talkingq-url/services/connection_manager.py | 12 +++- 6 files changed, 135 insertions(+), 6 deletions(-) diff --git a/banban-mini/src/pages/location/index.tsx b/banban-mini/src/pages/location/index.tsx index fa4a36c..c4b1087 100644 --- a/banban-mini/src/pages/location/index.tsx +++ b/banban-mini/src/pages/location/index.tsx @@ -3,6 +3,7 @@ import { useState } from 'react' import Taro, { useDidShow } from '@tarojs/taro' import { getToken } from '@/services/auth' import { loadCurrentChildBindingContext } from '@/services/binding' +import { getDeviceOnlineStatus } from '@/services/device' import { getCurrentDeviceLocation, getDeviceTrajectory, @@ -125,6 +126,7 @@ export default function Location() { const [selectedPoint, setSelectedPoint] = useState(null) const [trajectoryMode, setTrajectoryMode] = useState('current') const [coordinates, setCoordinates] = useState(DEFAULT_COORDINATES) + const [locationNotice, setLocationNotice] = useState(null) const [emptyState, setEmptyState] = useState<{ title: string; desc: string; actionText: string; actionUrl: string } | null>( null ) @@ -147,6 +149,7 @@ export default function Location() { setLoading(true) setEmptyState(null) + setLocationNotice(null) if (nextMode) { setTrajectoryMode(nextMode) setSelectedPoint(null) @@ -183,6 +186,16 @@ export default function Location() { return } + const onlineStatus = await getDeviceOnlineStatus(resolvedDeviceId) + if (onlineStatus && !onlineStatus.online) { + setDeviceLocation(null) + setTrajectory([]) + setSelectedPoint(null) + setCoordinates(DEFAULT_COORDINATES) + setLocationNotice(onlineStatus.message || '设备不在线或暂时无法上报位置') + return + } + const currentLocation = await getCurrentDeviceLocation(resolvedDeviceId) setDeviceLocation(currentLocation) @@ -413,7 +426,9 @@ export default function Location() { {summaryTitle} - {loading + {locationNotice + ? locationNotice + : loading ? '正在获取设备位置...' : trajectoryMode === 'current' ? `更新于 ${formatTime(deviceLocation?.updated_at)}` @@ -444,7 +459,8 @@ export default function Location() { ) : ( - 当前孩子暂无设备位置 + {locationNotice || '当前孩子暂无设备位置'} + {locationNotice && 请确认设备已开机并保持网络可用。} ) ) : trajectory.length > 0 ? ( diff --git a/banban-mini/src/pages/sleep/index.tsx b/banban-mini/src/pages/sleep/index.tsx index 3ad7c20..61710a0 100644 --- a/banban-mini/src/pages/sleep/index.tsx +++ b/banban-mini/src/pages/sleep/index.tsx @@ -15,6 +15,7 @@ import { DeviceFirmwareStatus, DeviceRoleSummary, getDeviceFirmwareStatus, + getDeviceOnlineStatus, getDeviceRole, getDeviceRoles, startDeviceFirmwareUpdate, @@ -258,6 +259,15 @@ export default function Sleep() { setIsUpdatingFirmware(true) try { + const onlineStatus = await getDeviceOnlineStatus(binding.device_id) + if (onlineStatus && !onlineStatus.online) { + Taro.showToast({ + title: '设备不在线,暂时无法发送更新指令', + icon: 'none', + }) + return + } + const nextFirmwareStatus = await startDeviceFirmwareUpdate(binding.device_id) setFirmwareStatus(nextFirmwareStatus) Taro.showToast({ diff --git a/banban-mini/src/services/device.ts b/banban-mini/src/services/device.ts index 0b0bc9f..c558184 100644 --- a/banban-mini/src/services/device.ts +++ b/banban-mini/src/services/device.ts @@ -28,6 +28,15 @@ export interface DeviceStatus { location_updated_at?: string | null } +export interface DeviceOnlineStatus { + device_id: string + online: boolean + reason: string + message: string + last_location_at?: string | null + checked_at: string +} + export interface DeviceAlarmItem { alarm_id: number device_id: string @@ -136,6 +145,18 @@ export async function getDeviceStatus(deviceId?: string): Promise { + const resolvedDeviceId = await resolveDeviceId(deviceId) + if (!resolvedDeviceId) return null + + try { + return await request(`/banban/devices/${resolvedDeviceId}/online-status`) + } catch (error: any) { + if (error?.status === 404) return null + throw error + } +} + export async function setDeviceVolume(level: number, deviceId?: string): Promise { const resolvedDeviceId = await resolveDeviceId(deviceId) if (!resolvedDeviceId) { diff --git a/talkingq-url/banban/routers/devices.py b/talkingq-url/banban/routers/devices.py index 6dd9ab4..d1ff7fc 100644 --- a/talkingq-url/banban/routers/devices.py +++ b/talkingq-url/banban/routers/devices.py @@ -1,12 +1,13 @@ import logging from collections.abc import Mapping -from datetime import datetime, time +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 services.connection_manager import connection_manager try: from banban.security import get_current_user_id from banban.schemas.location import ( @@ -95,6 +96,15 @@ class DeviceStatusResponse(BaseModel): 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 @@ -276,6 +286,18 @@ def _row_to_device_status_response(row: Mapping) -> DeviceStatusResponse: ) +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: return DeviceAlarmItem( alarm_id=int(row["alarm_id"]), @@ -385,6 +407,58 @@ async def get_device_status( 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, diff --git a/talkingq-url/handlers/websocket_handler.py b/talkingq-url/handlers/websocket_handler.py index 32fc339..8076a4a 100644 --- a/talkingq-url/handlers/websocket_handler.py +++ b/talkingq-url/handlers/websocket_handler.py @@ -58,7 +58,7 @@ async def websocket_endpoint(websocket: WebSocket): ) finally: if device_id: - # await connection_manager.remove_connection(device_id) + await connection_manager.remove_connection(device_id, websocket) await cleanup_device_sessions(device_id) # 清理设备相关的所有异步任务 await task_manager.cancel_device_tasks(device_id) diff --git a/talkingq-url/services/connection_manager.py b/talkingq-url/services/connection_manager.py index 216b824..9055184 100644 --- a/talkingq-url/services/connection_manager.py +++ b/talkingq-url/services/connection_manager.py @@ -12,15 +12,23 @@ class ConnectionManager: async with self.lock: self.connections[device_id] = websocket - async def remove_connection(self, device_id: str): + async def remove_connection(self, device_id: str, websocket: Optional[WebSocket] = None): async with self.lock: - if device_id in self.connections: + if device_id in self.connections and (websocket is None or self.connections[device_id] is websocket): del self.connections[device_id] async def get_connection(self, device_id: str) -> Optional[WebSocket]: async with self.lock: return self.connections.get(device_id) + async def is_connected(self, device_id: str) -> bool: + websocket = await self.get_connection(device_id) + return ( + websocket is not None + and getattr(getattr(websocket, "client_state", None), "name", None) == "CONNECTED" + and not getattr(websocket, "_closed", False) + ) + async def get_all_connections(self) -> Dict[str, WebSocket]: async with self.lock: return dict(self.connections)