feat(devices): skip realtime requests when device offline

This commit is contained in:
stu2not
2026-05-25 17:45:16 +08:00
parent 3449224cd2
commit 6c77672ca7
6 changed files with 135 additions and 6 deletions

View File

@@ -3,6 +3,7 @@ import { useState } from 'react'
import Taro, { useDidShow } from '@tarojs/taro' import Taro, { useDidShow } from '@tarojs/taro'
import { getToken } from '@/services/auth' import { getToken } from '@/services/auth'
import { loadCurrentChildBindingContext } from '@/services/binding' import { loadCurrentChildBindingContext } from '@/services/binding'
import { getDeviceOnlineStatus } from '@/services/device'
import { import {
getCurrentDeviceLocation, getCurrentDeviceLocation,
getDeviceTrajectory, getDeviceTrajectory,
@@ -125,6 +126,7 @@ export default function Location() {
const [selectedPoint, setSelectedPoint] = useState<DeviceTrajectoryPoint | null>(null) const [selectedPoint, setSelectedPoint] = useState<DeviceTrajectoryPoint | null>(null)
const [trajectoryMode, setTrajectoryMode] = useState<TrajectoryMode>('current') const [trajectoryMode, setTrajectoryMode] = useState<TrajectoryMode>('current')
const [coordinates, setCoordinates] = useState(DEFAULT_COORDINATES) const [coordinates, setCoordinates] = useState(DEFAULT_COORDINATES)
const [locationNotice, setLocationNotice] = useState<string | null>(null)
const [emptyState, setEmptyState] = useState<{ title: string; desc: string; actionText: string; actionUrl: string } | null>( const [emptyState, setEmptyState] = useState<{ title: string; desc: string; actionText: string; actionUrl: string } | null>(
null null
) )
@@ -147,6 +149,7 @@ export default function Location() {
setLoading(true) setLoading(true)
setEmptyState(null) setEmptyState(null)
setLocationNotice(null)
if (nextMode) { if (nextMode) {
setTrajectoryMode(nextMode) setTrajectoryMode(nextMode)
setSelectedPoint(null) setSelectedPoint(null)
@@ -183,6 +186,16 @@ export default function Location() {
return 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) const currentLocation = await getCurrentDeviceLocation(resolvedDeviceId)
setDeviceLocation(currentLocation) setDeviceLocation(currentLocation)
@@ -413,7 +426,9 @@ export default function Location() {
<View className='header-left'> <View className='header-left'>
<Text className='location-title'>{summaryTitle}</Text> <Text className='location-title'>{summaryTitle}</Text>
<Text className='location-update'> <Text className='location-update'>
{loading {locationNotice
? locationNotice
: loading
? '正在获取设备位置...' ? '正在获取设备位置...'
: trajectoryMode === 'current' : trajectoryMode === 'current'
? `更新于 ${formatTime(deviceLocation?.updated_at)}` ? `更新于 ${formatTime(deviceLocation?.updated_at)}`
@@ -444,7 +459,8 @@ export default function Location() {
</View> </View>
) : ( ) : (
<View className='location-empty'> <View className='location-empty'>
<Text className='location-empty-title'></Text> <Text className='location-empty-title'>{locationNotice || '当前孩子暂无设备位置'}</Text>
{locationNotice && <Text className='location-empty-desc'></Text>}
</View> </View>
) )
) : trajectory.length > 0 ? ( ) : trajectory.length > 0 ? (

View File

@@ -15,6 +15,7 @@ import {
DeviceFirmwareStatus, DeviceFirmwareStatus,
DeviceRoleSummary, DeviceRoleSummary,
getDeviceFirmwareStatus, getDeviceFirmwareStatus,
getDeviceOnlineStatus,
getDeviceRole, getDeviceRole,
getDeviceRoles, getDeviceRoles,
startDeviceFirmwareUpdate, startDeviceFirmwareUpdate,
@@ -258,6 +259,15 @@ export default function Sleep() {
setIsUpdatingFirmware(true) setIsUpdatingFirmware(true)
try { 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) const nextFirmwareStatus = await startDeviceFirmwareUpdate(binding.device_id)
setFirmwareStatus(nextFirmwareStatus) setFirmwareStatus(nextFirmwareStatus)
Taro.showToast({ Taro.showToast({

View File

@@ -28,6 +28,15 @@ export interface DeviceStatus {
location_updated_at?: string | null 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 { export interface DeviceAlarmItem {
alarm_id: number alarm_id: number
device_id: string device_id: string
@@ -136,6 +145,18 @@ export async function getDeviceStatus(deviceId?: string): Promise<DeviceStatus |
} }
} }
export async function getDeviceOnlineStatus(deviceId?: string): Promise<DeviceOnlineStatus | null> {
const resolvedDeviceId = await resolveDeviceId(deviceId)
if (!resolvedDeviceId) return null
try {
return await request<DeviceOnlineStatus>(`/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<DeviceVolumeUpdateResponse> { export async function setDeviceVolume(level: number, deviceId?: string): Promise<DeviceVolumeUpdateResponse> {
const resolvedDeviceId = await resolveDeviceId(deviceId) const resolvedDeviceId = await resolveDeviceId(deviceId)
if (!resolvedDeviceId) { if (!resolvedDeviceId) {

View File

@@ -1,12 +1,13 @@
import logging import logging
from collections.abc import Mapping 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 fastapi import APIRouter, Depends, HTTPException, Query, Request
from pydantic import BaseModel, Field from pydantic import BaseModel, Field
from sqlalchemy import text from sqlalchemy import text
import asyncio import asyncio
from datetime import timedelta from datetime import timedelta
from handlers.mqtt_handler import TalkingQMQTTService from handlers.mqtt_handler import TalkingQMQTTService
from services.connection_manager import connection_manager
try: try:
from banban.security import get_current_user_id from banban.security import get_current_user_id
from banban.schemas.location import ( from banban.schemas.location import (
@@ -95,6 +96,15 @@ class DeviceStatusResponse(BaseModel):
location_updated_at: 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): class DeviceAlarmItem(BaseModel):
alarm_id: int alarm_id: int
device_id: str 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: def _row_to_alarm_item(row: Mapping) -> DeviceAlarmItem:
return DeviceAlarmItem( return DeviceAlarmItem(
alarm_id=int(row["alarm_id"]), alarm_id=int(row["alarm_id"]),
@@ -385,6 +407,58 @@ async def get_device_status(
return _row_to_device_status_response(row) 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) @router.get("/{device_id}/alarms", response_model=DeviceAlarmListResponse)
async def list_device_alarms( async def list_device_alarms(
device_id: str, device_id: str,

View File

@@ -58,7 +58,7 @@ async def websocket_endpoint(websocket: WebSocket):
) )
finally: finally:
if device_id: 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 cleanup_device_sessions(device_id)
# 清理设备相关的所有异步任务 # 清理设备相关的所有异步任务
await task_manager.cancel_device_tasks(device_id) await task_manager.cancel_device_tasks(device_id)

View File

@@ -12,15 +12,23 @@ class ConnectionManager:
async with self.lock: async with self.lock:
self.connections[device_id] = websocket 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: 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] del self.connections[device_id]
async def get_connection(self, device_id: str) -> Optional[WebSocket]: async def get_connection(self, device_id: str) -> Optional[WebSocket]:
async with self.lock: async with self.lock:
return self.connections.get(device_id) 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 def get_all_connections(self) -> Dict[str, WebSocket]:
async with self.lock: async with self.lock:
return dict(self.connections) return dict(self.connections)