diff --git a/banban-mini/src/pages/device/index.scss b/banban-mini/src/pages/device/index.scss index 18dd9a2..80464f7 100644 --- a/banban-mini/src/pages/device/index.scss +++ b/banban-mini/src/pages/device/index.scss @@ -10,71 +10,74 @@ padding: 80px 32px 24px; .page-title { + display: block; font-size: 48px; + line-height: 1.2; font-weight: 700; color: #1A1A1A; - display: block; - line-height: 1.2; } } .status-card { - background: linear-gradient(135deg, #FF8C42 0%, #FF6B35 100%); - border-radius: 28px; - padding: 40px 36px; - margin: 0 24px 32px; position: relative; overflow: hidden; + margin: 0 24px 32px; + padding: 40px 36px; + border-radius: 28px; + background: linear-gradient(135deg, #FF8C42 0%, #FF6B35 100%); &::before { content: ''; position: absolute; - right: -20px; top: 50%; - transform: translateY(-50%); + right: -20px; width: 200px; height: 200px; - background: rgba(255, 255, 255, 0.1); border-radius: 50%; + transform: translateY(-50%); + background: rgba(255, 255, 255, 0.1); } } .status-badge { display: inline-flex; align-items: center; - background: rgba(255, 255, 255, 0.25); - border-radius: 20px; - padding: 10px 20px; margin-bottom: 24px; + padding: 10px 20px; + border-radius: 20px; + background: rgba(255, 255, 255, 0.25); .status-dot { width: 12px; height: 12px; - background: #4CD964; - border-radius: 50%; margin-right: 8px; + border-radius: 50%; + background: #4CD964; } .status-text { font-size: 24px; - color: #FFFFFF; font-weight: 500; + color: #FFFFFF; } } .battery-section { + position: relative; + z-index: 1; + .battery-percent { + display: block; + margin-bottom: 12px; font-size: 72px; + line-height: 1; font-weight: 700; color: #FFFFFF; - line-height: 1; - margin-bottom: 12px; - display: block; .percent { + margin-left: 4px; font-size: 32px; font-weight: 600; - margin-left: 4px; } } @@ -84,44 +87,75 @@ } } +.status-meta { + position: relative; + z-index: 1; + display: flex; + flex-wrap: wrap; + gap: 12px; + margin-top: 24px; +} + +.status-pill { + min-width: 140px; + padding: 12px 18px; + border-radius: 18px; + background: rgba(255, 255, 255, 0.18); +} + +.status-pill-label { + display: block; + margin-bottom: 4px; + font-size: 22px; + color: rgba(255, 255, 255, 0.78); +} + +.status-pill-value { + display: block; + font-size: 28px; + line-height: 1.2; + font-weight: 700; + color: #FFFFFF; +} + .device-avatar { position: absolute; - right: 36px; top: 50%; - transform: translateY(-50%); - width: 120px; - height: 120px; - background: rgba(255, 255, 255, 0.2); - border-radius: 50%; + right: 36px; display: flex; align-items: center; justify-content: center; + width: 120px; + height: 120px; border: 3px solid rgba(255, 255, 255, 0.3); + border-radius: 50%; + transform: translateY(-50%); + background: rgba(255, 255, 255, 0.2); } .robot-icon { .robot-img { width: 56px; height: 56px; - background: #FFFFFF; border-radius: 50%; + background: #FFFFFF; } } .section-title { + display: block; + margin: 0 24px 20px; font-size: 32px; font-weight: 700; color: #1A1A1A; - margin: 0 24px 20px; - display: block; } .control-card { - background: #FFFFFF; - border-radius: 16px; - margin: 0 24px 24px; - box-shadow: 0 2px 12px rgba(0, 0, 0, 0.08); overflow: hidden; + margin: 0 24px 24px; + border-radius: 16px; + background: #FFFFFF; + box-shadow: 0 2px 12px rgba(0, 0, 0, 0.08); } .control-item { @@ -135,62 +169,70 @@ align-items: stretch; padding: 20px 24px 24px; } +} - .volume-header { - display: flex; - align-items: center; - margin-bottom: 16px; +.control-left { + display: flex; + align-items: center; + flex: 1; + min-width: 0; +} - .control-left { - flex: 1; - } +.icon-bg { + display: flex; + align-items: center; + justify-content: center; + width: 72px; + height: 72px; + margin-right: 20px; + border-radius: 50%; + + &.purple { + background: #F0E6FF; } + &.orange { + background: #FFF0E6; + } + + .control-icon-img { + width: 36px; + height: 36px; + } +} + +.control-info { + display: flex; + flex-direction: column; + min-width: 0; +} + +.control-name { + margin-bottom: 6px; + font-size: 32px; + font-weight: 600; + color: #1A1A1A; +} + +.control-detail { + font-size: 26px; + font-weight: 500; + color: #FF8C42; +} + +.divider { + height: 1px; + margin: 0 24px; + background: #F0F0F0; +} + +.volume-header { + display: flex; + align-items: center; + margin-bottom: 16px; + .control-left { - display: flex; - align-items: center; flex: 1; - - .icon-bg { - width: 72px; - height: 72px; - border-radius: 50%; - display: flex; - align-items: center; - justify-content: center; - margin-right: 20px; - - &.purple { - background: #F0E6FF; - } - - &.orange { - background: #FFF0E6; - } - - .control-icon-img { - width: 36px; - height: 36px; - } - } - - .control-info { - display: flex; - flex-direction: column; - - .control-name { - font-size: 32px; - font-weight: 600; - color: #1A1A1A; - margin-bottom: 6px; - } - - .control-detail { - font-size: 26px; - color: #FF8C42; - font-weight: 500; - } - } } } @@ -200,9 +242,9 @@ padding: 0 8px; .volume-icon { + flex-shrink: 0; width: 24px; height: 24px; - flex-shrink: 0; } .slider { @@ -211,90 +253,64 @@ } } -.divider { - height: 1px; - background: #F0F0F0; - margin: 0 24px; -} - -.quick-card { - background: #FFFFFF; - border-radius: 24px; - margin: 0 24px 24px; - box-shadow: 0 2px 12px rgba(0, 0, 0, 0.04); +.fold-card { overflow: hidden; -} - -.quick-grid { - display: flex; - justify-content: space-around; - padding: 24px 16px; -} - -.quick-item { - display: flex; - flex-direction: column; - align-items: center; - padding: 16px; - - &:active { - opacity: 0.7; - } -} - -.quick-icon { - width: 88px; - height: 88px; - border-radius: 20px; - display: flex; - align-items: center; - justify-content: center; - font-size: 40px; - margin-bottom: 12px; - - &.bg-orange { - background: #FEF3E8; - } - - &.bg-green { - background: #E8F8F0; - } - - &.bg-blue { - background: #E8F0FE; - } -} - -.quick-text { - font-size: 26px; - color: #666666; - font-weight: 500; -} - -.loading { - min-height: 100%; - display: flex; - align-items: center; - justify-content: center; - - text { - font-size: 32px; - color: #999999; - } -} - -.detail-card { - background: #FFFFFF; - border-radius: 20px; margin: 0 24px 24px; - padding: 10px 24px; + border-radius: 20px; + background: #FFFFFF; box-shadow: 0 2px 12px rgba(0, 0, 0, 0.04); } +.fold-header { + display: flex; + align-items: center; + justify-content: space-between; + padding: 24px; +} + +.fold-heading { + flex: 1; + min-width: 0; + margin-right: 20px; +} + +.fold-title { + display: block; + font-size: 30px; + font-weight: 700; + color: #1A1A1A; +} + +.fold-subtitle { + display: block; + margin-top: 8px; + font-size: 24px; + color: #666666; + word-break: break-all; +} + +.fold-action { + display: flex; + align-items: center; + justify-content: center; + min-width: 88px; + height: 56px; + padding: 0 20px; + border-radius: 999px; + background: #FEF3E8; + font-size: 24px; + font-weight: 600; + color: #FF8C42; +} + +.detail-body { + padding: 0 24px 10px; +} + .detail-row { display: flex; - justify-content: space-between; align-items: center; + justify-content: space-between; padding: 20px 0; border-bottom: 1px solid #F3F3F3; @@ -311,29 +327,41 @@ .detail-value { max-width: 60%; font-size: 28px; - color: #1A1A1A; text-align: right; word-break: break-all; + color: #1A1A1A; +} + +.loading { + display: flex; + align-items: center; + justify-content: center; + min-height: 100%; + + text { + font-size: 32px; + color: #999999; + } } .empty-card { - background: #FFFFFF; - border-radius: 24px; margin: 0 24px; padding: 48px 32px; + border-radius: 24px; + background: #FFFFFF; box-shadow: 0 2px 12px rgba(0, 0, 0, 0.04); text-align: center; } .empty-robot { - width: 132px; - height: 132px; - background: #FEF3E8; - border-radius: 50%; - margin: 0 auto 24px; display: flex; align-items: center; justify-content: center; + width: 132px; + height: 132px; + margin: 0 auto 24px; + border-radius: 50%; + background: #FEF3E8; .robot-img { width: 68px; @@ -357,55 +385,40 @@ } .empty-btn { - margin-top: 28px; - height: 92px; - border-radius: 16px; - background: #FF8C42; display: flex; align-items: center; justify-content: center; + height: 92px; + margin-top: 28px; + border-radius: 16px; + background: #FF8C42; } .empty-btn-text { font-size: 30px; + font-weight: 600; color: #FFFFFF; - font-weight: 600; -} - -.helper-card { - background: #FFFFFF; - border-radius: 20px; - margin: 0 24px 24px; - padding: 28px 24px; - box-shadow: 0 2px 12px rgba(0, 0, 0, 0.04); -} - -.helper-title { - display: block; - font-size: 30px; - font-weight: 600; - color: #1A1A1A; } .child-focus-card { display: flex; align-items: center; - background: #FFFFFF; - border-radius: 24px; margin: 0 24px 24px; padding: 28px 24px; + border-radius: 24px; + background: #FFFFFF; box-shadow: 0 2px 12px rgba(0, 0, 0, 0.04); } .child-focus-avatar { - width: 96px; - height: 96px; - border-radius: 50%; - background: #FEF3E8; display: flex; align-items: center; justify-content: center; + width: 96px; + height: 96px; margin-right: 20px; + border-radius: 50%; + background: #FEF3E8; } .child-focus-icon { diff --git a/banban-mini/src/pages/device/index.tsx b/banban-mini/src/pages/device/index.tsx index cd54cc6..a14b8a8 100644 --- a/banban-mini/src/pages/device/index.tsx +++ b/banban-mini/src/pages/device/index.tsx @@ -1,17 +1,53 @@ -import { View, Text, Switch, Slider, Image } from '@tarojs/components' +import { View, Text, Slider, Image, Switch } from '@tarojs/components' import { useState } from 'react' import Taro, { useDidShow } from '@tarojs/taro' import { getToken } from '@/services/auth' import { Binding, loadCurrentChildBindingContext } from '@/services/binding' import { Child } from '@/services/child' +import { DeviceStatus, getDeviceStatus, setDeviceVolume } from '@/services/device' import './index.scss' +function formatTime(value?: string | null): string { + if (!value) return '--' + const date = new Date(value) + if (Number.isNaN(date.getTime())) return value + const month = `${date.getMonth() + 1}`.padStart(2, '0') + const day = `${date.getDate()}`.padStart(2, '0') + const hour = `${date.getHours()}`.padStart(2, '0') + const minute = `${date.getMinutes()}`.padStart(2, '0') + return `${month}-${day} ${hour}:${minute}` +} + +function formatCoordinates(status: DeviceStatus | null): string { + if (!status || status.lat === null || status.lat === undefined || status.lng === null || status.lng === undefined) { + return '--' + } + return `${status.lat.toFixed(6)}, ${status.lng.toFixed(6)}` +} + +function getSignalLabel(value?: number | null): string { + if (value === null || value === undefined) return '--' + const normalized = Number(value) + if (!Number.isFinite(normalized)) return '--' + if (normalized <= 4) { + if (normalized >= 3) return '强' + if (normalized >= 2) return '中' + return '弱' + } + if (normalized >= 67) return '强' + if (normalized >= 34) return '中' + return '弱' +} + export default function Device() { const [binding, setBinding] = useState(null) const [child, setChild] = useState(null) + const [deviceStatus, setDeviceStatus] = useState(null) const [isLoading, setIsLoading] = useState(true) const [sleepEnabled, setSleepEnabled] = useState(true) - const [volume, setVolume] = useState(50) + const [isDetailExpanded, setIsDetailExpanded] = useState(false) + const [isSavingVolume, setIsSavingVolume] = useState(false) + const [volumeValue, setVolumeValue] = useState(0) useDidShow(() => { void loadDeviceInfo() @@ -28,6 +64,15 @@ export default function Device() { const context = await loadCurrentChildBindingContext() setChild(context.currentChild) setBinding(context.currentBinding) + + if (context.currentBinding?.device_id) { + const nextStatus = await getDeviceStatus(context.currentBinding.device_id) + setDeviceStatus(nextStatus) + setVolumeValue(nextStatus?.volume ?? 0) + } else { + setDeviceStatus(null) + setVolumeValue(0) + } } catch (error: any) { console.error('[device] load failed:', error) Taro.showToast({ @@ -40,15 +85,45 @@ export default function Device() { } const handleSleepToggle = (e: any) => { - setSleepEnabled(e.detail.value) + const nextValue = Boolean(e.detail?.value) + setSleepEnabled(nextValue) Taro.showToast({ - title: e.detail.value ? '休眠展示已开启' : '休眠展示已关闭', + title: nextValue ? '休眠展示已开启' : '休眠展示已关闭', icon: 'none', }) } - const handleVolumeChange = (e: any) => { - setVolume(e.detail.value) + const handleToggleDetail = () => { + setIsDetailExpanded((current) => !current) + } + + const handleVolumeChanging = (e: any) => { + setVolumeValue(Number(e.detail?.value || 0)) + } + + const handleVolumeCommit = async (e: any) => { + if (!binding?.device_id) return + const nextLevel = Number(e.detail?.value || 0) + const fallbackLevel = deviceStatus?.volume ?? volumeValue ?? 0 + setVolumeValue(nextLevel) + setIsSavingVolume(true) + + try { + await setDeviceVolume(nextLevel, binding.device_id) + setDeviceStatus((current) => (current ? { ...current, volume: nextLevel } : current)) + Taro.showToast({ + title: '音量指令已发送', + icon: 'success', + }) + } catch (error: any) { + setVolumeValue(deviceStatus?.volume ?? fallbackLevel) + Taro.showToast({ + title: error?.message || '音量设置失败', + icon: 'none', + }) + } finally { + setIsSavingVolume(false) + } } if (isLoading) { @@ -115,8 +190,12 @@ export default function Device() { const childName = child.child_name || '未设置' const pageTitle = `${childName} 的伴伴` - const battery = 82 - const daysLeft = 4 + const batteryValue = deviceStatus?.power ?? deviceStatus?.battery_pct ?? null + const batteryDisplay = batteryValue === null || batteryValue === undefined ? '--' : String(batteryValue) + const signalLabel = getSignalLabel(deviceStatus?.signal) + const versionLabel = deviceStatus?.version || '--' + const coordinateLabel = formatCoordinates(deviceStatus) + const volumeLabel = `${volumeValue}%` return ( @@ -131,10 +210,20 @@ export default function Device() { - {battery} - % + {batteryDisplay} + {batteryDisplay !== '--' && %} - 模拟电量展示 (预计续航 {daysLeft} 天) + 当前电量 + + + + 信号 + {signalLabel} + + + 版本 + {versionLabel} + @@ -143,31 +232,12 @@ export default function Device() { - - - 当前儿童 - {childName} - - - 当前设备 - {binding.device_id} - - - 绑定时间 - {binding.bound_at || '--'} - - - - 基础控制 + 设备控制 - + 定时休眠 @@ -189,7 +259,10 @@ export default function Device() { mode='aspectFit' /> - 外放音量 + + 外放音量 + {isSavingVolume ? '发送中...' : `当前 ${volumeLabel}`} + @@ -202,8 +275,9 @@ export default function Device() { className='slider' min={0} max={100} - value={volume} - onChange={handleVolumeChange} + value={volumeValue} + onChanging={handleVolumeChanging} + onChange={handleVolumeCommit} activeColor='#FF8C42' backgroundColor='#E5E5E5' blockColor='#FF8C42' @@ -216,6 +290,45 @@ export default function Device() { + + + + + 设备详情 + {binding.device_id} + + {isDetailExpanded ? '收起' : '展开'} + + + {isDetailExpanded && ( + + + 当前儿童 + {childName} + + + 当前设备 + {binding.device_id} + + + 绑定时间 + {formatTime(binding.bound_at)} + + + 当前坐标 + {coordinateLabel} + + + 状态更新时间 + {formatTime(deviceStatus?.settings_updated_at)} + + + 定位更新时间 + {formatTime(deviceStatus?.location_updated_at)} + + + )} + ) } diff --git a/banban-mini/src/services/device.ts b/banban-mini/src/services/device.ts new file mode 100644 index 0000000..c7e875a --- /dev/null +++ b/banban-mini/src/services/device.ts @@ -0,0 +1,62 @@ +import { request } from './api' +import { loadCurrentChildBindingContext } from './binding' + +export interface DeviceStatus { + device_id: string + child_id?: number | null + child_name?: string | null + power?: number | null + volume?: number | null + signal?: number | null + version?: string | null + settings_updated_at?: string | null + coord_type?: string | null + lat?: number | null + lng?: number | null + accuracy_m?: number | null + altitude_m?: number | null + speed_mps?: number | null + heading_deg?: number | null + source?: number | null + battery_pct?: number | null + device_time?: string | null + server_time?: string | null + location_updated_at?: string | null +} + +export interface DeviceVolumeUpdateResponse { + device_id: string + level: number + msg_id: string +} + +async function resolveDeviceId(deviceId?: string): Promise { + const normalizedDeviceId = String(deviceId || '').trim() + if (normalizedDeviceId) return normalizedDeviceId + const context = await loadCurrentChildBindingContext() + return context.currentBinding?.device_id || '' +} + +export async function getDeviceStatus(deviceId?: string): Promise { + const resolvedDeviceId = await resolveDeviceId(deviceId) + if (!resolvedDeviceId) return null + + try { + return await request(`/banban/devices/${resolvedDeviceId}/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) { + throw new Error('当前没有可用设备') + } + + return request(`/banban/devices/${resolvedDeviceId}/volume`, { + method: 'POST', + data: { level }, + }) +} diff --git a/talkingq-url/banban/dao/device.py b/talkingq-url/banban/dao/device.py index 91373ec..517884d 100644 --- a/talkingq-url/banban/dao/device.py +++ b/talkingq-url/banban/dao/device.py @@ -60,4 +60,58 @@ class DeviceDAO(BaseDAO): ), params, ) - return result.mappings().all() \ No newline at end of file + return result.mappings().all() + + async def get_device_status( + self, + *, + device_id: str, + user_id: int, + ) -> Mapping[str, Any]: + from fastapi import HTTPException + + result = await self.execute( + text( + """ + SELECT + db.device_id, + db.child_id, + c.child_name, + ds.power, + ds.volume, + ds.`signal` AS signal_strength, + ds.`version` AS version, + ds.updated_at AS settings_updated_at, + cl.coord_type, + cl.lat, + cl.lng, + cl.accuracy_m, + cl.altitude_m, + cl.speed_mps, + cl.heading_deg, + cl.source, + cl.battery_pct, + cl.device_time, + cl.server_time, + cl.updated_at AS location_updated_at + FROM device_bindings AS db + LEFT JOIN children AS c + ON c.child_id = db.child_id + AND c.status = 1 + LEFT JOIN device_settings AS ds + ON ds.device_id = db.device_id + LEFT JOIN child_location_current AS cl + ON cl.child_id = db.child_id + AND cl.device_id = db.device_id + WHERE db.device_id = :device_id + AND db.owner_user_id = :user_id + AND db.status = 1 + LIMIT 1 + """ + ), + {"device_id": device_id, "user_id": user_id}, + ) + row = result.mappings().first() + if row is None: + raise HTTPException(status_code=404, detail="device not found") + return row diff --git a/talkingq-url/banban/routers/devices.py b/talkingq-url/banban/routers/devices.py index 212b12e..1dcae1d 100644 --- a/talkingq-url/banban/routers/devices.py +++ b/talkingq-url/banban/routers/devices.py @@ -3,7 +3,7 @@ from collections.abc import Mapping from datetime import datetime from fastapi import APIRouter, Depends, HTTPException, Query, Request -from pydantic import BaseModel +from pydantic import BaseModel, Field from sqlalchemy import text try: @@ -47,6 +47,39 @@ class DeviceMessageListResponse(BaseModel): next_cursor: int | None = None +class DeviceStatusResponse(BaseModel): + device_id: str + child_id: int | None = None + child_name: str | None = None + power: int | None = None + volume: int | None = None + signal: int | None = None + version: str | None = None + settings_updated_at: datetime | None = None + coord_type: str | None = None + lat: float | None = None + lng: float | None = None + accuracy_m: int | None = None + altitude_m: float | None = None + speed_mps: float | None = None + heading_deg: int | None = None + source: int | None = None + battery_pct: int | None = None + device_time: datetime | None = None + server_time: datetime | None = None + location_updated_at: datetime | None = None + + +class DeviceVolumeUpdateRequest(BaseModel): + level: int = Field(ge=0, le=100) + + +class DeviceVolumeUpdateResponse(BaseModel): + device_id: str + level: int + msg_id: str + + @@ -105,6 +138,31 @@ def _row_to_trajectory_item(row: Mapping, *, child_name: str | None) -> DeviceLo ) +def _row_to_device_status_response(row: Mapping) -> DeviceStatusResponse: + return DeviceStatusResponse( + device_id=str(row["device_id"]), + child_id=int(row["child_id"]) if row["child_id"] is not None else None, + child_name=row.get("child_name"), + power=row["power"], + volume=row["volume"], + signal=row["signal_strength"], + version=row["version"], + settings_updated_at=row["settings_updated_at"], + coord_type=row["coord_type"], + lat=float(row["lat"]) if row["lat"] is not None else None, + lng=float(row["lng"]) if row["lng"] is not None else None, + accuracy_m=row["accuracy_m"], + altitude_m=float(row["altitude_m"]) if row["altitude_m"] is not None else None, + speed_mps=float(row["speed_mps"]) if row["speed_mps"] is not None else None, + heading_deg=row["heading_deg"], + source=int(row["source"]) if row["source"] is not None else None, + battery_pct=row["battery_pct"], + device_time=row["device_time"], + server_time=row["server_time"], + location_updated_at=row["location_updated_at"], + ) + + @router.get("/{device_id}/messages", response_model=DeviceMessageListResponse) async def list_device_messages( device_id: str, @@ -142,6 +200,57 @@ async def list_device_messages( ) +@router.get("/{device_id}/status", response_model=DeviceStatusResponse) +async def get_device_status( + device_id: str, + request: Request, + current_user_id: int = Depends(get_current_user_id), +) -> DeviceStatusResponse: + row = await device_service.get_device_status( + device_id=device_id, + user_id=current_user_id, + ) + + logger.info( + "device status fetched", + extra={ + "event": "device_status", + "request_id": getattr(request.state, "request_id", None), + "user_id": current_user_id, + "device_id": device_id, + "child_id": row["child_id"], + }, + ) + return _row_to_device_status_response(row) + + +@router.post("/{device_id}/volume", response_model=DeviceVolumeUpdateResponse) +async def set_device_volume( + device_id: str, + payload: DeviceVolumeUpdateRequest, + request: Request, + current_user_id: int = Depends(get_current_user_id), +) -> DeviceVolumeUpdateResponse: + msg_id = await device_service.set_device_volume( + device_id=device_id, + user_id=current_user_id, + level=payload.level, + ) + + logger.info( + "device volume command sent", + extra={ + "event": "device_volume_set", + "request_id": getattr(request.state, "request_id", None), + "user_id": current_user_id, + "device_id": device_id, + "level": payload.level, + "msg_id": msg_id, + }, + ) + return DeviceVolumeUpdateResponse(device_id=device_id, level=payload.level, msg_id=msg_id) + + @router.get("/{device_id}/location", response_model=DeviceLocationCurrentResponse) async def get_current_device_location( device_id: str, @@ -203,4 +312,4 @@ async def get_device_location_trajectory( total=len(rows), start_at=start_at, end_at=end_at, - ) \ No newline at end of file + ) diff --git a/talkingq-url/banban/service/device.py b/talkingq-url/banban/service/device.py index 571444a..8e63add 100644 --- a/talkingq-url/banban/service/device.py +++ b/talkingq-url/banban/service/device.py @@ -2,6 +2,7 @@ from collections.abc import Mapping from typing import Any, List from services.database_service_base import DatabaseServiceBase +from fastapi import HTTPException from banban.dao.device import DeviceDAO @@ -38,6 +39,35 @@ class DeviceService(DatabaseServiceBase): finally: await db_session.close() + async def get_device_status( + self, + *, + device_id: str, + user_id: int, + ) -> Mapping[str, Any]: + db_session = await self.get_session() + try: + dao = DeviceDAO(db_session) + return await dao.get_device_status(device_id=device_id, user_id=user_id) + finally: + await db_session.close() + + async def set_device_volume( + self, + *, + device_id: str, + user_id: int, + level: int, + ) -> str: + await self.ensure_device_access(device_id=device_id, user_id=user_id) + + from handlers.mqtt_handler import TalkingQMQTTService + + service = await TalkingQMQTTService.get_instance() + if service is None: + raise HTTPException(status_code=503, detail="MQTT 服务未初始化") + return await service.send_volume_command(device_id, level) + # 创建全局 DeviceService 实例 -device_service = DeviceService() \ No newline at end of file +device_service = DeviceService() diff --git a/talkingq-url/banban/service/device_setting.py b/talkingq-url/banban/service/device_setting.py index 6e0a289..05cbac7 100644 --- a/talkingq-url/banban/service/device_setting.py +++ b/talkingq-url/banban/service/device_setting.py @@ -93,7 +93,14 @@ class DeviceSettingService(DatabaseServiceBase): finally: await db_session.close() - async def insert_or_update(self, device_id: str, power: int, volume: int, signal_strength: int, version_str: str) -> None: + async def insert_or_update( + self, + device_id: str, + power: Optional[int], + volume: Optional[int], + signal_strength: Optional[int], + version_str: Optional[str], + ) -> None: try: current_row = await self.get_setting_by_device_id(device_id=device_id) if current_row: @@ -104,4 +111,4 @@ class DeviceSettingService(DatabaseServiceBase): pass # 创建全局 DeviceSettingService 实例 -device_setting_service = DeviceSettingService() \ No newline at end of file +device_setting_service = DeviceSettingService() diff --git a/talkingq-url/database/models.py b/talkingq-url/database/models.py index f900c29..78e2111 100644 --- a/talkingq-url/database/models.py +++ b/talkingq-url/database/models.py @@ -256,10 +256,10 @@ class DeviceSetting(Base): timezone: Mapped[str] = mapped_column(String(32), server_default=text("'Asia/Shanghai'")) volume: Mapped[Optional[int]] = mapped_column(Integer) brightness: Mapped[Optional[int]] = mapped_column(Integer) - disable_weekdays: Mapped[Optional[str]] = mapped_column(String(32)) power: Mapped[Optional[int]] = mapped_column(Integer) - signal_strength: Mapped[Optional[int]] = mapped_column(Integer) - version_str: Mapped[Optional[str]] = mapped_column(String(64)) + signal: Mapped[Optional[int]] = mapped_column("signal", Integer) + version: Mapped[Optional[str]] = mapped_column("version", String(64)) + disable_weekdays: Mapped[Optional[str]] = mapped_column(String(32)) created_at: Mapped[Optional[datetime]] = mapped_column(DateTime, server_default=text("CURRENT_TIMESTAMP")) updated_at: Mapped[Optional[datetime]] = mapped_column( DateTime, diff --git a/talkingq-url/handlers/mqtt_handler.py b/talkingq-url/handlers/mqtt_handler.py index 42ddc47..e38bbd3 100644 --- a/talkingq-url/handlers/mqtt_handler.py +++ b/talkingq-url/handlers/mqtt_handler.py @@ -1,6 +1,7 @@ import asyncio import json import time +from datetime import datetime from typing import Awaitable, Callable, Dict, Optional import aiomqtt @@ -9,10 +10,10 @@ from banban.service.binding import BindingService from banban.service.device_setting import device_setting_service from banban.service.location import location_service from config import settings -from database.models import ChildLocationCurrent from services.card_service import card_service from services.device_target_cache import device_target_cache from services.offline_audio_cache import offline_audio_cache +from services.task_manager import task_manager from utils.logger import session_logger as logger @@ -90,14 +91,31 @@ class TalkingQMQTTService: logger.error("", "", f"mqtt loop failed: {exc}") self._connected = False + async def _schedule_persistence(self, device_id: str, label: str, coro) -> None: + async def _runner(): + try: + await coro + except Exception as exc: + logger.error(device_id, "mqtt_persistence", f"{label} failed: {exc}", exc_info=True) + + await task_manager.create_task( + _runner(), + device_id=device_id, + task_type="persistence", + ) + async def _handle_device_info(self, device_id: str, payload: dict): data = payload.get("data", {}) - await device_setting_service.insert_or_update( - device_id=device_id, - power=data.get("power"), - signal_strength=data.get("signal"), - version_str=data.get("version"), - volume=data.get("voice"), + await self._schedule_persistence( + device_id, + "device_info", + device_setting_service.insert_or_update( + device_id=device_id, + power=data.get("power"), + signal_strength=data.get("signal"), + version_str=data.get("version"), + volume=data.get("voice"), + ), ) await self._publish(f"device/{device_id}/event_resp", {"msg_id": "000", "status": "success"}) @@ -107,12 +125,31 @@ class TalkingQMQTTService: return data = payload.get("data", {}) - location = ChildLocationCurrent( - device_id=device_id, - lat=data.get("latitude"), - lon=data.get("longitude"), + raw_device_time = data.get("device_time") + parsed_device_time = None + if isinstance(raw_device_time, str) and raw_device_time.strip(): + try: + parsed_device_time = datetime.fromisoformat(raw_device_time.strip()) + except ValueError: + parsed_device_time = None + + await self._schedule_persistence( + device_id, + "gps", + location_service.report_mqtt_device_location( + device_id=device_id, + latitude=data.get("latitude"), + longitude=data.get("longitude"), + coord_type=data.get("coord_type"), + accuracy_m=data.get("accuracy_m"), + altitude_m=data.get("altitude_m"), + speed_mps=data.get("speed_mps"), + heading_deg=data.get("heading_deg"), + source=data.get("source"), + battery_pct=data.get("battery_pct"), + device_time=parsed_device_time, + ), ) - await location_service.insert_or_update(device_id=device_id, location=location) async def _handle_volume_response(self, device_id: str, payload: dict): if payload.get("status") != "success": @@ -120,13 +157,36 @@ class TalkingQMQTTService: return data = payload.get("data", {}) - await device_setting_service.insert_or_update(device_id=device_id, volume=data.get("current_level")) + current_level = data.get("current_level") + if current_level is None: + return + await self._schedule_persistence( + device_id, + "volume", + device_setting_service.insert_or_update( + device_id=device_id, + power=None, + volume=current_level, + signal_strength=None, + version_str=None, + ), + ) async def _handle_ota_response(self, device_id: str, payload: dict): status = payload.get("status") data = payload.get("data", {}) if status == "success": - await device_setting_service.insert_or_update(device_id=device_id, version_str=data.get("new_version")) + await self._schedule_persistence( + device_id, + "ota", + device_setting_service.insert_or_update( + device_id=device_id, + power=None, + volume=None, + signal_strength=None, + version_str=data.get("new_version"), + ), + ) elif status != "accepted": logger.warning(device_id, "", f"[OTA] command failed: {payload}")