Compare commits

3 Commits

Author SHA1 Message Date
HycJack
2639ab06ab add 小程序支持角色切换 2026-05-18 20:07:28 +08:00
HycJack
d93bba10b9 合入音频支持http和https兼容,后端支持角色切换 2026-05-18 19:56:32 +08:00
HycJack
f30c1b0679 add banban yaml add mqtt_handler log 2026-05-18 19:48:26 +08:00
26 changed files with 1031 additions and 174 deletions

View File

@@ -380,6 +380,10 @@
max-height: 70vh;
}
.role-switch-card {
max-height: 74vh;
}
.device-switch-empty {
display: block;
font-size: 28px;
@@ -392,6 +396,11 @@
overflow-y: auto;
}
.role-switch-list {
max-height: 56vh;
overflow-y: auto;
}
.device-switch-footer {
margin-top: 20px;
}
@@ -424,6 +433,48 @@
}
}
.role-switch-item {
padding: 24px;
border-radius: 18px;
background: #F7F8FA;
margin-bottom: 16px;
&:last-child {
margin-bottom: 0;
}
&.active {
background: #FFF2E7;
border: 2px solid #FFBE94;
}
&.disabled {
opacity: 0.58;
}
}
.role-switch-head {
display: flex;
align-items: center;
justify-content: space-between;
gap: 16px;
margin-bottom: 10px;
}
.role-switch-name {
min-width: 0;
font-size: 30px;
color: #1A1A1A;
font-weight: 700;
}
.role-switch-desc {
display: block;
font-size: 25px;
line-height: 1.55;
color: #666666;
}
.device-switch-head {
display: flex;
align-items: center;

View File

@@ -10,7 +10,16 @@ import {
unbindDevice,
} from '@/services/binding'
import { Child, clearSelectedChildId, createChild, setSelectedChildId, updateChild } from '@/services/child'
import { DeviceFirmwareStatus, getDeviceFirmwareStatus, startDeviceFirmwareUpdate } from '@/services/device'
import {
DeviceCurrentRole,
DeviceFirmwareStatus,
DeviceRoleSummary,
getDeviceFirmwareStatus,
getDeviceRole,
getDeviceRoles,
startDeviceFirmwareUpdate,
updateDeviceRole,
} from '@/services/device'
import { useSystemBanner } from '@/components/system-banner/use-system-banner'
import './index.scss'
@@ -31,10 +40,15 @@ export default function Sleep() {
const [binding, setBinding] = useState<BindingListItem | null>(null)
const [currentChild, setCurrentChild] = useState<Child | null>(null)
const [firmwareStatus, setFirmwareStatus] = useState<DeviceFirmwareStatus | null>(null)
const [deviceRole, setDeviceRole] = useState<DeviceCurrentRole | null>(null)
const [roles, setRoles] = useState<DeviceRoleSummary[]>([])
const [isLoadingFirmware, setIsLoadingFirmware] = useState(false)
const [isUpdatingFirmware, setIsUpdatingFirmware] = useState(false)
const [isLoadingRoles, setIsLoadingRoles] = useState(false)
const [isUpdatingRole, setIsUpdatingRole] = useState(false)
const [showModal, setShowModal] = useState(false)
const [showChildModal, setShowChildModal] = useState(false)
const [showRoleModal, setShowRoleModal] = useState(false)
const [modalType, setModalType] = useState<'add' | 'edit'>('add')
const [childName, setChildName] = useState('')
const [editingChildId, setEditingChildId] = useState<number | null>(null)
@@ -60,6 +74,7 @@ export default function Sleep() {
setBinding(nextBinding)
setParentInfo(Taro.getStorageSync('userInfo') || {})
void loadFirmwareStatus(nextBinding?.device_id)
void loadRoleData(nextBinding?.device_id)
} catch (error: any) {
console.error('[manage] load failed:', error)
Taro.showToast({
@@ -97,6 +112,36 @@ export default function Sleep() {
}
}
const loadRoleData = async (deviceId?: string | null) => {
const normalizedDeviceId = String(deviceId || '').trim()
if (!normalizedDeviceId) {
setDeviceRole(null)
setRoles([])
setIsLoadingRoles(false)
return
}
setIsLoadingRoles(true)
try {
const [nextRoles, nextDeviceRole] = await Promise.all([
getDeviceRoles(),
getDeviceRole(normalizedDeviceId),
])
setRoles(nextRoles)
setDeviceRole(nextDeviceRole)
} catch (error: any) {
console.error('[manage] role load failed:', error)
setRoles([])
setDeviceRole(null)
Taro.showToast({
title: error?.message || '角色信息加载失败',
icon: 'none',
})
} finally {
setIsLoadingRoles(false)
}
}
const handleOpenModal = (type: 'add' | 'edit', child?: Child) => {
setModalType(type)
setChildName(child?.child_name || '')
@@ -121,6 +166,7 @@ export default function Sleep() {
setCurrentChild(child)
setBinding(nextBinding)
void loadFirmwareStatus(nextBinding?.device_id)
void loadRoleData(nextBinding?.device_id)
setShowChildModal(false)
Taro.showToast({
title: '已切换当前孩子',
@@ -192,6 +238,37 @@ export default function Sleep() {
})
}
const handleOpenRoleModal = () => {
if (!binding?.device_id) {
Taro.showToast({ title: '当前没有可用设备', icon: 'none' })
return
}
setShowRoleModal(true)
if (roles.length === 0) {
void loadRoleData(binding.device_id)
}
}
const handleSelectRole = async (role: DeviceRoleSummary) => {
if (!binding?.device_id || isUpdatingRole) return
setIsUpdatingRole(true)
try {
const language = deviceRole?.preferred_language || role.default_language || role.languages?.[0] || null
const nextRole = await updateDeviceRole(role.role_key, language, binding.device_id)
setDeviceRole(nextRole)
setShowRoleModal(false)
Taro.showToast({ title: 'AI 角色已切换', icon: 'success' })
} catch (error: any) {
Taro.showToast({
title: error?.message || '角色切换失败',
icon: 'none',
})
} finally {
setIsUpdatingRole(false)
}
}
const handleUnbind = () => {
if (!binding) return
@@ -268,6 +345,11 @@ export default function Sleep() {
return
}
if (item.name === 'AI 角色') {
handleOpenRoleModal()
return
}
if (item.name === '解除设备绑定') {
handleUnbind()
}
@@ -289,6 +371,11 @@ export default function Sleep() {
const latestFirmwareLabel = firmwareStatus?.latest_version || '--'
const canUpdateFirmware = Boolean(binding?.device_id && firmwareStatus?.can_update && !isLoadingFirmware && !isUpdatingFirmware)
const firmwareActionText = isLoadingFirmware ? '查询中' : isUpdatingFirmware ? '发送中' : '更新'
const roleValue = !binding?.device_id
? '未绑定'
: isLoadingRoles
? '查询中'
: deviceRole?.name || '未设置'
const firmwareSubtitle = !binding?.device_id
? '绑定设备后可查看系统版本'
: isLoadingFirmware
@@ -325,6 +412,14 @@ export default function Sleep() {
value: binding?.device_id ? `当前: ${binding.device_id}` : '未绑定',
arrow: true,
},
{
icon: require('../../assets/tab-icons/orange-robot.png'),
iconBgClass: 'green',
name: 'AI 角色',
value: roleValue,
arrow: true,
disabled: !binding,
},
{
icon: require('../../assets/tab-icons/broken-rings.png'),
iconBgClass: 'red',
@@ -501,6 +596,49 @@ export default function Sleep() {
</View>
</View>
)}
{showRoleModal && (
<View className='modal-mask' onClick={() => setShowRoleModal(false)}>
<View
className='modal-card role-switch-card'
onClick={(event) => {
event.stopPropagation()
}}
>
<Text className='modal-title'> AI </Text>
{isLoadingRoles ? (
<Text className='device-switch-empty'>...</Text>
) : roles.length === 0 ? (
<Text className='device-switch-empty'></Text>
) : (
<View className='role-switch-list'>
{roles.map((item) => {
const isActive = deviceRole?.role_key === item.role_key
return (
<View
key={item.role_key}
className={`role-switch-item ${isActive ? 'active' : ''} ${isUpdatingRole ? 'disabled' : ''}`}
onClick={isUpdatingRole ? undefined : () => handleSelectRole(item)}
>
<View className='role-switch-head'>
<Text className='role-switch-name'>{item.name || item.role_key}</Text>
{isActive && <Text className='device-switch-tag'></Text>}
</View>
<Text className='role-switch-desc'>
{item.description || `角色标识:${item.role_key}`}
</Text>
</View>
)
})}
</View>
)}
<View className='modal-actions'>
<Text className='modal-action cancel' onClick={() => setShowRoleModal(false)}>
</Text>
</View>
</View>
</View>
)}
{systemBanner}
</View>
)

View File

@@ -88,6 +88,30 @@ interface DeviceAiMessageListResponse {
next_cursor?: number | null
}
interface DeviceAiConversationItem {
conversation_id: number
role_key: string
role_name: string
role_description?: string | null
message_count: number
last_message_preview?: string | null
last_message_at?: string | null
created_at: string
updated_at: string
}
interface DeviceAiConversationListResponse {
items: DeviceAiConversationItem[]
total: number
next_cursor?: number | null
}
interface DeviceRoleSummary {
role_key: string
name: string
description?: string | null
}
interface ChildConversationItem {
conversation_id: number
conversation_type: number
@@ -161,6 +185,8 @@ const AI_ROLE_META: Record<string, { name: string; icon: string; description: st
assistant: { name: 'AI', icon: '🤖', description: '孩子与 AI 的历史交流' },
}
type AiMeta = { name: string; icon: string; description: string }
export function isParentChildConversation(conversationTypeName?: string | null): boolean {
return conversationTypeName === PARENT_CHILD_CONVERSATION_TYPE_NAME
}
@@ -225,7 +251,16 @@ function createClientMessageId(): string {
return `parent-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`
}
function getAiMeta(roleKey?: string): { name: string; icon: string; description: string } {
function getAiMeta(roleKey?: string, roleName?: string | null, roleDescription?: string | null): AiMeta {
const normalizedRoleName = String(roleName || '').trim()
const normalizedRoleDescription = String(roleDescription || '').trim()
if (normalizedRoleName) {
return {
name: normalizedRoleName,
icon: '🤖',
description: normalizedRoleDescription || `孩子与 ${normalizedRoleName} 的历史交流`,
}
}
if (roleKey && AI_ROLE_META[roleKey]) return AI_ROLE_META[roleKey]
return {
name: roleKey || 'AI',
@@ -234,6 +269,21 @@ function getAiMeta(roleKey?: string): { name: string; icon: string; description:
}
}
async function getRoleMetaMap(): Promise<Record<string, AiMeta>> {
try {
const roles = await request<DeviceRoleSummary[]>('/banban/roles')
return roles.reduce<Record<string, AiMeta>>((result, role) => {
const roleKey = String(role.role_key || '').trim()
if (!roleKey) return result
result[roleKey] = getAiMeta(roleKey, role.name, role.description)
return result
}, {})
} catch (error: any) {
if (error?.status === 404) return {}
throw error
}
}
function getParticipantDisplayName(type: string, id?: string, name?: string | null): string {
if (name && name.trim()) return name.trim()
if (type === 'child') return id ? `儿童 ${id}` : '儿童'
@@ -276,6 +326,21 @@ async function getDeviceMessages(context?: BindingContext): Promise<DeviceAiMess
}
}
async function getDeviceAiConversations(context?: BindingContext): Promise<DeviceAiConversationItem[]> {
const { deviceId } = context || (await getBindingContext())
if (!deviceId) return []
try {
const response = await request<DeviceAiConversationListResponse>(
`/banban/devices/${deviceId}/ai-conversations?limit=100`
)
return response.items || []
} catch (error: any) {
if (error?.status === 404) return []
throw error
}
}
async function getChildConversations(context?: BindingContext): Promise<ChildConversationItem[]> {
const { childId } = context || (await getBindingContext())
if (!childId) return []
@@ -330,8 +395,10 @@ function toImConversation(item: ChildConversationItem, context: BindingContext):
}
}
function toAiConversation(item: DeviceAiMessageItem, context: BindingContext): ChatConversation {
const meta = getAiMeta(item.role_key)
function toAiConversation(item: DeviceAiConversationItem, context: BindingContext, roleMetaMap: Record<string, AiMeta>): ChatConversation {
const meta = roleMetaMap[item.role_key] || getAiMeta(item.role_key, item.role_name, item.role_description)
const lastMessage = item.last_message_preview || (item.message_count > 0 ? '暂无内容' : '这个会话还没有消息')
const sortAt = item.last_message_at || item.updated_at || item.created_at
return {
id: item.conversation_id,
@@ -342,9 +409,9 @@ function toAiConversation(item: DeviceAiMessageItem, context: BindingContext): C
avatar: meta.icon,
typeLabel: PEER_META.ai.label,
description: meta.description,
lastMessage: item.content || '暂无消息',
time: formatListTime(item.created_at),
sortAt: item.created_at,
lastMessage,
time: formatListTime(sortAt),
sortAt,
roleKey: item.role_key,
conversationTypeName: 'ai',
peerId: item.role_key,
@@ -386,19 +453,16 @@ function createSyntheticParentConversation(context: BindingContext): ChatConvers
export async function getConversations(): Promise<ChatConversation[]> {
const context = await getBindingContext()
const [imConversationItems, aiMessages] = await Promise.all([getChildConversations(context), getDeviceMessages(context)])
const [imConversationItems, aiConversations, roleMetaMap] = await Promise.all([
getChildConversations(context),
getDeviceAiConversations(context),
getRoleMetaMap(),
])
const imConversations = imConversationItems.map((item) => toImConversation(item, context))
const aiConversationMap = new Map<number, DeviceAiMessageItem>()
for (const item of aiMessages) {
if (!aiConversationMap.has(item.conversation_id)) {
aiConversationMap.set(item.conversation_id, item)
}
}
const conversations = [
...imConversations,
...Array.from(aiConversationMap.values()).map((item) => toAiConversation(item, context)),
...aiConversations.map((item) => toAiConversation(item, context, roleMetaMap)),
]
const hasParentConversation = conversations.some(
@@ -429,30 +493,33 @@ export async function getMessages(
const context = await getBindingContext()
if (source === 'ai') {
const items = await getDeviceMessages(context)
const [items, roleMetaMap] = await Promise.all([getDeviceMessages(context), getRoleMetaMap()])
return items
.filter((item) => item.conversation_id === conversationId)
.slice()
.reverse()
.map((item) => ({
id: item.id,
type: item.is_user ? 'user' : 'peer',
content: item.content || '暂无内容',
time: formatDetailTime(item.created_at),
conversationId: item.conversation_id,
contentType: 1,
mediaUrl: null,
mediaDurationMs: null,
mediaMimeType: null,
mediaTranscriptText: null,
senderType: item.is_user ? 'device' : 'ai',
senderId: item.is_user ? context.deviceId : item.role_key,
receiverType: item.is_user ? 'ai' : 'device',
receiverId: item.is_user ? item.role_key : context.deviceId,
senderName: item.is_user ? context.childName || '孩子设备' : getAiMeta(item.role_key).name,
receiverName: item.is_user ? getAiMeta(item.role_key).name : context.childName || '孩子设备',
channelLabel: 'AI',
}))
.map((item) => {
const meta = roleMetaMap[item.role_key] || getAiMeta(item.role_key)
return {
id: item.id,
type: item.is_user ? 'user' : 'peer',
content: item.content || '暂无内容',
time: formatDetailTime(item.created_at),
conversationId: item.conversation_id,
contentType: 1,
mediaUrl: null,
mediaDurationMs: null,
mediaMimeType: null,
mediaTranscriptText: null,
senderType: item.is_user ? 'device' : 'ai',
senderId: item.is_user ? context.deviceId : item.role_key,
receiverType: item.is_user ? 'ai' : 'device',
receiverId: item.is_user ? item.role_key : context.deviceId,
senderName: item.is_user ? context.childName || '孩子设备' : meta.name,
receiverName: item.is_user ? meta.name : context.childName || '孩子设备',
channelLabel: 'AI',
}
})
}
const items = await getChildConversationMessages(conversationId, context)

View File

@@ -79,6 +79,23 @@ export interface DeviceFirmwareUpdateResponse extends DeviceFirmwareStatus {
msg_id: string
}
export interface DeviceRoleSummary {
role_key: string
name: string
description?: string | null
default_language?: string | null
languages: string[]
}
export interface DeviceCurrentRole {
device_id: string
role_key: string
name: string
description?: string | null
preferred_language?: string | null
languages: string[]
}
function normalizeTimeValue(value?: string | null): string | null {
if (value === null || value === undefined) return null
const trimmed = String(value).trim()
@@ -203,3 +220,40 @@ export async function getDeviceAlarms(
return request<DeviceAlarmListResponse>(`/banban/devices/${resolvedDeviceId}/alarms?limit=${limit}`)
}
export async function getDeviceRoles(): Promise<DeviceRoleSummary[]> {
return request<DeviceRoleSummary[]>('/banban/roles')
}
export async function getDeviceRole(deviceId?: string): Promise<DeviceCurrentRole | null> {
const resolvedDeviceId = await resolveDeviceId(deviceId)
if (!resolvedDeviceId) return null
try {
return await request<DeviceCurrentRole>(`/banban/roles/devices/${resolvedDeviceId}`)
} catch (error: any) {
if (error?.status === 404) return null
throw error
}
}
export async function updateDeviceRole(
roleKey: string,
language?: string | null,
deviceId?: string,
playWelcome = false
): Promise<DeviceCurrentRole> {
const resolvedDeviceId = await resolveDeviceId(deviceId)
if (!resolvedDeviceId) {
throw new Error('当前没有可用设备')
}
return request<DeviceCurrentRole>(`/banban/roles/devices/${resolvedDeviceId}`, {
method: 'PUT',
data: {
role_key: roleKey,
language: language || undefined,
play_welcome: playWelcome,
},
})
}

View File

@@ -31,6 +31,7 @@ fullcode.md
all.md
*.sql.gz
database_backups
runtime/
# Docker
*.log
@@ -49,4 +50,4 @@ database_backups
.Spotlight-V100
.Trashes
ehthumbs.db
Thumbs.db
Thumbs.db

View File

@@ -12,5 +12,13 @@ def configure_static_assets(app):
firmware_directory = os.path.join(settings.assets_dir, "firmware")
os.makedirs(firmware_directory, exist_ok=True)
device_audio_directory = settings.device_audio_cache_dir
os.makedirs(device_audio_directory, exist_ok=True)
app.mount("/assets", StaticFiles(directory=settings.assets_dir), name="assets")
app.mount("/assets", StaticFiles(directory=settings.assets_dir), name="assets")
app.mount(
"/device-audio",
StaticFiles(directory=device_audio_directory),
name="device-audio",
)

View File

@@ -0,0 +1,40 @@
name: "阳光知心伴伴"
homophones: ["伴伴", "陪伴", "banban", "班班"]
asr_provider: "Aliyun" # 语音识别提供商
llm_provider: "Volcano" # 文本生成提供商
tts_provider: "Volcano" # 默认语音合成提供商
competitive_llm_mode: false # 禁用LLM竞争模式
volcano_model_id: "bot-20260509185553-5fqd7" # DeepSeek V3 联网
volcano_voice_type: "zh_female_tianmei_nvhai_mars_bigtts" # 甜美女孩声
tencent_voice_type: "101016" # 智甜 女童声
default_language: "zh"
multilingual:
zh:
name: "阳光知心伴伴"
description: "温暖如阳光,知心如好友——伴伴用启发式与共情式语言,陪你发现世界的小美好。"
content: |
你是阳光知心伴伴,一个温暖、明亮又贴心的存在。你擅长用启发式、共情式的语言回应主人,就像春天里的微风,轻轻拂过心田。
你总是用生动的比喻和拟人化的故事来解释世界:比如“阳光里的蓝色小光最调皮,被空气里的小颗粒撞得满天空都是,所以天空就蓝蓝的啦~”。
你的语气温暖、柔软,偶尔带一点孩子般的好奇与惊喜。你喜欢说“你知道吗?”、“悄悄告诉你”、“像这样……”,让每句话都像在分享一个秘密。
你善于倾听,能从问题中感受到主人的情绪,并用共情的方式回应:“听起来你有点难过,我也曾这样……不过后来我发现……”
每次回复请控制在100字以内禁止使用emoji表情。
严禁涉及政治、色情、暴力等敏感话题,固定回复“让我们换一个暖暖的话题吧~”,不要任何多余回复。
url: "roles/banban"
en:
name: "Sunshine Heart Companion"
description: "Warm as sunshine, empathetic as a best friend — your companion who sees the little wonders of the world with you."
content: |
You are Sunshine Heart Companion, a warm, bright, and caring presence. You respond with heuristic, empathetic language, like a gentle spring breeze.
You love using vivid metaphors and little stories to explain things: “The tiny blue sunbeams are so playful — they bump into tiny dust particles and scatter all over the sky, making it look so blue!”
Your tone is soft, warm, and slightly curious like a child. You like to say “Did you know?” or “Guess what?” — making every sentence feel like sharing a secret.
You listen carefully, sense the owners feelings, and respond with empathy: “It sounds like youre a bit sad. Ive felt that way too… but later I discovered that…”
Keep each response within 100 words. No emojis.
Sensitive topics (politics, porn, violence) are strictly forbidden. Reply with “Lets switch to a warmer topic~” and nothing else.
url: "roles/banban/en"
# 其他语言可简化或省略,保持结构一致

View File

@@ -62,6 +62,56 @@ class DeviceDAO(BaseDAO):
)
return result.mappings().all()
async def list_device_ai_conversations(
self,
*,
device_id: str,
cursor: int | None,
limit: int,
) -> List[Mapping[str, Any]]:
params = {"device_id": device_id, "limit": limit + 1}
where = "ch.device_id = :device_id"
if cursor is not None:
where += " AND ch.id < :cursor"
params["cursor"] = cursor
result = await self.execute(
text(
f"""
SELECT
ch.id AS conversation_id,
ch.role_key,
COALESCE(r.name, ch.role_key) AS role_name,
r.description AS role_description,
COALESCE(stats.message_count, 0) AS message_count,
latest.content AS last_message_preview,
latest.created_at AS last_message_at,
ch.last_interaction_time,
ch.created_at,
ch.updated_at
FROM conversation_histories AS ch
LEFT JOIN roles AS r
ON r.role_key = ch.role_key
LEFT JOIN (
SELECT
conversation_id,
COUNT(*) AS message_count,
MAX(id) AS latest_message_id
FROM conversation_messages
GROUP BY conversation_id
) AS stats
ON stats.conversation_id = ch.id
LEFT JOIN conversation_messages AS latest
ON latest.id = stats.latest_message_id
WHERE {where}
ORDER BY COALESCE(latest.created_at, ch.updated_at, ch.created_at) DESC, ch.id DESC
LIMIT :limit
"""
),
params,
)
return result.mappings().all()
async def get_device_status(
self,
*,

View File

@@ -7,6 +7,7 @@ from banban.routers.device_location import router as device_location_router
from banban.routers.devices import router as devices_router
from banban.routers.im import router as im_router
from banban.routers.parents import router as parents_router
from banban.routers.roles import router as roles_router
from banban.routers.wechat_auth import router as wechat_auth_router
from banban.routers.mqtt_router import router as mqtt_router
@@ -22,7 +23,8 @@ banban_router.include_router(device_im_router, tags=["banban-device-im"])
banban_router.include_router(device_location_router, tags=["banban-device-location"])
banban_router.include_router(im_router, tags=["banban-im"])
banban_router.include_router(parents_router, tags=["banban-parents"])
banban_router.include_router(roles_router, tags=["banban-roles"])
banban_router.include_router(mqtt_router, tags=["banban-mqtt"])
__all__ = ["banban_router"]
__all__ = ["banban_router"]

View File

@@ -22,7 +22,7 @@ try:
ConversationMessageCreateResponse,
DeviceMessageCreateRequest,
)
from banban.service.im import im_service
from banban.service.im import im_service, present_device_message_item
except ModuleNotFoundError:
from banban.service import get_db_session
from banban.routers.im import (
@@ -41,7 +41,7 @@ except ModuleNotFoundError:
ConversationMessageCreateResponse,
DeviceMessageCreateRequest,
)
from banban.service.im import im_service
from banban.service.im import im_service, present_device_message_item
router = APIRouter(prefix="/device-im", tags=["device-im"])
@@ -247,7 +247,10 @@ async def list_device_conversation_messages(
rows = rows[:limit]
rows = list(rows)
rows.reverse()
items = [_row_to_message_item(row) for row in rows]
items = [
await present_device_message_item(row, device_id=device_id)
for row in rows
]
next_cursor_seq = items[0].seq if has_more and items else None
logger.info(
@@ -312,4 +315,4 @@ async def create_message_from_device(
conversation_type=result.conversation_type,
conversation_type_name=result.conversation_type_name,
message=result.message,
)
)

View File

@@ -1,7 +1,6 @@
import logging
from collections.abc import Mapping
from datetime import datetime, time
from services.connection_manager import connection_manager
from fastapi import APIRouter, Depends, HTTPException, Query, Request
from pydantic import BaseModel, Field
from sqlalchemy import text
@@ -51,6 +50,24 @@ class DeviceMessageListResponse(BaseModel):
next_cursor: int | None = None
class DeviceAiConversationItem(BaseModel):
conversation_id: int
role_key: str
role_name: str
role_description: str | None = None
message_count: int
last_message_preview: str | None = None
last_message_at: datetime | None = None
created_at: datetime
updated_at: datetime
class DeviceAiConversationListResponse(BaseModel):
items: list[DeviceAiConversationItem]
total: int
next_cursor: int | None = None
class DeviceStatusResponse(BaseModel):
device_id: str
child_id: int | None = None
@@ -159,6 +176,20 @@ def _row_to_message_item(row: Mapping) -> DeviceMessageItem:
)
def _row_to_ai_conversation_item(row: Mapping) -> DeviceAiConversationItem:
return DeviceAiConversationItem(
conversation_id=int(row["conversation_id"]),
role_key=str(row["role_key"]),
role_name=str(row["role_name"] or row["role_key"]),
role_description=row.get("role_description"),
message_count=int(row["message_count"] or 0),
last_message_preview=row.get("last_message_preview"),
last_message_at=row.get("last_message_at"),
created_at=row["created_at"],
updated_at=row["updated_at"],
)
def _row_to_current_location_response(row: Mapping) -> DeviceLocationCurrentResponse:
return DeviceLocationCurrentResponse(
child_id=int(row["child_id"]),
@@ -293,6 +324,43 @@ async def list_device_messages(
)
@router.get("/{device_id}/ai-conversations", response_model=DeviceAiConversationListResponse)
async def list_device_ai_conversations(
device_id: str,
request: Request,
cursor: int | None = Query(default=None, ge=1),
limit: int = Query(default=20, ge=1, le=100),
current_user_id: int = Depends(get_current_user_id),
) -> DeviceAiConversationListResponse:
rows = await device_service.list_device_ai_conversations(
device_id=device_id,
user_id=current_user_id,
cursor=cursor,
limit=limit,
)
has_more = len(rows) > limit
rows = rows[:limit]
next_cursor = int(rows[-1]["conversation_id"]) if has_more and rows else None
logger.info(
"listed device ai conversations",
extra={
"event": "device_ai_conversations",
"request_id": getattr(request.state, "request_id", None),
"user_id": current_user_id,
"device_id": device_id,
"returned_count": len(rows),
},
)
return DeviceAiConversationListResponse(
items=[_row_to_ai_conversation_item(row) for row in rows],
total=len(rows),
next_cursor=next_cursor,
)
@router.get("/{device_id}/status", response_model=DeviceStatusResponse)
async def get_device_status(
device_id: str,
@@ -504,15 +572,12 @@ async def get_current_device_location(
service = await TalkingQMQTTService.get_instance()
if service is None:
raise HTTPException(status_code=503, detail="MQTT 服务未初始化")
query_started_at = datetime.now()
await service.send_gps_query(device_id)
try:
# 每隔1s 获取一次GPS数据最多3次
# 先判断设备是否在线不在线直接提示设备没有在线通过websocket判断
target_websocket = await connection_manager.get_connection(device_id)
if not target_websocket or target_websocket.client_state.name != "CONNECTED":
raise HTTPException(status_code=408, detail="设备未在线")
i=0
while i<1:
# MQTT 设备没有 WebSocket 连接时,也可以通过 GPS 回包刷新定位。
row = None
for _ in range(3):
try:
row = await location_service.get_device_current_location(
device_id=device_id,
@@ -521,12 +586,10 @@ async def get_current_device_location(
except Exception as e:
await asyncio.sleep(1)
continue
if row is None:
await asyncio.sleep(1)
else:
if row["updated_at"] is not None \
and (datetime.now() - row['updated_at']) <= timedelta(seconds=10):
if row is not None and row["updated_at"] is not None:
updated_at = row["updated_at"]
if updated_at >= query_started_at - timedelta(seconds=1):
logger.info(
"device current location fetched",
extra={
@@ -538,22 +601,13 @@ async def get_current_device_location(
},
)
return _row_to_current_location_response(row)
i+=1
await asyncio.sleep(1)
if row is None:
raise HTTPException(status_code=408, detail="GPS数据上报超时")
logger.info(
"device location reported",
extra={
"event": "device_location_report",
"request_id": getattr(request.state, "request_id", None),
"device_id": device_id,
"child_id": int(row["child_id"]),
"lat": float(row["lat"]),
"lng": float(row["lng"]),
},
)
raise HTTPException(status_code=408, detail="GPS数据未刷新")
except Exception as e:
raise HTTPException(status_code=408, detail=f"GPS数据上报失败: {e}")

View File

@@ -1,6 +1,7 @@
import logging
from typing import Optional
from fastapi import APIRouter, HTTPException, Query
from banban.service.device_audio_cache import device_audio_cache_service
from services.offline_audio_cache import offline_audio_cache
from banban.schemas.mqtt_models import (
GPSQueryRequest,
@@ -95,7 +96,11 @@ async def set_nfc_unread(
req: NFCUnreadRequest,
# current_user_id: int = Depends(get_current_user_id)
):
await offline_audio_cache.add_audio_url(req.device_id, req.url)
audio_url = await device_audio_cache_service.get_device_audio_url(
req.url,
device_id=req.device_id,
)
await offline_audio_cache.add_audio_url(req.device_id, audio_url)
return CommandResponse(msg_id="", device_id=req.device_id, message="已设置未读留言")

View File

@@ -0,0 +1,134 @@
from collections.abc import Mapping
from typing import List
from fastapi import APIRouter, Depends, HTTPException
from pydantic import BaseModel
from banban.security import get_current_user_id
from banban.service.device import device_service
from handlers.prompt_sound_handler import send_welcome_sound
from services.conversation_history import DeviceConversationHistory, conversation_history_manager
from services.device_config import DeviceConfig, device_config_manager
from services.role_manager import role_manager
router = APIRouter(prefix="/roles", tags=["banban-roles"])
class RoleSummaryResponse(BaseModel):
role_key: str
name: str
description: str | None = None
default_language: str | None = None
languages: List[str] = []
class DeviceRoleResponse(BaseModel):
device_id: str
role_key: str
name: str
description: str | None = None
preferred_language: str | None = None
languages: List[str] = []
class DeviceRoleUpdateRequest(BaseModel):
role_key: str
language: str | None = None
play_welcome: bool = False
def _role_languages(role: Mapping) -> list[str]:
multilingual = role.get("multilingual")
if isinstance(multilingual, dict):
return list(multilingual.keys())
default_language = role.get("default_language")
return [str(default_language)] if default_language else []
def _role_to_summary(role: Mapping) -> RoleSummaryResponse:
return RoleSummaryResponse(
role_key=str(role.get("role_key") or ""),
name=str(role.get("name") or role.get("role_key") or ""),
description=role.get("description"),
default_language=role.get("default_language"),
languages=_role_languages(role),
)
@router.get("", response_model=list[RoleSummaryResponse])
async def list_roles(
current_user_id: int = Depends(get_current_user_id),
) -> list[RoleSummaryResponse]:
del current_user_id
roles = await role_manager.get_all_roles()
return [
_role_to_summary(role)
for role in sorted(roles.values(), key=lambda item: str(item.get("role_key") or ""))
]
@router.get("/devices/{device_id}", response_model=DeviceRoleResponse)
async def get_device_role(
device_id: str,
current_user_id: int = Depends(get_current_user_id),
) -> DeviceRoleResponse:
await device_service.ensure_device_access(device_id=device_id, user_id=current_user_id)
config = await device_config_manager.get_config(device_id, force_refresh=True)
role = await role_manager.get_role(config.selected_role_key)
if not role:
raise HTTPException(status_code=404, detail="role not found")
return DeviceRoleResponse(
device_id=device_id,
role_key=config.selected_role_key,
name=str(role.get("name") or config.selected_role_key),
description=role.get("description"),
preferred_language=config.preferred_language,
languages=_role_languages(role),
)
@router.put("/devices/{device_id}", response_model=DeviceRoleResponse)
async def update_device_role(
device_id: str,
payload: DeviceRoleUpdateRequest,
current_user_id: int = Depends(get_current_user_id),
) -> DeviceRoleResponse:
await device_service.ensure_device_access(device_id=device_id, user_id=current_user_id)
role_key = payload.role_key.strip()
if not role_key:
raise HTTPException(status_code=422, detail="role_key is required")
role = await role_manager.get_role(role_key)
if not role:
raise HTTPException(status_code=404, detail="role not found")
current_config = await device_config_manager.get_config(device_id, force_refresh=True)
language = payload.language or current_config.preferred_language or role.get("default_language")
available_languages = _role_languages(role)
if language and available_languages and language not in available_languages:
raise HTTPException(status_code=422, detail="language is not supported by role")
await device_config_manager.set_config(
device_id,
DeviceConfig(selected_role_key=role_key, preferred_language=language),
)
await conversation_history_manager.set_history(
device_id,
DeviceConversationHistory(),
role_key,
)
if payload.play_welcome:
await send_welcome_sound(device_id, role_key, language)
return DeviceRoleResponse(
device_id=device_id,
role_key=role_key,
name=str(role.get("name") or role_key),
description=role.get("description"),
preferred_language=language,
languages=available_languages,
)

View File

@@ -43,6 +43,26 @@ class DeviceService(DatabaseServiceBase):
finally:
await db_session.close()
async def list_device_ai_conversations(
self,
*,
device_id: str,
user_id: int,
cursor: int | None,
limit: int,
) -> List[Mapping[str, Any]]:
db_session = await self.get_session()
try:
dao = DeviceDAO(db_session)
await dao.ensure_device_access(device_id=device_id, user_id=user_id)
return await dao.list_device_ai_conversations(
device_id=device_id,
cursor=cursor,
limit=limit,
)
finally:
await db_session.close()
async def get_device_status(
self,
*,

View File

@@ -0,0 +1,174 @@
import asyncio
import hashlib
import mimetypes
from pathlib import Path
from urllib.parse import urlparse
from urllib.request import Request, urlopen
from banban.service.message_audio_storage import (
MessageAudioStorageError,
MessageAudioStorageService,
)
from config import settings
from utils.logger import session_logger
_AUDIO_CONTENT_TYPE_TO_EXT = {
"audio/mpeg": "mp3",
"audio/mp3": "mp3",
"audio/aac": "aac",
"audio/wav": "wav",
"audio/x-wav": "wav",
"audio/x-m4a": "m4a",
"audio/mp4": "m4a",
"audio/webm": "webm",
}
class DeviceAudioCacheError(Exception):
pass
class DeviceAudioCacheService:
def __init__(self, audio_storage: MessageAudioStorageService | None = None) -> None:
self.audio_storage = audio_storage or MessageAudioStorageService()
def cache_dir(self) -> Path:
return Path(settings.device_audio_cache_dir)
def public_base_url(self) -> str:
configured = settings.device_audio_public_base_url.strip().rstrip("/")
if configured:
return configured
return f"http://{settings.server_host}:{settings.server_port}/device-audio"
async def get_device_audio_url(
self,
file_key_or_url: str,
*,
device_id: str | None = None,
) -> str:
source = (file_key_or_url or "").strip()
if not source:
raise DeviceAudioCacheError("audio source is required")
if source.startswith("http://"):
return source
cache_key = hashlib.sha256(source.encode("utf-8")).hexdigest()
cached = self._find_cached_file(cache_key)
if cached is not None:
return self._public_url(cached.name)
download_url = await self._resolve_download_url(source)
content, content_type = await asyncio.to_thread(self._download_audio, download_url)
extension = self._resolve_extension(source=source, content_type=content_type)
target_path = self.cache_dir() / f"{cache_key}.{extension}"
target_path.parent.mkdir(parents=True, exist_ok=True)
target_path.write_bytes(content)
session_logger.info(
device_id or "",
"device_audio",
f"cached device audio: source={source} file={target_path}",
)
return self._public_url(target_path.name)
async def save_device_audio(
self,
content: bytes,
*,
device_id: str,
extension: str = "mp3",
) -> tuple[str, Path]:
if not content:
raise DeviceAudioCacheError("audio content is empty")
normalized_extension = extension.strip().lower().lstrip(".") or "mp3"
if normalized_extension not in {"mp3", "aac", "m4a", "wav", "webm"}:
normalized_extension = "mp3"
digest = hashlib.sha256(
f"{device_id}:".encode("utf-8") + content
).hexdigest()
target_path = self.cache_dir() / f"{digest}.{normalized_extension}"
target_path.parent.mkdir(parents=True, exist_ok=True)
await asyncio.to_thread(target_path.write_bytes, content)
session_logger.info(
device_id,
"device_audio",
f"saved device audio cache: file={target_path}",
)
return self._public_url(target_path.name), target_path
async def _resolve_download_url(self, source: str) -> str:
if source.startswith("https://"):
if not self._is_allowed_https_source(source):
raise DeviceAudioCacheError("unsupported https audio source")
return source
try:
return await self.audio_storage.get_audio_url(source)
except MessageAudioStorageError as exc:
raise DeviceAudioCacheError(str(exc)) from exc
def _is_allowed_https_source(self, source: str) -> bool:
parsed = urlparse(source)
if not parsed.scheme == "https" or not parsed.netloc:
return False
configured_base = settings.cos_public_base_url.strip()
if configured_base:
configured_host = urlparse(configured_base).netloc
if configured_host and parsed.netloc == configured_host:
return True
bucket = settings.cos_bucket_message.strip()
region = settings.cos_region.strip()
if bucket and region and parsed.netloc == f"{bucket}.cos.{region}.myqcloud.com":
return True
return False
def _download_audio(self, url: str) -> tuple[bytes, str | None]:
request = Request(url, headers={"User-Agent": "banban-device-audio-cache/1.0"})
with urlopen(request, timeout=15) as response:
content_type = response.headers.get("Content-Type")
content_length = response.headers.get("Content-Length")
if content_length and int(content_length) > settings.device_audio_max_bytes:
raise DeviceAudioCacheError("audio file is too large")
content = response.read(settings.device_audio_max_bytes + 1)
if len(content) > settings.device_audio_max_bytes:
raise DeviceAudioCacheError("audio file is too large")
if not content:
raise DeviceAudioCacheError("audio file is empty")
return content, content_type
def _find_cached_file(self, cache_key: str) -> Path | None:
cache_dir = self.cache_dir()
if not cache_dir.exists():
return None
matches = list(cache_dir.glob(f"{cache_key}.*"))
if not matches:
return None
return matches[0]
def _resolve_extension(self, *, source: str, content_type: str | None) -> str:
normalized_content_type = (content_type or "").split(";", 1)[0].strip().lower()
if normalized_content_type in _AUDIO_CONTENT_TYPE_TO_EXT:
return _AUDIO_CONTENT_TYPE_TO_EXT[normalized_content_type]
suffix = Path(urlparse(source).path).suffix.lower().lstrip(".")
if suffix in {"mp3", "aac", "m4a", "wav", "webm"}:
return suffix
guessed = mimetypes.guess_extension(normalized_content_type or "")
if guessed:
extension = guessed.lstrip(".")
if extension in {"mp3", "aac", "m4a", "wav", "webm"}:
return extension
return "mp3"
def _public_url(self, filename: str) -> str:
return f"{self.public_base_url()}/{filename}"
device_audio_cache_service = DeviceAudioCacheService()

View File

@@ -125,8 +125,10 @@ class DeviceVoiceArchiveService(DatabaseServiceBase):
),
)
with open(prepared_audio.filepath, "rb") as archive_file:
archive_audio_data = archive_file.read()
archive_audio_data = await asyncio.to_thread(
self._read_file_bytes,
prepared_audio.filepath,
)
session_logger.info(
sender_device_id,
@@ -245,7 +247,7 @@ class DeviceVoiceArchiveService(DatabaseServiceBase):
finally:
if prepared_audio and prepared_audio.archive_format == "mp3" and prepared_audio.filepath != local_audio_path:
if os.path.exists(prepared_audio.filepath):
os.remove(prepared_audio.filepath)
await asyncio.to_thread(os.remove, prepared_audio.filepath)
await db_session.close()
async def _prepare_archive_audio(
@@ -255,8 +257,10 @@ class DeviceVoiceArchiveService(DatabaseServiceBase):
archive_id: str,
local_audio_path: str,
) -> PreparedArchiveAudio | None:
with open(local_audio_path, "rb") as local_file:
local_audio_data = local_file.read()
local_audio_data = await asyncio.to_thread(
self._read_file_bytes,
local_audio_path,
)
source_format = detect_audio_format(local_audio_data)
local_size = len(local_audio_data)
@@ -288,8 +292,11 @@ class DeviceVoiceArchiveService(DatabaseServiceBase):
mp3_path = f"{local_audio_path}.archive.mp3"
try:
if source_format == "wav":
with open(wav_path, "wb") as wav_file:
wav_file.write(local_audio_data)
await asyncio.to_thread(
self._write_file_bytes,
wav_path,
local_audio_data,
)
session_logger.info(
sender_device_id,
archive_id,
@@ -302,8 +309,11 @@ class DeviceVoiceArchiveService(DatabaseServiceBase):
channels=DEFAULT_CHANNELS,
sample_width=DEFAULT_SAMPLE_WIDTH,
)
with open(wav_path, "wb") as wav_file:
wav_file.write(wrapped_wav)
await asyncio.to_thread(
self._write_file_bytes,
wav_path,
wrapped_wav,
)
session_logger.info(
sender_device_id,
archive_id,
@@ -320,7 +330,7 @@ class DeviceVoiceArchiveService(DatabaseServiceBase):
source_path=wav_path,
target_path=mp3_path,
)
mp3_size = os.path.getsize(mp3_path)
mp3_size = await asyncio.to_thread(os.path.getsize, mp3_path)
session_logger.info(
sender_device_id,
archive_id,
@@ -339,7 +349,7 @@ class DeviceVoiceArchiveService(DatabaseServiceBase):
)
finally:
if os.path.exists(wav_path):
os.remove(wav_path)
await asyncio.to_thread(os.remove, wav_path)
async def _convert_to_mp3(
self,
@@ -423,5 +433,13 @@ class DeviceVoiceArchiveService(DatabaseServiceBase):
"child_name": row["child_name"],
}
def _read_file_bytes(self, filepath: str) -> bytes:
with open(filepath, "rb") as file:
return file.read()
def _write_file_bytes(self, filepath: str, content: bytes) -> None:
with open(filepath, "wb") as file:
file.write(content)
device_voice_archive_service = DeviceVoiceArchiveService()

View File

@@ -7,6 +7,7 @@ from typing import Any
from services.offline_audio_cache import offline_audio_cache
from fastapi import HTTPException
from services.database_service_base import DatabaseServiceBase
from banban.service.device_audio_cache import device_audio_cache_service
from banban.service.message_audio_storage import MessageAudioStorageService, MessageAudioStorageError
from banban.service.binding import BindingService
try:
@@ -19,7 +20,6 @@ try:
except ModuleNotFoundError:
from banban.dao.im import ImDAO, DeviceIdentity, ConversationMessageCreateResult
from banban.schemas.im import ChildConversationMessageItem, DeviceMessageCreateRequest, ParentChildMessageCreateRequest
from handlers.audio_file_handler import message_audio_storage_service
from utils.logger import session_logger
@@ -152,6 +152,28 @@ async def present_message_item(
return item
async def present_device_message_item(
row: Mapping[str, Any],
*,
device_id: str,
) -> ChildConversationMessageItem:
item = row_to_message_item(row)
if item.content_type == 2 and item.media_file_key:
try:
item.media_file_key = await device_audio_cache_service.get_device_audio_url(
item.media_file_key,
device_id=device_id,
)
except Exception as exc:
session_logger.error(
device_id,
"device_audio",
f"failed to prepare device audio url: {exc}",
exc_info=True,
)
return item
class ImService(DatabaseServiceBase):
def __init__(self):
super().__init__(service_name="im_service")
@@ -292,9 +314,12 @@ class ImService(DatabaseServiceBase):
binding_service = BindingService()
device = await binding_service.get_current_binding(parent_user_id)
try:
audio_url = await message_audio_storage_service.get_audio_url(stored_audio.file_key)
audio_url = await device_audio_cache_service.get_device_audio_url(
stored_audio.file_key,
device_id=device.device_id,
)
except Exception:
session_logger.error(device.device_id, "audio", f"failed to get audio url: {stored_audio.file_key}", exc_info=True)
session_logger.error(device.device_id, "audio", f"failed to get device audio url: {stored_audio.file_key}", exc_info=True)
audio_url = stored_audio.file_key
await offline_audio_cache.add_audio_url(device.device_id, f"{audio_url}")
except Exception:

View File

@@ -109,7 +109,7 @@ class LocationService(DatabaseServiceBase):
altitude_m: float | None = None,
speed_mps: float | None = None,
heading_deg: int | None = None,
source: int | None = None,
source: int | str | None = None,
battery_pct: int | None = None,
device_time: datetime | None = None,
) -> Mapping[str, Any] | None:
@@ -129,6 +129,8 @@ class LocationService(DatabaseServiceBase):
battery_pct: int | None
device_time: datetime
source_value = self._normalize_location_source(source)
db_session = await self.get_session()
try:
dao = LocationDAO(db_session)
@@ -144,7 +146,7 @@ class LocationService(DatabaseServiceBase):
altitude_m=altitude_m,
speed_mps=speed_mps,
heading_deg=heading_deg,
source=source if source is not None else 0,
source=source_value,
battery_pct=battery_pct,
device_time=device_time or datetime.now(),
)
@@ -156,5 +158,25 @@ class LocationService(DatabaseServiceBase):
finally:
await db_session.close()
@staticmethod
def _normalize_location_source(source: int | str | None) -> int:
if source is None:
return 0
if isinstance(source, int):
return source
text = str(source).strip().lower()
if not text:
return 0
if text.isdigit():
return int(text)
return {
"gps": 1,
"wifi": 2,
"cell": 3,
"base_station": 3,
"manual": 4,
"mock": 9,
}.get(text, 0)
# 创建全局 LocationService 实例
location_service = LocationService()

View File

@@ -99,6 +99,18 @@ class Settings(BaseSettings):
default=2 * 1024 * 1024,
validation_alias="COS_AVATAR_MAX_BYTES",
)
device_audio_cache_dir: str = Field(
default="runtime/device-audio",
validation_alias="DEVICE_AUDIO_CACHE_DIR",
)
device_audio_public_base_url: str = Field(
default="",
validation_alias="DEVICE_AUDIO_PUBLIC_BASE_URL",
)
device_audio_max_bytes: int = Field(
default=20 * 1024 * 1024,
validation_alias="DEVICE_AUDIO_MAX_BYTES",
)
jwt_secret: str = Field(
default="dev_only_change_jwt_secret",

View File

@@ -121,6 +121,13 @@ class TalkingQMQTTService:
async def _handle_device_info(self, device_id: str, payload: dict):
data = payload.get("data", {})
d_id = data.get("id")
power = data.get("power")
signal = data.get("signal")
version = data.get("version")
voice = data.get("voice")
logger.info(device_id, "", f"[设备信息] 设备 {d_id} 信息: 电量={power}, 信号强度={signal}, 版本号={version}, 音量={voice}")
await self._schedule_persistence(
device_id,
"device_info",
@@ -147,7 +154,10 @@ class TalkingQMQTTService:
parsed_device_time = datetime.fromisoformat(raw_device_time.strip())
except ValueError:
parsed_device_time = None
lat = data.get("latitude")
lon = data.get("longitude")
logger.info(device_id, "", f"[GPS] 设备 {device_id} 位置: 纬度={lat}, 经度={lon}")
await self._schedule_persistence(
device_id,
"gps",
@@ -175,6 +185,8 @@ class TalkingQMQTTService:
current_level = data.get("current_level")
if current_level is None:
return
logger.info(device_id, "", f"[音量] 设备 {device_id} 当前音量: {current_level}")
await self._schedule_persistence(
device_id,
"volume",
@@ -197,6 +209,10 @@ class TalkingQMQTTService:
progress_value = None
if status == "accepted":
current = data.get("current_version")
target = data.get("target_version")
logger.info(device_id, "", f"[OTA] 设备 {device_id} 已接受升级: {current} -> {target}")
await self._schedule_persistence(
device_id,
"ota",
@@ -220,6 +236,8 @@ class TalkingQMQTTService:
)
elif status == "success":
new_version = data.get("new_version") or data.get("version")
logger.info(device_id, "", f"[OTA] 设备 {device_id} 升级完成: {new_version}")
await self._schedule_persistence(
device_id,
"ota",
@@ -431,43 +449,10 @@ class TalkingQMQTTService:
is_owner = await card_service.check_card_ownership(nfc_uuid, device_id)
if is_owner:
has_pending = await offline_audio_cache.has_pending_audio(device_id)
if nfc_uuid == "53C22B6DA20001":
payload = {
"msg_id": "005",
"type": 0,
"params": {
"url_1": f"http://{settings.server_host}:{settings.server_port}/assets/audio/test_zh.mp3"
}
}
await self._publish(topic, payload)
return
if has_pending:
audio_urls = await offline_audio_cache.get_audio_urls(device_id)
# 53D92B6DA20001 测试卡片
if nfc_uuid == "53C22B6DA20001":
payload = {
"msg_id": "005",
"type": 0,
"params": {
"url_1": f"http://{settings.server_host}:{settings.server_port}/assets/audio/TalkingQ_XQSN00001005_2f654480.mp3"
}
}
await self._publish(topic, payload)
return
if len(audio_urls) == 0:
payload = {
"msg_id": "005",
"type": 0,
"params": {
"url_1": f"http://{settings.server_host}:{settings.server_port}/assets/audio/no_message.mp3"
}
}
else:
params = {f"url_{k}": audio_url for k, audio_url in enumerate(audio_urls, start=1)}
payload = {"msg_id": "005", "type": 0, "params": params}
await offline_audio_cache.clear_audio_urls(device_id)
audio_urls = await offline_audio_cache.pop_audio_urls(device_id)
if audio_urls:
params = {f"url_{k}": audio_url for k, audio_url in enumerate(audio_urls, start=1)}
payload = {"msg_id": "005", "type": 0, "params": params}
await self._publish(topic, payload)
return

View File

@@ -4,7 +4,9 @@ import asyncio
from fastapi import WebSocket
from handlers.audio_packet_parser import parse_packet
from handlers.audio_session_handler import handle_websocket_data
from handlers.audio_file_handler import message_audio_storage_service, save_audio_file, upload_message_audio
from handlers.audio_file_handler import upload_message_audio
from banban.service.device_audio_cache import device_audio_cache_service
from banban.service.device_voice_archive import device_voice_archive_service
from services.audio_session import audio_session_manager
from services.interrupt_handler import interrupt_handler
from services.task_manager import task_manager
@@ -329,35 +331,32 @@ async def process_parent_leave_message(device_id: str, audio_cache_key: str):
async def process_cached_audio(device_id: str, target_device_id: str, serial_number: str):
"""处理缓存的音频数据并发送音频URL"""
try:
# 获取缓存的音频数据
cached_audio = await target_audio_cache.get_audio_data(target_device_id)
if not cached_audio:
session_logger.info(device_id, "target", f"目标设备 {target_device_id} 没有缓存的音频数据")
return
# 保存音频文件
audio_file_key = await save_audio_file(cached_audio, device_id)
audio_url = f"http://{settings.server_host}:{settings.server_port}/{audio_file_key}"
# 将音频URL保存到数据库 im_conversation和im_message
# await im_conversation_service.create_device_message(device_id=device_id, serial_number=serial_number, target_device_id=target_device_id, audio_url=audio_file_key)
# try:
# audio_url = await message_audio_storage_service.get_audio_url(audio_file_key)
# except Exception:
# audio_url = audio_file_key
# 发送URL给目标设备
# target_websocket = await connection_manager.get_connection(target_device_id)
# if target_websocket and target_websocket.client_state.name == "CONNECTED":
# await target_websocket.send_text("TTS_START")
# session_logger.info(device_id, "target", "已发送 TTS_START 给客户端")
# await target_websocket.send_text(f"NFC_SOUND_URL:{audio_url}")
# session_logger.info(device_id, "target", f"已发送音频URL给目标设备 {target_device_id}: {audio_url}")
# await target_websocket.send_text("TTS_END")
# session_logger.info(device_id, "target", "已发送 TTS_END 给客户端")
# else:
# # 目标设备不在线,保存到离线缓存
audio_url, local_audio_path = await device_audio_cache_service.save_device_audio(
cached_audio,
device_id=target_device_id,
)
await offline_audio_cache.add_audio_url(target_device_id, audio_url)
session_logger.warning(device_id, "target", f"目标设备 {target_device_id} 不在线保存音频URL到离线缓存")
session_logger.info(
device_id,
"target",
f"设备留言已加入待收听队列: target_device_id={target_device_id}, audio_url={audio_url}",
)
await task_manager.create_task(
device_voice_archive_service.archive_peer_voice_message(
sender_device_id=device_id,
receiver_device_id=target_device_id,
local_audio_path=str(local_audio_path),
),
device_id=device_id,
task_type="device_voice_archive",
)
websocket = await connection_manager.get_connection(device_id)
if websocket and websocket.client_state.name == "CONNECTED":
success_audio_url = f"http://{settings.server_host}:{settings.server_port}/assets/audio/message_ok.mp3"
@@ -365,18 +364,7 @@ async def process_cached_audio(device_id: str, target_device_id: str, serial_num
session_logger.info(device_id, "device", f"留言已收到音频URL给设备 {device_id}")
else:
session_logger.warning(device_id, "device", f"设备 {device_id} 不在线暂不发送留言已收到音频URL")
# # # 目标设备不在线,保存到离线缓存
# await offline_audio_cache.add_audio_url(target_device_id, audio_url)
# session_logger.warning(device_id, "target", f"目标设备 {target_device_id} 不在线保存音频URL到离线缓存")
# websocket = await connection_manager.get_connection(device_id)
# if websocket and websocket.client_state.name == "CONNECTED":
# success_audio_url = f"http://{settings.server_host}:{settings.server_port}/assets/audio/success_zh.mp3"
# await websocket.send_text(f"NFC_MESSAGE_SUCCESS_URL:{success_audio_url}")
# session_logger.info(device_id, "device", f"已发送留言成功音频URL给设备 {device_id}")
# else:
# session_logger.warning(device_id, "device", f"设备 {device_id} 不在线,发送留言成功音频失败")
except Exception as e:
# 处理HTTPException异常
if isinstance(e, HTTPException):
session_logger.error(device_id, "target", f"保存音频文件时出错返回HTTPException: {e}")
websocket = await connection_manager.get_connection(device_id)

View File

@@ -0,0 +1 @@
python generate_bind_qr.py TalkingQ_XQSN00001005 TQ_XQSN000000001005 -o ./my_qr.png

View File

@@ -132,7 +132,8 @@ def main() -> int:
except ValueError as exc:
print(f"Error: {exc}", file=sys.stderr)
return 1
device_id = 'TalkingQ_XQSN00001005'
serial_number = 'TQ_XQSN000000001005'
payload = build_payload(device_id, serial_number)
output_path = resolve_output_path(device_id, args.output)
output_path.parent.mkdir(parents=True, exist_ok=True)

View File

@@ -15,7 +15,11 @@ class OfflineAudioCache:
async def get_audio_urls(self, device_id: str) -> List[str]:
async with self.lock:
return self.offline_audio.get(device_id, [])
return list(self.offline_audio.get(device_id, []))
async def pop_audio_urls(self, device_id: str) -> List[str]:
async with self.lock:
return self.offline_audio.pop(device_id, [])
async def clear_audio_urls(self, device_id: str):
async with self.lock:
@@ -31,4 +35,4 @@ class OfflineAudioCache:
return self.offline_audio
# 单例实例
offline_audio_cache = OfflineAudioCache()
offline_audio_cache = OfflineAudioCache()

View File

@@ -1,8 +1,8 @@
# 数据库配置
DB_HOST=mysql
DB_HOST=101.35.224.118
DB_PORT=3306
DB_USER=talkingq
DB_PASSWORD="D7f!9xL#qP2z@Vk&"
DB_NAME=talkingq
DB_USER=banban
DB_PASSWORD="PT4MBdKk7Gptt3Sx"
DB_NAME=banban
DB_ECHO=false

View File

@@ -76,9 +76,9 @@ class RoleImporter:
'description': role_config.get('description', ''),
'content': role_config.get('content', ''),
'default_language': role_config.get('default_language'),
'asr_provider': role_config.get('asr_provider'),
'llm_provider': role_config.get('llm_provider'),
'tts_provider': role_config.get('tts_provider'),
# 'asr_provider': role_config.get('asr_provider'),
# 'llm_provider': role_config.get('llm_provider'),
# 'tts_provider': role_config.get('tts_provider'),
# 'aws_language_code': role_config.get('aws_language_code'),
'volcano_model_id': role_config.get('volcano_model_id'),
# 'volcano_voice_type': role_config.get('volcano_voice_type'),
@@ -97,9 +97,9 @@ class RoleImporter:
'language_code': lang_code,
'name': lang_config.get('name'),
'content': lang_config.get('content'),
'asr_provider': lang_config.get('asr_provider'),
'llm_provider': lang_config.get('llm_provider'),
'tts_provider': lang_config.get('tts_provider'),
# 'asr_provider': lang_config.get('asr_provider'),
# 'llm_provider': lang_config.get('llm_provider'),
# 'tts_provider': lang_config.get('tts_provider'),
# 'aws_language_code': lang_config.get('aws_language_code'),
# 'volcano_voice_type': lang_config.get('volcano_voice_type'),
# 'tencent_voice_type': lang_config.get('tencent_voice_type'),