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 { 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<DeviceTrajectoryPoint | null>(null)
const [trajectoryMode, setTrajectoryMode] = useState<TrajectoryMode>('current')
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>(
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() {
<View className='header-left'>
<Text className='location-title'>{summaryTitle}</Text>
<Text className='location-update'>
{loading
{locationNotice
? locationNotice
: loading
? '正在获取设备位置...'
: trajectoryMode === 'current'
? `更新于 ${formatTime(deviceLocation?.updated_at)}`
@@ -444,7 +459,8 @@ export default function Location() {
</View>
) : (
<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>
)
) : trajectory.length > 0 ? (

View File

@@ -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({

View File

@@ -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<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> {
const resolvedDeviceId = await resolveDeviceId(deviceId)
if (!resolvedDeviceId) {

View File

@@ -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,

View File

@@ -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)

View File

@@ -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)