4 Commits
43 changed files with 841 additions and 128 deletions
@@ -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 ? '当前设备已运行最新版本,无需更新。'
+2 -1
View File
@@ -1,3 +1,4 @@
export default definePageConfig({
navigationStyle: 'custom'
navigationStyle: 'custom',
enableShareAppMessage: true,
})
+5 -3
View File
@@ -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
+3 -2
View File
@@ -1,4 +1,5 @@
export default {
navigationBarTitleText: '聊天记录',
navigationStyle: 'custom'
}
navigationStyle: 'custom',
enableShareAppMessage: true,
}
+2
View File
@@ -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)
+3 -2
View File
@@ -1,3 +1,4 @@
export default definePageConfig({
navigationStyle: 'custom'
})
navigationStyle: 'custom',
enableShareAppMessage: true,
})
+2
View File
@@ -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,
})
+2
View File
@@ -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,
})
+2
View File
@@ -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)
+3 -2
View File
@@ -1,3 +1,4 @@
export default definePageConfig({
navigationStyle: 'custom'
})
navigationStyle: 'custom',
enableShareAppMessage: true,
})
+2 -3
View File
@@ -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,
})
+2
View File
@@ -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) : ''
-4
View File
@@ -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 '请输入宝贝姓名'
+21 -6
View File
@@ -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: '暂无完整版本信息' }
}
+40
View File
@@ -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)
})
+1
View File
@@ -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")
+15
View File
@@ -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
+25 -8
View File
@@ -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 -4
View File
@@ -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)
+6
View File
@@ -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):
+107 -23
View File
@@ -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)}")
+7 -1
View File
@@ -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()