feat(devices): skip realtime requests when device offline
This commit is contained in:
@@ -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 ? (
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user