Compare commits
4
Commits
ffdd8eaff1
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9ebb1a78ec | ||
|
|
a02e0d5f13 | ||
|
|
7d05e78a64 | ||
|
|
b6906bc8b6 |
@@ -1,17 +1,20 @@
|
||||
import type { DeviceFirmwareStatus } from '@/services/device'
|
||||
import { isFirmwareCurrent, isFirmwareTargetReached, isFirmwareUpdateInProgress } from '../../utils/firmware-status'
|
||||
import { isFirmwareCurrent, isFirmwareTargetReached, isFirmwareUpdateInProgress, normalizeFirmwareStatus } from '../../utils/firmware-status'
|
||||
|
||||
export function firmwareProgressView(status: DeviceFirmwareStatus | null, refreshError = false) {
|
||||
const state = String(status?.update_status || '').trim().toLowerCase()
|
||||
const normalizedStatus = normalizeFirmwareStatus(status)
|
||||
const state = String(normalizedStatus?.update_status || '').trim().toLowerCase()
|
||||
const complete = state === 'success' || state === 'completed'
|
||||
|| (isFirmwareUpdateInProgress(state) && isFirmwareTargetReached(status))
|
||||
const current = !complete && !isFirmwareUpdateInProgress(state) && isFirmwareCurrent(status)
|
||||
|| (isFirmwareUpdateInProgress(state) && isFirmwareTargetReached(normalizedStatus))
|
||||
const current = !complete && !isFirmwareUpdateInProgress(state) && isFirmwareCurrent(normalizedStatus)
|
||||
const failed = state === 'failed' && !current
|
||||
const value = Number(status?.progress)
|
||||
const value = Number(normalizedStatus?.progress)
|
||||
const progress = complete ? 100 : Number.isFinite(value) ? Math.max(0, Math.min(100, value)) : 0
|
||||
const hasProgress = complete || progress > 0
|
||||
const target = status?.target_version
|
||||
const version = current ? status?.current_version : target && !['unknown', 'updating'].includes(target) ? target : status?.latest_version
|
||||
// MQTT 003 carries target_version; latest_version is display-only fallback
|
||||
// for older status rows and must not be used for completion detection.
|
||||
const target = normalizedStatus?.target_version
|
||||
const version = current ? normalizedStatus?.current_version : target && !['unknown', 'updating'].includes(target) ? target : normalizedStatus?.latest_version
|
||||
const title = complete ? '固件更新完成' : current ? '当前已是最新版本' : failed ? '固件更新失败' : '固件更新中'
|
||||
const message = complete ? '设备已完成更新,可以继续使用。'
|
||||
: current ? '当前设备已运行最新版本,无需更新。'
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
export default definePageConfig({
|
||||
navigationStyle: 'custom'
|
||||
navigationStyle: 'custom',
|
||||
enableShareAppMessage: true,
|
||||
})
|
||||
|
||||
@@ -20,13 +20,13 @@ import {
|
||||
CHILD_NAME_RULE_MESSAGE,
|
||||
createChild,
|
||||
getChildren,
|
||||
sanitizeChildNameInput,
|
||||
resolveChildSelection,
|
||||
setSelectedChildId as setStoredSelectedChildId,
|
||||
validateChildNameInput,
|
||||
} from '@/services/child'
|
||||
import { useSystemBanner } from '@/components/system-banner/use-system-banner'
|
||||
import { trafficPageUrl } from '@/services/traffic-binding'
|
||||
import { usePageShare } from '@/utils/page-share'
|
||||
|
||||
import './index.scss'
|
||||
|
||||
@@ -161,6 +161,7 @@ function parseBindingPayload(rawValue: string): { deviceId: string; serialNumber
|
||||
}
|
||||
|
||||
export default function Bind() {
|
||||
usePageShare()
|
||||
const router = useRouter()
|
||||
const isAssignMode = String(router.params?.mode || '').trim() === 'assign'
|
||||
const isCardMode = String(router.params?.mode || '').trim() === 'card'
|
||||
@@ -802,9 +803,10 @@ export default function Bind() {
|
||||
? children.length > 0 ? '不选已有宝贝时,可填写2-5位简体中文姓名(暂不支持数字和英文)' : CHILD_NAME_RULE_MESSAGE
|
||||
: children.length > 0 ? '不选已有宝贝时,可填写2-5位简体中文姓名(暂不支持数字和英文)' : '可不填,稍后再关联宝贝'
|
||||
}
|
||||
maxlength={5}
|
||||
maxlength={-1}
|
||||
onInput={(event) => {
|
||||
const nextChildName = sanitizeChildNameInput(event.detail.value)
|
||||
// Preserve pinyin composition; validate the final name on submit.
|
||||
const nextChildName = event.detail.value
|
||||
setNewChildName(nextChildName)
|
||||
if (nextChildName) {
|
||||
setSelectedChildId(null)
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
export default definePageConfig({
|
||||
navigationStyle: 'custom'
|
||||
navigationStyle: 'custom',
|
||||
enableShareAppMessage: true,
|
||||
})
|
||||
|
||||
@@ -13,6 +13,7 @@ import {
|
||||
PeerKind,
|
||||
} from '../../../services/chat'
|
||||
import { useSystemBanner } from '@/components/system-banner/use-system-banner'
|
||||
import { usePageShare } from '@/utils/page-share'
|
||||
import './index.scss'
|
||||
|
||||
function normalizePeerKind(value?: string): PeerKind {
|
||||
@@ -194,6 +195,7 @@ function getAudioUrlDebugInfo(rawUrl: string) {
|
||||
}
|
||||
|
||||
export default function ChatDetail() {
|
||||
usePageShare()
|
||||
const systemBanner = useSystemBanner()
|
||||
const router = useRouter()
|
||||
const params = router.params
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
export default {
|
||||
navigationBarTitleText: '聊天记录',
|
||||
navigationStyle: 'custom'
|
||||
}
|
||||
navigationStyle: 'custom',
|
||||
enableShareAppMessage: true,
|
||||
}
|
||||
|
||||
@@ -9,9 +9,11 @@ import Taro, { useDidShow } from '@tarojs/taro'
|
||||
import { getToken } from '@/services/auth'
|
||||
import { getConversations, ChatConversation } from '../../services/chat'
|
||||
import { useSystemBanner } from '@/components/system-banner/use-system-banner'
|
||||
import { usePageShare } from '@/utils/page-share'
|
||||
import './index.scss'
|
||||
|
||||
export default function Chat() {
|
||||
usePageShare()
|
||||
const systemBanner = useSystemBanner()
|
||||
const [conversations, setConversations] = useState<ChatConversation[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
export default definePageConfig({
|
||||
navigationStyle: 'custom'
|
||||
})
|
||||
navigationStyle: 'custom',
|
||||
enableShareAppMessage: true,
|
||||
})
|
||||
|
||||
@@ -10,6 +10,7 @@ import { Binding, loadCurrentChildBindingContext } from '@/services/binding'
|
||||
import { Child } from '@/services/child'
|
||||
import { DeviceAlarmItem, DeviceStatus, getDeviceAlarms, getDeviceStatus, getDeviceVolumeCommandStatus, setDeviceRemoteSleepWake, setDeviceVolume } from '@/services/device'
|
||||
import { useSystemBanner } from '@/components/system-banner/use-system-banner'
|
||||
import { usePageShare } from '@/utils/page-share'
|
||||
import './index.scss'
|
||||
|
||||
function formatTime(value?: string | null): string {
|
||||
@@ -119,6 +120,7 @@ function getAlarmLocationHint(alarm: DeviceAlarmItem | null): string {
|
||||
}
|
||||
|
||||
export default function Device() {
|
||||
usePageShare()
|
||||
const systemBanner = useSystemBanner()
|
||||
const [binding, setBinding] = useState<Binding | null>(null)
|
||||
const [pendingBinding, setPendingBinding] = useState<Binding | null>(null)
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
export default definePageConfig({
|
||||
navigationBarTitleText: '家庭邀请',
|
||||
enableShareAppMessage: true,
|
||||
})
|
||||
|
||||
@@ -7,9 +7,11 @@ import { getToken } from '@/services/auth'
|
||||
import { setSelectedBindingDeviceId } from '@/services/binding'
|
||||
import { setSelectedChildId } from '@/services/child'
|
||||
import { acceptFamilyInvitation, FamilyInvitation, getFamilyInvitation } from '@/services/family'
|
||||
import { usePageShare } from '@/utils/page-share'
|
||||
import './index.scss'
|
||||
|
||||
export default function FamilyInvite() {
|
||||
usePageShare()
|
||||
const router = useRouter()
|
||||
const inviteToken = String(router.params?.token || '').trim()
|
||||
const redirectUrl = `/pages/family-invite/index?token=${inviteToken}`
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
export default definePageConfig({
|
||||
navigationStyle: 'custom'
|
||||
})
|
||||
navigationStyle: 'custom',
|
||||
enableShareAppMessage: true,
|
||||
})
|
||||
|
||||
@@ -13,6 +13,7 @@ import {
|
||||
DeviceTrajectoryPoint,
|
||||
} from '@/services/location'
|
||||
import { useSystemBanner } from '@/components/system-banner/use-system-banner'
|
||||
import { usePageShare } from '@/utils/page-share'
|
||||
import './index.scss'
|
||||
|
||||
type TrajectoryMode = 'current' | 'today' | 'recent'
|
||||
@@ -127,6 +128,7 @@ function getTrajectoryMarkerId(index: number): number {
|
||||
}
|
||||
|
||||
export default function Location() {
|
||||
usePageShare()
|
||||
const systemBanner = useSystemBanner()
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [deviceLocation, setDeviceLocation] = useState<DeviceLocation | null>(null)
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
export default definePageConfig({
|
||||
navigationBarTitleText: '微信登录',
|
||||
enableShareAppMessage: true,
|
||||
})
|
||||
|
||||
@@ -3,6 +3,7 @@ import { View, Text, Button, Checkbox, CheckboxGroup, Image } from '@tarojs/comp
|
||||
import { useEffect, useState } from 'react'
|
||||
import Taro from '@tarojs/taro'
|
||||
import { getToken, wechatLogin } from '@/services/auth'
|
||||
import { usePageShare } from '@/utils/page-share'
|
||||
import './index.scss'
|
||||
|
||||
const LOGIN_PAGE_URL = '/pages/login/index'
|
||||
@@ -39,6 +40,7 @@ function navigateAfterLogin(url: string): void {
|
||||
}
|
||||
|
||||
export default function Login() {
|
||||
usePageShare()
|
||||
const [submitting, setSubmitting] = useState(false)
|
||||
const [agreementAccepted, setAgreementAccepted] = useState(false)
|
||||
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
export default definePageConfig({
|
||||
enableShareAppMessage: true,
|
||||
})
|
||||
@@ -4,6 +4,7 @@ import { useEffect, useState } from 'react'
|
||||
import Taro, { useRouter } from '@tarojs/taro'
|
||||
import { getToken } from '@/services/auth'
|
||||
import { resolveWechatMpNotificationLink } from '@/services/notification'
|
||||
import { usePageShare } from '@/utils/page-share'
|
||||
import './index.scss'
|
||||
|
||||
function buildQuery(params: Record<string, string | number | null | undefined>): string {
|
||||
@@ -18,6 +19,7 @@ function buildCurrentPageUrl(token: string): string {
|
||||
}
|
||||
|
||||
export default function NotificationEntry() {
|
||||
usePageShare()
|
||||
const router = useRouter()
|
||||
const token = String(router.params.token || '').trim()
|
||||
const [message, setMessage] = useState('正在打开留言...')
|
||||
|
||||
@@ -2,4 +2,5 @@ export default definePageConfig({
|
||||
navigationBarTitleText: '定时休眠',
|
||||
navigationBarBackgroundColor: '#F5F7FA',
|
||||
navigationBarTextStyle: 'black',
|
||||
enableShareAppMessage: true,
|
||||
})
|
||||
|
||||
@@ -7,6 +7,7 @@ import { loadCurrentChildBindingContext, Binding } from '@/services/binding'
|
||||
import { Child } from '@/services/child'
|
||||
import { DeviceStatus, getDeviceStatus, setDeviceSleepSchedule } from '@/services/device'
|
||||
import { useSystemBanner } from '@/components/system-banner/use-system-banner'
|
||||
import { usePageShare } from '@/utils/page-share'
|
||||
import './index.scss'
|
||||
|
||||
function formatSleepRange(status: DeviceStatus | null): string {
|
||||
@@ -28,6 +29,7 @@ function formatUpdatedAt(value?: string | null): string {
|
||||
}
|
||||
|
||||
export default function SleepSchedule() {
|
||||
usePageShare()
|
||||
const systemBanner = useSystemBanner()
|
||||
const [isLoading, setIsLoading] = useState(true)
|
||||
const [isSaving, setIsSaving] = useState(false)
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
export default definePageConfig({
|
||||
navigationStyle: 'custom'
|
||||
})
|
||||
navigationStyle: 'custom',
|
||||
enableShareAppMessage: true,
|
||||
})
|
||||
|
||||
@@ -26,7 +26,6 @@ import {
|
||||
CHILD_NAME_RULE_MESSAGE,
|
||||
clearSelectedChildId,
|
||||
deleteChild,
|
||||
sanitizeChildNameInput,
|
||||
setSelectedChildId,
|
||||
updateChild,
|
||||
validateChildNameInput,
|
||||
@@ -1496,8 +1495,8 @@ export default function Sleep() {
|
||||
value={childName}
|
||||
placeholder={CHILD_NAME_RULE_MESSAGE}
|
||||
focus
|
||||
maxlength={5}
|
||||
onInput={(event) => setChildName(sanitizeChildNameInput(event.detail.value))}
|
||||
maxlength={-1}
|
||||
onInput={(event) => setChildName(event.detail.value)}
|
||||
/>
|
||||
</View>
|
||||
<View className='modal-actions'>
|
||||
|
||||
@@ -1 +1,4 @@
|
||||
export default definePageConfig({ navigationBarTitleText: '流量服务关联' })
|
||||
export default definePageConfig({
|
||||
navigationBarTitleText: '流量服务关联',
|
||||
enableShareAppMessage: true,
|
||||
})
|
||||
|
||||
@@ -4,11 +4,13 @@ import { useRef, useState } from 'react'
|
||||
import { getToken } from '@/services/auth'
|
||||
import { clearSelectedBindingDeviceId } from '@/services/binding'
|
||||
import { getPageStyle } from '@/utils/page-style'
|
||||
import { usePageShare } from '@/utils/page-share'
|
||||
import { TrafficOperation, getTrafficOperation, resumeTrafficOperation,
|
||||
flushTrafficReceipt, finishTrafficUnbind, jumpTraffic } from '@/services/traffic-binding'
|
||||
import './index.scss'
|
||||
|
||||
export default function TrafficBindingPage() {
|
||||
usePageShare()
|
||||
const { params } = useRouter()
|
||||
const deviceId = String(params.device_id || '')
|
||||
const action = params.action === 'unbind' ? 'unbind' : 'bind'
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
export default definePageConfig({
|
||||
navigationBarTitleText: '服务号通知',
|
||||
enableShareAppMessage: true,
|
||||
})
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
import { Text, View, WebView } from '@tarojs/components'
|
||||
import { useRouter } from '@tarojs/taro'
|
||||
import { usePageShare } from '@/utils/page-share'
|
||||
import './index.scss'
|
||||
|
||||
export default function WebviewPage() {
|
||||
usePageShare()
|
||||
const router = useRouter()
|
||||
const encodedUrl = String(router.params?.url || '').trim()
|
||||
const targetUrl = encodedUrl ? decodeURIComponent(encodedUrl) : ''
|
||||
|
||||
@@ -17,10 +17,6 @@ export function normalizeChildNameInput(value?: string | null): string {
|
||||
return String(value || '').trim()
|
||||
}
|
||||
|
||||
export function sanitizeChildNameInput(value?: string | null): string {
|
||||
return normalizeChildNameInput(value).replace(/[^\u4e00-\u9fa5]/g, '').slice(0, 5)
|
||||
}
|
||||
|
||||
export function validateChildNameInput(value?: string | null): string {
|
||||
const childName = normalizeChildNameInput(value)
|
||||
if (!childName) return '请输入宝贝姓名'
|
||||
|
||||
@@ -8,6 +8,20 @@ export function isFirmwareUpdateInProgress(status?: string | null): boolean {
|
||||
return ['sent', 'accepted', 'updating'].includes(normalizeFirmwareUpdateStatus(status))
|
||||
}
|
||||
|
||||
export function normalizeFirmwareStatus(status: DeviceFirmwareStatus | null): DeviceFirmwareStatus | null {
|
||||
if (!status) return null
|
||||
|
||||
const numericProgress = Number(status.progress)
|
||||
const progress = Number.isFinite(numericProgress)
|
||||
? Math.max(0, Math.min(100, numericProgress))
|
||||
: 0
|
||||
|
||||
return {
|
||||
...status,
|
||||
progress,
|
||||
}
|
||||
}
|
||||
|
||||
function firmwareVersionParts(value?: string | null): number[] | null {
|
||||
const normalized = String(value || '').trim().toLowerCase()
|
||||
if (!normalized || ['unknown', 'updating', '0.0.0', '--'].includes(normalized)) return null
|
||||
@@ -36,15 +50,16 @@ export function isFirmwareCurrent(status: DeviceFirmwareStatus | null): boolean
|
||||
}
|
||||
|
||||
export function firmwareSummaryView(status: DeviceFirmwareStatus | null) {
|
||||
if (!status) return { action: '暂不可用', subtitle: '暂时无法获取版本,请稍后重试' }
|
||||
if (isFirmwareUpdateInProgress(status.update_status) && !isFirmwareTargetReached(status)) {
|
||||
const normalizedStatus = normalizeFirmwareStatus(status)
|
||||
if (!normalizedStatus) return { action: '暂不可用', subtitle: '暂时无法获取版本,请稍后重试' }
|
||||
if (isFirmwareUpdateInProgress(normalizedStatus.update_status) && !isFirmwareTargetReached(normalizedStatus)) {
|
||||
return { action: '查看进度', subtitle: '设备正在更新,请稍候' }
|
||||
}
|
||||
if (isFirmwareCurrent(status)) return { action: '已最新', subtitle: '当前已是最新版本' }
|
||||
if (normalizeFirmwareUpdateStatus(status.update_status) === 'failed') {
|
||||
return { action: status.update_available ? '更新' : '版本待确认', subtitle: status.can_update ? '更新失败,可重试' : '上次更新失败,当前版本待确认' }
|
||||
if (isFirmwareCurrent(normalizedStatus)) return { action: '已最新', subtitle: '当前已是最新版本' }
|
||||
if (normalizeFirmwareUpdateStatus(normalizedStatus.update_status) === 'failed') {
|
||||
return { action: normalizedStatus.update_available ? '更新' : '版本待确认', subtitle: normalizedStatus.can_update ? '更新失败,可重试' : '上次更新失败,当前版本待确认' }
|
||||
}
|
||||
return status.update_available
|
||||
return normalizedStatus.update_available
|
||||
? { action: '更新', subtitle: '发现可更新版本' }
|
||||
: { action: '版本待确认', subtitle: '暂无完整版本信息' }
|
||||
}
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
import { useRouter, useShareAppMessage } from '@tarojs/taro'
|
||||
|
||||
const DEFAULT_SHARE_TITLES: Record<string, string> = {
|
||||
'/pages/device/index': '伴伴设备管理',
|
||||
'/pages/login/index': '登录伴伴小程序',
|
||||
'/pages/bind/index': '绑定伴伴设备',
|
||||
'/pages/traffic-binding/index': '伴伴流量服务',
|
||||
'/pages/notification/index': '查看伴伴通知',
|
||||
'/pages/chat/index': '伴伴聊天记录',
|
||||
'/pages/chat/detail/index': '查看伴伴聊天',
|
||||
'/pages/location/index': '查看设备位置',
|
||||
'/pages/sleep/index': '伴伴设备管理',
|
||||
'/pages/sleep-schedule/index': '设置设备休眠时间',
|
||||
'/pages/family-invite/index': '家庭共享邀请',
|
||||
'/pages/webview/index': '伴伴服务通知',
|
||||
}
|
||||
|
||||
function normalizePath(path: string): string {
|
||||
if (!path) return '/pages/device/index'
|
||||
return path.startsWith('/') ? path : `/${path}`
|
||||
}
|
||||
|
||||
function buildPagePath(path: string, params: Record<string, string | undefined>): string {
|
||||
const query = Object.entries(params)
|
||||
.filter(([, value]) => value !== undefined && value !== '')
|
||||
.map(([key, value]) => `${encodeURIComponent(key)}=${encodeURIComponent(String(value))}`)
|
||||
.join('&')
|
||||
|
||||
return query ? `${path}?${query}` : path
|
||||
}
|
||||
|
||||
export function usePageShare(title?: string): void {
|
||||
const router = useRouter()
|
||||
const pagePath = normalizePath(router.path)
|
||||
|
||||
useShareAppMessage(() => ({
|
||||
title: title || DEFAULT_SHARE_TITLES[pagePath] || '伴伴小程序',
|
||||
path: buildPagePath(pagePath, router.params || {}),
|
||||
}))
|
||||
}
|
||||
@@ -32,6 +32,22 @@ test('received progress is used directly, including fractional values', () => {
|
||||
assert.equal(view.percent, 65)
|
||||
assert.equal(view.complete, false)
|
||||
})
|
||||
test('MQTT 003 target_version is shown while progress is updating', () => {
|
||||
const view = firmwareProgressView({
|
||||
...status('updating', 30),
|
||||
latest_version: '2.0.4',
|
||||
target_version: '2.0.4',
|
||||
})
|
||||
assert.equal(view.version, '2.0.4')
|
||||
assert.equal(view.progress, 30)
|
||||
assert.equal(view.percent, 30)
|
||||
assert.match(view.message, /保持设备电量充足/)
|
||||
})
|
||||
test('active status without target_version falls back to latest_version', () => {
|
||||
const view = firmwareProgressView(status('updating', 30))
|
||||
assert.equal(view.version, '1.8.2')
|
||||
assert.equal(view.progress, 30)
|
||||
})
|
||||
test('nearly complete progress never rounds up to 100', () => {
|
||||
assert.equal(firmwareProgressView(status('updating', 99.9)).percent, 99)
|
||||
})
|
||||
|
||||
@@ -71,6 +71,7 @@ class Settings(BaseSettings):
|
||||
|
||||
assets_dir: str = Field(default="assets", validation_alias="ASSETS_DIR")
|
||||
session_timeout: int = Field(default=600, validation_alias="SESSION_TIMEOUT")
|
||||
voice_target_timeout_seconds: float = Field(default=120, gt=0, le=600, validation_alias="VOICE_TARGET_TIMEOUT_SECONDS")
|
||||
conversation_history_timeout: int = Field(default=1800, validation_alias="CONVERSATION_HISTORY_TIMEOUT")
|
||||
max_conversation_history: int = Field(default=5, validation_alias="MAX_CONVERSATION_HISTORY")
|
||||
cleanup_interval: int = Field(default=300, validation_alias="CLEANUP_INTERVAL")
|
||||
|
||||
@@ -127,6 +127,21 @@ Headers:
|
||||
}
|
||||
```
|
||||
|
||||
设备通过 MQTT `msg_id=003` 上报实时进度时,使用 JSON `data.target_version` 和 `data.progress`,例如:
|
||||
|
||||
```json
|
||||
{
|
||||
"msg_id": "003",
|
||||
"status": "updating",
|
||||
"data": {
|
||||
"target_version": "2.0.4",
|
||||
"progress": 30
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
服务器保存该进度,小程序继续通过本状态接口轮询显示。`progress=100` 只表示设备报告写入进度完成,设备重启后上报的实际版本仍是最终完成依据。
|
||||
|
||||
### 4. 管理固件配置信息(管理员接口)
|
||||
|
||||
**获取固件配置**
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
# OTA `003` 实时进度协议
|
||||
|
||||
设备通过 `device/{device_id}/response` 上报 OTA 状态。服务器下发 OTA 命令仍使用 `msg_id=003`,设备回执使用相同的 `msg_id`。
|
||||
|
||||
## 进行中回执
|
||||
|
||||
```json
|
||||
{
|
||||
"msg_id": "003",
|
||||
"status": "updating",
|
||||
"data": {
|
||||
"target_version": "2.0.4",
|
||||
"progress": 30
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
`progress` 表示设备当前已完成的百分比,范围为 `0` 到 `100`。设备应在状态变化时上报,并避免高频重复发送。`target_version` 应与服务器下发任务的目标版本一致。
|
||||
|
||||
## 状态约定
|
||||
|
||||
| 状态 | 必要字段 | 服务器行为 |
|
||||
| --- | --- | --- |
|
||||
| `accepted` | `current_version`、`target_version` | 记录设备已接受任务,进度默认为 `0` |
|
||||
| `updating` | `target_version`、`progress` | 保存最新进度并供小程序查询 |
|
||||
| `success` | `new_version` 或 `target_version` | 记录完成,进度置为 `100`,随后以设备重启后的版本上报作为最终确认 |
|
||||
| `failed` | `target_version`、可选 `progress` | 保存失败状态和最后有效进度 |
|
||||
|
||||
服务器兼容旧设备上报的 `version`、`new_version` 字段,并优先使用 `target_version` 作为本次任务目标。非法进度值不会覆盖已保存的有效进度。
|
||||
|
||||
小程序通过设备固件状态接口轮询 `update_status`、`progress` 和 `target_version`,显示最近一次服务器收到的设备进度。进度达到 `100` 仍显示为等待设备确认,直到设备重启后上报目标版本。
|
||||
@@ -0,0 +1,36 @@
|
||||
# 留言状态与后续 AI 对话恢复
|
||||
|
||||
留言目标只属于一次录音和一个 WebSocket 连接。目标设置后默认空闲有效期为
|
||||
120 秒,可用 `VOICE_TARGET_TIMEOUT_SECONDS` 调整;录音开始及有效留言音频会刷新
|
||||
空闲期限。每次访问均检查过期状态,后台会定期回收过期目标及缓存。
|
||||
|
||||
开始包统一返回一次 `START_ACK`。存在留言目标时,根据首个音频包选择流程:
|
||||
类型 `0` 清除待处理留言目标并完成 AI 初始化,原始首包随后正常送入 ASR;
|
||||
类型 `4` 写入本次留言的独立缓存。录音开始传输音频后不允许切换类型。
|
||||
|
||||
结束、取消、异常、空音频、超时和连接断开均会释放对应目标及音频缓存。
|
||||
结束时先消费本次目标,再处理上传和通知,避免旧处理完成时清除新设置的目标。
|
||||
旧连接断开只清理该连接的会话和任务,不影响替换它的新连接。
|
||||
音频缓存键按发送设备及本次目标独立生成,多个发送方对同一接收方留言时互不覆盖。
|
||||
|
||||
本次修复保留既有数据包类型、鉴权及设备协议。家长留言的上传、会话保存和通知流程
|
||||
沿用原实现。
|
||||
|
||||
## 本地验证(2026-10-28)
|
||||
|
||||
- 全量后端回归 243/243,包括新增的 28 项留言状态与恢复测试。
|
||||
- 本机隔离 MySQL、原有 `/ws` 路由和真实 ASR/LLM/TTS:正常对话及静音 10/10,
|
||||
中断及恢复 4/4;包含两个角色在待处理、过期、取消家长留言后的 AI 对话。
|
||||
- 实际 WebSocket 家长留言取消 2 次,均收到取消确认及处理完成通知,随后 AI 对话成功。
|
||||
- 共 52 段音频通过 HTTP 200、Range 206、完整 MP3 解码及非零 PCM 检查。
|
||||
- 正常留言的上传、保存、通知和失败分支使用本地替身测试,未向真实家长发送通知。
|
||||
- 没有访问或部署生产服务,未验证实物设备的麦克风、扬声器及播放中打断。
|
||||
|
||||
覆盖边界:留言取消、空音频、处理异常、过期及未结束录音后的 AI 恢复已有回归;
|
||||
AI 中断后再次 AI 对话已有真实服务验证。留言取消后再次留言验证了录音隔离,
|
||||
AI 中断后留言验证了启动、收音及取消,尚未串联验证这两种情况下第二条留言完整发送。
|
||||
正常家长留言处理的成功、空音频和上传异常分支已用模拟外部接口验证。
|
||||
|
||||
状态测试:`.venv/bin/python -m pytest tests/test_voice_target_lifecycle.py -q`
|
||||
|
||||
完整结果:[本地测试汇总](../output/voice-target-recovery-20261028/summary.json)。
|
||||
@@ -22,8 +22,10 @@ async def handle_websocket_data(
|
||||
|
||||
if packet_type == 1: # 开始包
|
||||
try:
|
||||
await websocket.send_text("START_ACK")
|
||||
session_logger.info(device_id, session_id, "已发送开始包确认")
|
||||
if not session.start_ack_sent:
|
||||
await websocket.send_text("START_ACK")
|
||||
session.start_ack_sent = True
|
||||
session_logger.info(device_id, session_id, "已发送开始包确认")
|
||||
except Exception as e:
|
||||
session_logger.error(device_id, session_id, f"发送开始包确认失败: {str(e)}")
|
||||
session.sample_rate = sample_rate
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import asyncio
|
||||
import json
|
||||
import math
|
||||
import time
|
||||
from datetime import datetime
|
||||
from typing import Awaitable, Callable, Dict, Optional
|
||||
@@ -311,26 +312,39 @@ class TalkingQMQTTService:
|
||||
|
||||
async def _handle_ota_response(self, device_id: str, payload: dict):
|
||||
status = payload.get("status")
|
||||
data = payload.get("data", {})
|
||||
data = payload.get("data") or {}
|
||||
if not isinstance(data, dict):
|
||||
logger.warning(device_id, "", f"[OTA] invalid response data: {payload}")
|
||||
return
|
||||
|
||||
target_version = data.get("target_version") or data.get("version") or data.get("new_version")
|
||||
progress = data.get("progress")
|
||||
try:
|
||||
progress_value = float(progress) if progress is not None else None
|
||||
parsed_progress = float(progress) if progress is not None else None
|
||||
progress_value = (
|
||||
parsed_progress
|
||||
if parsed_progress is not None and math.isfinite(parsed_progress) and 0 <= parsed_progress <= 100
|
||||
else None
|
||||
)
|
||||
except (TypeError, ValueError):
|
||||
progress_value = None
|
||||
|
||||
if progress is not None and progress_value is None:
|
||||
logger.warning(device_id, "", f"[OTA] invalid progress value: {progress!r}")
|
||||
|
||||
if status == "accepted":
|
||||
current = data.get("current_version")
|
||||
target = data.get("target_version")
|
||||
logger.info(device_id, "", f"[OTA] 设备 {device_id} 已接受升级: {current} -> {target}")
|
||||
logger.info(device_id, "", f"[OTA] 设备 {device_id} 已接受升级: {current} -> {target_version}")
|
||||
|
||||
await self._schedule_persistence(
|
||||
device_id,
|
||||
"ota",
|
||||
device_firmware_update_manager.update_firmware_update(
|
||||
device_id=device_id,
|
||||
firmware_version=data.get("target_version") or data.get("version") or data.get("new_version") or "unknown",
|
||||
firmware_version=target_version or "unknown",
|
||||
update_status="accepted",
|
||||
progress=progress_value if progress_value is not None else 0.0,
|
||||
target_version=target_version,
|
||||
),
|
||||
)
|
||||
elif status == "updating":
|
||||
@@ -339,13 +353,14 @@ class TalkingQMQTTService:
|
||||
"ota",
|
||||
device_firmware_update_manager.update_firmware_update(
|
||||
device_id=device_id,
|
||||
firmware_version=data.get("version") or data.get("new_version") or "updating",
|
||||
firmware_version=target_version or "updating",
|
||||
update_status="updating",
|
||||
progress=progress_value,
|
||||
target_version=target_version,
|
||||
),
|
||||
)
|
||||
elif status == "success":
|
||||
new_version = data.get("new_version") or data.get("version")
|
||||
new_version = data.get("new_version") or data.get("version") or data.get("target_version")
|
||||
logger.info(device_id, "", f"[OTA] 设备 {device_id} 升级完成: {new_version}")
|
||||
|
||||
await self._schedule_persistence(
|
||||
@@ -367,6 +382,7 @@ class TalkingQMQTTService:
|
||||
firmware_version=new_version or "unknown",
|
||||
update_status="success",
|
||||
progress=100.0,
|
||||
target_version=target_version,
|
||||
),
|
||||
)
|
||||
elif status == "failed":
|
||||
@@ -375,9 +391,10 @@ class TalkingQMQTTService:
|
||||
"ota",
|
||||
device_firmware_update_manager.update_firmware_update(
|
||||
device_id=device_id,
|
||||
firmware_version=data.get("version") or data.get("new_version") or "unknown",
|
||||
firmware_version=target_version or "unknown",
|
||||
update_status="failed",
|
||||
progress=progress_value,
|
||||
target_version=target_version,
|
||||
),
|
||||
)
|
||||
logger.warning(device_id, "", f"[OTA] command failed: {payload}")
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
from services.interrupt_handler import interrupt_handler
|
||||
from services.audio_session import audio_session_manager
|
||||
from utils.logger import session_logger
|
||||
from services.device_target_cache import device_target_cache
|
||||
from services.task_manager import task_manager
|
||||
|
||||
async def handle_old_session_cleanup(old_session, session_key):
|
||||
"""
|
||||
@@ -21,19 +23,22 @@ async def handle_old_session_cleanup(old_session, session_key):
|
||||
session_logger.error("unknown", "unknown", f"清理旧会话资源时出错: {e}")
|
||||
|
||||
|
||||
async def cleanup_device_sessions(device_id):
|
||||
async def cleanup_device_sessions(device_id, websocket=None):
|
||||
"""
|
||||
清理设备所有会话
|
||||
|
||||
Args:
|
||||
device_id: 设备ID
|
||||
"""
|
||||
await device_target_cache.remove_target(device_id, websocket=websocket, reason="disconnected")
|
||||
sessions_to_clean = []
|
||||
for session_key, session in await audio_session_manager.get_all_sessions():
|
||||
if session_key[0] == device_id:
|
||||
if session_key[0] == device_id and (websocket is None or session.websocket is websocket):
|
||||
sessions_to_clean.append((session_key, session))
|
||||
|
||||
for session_key, session in sessions_to_clean:
|
||||
if websocket is not None and session.websocket is not websocket:
|
||||
continue
|
||||
if hasattr(session, "set_interrupted"):
|
||||
session_logger.info(
|
||||
device_id, session_key[1], "WebSocket连接断开,中断会话"
|
||||
@@ -44,8 +49,11 @@ async def cleanup_device_sessions(device_id):
|
||||
await interrupt_handler.set_interrupt_state(session_key, True)
|
||||
if session.llm_service and hasattr(session.llm_service, "closed"):
|
||||
session.llm_service.closed = True
|
||||
await task_manager.cancel_session_tasks(session_key)
|
||||
|
||||
for session_key, _ in sessions_to_clean:
|
||||
for session_key, session in sessions_to_clean:
|
||||
if websocket is not None and session.websocket is not websocket:
|
||||
continue
|
||||
await audio_session_manager.remove_session(session_key)
|
||||
await interrupt_handler.remove_session(session_key)
|
||||
session_logger.info(
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
from fastapi import WebSocket, WebSocketDisconnect
|
||||
from utils.logger import session_logger
|
||||
from services.connection_manager import connection_manager
|
||||
from services.task_manager import task_manager
|
||||
from services.offline_audio_cache import offline_audio_cache
|
||||
from handlers.websocket_auth_handler import authenticate_websocket
|
||||
from handlers.websocket_message_handler import handle_websocket_messages
|
||||
@@ -59,9 +58,7 @@ async def websocket_endpoint(websocket: WebSocket):
|
||||
finally:
|
||||
if device_id:
|
||||
await connection_manager.remove_connection(device_id, websocket)
|
||||
await cleanup_device_sessions(device_id)
|
||||
# 清理设备相关的所有异步任务
|
||||
await task_manager.cancel_device_tasks(device_id)
|
||||
await cleanup_device_sessions(device_id, websocket)
|
||||
|
||||
session_logger.info(
|
||||
device_id or "unknown",
|
||||
|
||||
@@ -44,6 +44,7 @@ async def handle_websocket_messages(websocket: WebSocket, device_id: str, serial
|
||||
websocket: WebSocket连接
|
||||
device_id: 设备ID
|
||||
"""
|
||||
await device_target_cache.bind_connection(device_id, websocket)
|
||||
current_active_session = None
|
||||
first_audio_received_time = None
|
||||
|
||||
@@ -156,54 +157,111 @@ async def handle_text_message(websocket: WebSocket, device_id: str, text_data: s
|
||||
|
||||
async def handle_binary_message(websocket: WebSocket, device_id: str, serial_number: str, binary_data, current_active_session, first_audio_received_time):
|
||||
"""处理二进制音频消息"""
|
||||
active_connection = await connection_manager.get_connection(device_id)
|
||||
if active_connection is not None and active_connection is not websocket:
|
||||
session_logger.warning(device_id, "connection", "忽略已被替换连接的录音包")
|
||||
return current_active_session, first_audio_received_time
|
||||
session_key, session, packet_type, audio_data, sample_rate = await parse_packet(binary_data, websocket)
|
||||
|
||||
if session:
|
||||
session.websocket = websocket
|
||||
session_id = session_key[1]
|
||||
|
||||
if packet_type == 1: # 开始包
|
||||
voice_target = await device_target_cache.get_voice_target(device_id)
|
||||
if voice_target:
|
||||
await target_audio_cache.clear_audio_data(voice_target.audio_cache_key)
|
||||
return None, None
|
||||
current_active_session = await handle_start_packet(
|
||||
device_id, session_id, session_key, session, current_active_session
|
||||
if not session:
|
||||
return current_active_session, first_audio_received_time
|
||||
session_id = session_key[1]
|
||||
if session.websocket is not None and session.websocket is not websocket:
|
||||
session_logger.warning(device_id, session_id, "忽略其他连接的录音包")
|
||||
return current_active_session, first_audio_received_time
|
||||
session.websocket = websocket
|
||||
session.last_activity_time = asyncio.get_running_loop().time()
|
||||
|
||||
if packet_type == 1:
|
||||
if session.start_received:
|
||||
return current_active_session, first_audio_received_time
|
||||
session.start_received = True
|
||||
session.voice_target = await device_target_cache.claim_target(device_id, websocket, session_id)
|
||||
if session.voice_target:
|
||||
# Acknowledge START now; the first audio packet selects AI (0) or
|
||||
# message (4). A pending message must not swallow an AI recording.
|
||||
await websocket.send_text("START_ACK")
|
||||
session.start_ack_sent = True
|
||||
return session_key, None
|
||||
current_active_session = await start_ai_recording(
|
||||
websocket, device_id, session_id, session_key, session, current_active_session, sample_rate
|
||||
)
|
||||
return current_active_session, None
|
||||
|
||||
if not session.start_received or session.recording_finished:
|
||||
session_logger.warning(device_id, session_id, "忽略未开始或已结束的录音包")
|
||||
return current_active_session, first_audio_received_time
|
||||
|
||||
if packet_type == 0:
|
||||
if session.message_audio_received:
|
||||
session_logger.warning(device_id, session_id, "忽略留言录音中混入的普通对话音频")
|
||||
return current_active_session, first_audio_received_time
|
||||
if not session.ai_started:
|
||||
target = session.voice_target
|
||||
if target:
|
||||
await device_target_cache.remove_target(device_id, expected=target, reason="ai_audio")
|
||||
session.voice_target = None
|
||||
current_active_session = await start_ai_recording(
|
||||
websocket, device_id, session_id, session_key, session, current_active_session, sample_rate
|
||||
)
|
||||
elif packet_type == 3: # 中断包
|
||||
voice_target = await device_target_cache.get_voice_target(device_id)
|
||||
if voice_target:
|
||||
return None, None
|
||||
current_active_session = await handle_interrupt_packet(
|
||||
device_id, session_id, session_key, session, current_active_session
|
||||
)
|
||||
elif packet_type == 2: # 结束包
|
||||
voice_target = await device_target_cache.get_voice_target(device_id)
|
||||
if voice_target:
|
||||
session_logger.info(device_id, session_id, f"收到结束包,开始处理缓存音频数据")
|
||||
if voice_target.kind == VOICE_TARGET_KIND_PARENT:
|
||||
await process_parent_leave_message(device_id, voice_target.audio_cache_key)
|
||||
else:
|
||||
await process_cached_audio(device_id, voice_target.target_device_id, serial_number)
|
||||
await device_target_cache.remove_target(device_id)
|
||||
return None, None
|
||||
if current_active_session == session_key:
|
||||
current_active_session = None
|
||||
elif packet_type == 4: # 发送给目标设备的音频包
|
||||
await handle_target_audio_packet(device_id, audio_data)
|
||||
return None, None
|
||||
elif packet_type == 0 and first_audio_received_time is None:
|
||||
if first_audio_received_time is None:
|
||||
first_audio_received_time = time.perf_counter()
|
||||
session.start_time = first_audio_received_time
|
||||
session_logger.info(
|
||||
device_id, session_id, f"收到第一个音频数据包时间: {first_audio_received_time}"
|
||||
)
|
||||
|
||||
await handle_websocket_data(websocket, session_key, session, packet_type, audio_data, sample_rate)
|
||||
|
||||
session_logger.info(device_id, session_id, f"收到第一个音频数据包时间: {first_audio_received_time}")
|
||||
elif packet_type == 4:
|
||||
target = session.voice_target
|
||||
if target and not session.ai_started:
|
||||
if await handle_target_audio_packet(device_id, audio_data, target, websocket, session_id):
|
||||
session.message_audio_received = True
|
||||
else:
|
||||
session_logger.warning(device_id, session_id, "忽略没有对应留言目标的留言音频包")
|
||||
return current_active_session, first_audio_received_time
|
||||
elif packet_type == 3:
|
||||
if session.voice_target:
|
||||
await device_target_cache.remove_target(device_id, expected=session.voice_target, reason="interrupted")
|
||||
session.voice_target = None
|
||||
session.recording_finished = True
|
||||
await websocket.send_text("INTERRUPT_ACK")
|
||||
await websocket.send_text("INTERRUPT_PROCESSED")
|
||||
return None if current_active_session == session_key else current_active_session, None
|
||||
current_active_session = await handle_interrupt_packet(
|
||||
device_id, session_id, session_key, session, current_active_session
|
||||
)
|
||||
elif packet_type == 2:
|
||||
if session.voice_target:
|
||||
target = await device_target_cache.take_target(device_id, session.voice_target, websocket, session_id)
|
||||
session.voice_target = None
|
||||
session.recording_finished = True
|
||||
try:
|
||||
await websocket.send_text("END_ACK")
|
||||
if target:
|
||||
session_logger.info(device_id, session_id, "收到结束包,开始处理缓存音频数据")
|
||||
if target.kind == VOICE_TARGET_KIND_PARENT:
|
||||
await process_parent_leave_message(device_id, target.audio_cache_key)
|
||||
else:
|
||||
await process_cached_audio(device_id, target.target_device_id, serial_number,
|
||||
audio_cache_key=target.audio_cache_key)
|
||||
finally:
|
||||
if target:
|
||||
await target_audio_cache.clear_audio_data(target.audio_cache_key)
|
||||
return None if current_active_session == session_key else current_active_session, None
|
||||
if current_active_session == session_key:
|
||||
current_active_session = None
|
||||
|
||||
await handle_websocket_data(websocket, session_key, session, packet_type, audio_data, sample_rate)
|
||||
if packet_type in (2, 3):
|
||||
session.recording_finished = True
|
||||
return current_active_session, first_audio_received_time
|
||||
|
||||
|
||||
async def start_ai_recording(websocket, device_id, session_id, session_key, session, current_active_session, sample_rate):
|
||||
current_active_session = await handle_start_packet(
|
||||
device_id, session_id, session_key, session, current_active_session
|
||||
)
|
||||
await handle_websocket_data(websocket, session_key, session, 1, b"", sample_rate)
|
||||
session.ai_started = True
|
||||
return current_active_session
|
||||
|
||||
|
||||
async def handle_start_packet(device_id, session_id, session_key, session, current_active_session):
|
||||
"""处理会话开始包"""
|
||||
if current_active_session and current_active_session[1] != session_id:
|
||||
@@ -255,20 +313,17 @@ async def handle_interrupt_packet(device_id, session_id, session_key, session, c
|
||||
return current_active_session
|
||||
|
||||
|
||||
async def handle_target_audio_packet(device_id: str, audio_data: bytes):
|
||||
async def handle_target_audio_packet(device_id: str, audio_data: bytes, voice_target, websocket, session_id):
|
||||
"""处理发送给目标设备的音频包"""
|
||||
try:
|
||||
voice_target = await device_target_cache.get_voice_target(device_id)
|
||||
|
||||
if not voice_target:
|
||||
session_logger.warning(device_id, "target", "未设置目标设备,无法发送音频")
|
||||
return
|
||||
|
||||
await target_audio_cache.add_audio_data(voice_target.audio_cache_key, audio_data)
|
||||
if not await device_target_cache.append_audio(device_id, voice_target, websocket, session_id, audio_data):
|
||||
session_logger.warning(device_id, session_id, "忽略已过期或不属于本次录音的留言音频")
|
||||
return
|
||||
if voice_target.kind == VOICE_TARGET_KIND_PARENT:
|
||||
session_logger.info(device_id, "target", "已缓存发给家长的留言音频数据")
|
||||
else:
|
||||
session_logger.info(device_id, "target", f"已缓存音频数据到目标设备 {voice_target.target_device_id}")
|
||||
return True
|
||||
|
||||
except Exception as e:
|
||||
session_logger.error(device_id, "target", f"处理目标音频包时出错: {e}", exc_info=True)
|
||||
@@ -337,10 +392,11 @@ async def process_parent_leave_message(device_id: str, audio_cache_key: str):
|
||||
await target_audio_cache.clear_audio_data(audio_cache_key)
|
||||
|
||||
|
||||
async def process_cached_audio(device_id: str, target_device_id: str, serial_number: str):
|
||||
async def process_cached_audio(device_id: str, target_device_id: str, serial_number: str, *, audio_cache_key=None):
|
||||
"""处理缓存的音频数据并发送音频URL"""
|
||||
try:
|
||||
cached_audio = await target_audio_cache.get_audio_data(target_device_id)
|
||||
cache_key = audio_cache_key or target_device_id
|
||||
cached_audio = await target_audio_cache.get_audio_data(cache_key)
|
||||
if not cached_audio:
|
||||
session_logger.info(device_id, "target", f"目标设备 {target_device_id} 没有缓存的音频数据")
|
||||
return
|
||||
@@ -398,4 +454,4 @@ async def process_cached_audio(device_id: str, target_device_id: str, serial_num
|
||||
session_logger.error(device_id, "target", f"处理缓存音频时出错: {e}", exc_info=True)
|
||||
finally:
|
||||
# 清除缓存
|
||||
await target_audio_cache.clear_audio_data(target_device_id)
|
||||
await target_audio_cache.clear_audio_data(cache_key)
|
||||
|
||||
@@ -30,6 +30,12 @@ class AudioSession:
|
||||
self.session_id = None
|
||||
self.sample_rate = sample_rate
|
||||
self.audio_queue = None
|
||||
self.voice_target = None
|
||||
self.start_received = False
|
||||
self.start_ack_sent = False
|
||||
self.ai_started = False
|
||||
self.recording_finished = False
|
||||
self.message_audio_received = False
|
||||
|
||||
async def set_interrupted(self, interrupted=True):
|
||||
|
||||
|
||||
@@ -1,59 +1,143 @@
|
||||
import asyncio
|
||||
import time
|
||||
from dataclasses import dataclass
|
||||
from typing import Dict, Optional
|
||||
from uuid import uuid4
|
||||
|
||||
from config import settings
|
||||
from services.connection_manager import connection_manager
|
||||
from services.target_audio_cache import target_audio_cache
|
||||
from utils.logger import session_logger
|
||||
|
||||
|
||||
VOICE_TARGET_KIND_DEVICE = "device"
|
||||
VOICE_TARGET_KIND_PARENT = "parent"
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
@dataclass
|
||||
class VoiceTarget:
|
||||
kind: str
|
||||
target_device_id: Optional[str] = None
|
||||
uuid: Optional[str] = None
|
||||
audio_cache_key: Optional[str] = None
|
||||
websocket: object = None
|
||||
session_id: Optional[str] = None
|
||||
expires_at: float = 0
|
||||
|
||||
|
||||
class DeviceTargetCache:
|
||||
def __init__(self):
|
||||
def __init__(self, *, timeout_seconds=None, clock=None):
|
||||
self.targets: Dict[str, VoiceTarget] = {}
|
||||
self.lock = asyncio.Lock()
|
||||
self.timeout_seconds = timeout_seconds if timeout_seconds is not None else settings.voice_target_timeout_seconds
|
||||
self.clock = clock or time.monotonic
|
||||
|
||||
async def _remove_locked(self, device_id, target, reason, *, clear_audio=True):
|
||||
del self.targets[device_id]
|
||||
if clear_audio:
|
||||
await target_audio_cache.clear_audio_data(target.audio_cache_key)
|
||||
session_logger.info(device_id, target.session_id or "target",
|
||||
f"留言目标已清除: kind={target.kind}, reason={reason}")
|
||||
|
||||
async def _get_locked(self, device_id):
|
||||
target = self.targets.get(device_id)
|
||||
if target and self.clock() >= target.expires_at:
|
||||
await self._remove_locked(device_id, target, "timeout")
|
||||
return None
|
||||
return target
|
||||
|
||||
async def get_target(self, device_id: str) -> Optional[str]:
|
||||
async with self.lock:
|
||||
target = self.targets.get(device_id)
|
||||
if not target or target.kind != VOICE_TARGET_KIND_DEVICE:
|
||||
return None
|
||||
return target.target_device_id
|
||||
target = await self.get_voice_target(device_id)
|
||||
return target.target_device_id if target and target.kind == VOICE_TARGET_KIND_DEVICE else None
|
||||
|
||||
async def get_voice_target(self, device_id: str) -> Optional[VoiceTarget]:
|
||||
async with self.lock:
|
||||
return self.targets.get(device_id)
|
||||
return await self._get_locked(device_id)
|
||||
|
||||
async def _set_target(self, device_id, *, kind, target_device_id=None, uuid=None):
|
||||
async with self.lock:
|
||||
old = self.targets.get(device_id)
|
||||
if old:
|
||||
await self._remove_locked(device_id, old, "replaced")
|
||||
self.targets[device_id] = VoiceTarget(
|
||||
kind=kind, target_device_id=target_device_id, uuid=uuid,
|
||||
audio_cache_key=f"voice:{device_id}:{uuid4().hex}",
|
||||
websocket=await connection_manager.get_connection(device_id),
|
||||
expires_at=self.clock() + self.timeout_seconds,
|
||||
)
|
||||
session_logger.info(device_id, "target", f"留言目标已设置: kind={kind}, timeout={self.timeout_seconds}s")
|
||||
|
||||
async def set_target(self, device_id: str, target_device_id: str):
|
||||
async with self.lock:
|
||||
self.targets[device_id] = VoiceTarget(
|
||||
kind=VOICE_TARGET_KIND_DEVICE,
|
||||
target_device_id=target_device_id,
|
||||
audio_cache_key=target_device_id,
|
||||
)
|
||||
await self._set_target(device_id, kind=VOICE_TARGET_KIND_DEVICE, target_device_id=target_device_id)
|
||||
|
||||
async def set_parent_target(self, device_id: str, uuid: str):
|
||||
async with self.lock:
|
||||
self.targets[device_id] = VoiceTarget(
|
||||
kind=VOICE_TARGET_KIND_PARENT,
|
||||
uuid=uuid,
|
||||
audio_cache_key=f"parent:{device_id}",
|
||||
)
|
||||
await self._set_target(device_id, kind=VOICE_TARGET_KIND_PARENT, uuid=uuid)
|
||||
|
||||
async def remove_target(self, device_id: str):
|
||||
async def claim_target(self, device_id, websocket, session_id):
|
||||
"""Bind one pending intent to one recording on one connection."""
|
||||
async with self.lock:
|
||||
if device_id in self.targets:
|
||||
del self.targets[device_id]
|
||||
active = await connection_manager.get_connection(device_id)
|
||||
if active is not None and active is not websocket:
|
||||
return None
|
||||
target = await self._get_locked(device_id)
|
||||
if target is None:
|
||||
return None
|
||||
if target.websocket is not None and target.websocket is not websocket:
|
||||
await self._remove_locked(device_id, target, "connection_changed")
|
||||
return None
|
||||
if target.session_id is not None and target.session_id != session_id:
|
||||
await self._remove_locked(device_id, target, "new_recording")
|
||||
return None
|
||||
target.websocket, target.session_id = websocket, session_id
|
||||
target.expires_at = self.clock() + self.timeout_seconds
|
||||
session_logger.info(device_id, session_id, f"留言目标已关联录音: kind={target.kind}")
|
||||
return target
|
||||
|
||||
async def bind_connection(self, device_id, websocket):
|
||||
"""Adopt commands received before connect; discard older socket intents."""
|
||||
async with self.lock:
|
||||
if await connection_manager.get_connection(device_id) is not websocket:
|
||||
return
|
||||
target = await self._get_locked(device_id)
|
||||
if target is None:
|
||||
return
|
||||
if target.websocket is not None and target.websocket is not websocket:
|
||||
await self._remove_locked(device_id, target, "connection_changed")
|
||||
else:
|
||||
target.websocket = websocket
|
||||
|
||||
async def append_audio(self, device_id, target, websocket, session_id, audio):
|
||||
async with self.lock:
|
||||
current = await self._get_locked(device_id)
|
||||
if current is not target or target.websocket is not websocket or target.session_id != session_id:
|
||||
return False
|
||||
target.expires_at = self.clock() + self.timeout_seconds
|
||||
await target_audio_cache.add_audio_data(target.audio_cache_key, audio)
|
||||
return True
|
||||
|
||||
async def take_target(self, device_id, target, websocket, session_id):
|
||||
"""Consume the exact recording before slow upload/notification work."""
|
||||
async with self.lock:
|
||||
current = await self._get_locked(device_id)
|
||||
if current is not target or target.websocket is not websocket or target.session_id != session_id:
|
||||
return None
|
||||
await self._remove_locked(device_id, target, "recording_ended", clear_audio=False)
|
||||
return target
|
||||
|
||||
async def remove_target(self, device_id: str, *, expected=None, websocket=None, reason="cancelled"):
|
||||
async with self.lock:
|
||||
target = self.targets.get(device_id)
|
||||
if target is None or (expected is not None and target is not expected):
|
||||
return False
|
||||
if websocket is not None and target.websocket is not websocket:
|
||||
return False
|
||||
await self._remove_locked(device_id, target, reason)
|
||||
return True
|
||||
|
||||
async def get_all_targets(self) -> list:
|
||||
async with self.lock:
|
||||
for device_id in list(self.targets):
|
||||
await self._get_locked(device_id)
|
||||
return list(self.targets.items())
|
||||
|
||||
|
||||
|
||||
@@ -3,6 +3,7 @@ from config import settings
|
||||
from services.audio_session import audio_session_manager
|
||||
from utils.logger import session_logger
|
||||
from services.conversation_history import conversation_history_manager
|
||||
from services.device_target_cache import device_target_cache
|
||||
|
||||
async def run_in_executor(func, *args):
|
||||
loop = asyncio.get_running_loop()
|
||||
@@ -12,6 +13,7 @@ async def background_cleanup_task():
|
||||
try:
|
||||
while True:
|
||||
await asyncio.sleep(settings.cleanup_interval)
|
||||
await device_target_cache.get_all_targets()
|
||||
|
||||
current_time = asyncio.get_running_loop().time()
|
||||
audio_sessions_to_clean = []
|
||||
@@ -54,4 +56,4 @@ async def background_cleanup_task():
|
||||
except asyncio.CancelledError:
|
||||
session_logger.info("system", "cleanup", "后台清理任务已取消")
|
||||
except Exception as e:
|
||||
session_logger.error("system", "cleanup", f"后台清理任务出错: {str(e)}")
|
||||
session_logger.error("system", "cleanup", f"后台清理任务出错: {str(e)}")
|
||||
|
||||
@@ -146,9 +146,15 @@ async def test_simulated_device_force_ota_dispatches_once_and_completes(monkeypa
|
||||
|
||||
await mqtt_service._handle_ota_response(
|
||||
device_id,
|
||||
{"status": "updating", "data": {"version": "1.8.5", "progress": 65}},
|
||||
{
|
||||
"msg_id": "003",
|
||||
"status": "updating",
|
||||
"data": {"target_version": "1.8.5", "progress": 65},
|
||||
},
|
||||
)
|
||||
assert record["update_status"] == "updating"
|
||||
assert record["firmware_version"] == "1.8.5"
|
||||
assert record["target_version"] == "1.8.5"
|
||||
assert record["progress"] == 65.0
|
||||
|
||||
await mqtt_service._handle_ota_response(
|
||||
|
||||
@@ -0,0 +1,345 @@
|
||||
"""Regression for stale message intents swallowing the next AI recording."""
|
||||
import asyncio
|
||||
import struct
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import AsyncMock
|
||||
from unittest.mock import Mock
|
||||
|
||||
import pytest
|
||||
|
||||
from handlers import audio_packet_parser as parser
|
||||
from handlers import audio_session_handler as audio_handler
|
||||
from handlers import session_cleanup_handler as cleanup
|
||||
from handlers import websocket_message_handler as messages
|
||||
from handlers import websocket_handler as endpoint
|
||||
from services import device_target_cache as targets_module
|
||||
from services.audio_session import AudioSessionManager
|
||||
from services.connection_manager import ConnectionManager
|
||||
from services.device_target_cache import DeviceTargetCache
|
||||
from services.target_audio_cache import TargetAudioCache
|
||||
|
||||
DEVICE = 'TalkingQ_local_voice'
|
||||
PROCESS_PARENT_MESSAGE = messages.process_parent_leave_message
|
||||
|
||||
|
||||
def packet(sid, kind, audio=b''):
|
||||
return DEVICE.encode()+b'\0'+sid.encode()+b'\0'+struct.pack('<IBI', 1, kind, len(audio))+audio
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def state(monkeypatch):
|
||||
now = [100.0]
|
||||
connections, sessions, buffers = ConnectionManager(), AudioSessionManager(), TargetAudioCache()
|
||||
targets = DeviceTargetCache(timeout_seconds=10, clock=lambda: now[0])
|
||||
started, ended = [], []
|
||||
ws = SimpleNamespace(send_text=AsyncMock(), accept=AsyncMock(), client='local',
|
||||
client_state=SimpleNamespace(name='CONNECTED'))
|
||||
for module in [messages, endpoint, targets_module]:
|
||||
monkeypatch.setattr(module, 'connection_manager', connections)
|
||||
for module in [messages, cleanup]:
|
||||
monkeypatch.setattr(module, 'device_target_cache', targets)
|
||||
for module in [messages, targets_module]:
|
||||
monkeypatch.setattr(module, 'target_audio_cache', buffers)
|
||||
for module in [messages, cleanup, parser]:
|
||||
monkeypatch.setattr(module, 'audio_session_manager', sessions)
|
||||
monkeypatch.setattr(cleanup, 'task_manager', SimpleNamespace(cancel_session_tasks=AsyncMock()))
|
||||
monkeypatch.setattr(parser, 'get_asr', AsyncMock(side_effect=lambda _: SimpleNamespace(connected=False)))
|
||||
monkeypatch.setattr(parser, 'get_llm', AsyncMock(side_effect=lambda _: SimpleNamespace(close=AsyncMock())))
|
||||
monkeypatch.setattr(parser, 'get_tts', AsyncMock(side_effect=lambda _: SimpleNamespace(close=AsyncMock())))
|
||||
|
||||
async def prepare(session, device_id, sid):
|
||||
started.append(sid)
|
||||
session.asr_service.connected = True
|
||||
session.send_queue = asyncio.Queue()
|
||||
|
||||
async def finish(session, key):
|
||||
chunks = []
|
||||
while not session.send_queue.empty():
|
||||
chunks.append(session.send_queue.get_nowait())
|
||||
ended.append((key[1], b''.join(chunks)))
|
||||
|
||||
monkeypatch.setattr(audio_handler, 'handle_start_packet', prepare)
|
||||
monkeypatch.setattr(audio_handler, 'handle_end_packet', finish)
|
||||
monkeypatch.setattr(messages, 'process_parent_leave_message', AsyncMock())
|
||||
monkeypatch.setattr(messages, 'process_cached_audio', AsyncMock())
|
||||
return SimpleNamespace(now=now, connections=connections, sessions=sessions, buffers=buffers,
|
||||
targets=targets, ws=ws, started=started, ended=ended)
|
||||
|
||||
|
||||
async def send(state, sid, kind, audio=b'', *, ws=None, active=None):
|
||||
return await messages.handle_binary_message(ws or state.ws, DEVICE, 'LOCAL-SERIAL',
|
||||
packet(sid, kind, audio), active, None)
|
||||
|
||||
|
||||
async def set_target(state, kind):
|
||||
if kind == 'parent':
|
||||
await state.targets.set_parent_target(DEVICE, DEVICE)
|
||||
else:
|
||||
await state.targets.set_target(DEVICE, 'TalkingQ_receiver')
|
||||
return await state.targets.get_voice_target(DEVICE)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.parametrize('kind', ['parent', 'device'])
|
||||
async def test_ai_audio_recovers_from_pending_message_without_losing_first_chunk(state, kind):
|
||||
target = await set_target(state, kind)
|
||||
await send(state, 'ai', 1)
|
||||
assert state.started == []
|
||||
await send(state, 'ai', 0, b'first-audio')
|
||||
await send(state, 'ai', 0, b'-tail')
|
||||
await send(state, 'ai', 2)
|
||||
assert state.started == ['ai']
|
||||
assert state.ended == [('ai', b'first-audio-tail')]
|
||||
assert [c.args[0] for c in state.ws.send_text.call_args_list] == ['START_ACK', 'END_ACK']
|
||||
assert await state.targets.get_voice_target(DEVICE) is None
|
||||
assert await state.buffers.get_audio_data(target.audio_cache_key) is None
|
||||
messages.process_parent_leave_message.assert_not_awaited()
|
||||
messages.process_cached_audio.assert_not_awaited()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.parametrize('kind', ['parent', 'device'])
|
||||
@pytest.mark.parametrize('outcome', ['success', 'empty', 'error', 'cancelled'])
|
||||
async def test_message_terminal_paths_release_only_their_recording(state, kind, outcome):
|
||||
target = await set_target(state, kind)
|
||||
await send(state, 'message', 1)
|
||||
if outcome != 'empty':
|
||||
await send(state, 'message', 4, b'message-audio')
|
||||
processor = messages.process_parent_leave_message if kind == 'parent' else messages.process_cached_audio
|
||||
if outcome == 'error':
|
||||
processor.side_effect = RuntimeError('upload failed')
|
||||
with pytest.raises(RuntimeError, match='upload failed'):
|
||||
await send(state, 'message', 2)
|
||||
else:
|
||||
await send(state, 'message', 3 if outcome == 'cancelled' else 2)
|
||||
assert await state.targets.get_voice_target(DEVICE) is None
|
||||
assert await state.buffers.get_audio_data(target.audio_cache_key) is None
|
||||
assert state.started == []
|
||||
if outcome == 'cancelled':
|
||||
processor.assert_not_awaited()
|
||||
assert [c.args[0] for c in state.ws.send_text.call_args_list][-2:] == ['INTERRUPT_ACK', 'INTERRUPT_PROCESSED']
|
||||
else:
|
||||
processor.assert_awaited_once()
|
||||
await send(state, 'next-ai', 1)
|
||||
await send(state, 'next-ai', 0, b'next-question')
|
||||
await send(state, 'next-ai', 2)
|
||||
assert state.ended == [('next-ai', b'next-question')]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_cancelled_message_cannot_receive_late_audio_or_end(state):
|
||||
await set_target(state, 'parent')
|
||||
await send(state, 'cancelled', 1)
|
||||
await send(state, 'cancelled', 4, b'old')
|
||||
await send(state, 'cancelled', 3)
|
||||
await set_target(state, 'parent')
|
||||
fresh = await state.targets.get_voice_target(DEVICE)
|
||||
await send(state, 'fresh', 1)
|
||||
await send(state, 'fresh', 4, b'new')
|
||||
await send(state, 'cancelled', 4, b'late')
|
||||
await send(state, 'cancelled', 2)
|
||||
assert await state.targets.get_voice_target(DEVICE) is fresh
|
||||
assert await state.buffers.get_audio_data(fresh.audio_cache_key) == b'new'
|
||||
messages.process_parent_leave_message.assert_not_awaited()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.parametrize('claimed', [False, True])
|
||||
async def test_timeout_clears_pending_or_partial_message_and_allows_ai(state, claimed):
|
||||
target = await set_target(state, 'parent')
|
||||
if claimed:
|
||||
await send(state, 'expired', 1)
|
||||
await send(state, 'expired', 4, b'partial')
|
||||
state.now[0] += 11
|
||||
assert await state.targets.get_voice_target(DEVICE) is None
|
||||
assert await state.buffers.get_audio_data(target.audio_cache_key) is None
|
||||
await send(state, 'ai', 1)
|
||||
await send(state, 'ai', 0, b'question')
|
||||
await send(state, 'ai', 2)
|
||||
assert state.ended == [('ai', b'question')]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_active_message_refreshes_idle_timeout(state):
|
||||
target = await set_target(state, 'parent')
|
||||
await send(state, 'message', 1)
|
||||
for _ in range(4):
|
||||
state.now[0] += 9
|
||||
await send(state, 'message', 4, b'a')
|
||||
assert await state.targets.get_voice_target(DEVICE) is target
|
||||
assert await state.buffers.get_audio_data(target.audio_cache_key) == b'aaaa'
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_replacement_during_upload_survives_old_recording_cleanup(state):
|
||||
old = await set_target(state, 'parent')
|
||||
await send(state, 'old', 1)
|
||||
await send(state, 'old', 4, b'old-audio')
|
||||
replacement = []
|
||||
|
||||
async def upload(*args):
|
||||
await set_target(state, 'parent')
|
||||
replacement.append(await state.targets.get_voice_target(DEVICE))
|
||||
|
||||
messages.process_parent_leave_message.side_effect = upload
|
||||
await send(state, 'old', 2)
|
||||
assert await state.targets.get_voice_target(DEVICE) is replacement[0]
|
||||
assert replacement[0].audio_cache_key != old.audio_cache_key
|
||||
assert await state.buffers.get_audio_data(old.audio_cache_key) is None
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_new_message_command_during_ai_does_not_hijack_its_end(state):
|
||||
await send(state, 'ai', 1)
|
||||
await send(state, 'ai', 0, b'question')
|
||||
target = await set_target(state, 'parent')
|
||||
await send(state, 'ai', 2)
|
||||
assert state.ended == [('ai', b'question')]
|
||||
assert await state.targets.get_voice_target(DEVICE) is target
|
||||
messages.process_parent_leave_message.assert_not_awaited()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_two_senders_to_same_receiver_use_separate_buffers(state):
|
||||
await state.targets.set_target(DEVICE, 'receiver')
|
||||
await state.targets.set_target('other-sender', 'receiver')
|
||||
a = await state.targets.claim_target(DEVICE, state.ws, 'a')
|
||||
b = await state.targets.claim_target('other-sender', state.ws, 'b')
|
||||
await state.targets.append_audio(DEVICE, a, state.ws, 'a', b'one')
|
||||
await state.targets.append_audio('other-sender', b, state.ws, 'b', b'two')
|
||||
await state.targets.remove_target(DEVICE, expected=a)
|
||||
assert await state.buffers.get_audio_data(b.audio_cache_key) == b'two'
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.parametrize('claimed', [False, True])
|
||||
async def test_disconnect_clears_pending_or_partial_target(state, claimed):
|
||||
await state.connections.add_connection(DEVICE, state.ws)
|
||||
target = await set_target(state, 'parent')
|
||||
if claimed:
|
||||
await send(state, 'message', 1)
|
||||
await send(state, 'message', 4, b'partial')
|
||||
session = await state.sessions.get_session((DEVICE, 'message'))
|
||||
session.set_interrupted = AsyncMock()
|
||||
await cleanup.cleanup_device_sessions(DEVICE, state.ws)
|
||||
assert await state.targets.get_voice_target(DEVICE) is None
|
||||
assert await state.buffers.get_audio_data(target.audio_cache_key) is None
|
||||
assert await state.sessions.get_all_sessions() == []
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_old_socket_cleanup_and_late_packets_preserve_new_connection(state, monkeypatch):
|
||||
old = state.ws
|
||||
new = SimpleNamespace(send_text=AsyncMock())
|
||||
|
||||
async def authenticate(ws):
|
||||
await state.connections.add_connection(DEVICE, old)
|
||||
return True, DEVICE, 'LOCAL'
|
||||
|
||||
async def replacement_connects(*args):
|
||||
await state.connections.add_connection(DEVICE, new)
|
||||
await set_target(state, 'parent')
|
||||
await send(state, 'new', 1, ws=new)
|
||||
await send(state, 'new', 4, b'new-audio', ws=new)
|
||||
await send(state, 'old-late-start', 1, ws=old)
|
||||
await send(state, 'old-late-end', 2, ws=old)
|
||||
|
||||
monkeypatch.setattr(endpoint, 'authenticate_websocket', authenticate)
|
||||
monkeypatch.setattr(endpoint, 'handle_websocket_messages', replacement_connects)
|
||||
await endpoint.websocket_endpoint(old)
|
||||
target = await state.targets.get_voice_target(DEVICE)
|
||||
assert target.websocket is new
|
||||
assert await state.buffers.get_audio_data(target.audio_cache_key) == b'new-audio'
|
||||
assert await state.connections.get_connection(DEVICE) is new
|
||||
assert await state.sessions.get_session((DEVICE, 'new')) is not None
|
||||
cleanup.task_manager.cancel_session_tasks.assert_not_awaited()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_command_before_connect_is_adopted_and_cleared_on_early_disconnect(state):
|
||||
target = await set_target(state, 'parent')
|
||||
assert target.websocket is None
|
||||
await state.connections.add_connection(DEVICE, state.ws)
|
||||
await state.targets.bind_connection(DEVICE, state.ws)
|
||||
await cleanup.cleanup_device_sessions(DEVICE, state.ws)
|
||||
assert await state.targets.get_voice_target(DEVICE) is None
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_reconnect_discards_old_partial_message(state):
|
||||
await state.connections.add_connection(DEVICE, state.ws)
|
||||
old = await set_target(state, 'parent')
|
||||
await send(state, 'old', 1)
|
||||
await send(state, 'old', 4, b'old')
|
||||
new = SimpleNamespace(send_text=AsyncMock())
|
||||
await state.connections.add_connection(DEVICE, new)
|
||||
await state.targets.bind_connection(DEVICE, new)
|
||||
assert await state.targets.get_voice_target(DEVICE) is None
|
||||
assert await state.buffers.get_audio_data(old.audio_cache_key) is None
|
||||
await send(state, 'new-ai', 1, ws=new)
|
||||
await send(state, 'new-ai', 0, b'reconnected-question', ws=new)
|
||||
await send(state, 'new-ai', 2, ws=new)
|
||||
assert state.ended == [('new-ai', b'reconnected-question')]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.parametrize('outcome', ['success', 'empty', 'upload_error'])
|
||||
async def test_real_parent_message_processing_keeps_notifications_and_clears_state(state, monkeypatch, outcome):
|
||||
monkeypatch.setattr(messages, 'process_parent_leave_message', PROCESS_PARENT_MESSAGE)
|
||||
upload = AsyncMock(return_value=SimpleNamespace(file_key='local-test/audio.wav'))
|
||||
create = AsyncMock(return_value=(SimpleNamespace(child_name='local-test'), None, []))
|
||||
notify = Mock()
|
||||
if outcome == 'upload_error':
|
||||
upload.side_effect = RuntimeError('simulated upload failure')
|
||||
monkeypatch.setattr(messages, 'upload_message_audio', upload)
|
||||
monkeypatch.setattr(messages.im_conversation_service, 'create_device_parent_leave_message', create)
|
||||
monkeypatch.setattr(messages.wechat_mp_notification_service, 'schedule_leave_message_notification', notify)
|
||||
await state.connections.add_connection(DEVICE, state.ws)
|
||||
target = await set_target(state, 'parent')
|
||||
await send(state, 'parent', 1)
|
||||
if outcome != 'empty':
|
||||
await send(state, 'parent', 4, b'\x01\x00'*400)
|
||||
await send(state, 'parent', 2)
|
||||
assert await state.targets.get_voice_target(DEVICE) is None
|
||||
assert await state.buffers.get_audio_data(target.audio_cache_key) is None
|
||||
if outcome == 'success':
|
||||
upload.assert_awaited_once()
|
||||
create.assert_awaited_once()
|
||||
assert create.call_args.kwargs['audio_content'].startswith(b'RIFF')
|
||||
notify.assert_called_once()
|
||||
assert state.ws.send_text.call_args.args[0].startswith('PROMPT_SOUND_URL:')
|
||||
else:
|
||||
create.assert_not_awaited()
|
||||
notify.assert_not_called()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.parametrize('first_kind', [0, 4])
|
||||
async def test_recording_cannot_switch_between_ai_and_message_after_audio(state, first_kind):
|
||||
target = await set_target(state, 'parent')
|
||||
await send(state, 'mixed', 1)
|
||||
await send(state, 'mixed', first_kind, b'first')
|
||||
await send(state, 'mixed', 4 if first_kind == 0 else 0, b'wrong-mode')
|
||||
if first_kind == 0:
|
||||
await send(state, 'mixed', 2)
|
||||
assert state.ended == [('mixed', b'first')]
|
||||
messages.process_parent_leave_message.assert_not_awaited()
|
||||
else:
|
||||
assert await state.buffers.get_audio_data(target.audio_cache_key) == b'first'
|
||||
assert state.started == []
|
||||
await send(state, 'mixed', 2)
|
||||
messages.process_parent_leave_message.assert_awaited_once()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_new_recording_recovers_abandoned_message_without_reusing_audio(state):
|
||||
abandoned = await set_target(state, 'parent')
|
||||
await send(state, 'abandoned', 1)
|
||||
await send(state, 'abandoned', 4, b'abandoned-audio')
|
||||
await send(state, 'new-ai', 1)
|
||||
await send(state, 'new-ai', 0, b'question')
|
||||
await send(state, 'abandoned', 2, active=(DEVICE, 'new-ai'))
|
||||
await send(state, 'new-ai', 2)
|
||||
assert state.ended == [('new-ai', b'question')]
|
||||
assert await state.buffers.get_audio_data(abandoned.audio_cache_key) is None
|
||||
messages.process_parent_leave_message.assert_not_awaited()
|
||||
Reference in New Issue
Block a user