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 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 ? (
|
||||||
|
|||||||
@@ -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({
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
Reference in New Issue
Block a user