设备首页接入真实状态与音量控制

This commit is contained in:
stu2not
2026-05-05 16:31:39 +08:00
parent 0dd3246d81
commit 12a7a3a116
9 changed files with 699 additions and 251 deletions

View File

@@ -10,71 +10,74 @@
padding: 80px 32px 24px; padding: 80px 32px 24px;
.page-title { .page-title {
display: block;
font-size: 48px; font-size: 48px;
line-height: 1.2;
font-weight: 700; font-weight: 700;
color: #1A1A1A; color: #1A1A1A;
display: block;
line-height: 1.2;
} }
} }
.status-card { .status-card {
background: linear-gradient(135deg, #FF8C42 0%, #FF6B35 100%);
border-radius: 28px;
padding: 40px 36px;
margin: 0 24px 32px;
position: relative; position: relative;
overflow: hidden; overflow: hidden;
margin: 0 24px 32px;
padding: 40px 36px;
border-radius: 28px;
background: linear-gradient(135deg, #FF8C42 0%, #FF6B35 100%);
&::before { &::before {
content: ''; content: '';
position: absolute; position: absolute;
right: -20px;
top: 50%; top: 50%;
transform: translateY(-50%); right: -20px;
width: 200px; width: 200px;
height: 200px; height: 200px;
background: rgba(255, 255, 255, 0.1);
border-radius: 50%; border-radius: 50%;
transform: translateY(-50%);
background: rgba(255, 255, 255, 0.1);
} }
} }
.status-badge { .status-badge {
display: inline-flex; display: inline-flex;
align-items: center; align-items: center;
background: rgba(255, 255, 255, 0.25);
border-radius: 20px;
padding: 10px 20px;
margin-bottom: 24px; margin-bottom: 24px;
padding: 10px 20px;
border-radius: 20px;
background: rgba(255, 255, 255, 0.25);
.status-dot { .status-dot {
width: 12px; width: 12px;
height: 12px; height: 12px;
background: #4CD964;
border-radius: 50%;
margin-right: 8px; margin-right: 8px;
border-radius: 50%;
background: #4CD964;
} }
.status-text { .status-text {
font-size: 24px; font-size: 24px;
color: #FFFFFF;
font-weight: 500; font-weight: 500;
color: #FFFFFF;
} }
} }
.battery-section { .battery-section {
position: relative;
z-index: 1;
.battery-percent { .battery-percent {
display: block;
margin-bottom: 12px;
font-size: 72px; font-size: 72px;
line-height: 1;
font-weight: 700; font-weight: 700;
color: #FFFFFF; color: #FFFFFF;
line-height: 1;
margin-bottom: 12px;
display: block;
.percent { .percent {
margin-left: 4px;
font-size: 32px; font-size: 32px;
font-weight: 600; 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 { .device-avatar {
position: absolute; position: absolute;
right: 36px;
top: 50%; top: 50%;
transform: translateY(-50%); right: 36px;
width: 120px;
height: 120px;
background: rgba(255, 255, 255, 0.2);
border-radius: 50%;
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
width: 120px;
height: 120px;
border: 3px solid rgba(255, 255, 255, 0.3); 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-icon {
.robot-img { .robot-img {
width: 56px; width: 56px;
height: 56px; height: 56px;
background: #FFFFFF;
border-radius: 50%; border-radius: 50%;
background: #FFFFFF;
} }
} }
.section-title { .section-title {
display: block;
margin: 0 24px 20px;
font-size: 32px; font-size: 32px;
font-weight: 700; font-weight: 700;
color: #1A1A1A; color: #1A1A1A;
margin: 0 24px 20px;
display: block;
} }
.control-card { .control-card {
background: #FFFFFF;
border-radius: 16px;
margin: 0 24px 24px;
box-shadow: 0 2px 12px rgba(0, 0, 0, 0.08);
overflow: hidden; overflow: hidden;
margin: 0 24px 24px;
border-radius: 16px;
background: #FFFFFF;
box-shadow: 0 2px 12px rgba(0, 0, 0, 0.08);
} }
.control-item { .control-item {
@@ -135,62 +169,70 @@
align-items: stretch; align-items: stretch;
padding: 20px 24px 24px; padding: 20px 24px 24px;
} }
}
.volume-header { .control-left {
display: flex; display: flex;
align-items: center; align-items: center;
margin-bottom: 16px; flex: 1;
min-width: 0;
}
.control-left { .icon-bg {
flex: 1; 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 { .control-left {
display: flex;
align-items: center;
flex: 1; 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; padding: 0 8px;
.volume-icon { .volume-icon {
flex-shrink: 0;
width: 24px; width: 24px;
height: 24px; height: 24px;
flex-shrink: 0;
} }
.slider { .slider {
@@ -211,90 +253,64 @@
} }
} }
.divider { .fold-card {
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);
overflow: hidden; 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; margin: 0 24px 24px;
padding: 10px 24px; border-radius: 20px;
background: #FFFFFF;
box-shadow: 0 2px 12px rgba(0, 0, 0, 0.04); 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 { .detail-row {
display: flex; display: flex;
justify-content: space-between;
align-items: center; align-items: center;
justify-content: space-between;
padding: 20px 0; padding: 20px 0;
border-bottom: 1px solid #F3F3F3; border-bottom: 1px solid #F3F3F3;
@@ -311,29 +327,41 @@
.detail-value { .detail-value {
max-width: 60%; max-width: 60%;
font-size: 28px; font-size: 28px;
color: #1A1A1A;
text-align: right; text-align: right;
word-break: break-all; 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 { .empty-card {
background: #FFFFFF;
border-radius: 24px;
margin: 0 24px; margin: 0 24px;
padding: 48px 32px; padding: 48px 32px;
border-radius: 24px;
background: #FFFFFF;
box-shadow: 0 2px 12px rgba(0, 0, 0, 0.04); box-shadow: 0 2px 12px rgba(0, 0, 0, 0.04);
text-align: center; text-align: center;
} }
.empty-robot { .empty-robot {
width: 132px;
height: 132px;
background: #FEF3E8;
border-radius: 50%;
margin: 0 auto 24px;
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
width: 132px;
height: 132px;
margin: 0 auto 24px;
border-radius: 50%;
background: #FEF3E8;
.robot-img { .robot-img {
width: 68px; width: 68px;
@@ -357,55 +385,40 @@
} }
.empty-btn { .empty-btn {
margin-top: 28px;
height: 92px;
border-radius: 16px;
background: #FF8C42;
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
height: 92px;
margin-top: 28px;
border-radius: 16px;
background: #FF8C42;
} }
.empty-btn-text { .empty-btn-text {
font-size: 30px; font-size: 30px;
font-weight: 600;
color: #FFFFFF; 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 { .child-focus-card {
display: flex; display: flex;
align-items: center; align-items: center;
background: #FFFFFF;
border-radius: 24px;
margin: 0 24px 24px; margin: 0 24px 24px;
padding: 28px 24px; padding: 28px 24px;
border-radius: 24px;
background: #FFFFFF;
box-shadow: 0 2px 12px rgba(0, 0, 0, 0.04); box-shadow: 0 2px 12px rgba(0, 0, 0, 0.04);
} }
.child-focus-avatar { .child-focus-avatar {
width: 96px;
height: 96px;
border-radius: 50%;
background: #FEF3E8;
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
width: 96px;
height: 96px;
margin-right: 20px; margin-right: 20px;
border-radius: 50%;
background: #FEF3E8;
} }
.child-focus-icon { .child-focus-icon {

View File

@@ -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 { 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 { Binding, loadCurrentChildBindingContext } from '@/services/binding' import { Binding, loadCurrentChildBindingContext } from '@/services/binding'
import { Child } from '@/services/child' import { Child } from '@/services/child'
import { DeviceStatus, getDeviceStatus, setDeviceVolume } from '@/services/device'
import './index.scss' 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() { export default function Device() {
const [binding, setBinding] = useState<Binding | null>(null) const [binding, setBinding] = useState<Binding | null>(null)
const [child, setChild] = useState<Child | null>(null) const [child, setChild] = useState<Child | null>(null)
const [deviceStatus, setDeviceStatus] = useState<DeviceStatus | null>(null)
const [isLoading, setIsLoading] = useState(true) const [isLoading, setIsLoading] = useState(true)
const [sleepEnabled, setSleepEnabled] = 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(() => { useDidShow(() => {
void loadDeviceInfo() void loadDeviceInfo()
@@ -28,6 +64,15 @@ export default function Device() {
const context = await loadCurrentChildBindingContext() const context = await loadCurrentChildBindingContext()
setChild(context.currentChild) setChild(context.currentChild)
setBinding(context.currentBinding) 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) { } catch (error: any) {
console.error('[device] load failed:', error) console.error('[device] load failed:', error)
Taro.showToast({ Taro.showToast({
@@ -40,15 +85,45 @@ export default function Device() {
} }
const handleSleepToggle = (e: any) => { const handleSleepToggle = (e: any) => {
setSleepEnabled(e.detail.value) const nextValue = Boolean(e.detail?.value)
setSleepEnabled(nextValue)
Taro.showToast({ Taro.showToast({
title: e.detail.value ? '休眠展示已开启' : '休眠展示已关闭', title: nextValue ? '休眠展示已开启' : '休眠展示已关闭',
icon: 'none', icon: 'none',
}) })
} }
const handleVolumeChange = (e: any) => { const handleToggleDetail = () => {
setVolume(e.detail.value) 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) { if (isLoading) {
@@ -115,8 +190,12 @@ export default function Device() {
const childName = child.child_name || '未设置' const childName = child.child_name || '未设置'
const pageTitle = `${childName} 的伴伴` const pageTitle = `${childName} 的伴伴`
const battery = 82 const batteryValue = deviceStatus?.power ?? deviceStatus?.battery_pct ?? null
const daysLeft = 4 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 ( return (
<View className='device-page'> <View className='device-page'>
@@ -131,10 +210,20 @@ export default function Device() {
</View> </View>
<View className='battery-section'> <View className='battery-section'>
<Text className='battery-percent'> <Text className='battery-percent'>
{battery} {batteryDisplay}
<Text className='percent'>%</Text> {batteryDisplay !== '--' && <Text className='percent'>%</Text>}
</Text> </Text>
<Text className='battery-label'> ( {daysLeft} )</Text> <Text className='battery-label'></Text>
</View>
<View className='status-meta'>
<View className='status-pill'>
<Text className='status-pill-label'></Text>
<Text className='status-pill-value'>{signalLabel}</Text>
</View>
<View className='status-pill'>
<Text className='status-pill-label'></Text>
<Text className='status-pill-value'>{versionLabel}</Text>
</View>
</View> </View>
<View className='device-avatar'> <View className='device-avatar'>
<View className='robot-icon'> <View className='robot-icon'>
@@ -143,31 +232,12 @@ export default function Device() {
</View> </View>
</View> </View>
<View className='detail-card'> <View className='section-title'></View>
<View className='detail-row'>
<Text className='detail-label'></Text>
<Text className='detail-value'>{childName}</Text>
</View>
<View className='detail-row'>
<Text className='detail-label'></Text>
<Text className='detail-value'>{binding.device_id}</Text>
</View>
<View className='detail-row'>
<Text className='detail-label'></Text>
<Text className='detail-value'>{binding.bound_at || '--'}</Text>
</View>
</View>
<View className='section-title'></View>
<View className='control-card'> <View className='control-card'>
<View className='control-item'> <View className='control-item'>
<View className='control-left'> <View className='control-left'>
<View className='icon-bg purple'> <View className='icon-bg purple'>
<Image <Image className='control-icon-img' src={require('../../assets/tab-icons/moon.png')} mode='aspectFit' />
className='control-icon-img'
src={require('../../assets/tab-icons/moon.png')}
mode='aspectFit'
/>
</View> </View>
<View className='control-info'> <View className='control-info'>
<Text className='control-name'></Text> <Text className='control-name'></Text>
@@ -189,7 +259,10 @@ export default function Device() {
mode='aspectFit' mode='aspectFit'
/> />
</View> </View>
<Text className='control-name'></Text> <View className='control-info'>
<Text className='control-name'></Text>
<Text className='control-detail'>{isSavingVolume ? '发送中...' : `当前 ${volumeLabel}`}</Text>
</View>
</View> </View>
</View> </View>
<View className='volume-slider'> <View className='volume-slider'>
@@ -202,8 +275,9 @@ export default function Device() {
className='slider' className='slider'
min={0} min={0}
max={100} max={100}
value={volume} value={volumeValue}
onChange={handleVolumeChange} onChanging={handleVolumeChanging}
onChange={handleVolumeCommit}
activeColor='#FF8C42' activeColor='#FF8C42'
backgroundColor='#E5E5E5' backgroundColor='#E5E5E5'
blockColor='#FF8C42' blockColor='#FF8C42'
@@ -216,6 +290,45 @@ export default function Device() {
</View> </View>
</View> </View>
</View> </View>
<View className='fold-card'>
<View className='fold-header' onClick={handleToggleDetail}>
<View className='fold-heading'>
<Text className='fold-title'></Text>
<Text className='fold-subtitle'>{binding.device_id}</Text>
</View>
<Text className='fold-action'>{isDetailExpanded ? '收起' : '展开'}</Text>
</View>
{isDetailExpanded && (
<View className='detail-body'>
<View className='detail-row'>
<Text className='detail-label'></Text>
<Text className='detail-value'>{childName}</Text>
</View>
<View className='detail-row'>
<Text className='detail-label'></Text>
<Text className='detail-value'>{binding.device_id}</Text>
</View>
<View className='detail-row'>
<Text className='detail-label'></Text>
<Text className='detail-value'>{formatTime(binding.bound_at)}</Text>
</View>
<View className='detail-row'>
<Text className='detail-label'></Text>
<Text className='detail-value'>{coordinateLabel}</Text>
</View>
<View className='detail-row'>
<Text className='detail-label'></Text>
<Text className='detail-value'>{formatTime(deviceStatus?.settings_updated_at)}</Text>
</View>
<View className='detail-row'>
<Text className='detail-label'></Text>
<Text className='detail-value'>{formatTime(deviceStatus?.location_updated_at)}</Text>
</View>
</View>
)}
</View>
</View> </View>
) )
} }

View File

@@ -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<string> {
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<DeviceStatus | null> {
const resolvedDeviceId = await resolveDeviceId(deviceId)
if (!resolvedDeviceId) return null
try {
return await request<DeviceStatus>(`/banban/devices/${resolvedDeviceId}/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) {
throw new Error('当前没有可用设备')
}
return request<DeviceVolumeUpdateResponse>(`/banban/devices/${resolvedDeviceId}/volume`, {
method: 'POST',
data: { level },
})
}

View File

@@ -60,4 +60,58 @@ class DeviceDAO(BaseDAO):
), ),
params, params,
) )
return result.mappings().all() 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

View File

@@ -3,7 +3,7 @@ from collections.abc import Mapping
from datetime import datetime from datetime import datetime
from fastapi import APIRouter, Depends, HTTPException, Query, Request from fastapi import APIRouter, Depends, HTTPException, Query, Request
from pydantic import BaseModel from pydantic import BaseModel, Field
from sqlalchemy import text from sqlalchemy import text
try: try:
@@ -47,6 +47,39 @@ class DeviceMessageListResponse(BaseModel):
next_cursor: int | None = None 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) @router.get("/{device_id}/messages", response_model=DeviceMessageListResponse)
async def list_device_messages( async def list_device_messages(
device_id: str, 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) @router.get("/{device_id}/location", response_model=DeviceLocationCurrentResponse)
async def get_current_device_location( async def get_current_device_location(
device_id: str, device_id: str,
@@ -203,4 +312,4 @@ async def get_device_location_trajectory(
total=len(rows), total=len(rows),
start_at=start_at, start_at=start_at,
end_at=end_at, end_at=end_at,
) )

View File

@@ -2,6 +2,7 @@ from collections.abc import Mapping
from typing import Any, List from typing import Any, List
from services.database_service_base import DatabaseServiceBase from services.database_service_base import DatabaseServiceBase
from fastapi import HTTPException
from banban.dao.device import DeviceDAO from banban.dao.device import DeviceDAO
@@ -38,6 +39,35 @@ class DeviceService(DatabaseServiceBase):
finally: finally:
await db_session.close() 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 实例 # 创建全局 DeviceService 实例
device_service = DeviceService() device_service = DeviceService()

View File

@@ -93,7 +93,14 @@ class DeviceSettingService(DatabaseServiceBase):
finally: finally:
await db_session.close() 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: try:
current_row = await self.get_setting_by_device_id(device_id=device_id) current_row = await self.get_setting_by_device_id(device_id=device_id)
if current_row: if current_row:
@@ -104,4 +111,4 @@ class DeviceSettingService(DatabaseServiceBase):
pass pass
# 创建全局 DeviceSettingService 实例 # 创建全局 DeviceSettingService 实例
device_setting_service = DeviceSettingService() device_setting_service = DeviceSettingService()

View File

@@ -256,10 +256,10 @@ class DeviceSetting(Base):
timezone: Mapped[str] = mapped_column(String(32), server_default=text("'Asia/Shanghai'")) timezone: Mapped[str] = mapped_column(String(32), server_default=text("'Asia/Shanghai'"))
volume: Mapped[Optional[int]] = mapped_column(Integer) volume: Mapped[Optional[int]] = mapped_column(Integer)
brightness: 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) power: Mapped[Optional[int]] = mapped_column(Integer)
signal_strength: Mapped[Optional[int]] = mapped_column(Integer) signal: Mapped[Optional[int]] = mapped_column("signal", Integer)
version_str: Mapped[Optional[str]] = mapped_column(String(64)) 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")) created_at: Mapped[Optional[datetime]] = mapped_column(DateTime, server_default=text("CURRENT_TIMESTAMP"))
updated_at: Mapped[Optional[datetime]] = mapped_column( updated_at: Mapped[Optional[datetime]] = mapped_column(
DateTime, DateTime,

View File

@@ -1,6 +1,7 @@
import asyncio import asyncio
import json import json
import time import time
from datetime import datetime
from typing import Awaitable, Callable, Dict, Optional from typing import Awaitable, Callable, Dict, Optional
import aiomqtt import aiomqtt
@@ -9,10 +10,10 @@ from banban.service.binding import BindingService
from banban.service.device_setting import device_setting_service from banban.service.device_setting import device_setting_service
from banban.service.location import location_service from banban.service.location import location_service
from config import settings from config import settings
from database.models import ChildLocationCurrent
from services.card_service import card_service from services.card_service import card_service
from services.device_target_cache import device_target_cache from services.device_target_cache import device_target_cache
from services.offline_audio_cache import offline_audio_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 from utils.logger import session_logger as logger
@@ -90,14 +91,31 @@ class TalkingQMQTTService:
logger.error("", "", f"mqtt loop failed: {exc}") logger.error("", "", f"mqtt loop failed: {exc}")
self._connected = False 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): async def _handle_device_info(self, device_id: str, payload: dict):
data = payload.get("data", {}) data = payload.get("data", {})
await device_setting_service.insert_or_update( await self._schedule_persistence(
device_id=device_id, device_id,
power=data.get("power"), "device_info",
signal_strength=data.get("signal"), device_setting_service.insert_or_update(
version_str=data.get("version"), device_id=device_id,
volume=data.get("voice"), 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"}) await self._publish(f"device/{device_id}/event_resp", {"msg_id": "000", "status": "success"})
@@ -107,12 +125,31 @@ class TalkingQMQTTService:
return return
data = payload.get("data", {}) data = payload.get("data", {})
location = ChildLocationCurrent( raw_device_time = data.get("device_time")
device_id=device_id, parsed_device_time = None
lat=data.get("latitude"), if isinstance(raw_device_time, str) and raw_device_time.strip():
lon=data.get("longitude"), 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): async def _handle_volume_response(self, device_id: str, payload: dict):
if payload.get("status") != "success": if payload.get("status") != "success":
@@ -120,13 +157,36 @@ class TalkingQMQTTService:
return return
data = payload.get("data", {}) 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): async def _handle_ota_response(self, device_id: str, payload: dict):
status = payload.get("status") status = payload.get("status")
data = payload.get("data", {}) data = payload.get("data", {})
if status == "success": 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": elif status != "accepted":
logger.warning(device_id, "", f"[OTA] command failed: {payload}") logger.warning(device_id, "", f"[OTA] command failed: {payload}")