Compare commits

...

2 Commits

Author SHA1 Message Date
stu2not
6f9cc3e3e0 简化固件更新状态展示 2026-05-09 10:55:11 +08:00
stu2not
5fede9cda1 支持设备短按向家长留言 2026-05-09 10:55:06 +08:00
7 changed files with 130 additions and 89 deletions

View File

@@ -292,36 +292,6 @@
} }
} }
.system-update-progress {
display: flex;
align-items: center;
gap: 16px;
padding-top: 18px;
}
.system-update-progress-track {
flex: 1;
height: 12px;
border-radius: 999px;
background: #E5E7EB;
overflow: hidden;
}
.system-update-progress-bar {
height: 100%;
border-radius: 999px;
background: #16A34A;
}
.system-update-progress-text {
flex-shrink: 0;
width: 72px;
font-size: 24px;
font-weight: 600;
text-align: right;
color: #15803D;
}
.menu-item.disabled { .menu-item.disabled {
opacity: 0.45; opacity: 0.45;
} }

View File

@@ -23,17 +23,6 @@ interface MenuItem {
disabled?: boolean disabled?: boolean
} }
function getFirmwareStatusLabel(status?: string | null): string {
const normalized = String(status || '').trim()
if (!normalized || normalized === 'idle') return '未更新'
if (normalized === 'sent') return '指令已发送'
if (normalized === 'accepted') return '设备已接收'
if (normalized === 'updating') return '更新中'
if (normalized === 'success' || normalized === 'completed') return '已更新'
if (normalized === 'failed') return '更新失败'
return normalized
}
export default function Sleep() { export default function Sleep() {
const systemBanner = useSystemBanner() const systemBanner = useSystemBanner()
const [loading, setLoading] = useState(true) const [loading, setLoading] = useState(true)
@@ -298,13 +287,6 @@ export default function Sleep() {
const parentDisplayName = parentInfo.nickname?.trim() || '家长' const parentDisplayName = parentInfo.nickname?.trim() || '家长'
const currentFirmwareLabel = firmwareStatus?.current_version || '--' const currentFirmwareLabel = firmwareStatus?.current_version || '--'
const latestFirmwareLabel = firmwareStatus?.latest_version || '--' const latestFirmwareLabel = firmwareStatus?.latest_version || '--'
const firmwareStatusLabel = binding?.device_id
? isLoadingFirmware
? '查询中'
: getFirmwareStatusLabel(firmwareStatus?.update_status)
: '未绑定设备'
const firmwareProgress = Math.max(0, Math.min(100, Math.round(Number(firmwareStatus?.progress || 0))))
const showFirmwareProgress = ['sent', 'accepted', 'updating'].includes(String(firmwareStatus?.update_status || ''))
const canUpdateFirmware = Boolean(binding?.device_id && firmwareStatus?.can_update && !isLoadingFirmware && !isUpdatingFirmware) const canUpdateFirmware = Boolean(binding?.device_id && firmwareStatus?.can_update && !isLoadingFirmware && !isUpdatingFirmware)
const firmwareActionText = isLoadingFirmware ? '查询中' : isUpdatingFirmware ? '发送中' : '更新' const firmwareActionText = isLoadingFirmware ? '查询中' : isUpdatingFirmware ? '发送中' : '更新'
const firmwareSubtitle = !binding?.device_id const firmwareSubtitle = !binding?.device_id
@@ -416,18 +398,6 @@ export default function Sleep() {
<Text className='system-update-label'></Text> <Text className='system-update-label'></Text>
<Text className='system-update-value highlight'>{latestFirmwareLabel}</Text> <Text className='system-update-value highlight'>{latestFirmwareLabel}</Text>
</View> </View>
<View className='system-update-row'>
<Text className='system-update-label'></Text>
<Text className='system-update-value'>{firmwareStatusLabel}</Text>
</View>
{showFirmwareProgress && (
<View className='system-update-progress'>
<View className='system-update-progress-track'>
<View className='system-update-progress-bar' style={{ width: `${firmwareProgress}%` }}></View>
</View>
<Text className='system-update-progress-text'>{firmwareProgress}%</Text>
</View>
)}
</View> </View>
</View> </View>

View File

@@ -140,6 +140,8 @@ async def present_message_item(
) -> ChildConversationMessageItem: ) -> ChildConversationMessageItem:
item = row_to_message_item(row) item = row_to_message_item(row)
if item.content_type == 2 and item.media_file_key: if item.content_type == 2 and item.media_file_key:
if item.media_file_key.startswith(("http://", "https://")):
return item
try: try:
item.media_file_key = await audio_storage.get_audio_url(item.media_file_key) item.media_file_key = await audio_storage.get_audio_url(item.media_file_key)
except MessageAudioStorageError: except MessageAudioStorageError:

View File

@@ -68,6 +68,10 @@ class Settings(BaseSettings):
talkingq_mqtt_qos: int = Field(default=1, validation_alias="TALKINGQ_MQTT_QOS") talkingq_mqtt_qos: int = Field(default=1, validation_alias="TALKINGQ_MQTT_QOS")
talkingq_mqtt_keepalive: int = Field(default=60, validation_alias="TALKINGQ_MQTT_KEEPALIVE") talkingq_mqtt_keepalive: int = Field(default=60, validation_alias="TALKINGQ_MQTT_KEEPALIVE")
talkingq_mqtt_nfc_notice_interval: int = Field(default=600, validation_alias="TALKINGQ_MQTT_NFC_NOTICE_INTERVAL") talkingq_mqtt_nfc_notice_interval: int = Field(default=600, validation_alias="TALKINGQ_MQTT_NFC_NOTICE_INTERVAL")
talkingq_parent_message_uuid: str = Field(
default="TalkingQ_xxx",
validation_alias="TALKINGQ_PARENT_MESSAGE_UUID",
)
admin_api_key: str = Field(default="", validation_alias="ADMIN_API_KEY") admin_api_key: str = Field(default="", validation_alias="ADMIN_API_KEY")
client_api_key: str = Field(default="", validation_alias="CLIENT_API_KEY") client_api_key: str = Field(default="", validation_alias="CLIENT_API_KEY")

View File

@@ -335,8 +335,21 @@ class TalkingQMQTTService:
async def _handle_short_press_message(self, device_id: str, payload: dict): async def _handle_short_press_message(self, device_id: str, payload: dict):
params = payload.get("params", {}) params = payload.get("params", {})
nfc_uuid = params.get("uuid") nfc_uuid = str(params.get("uuid") or "").strip()
logger.info(device_id, "", f"[短按留言] 设备 {device_id} 短按发送留言, UUID={nfc_uuid}") logger.info(device_id, "", f"[短按留言] 设备 {device_id} 短按发送留言, UUID={nfc_uuid}")
if nfc_uuid == settings.talkingq_parent_message_uuid:
await device_target_cache.set_parent_target(device_id, nfc_uuid)
response_payload = {
"msg_id": "011",
"status": "success",
"type": 0,
"params": {
"url": "http://175.24.73.253:8080/assets/audio/message_ok.mp3"
},
}
await self._publish(f"device/{device_id}/event_resp", response_payload)
return
media_file_key = str(params.get("media_file_key") or params.get("audio_url") or "").strip() media_file_key = str(params.get("media_file_key") or params.get("audio_url") or "").strip()
if media_file_key: if media_file_key:
try: try:
@@ -351,6 +364,18 @@ class TalkingQMQTTService:
ext_json=params.get("ext_json") if isinstance(params.get("ext_json"), dict) else None, ext_json=params.get("ext_json") if isinstance(params.get("ext_json"), dict) else None,
) )
logger.info(device_id, "", f"[短按留言] 设备 {device_id} 留言已写入家长会话") logger.info(device_id, "", f"[短按留言] 设备 {device_id} 留言已写入家长会话")
await self._publish(
f"device/{device_id}/event_resp",
{
"msg_id": "011",
"status": "success",
"type": 0,
"params": {
"url": "http://175.24.73.253:8080/assets/audio/message_ok.mp3"
},
},
)
return
except Exception as exc: except Exception as exc:
logger.warning(device_id, "", f"[短按留言] 设备 {device_id} 留言写入失败: {exc}") logger.warning(device_id, "", f"[短按留言] 设备 {device_id} 留言写入失败: {exc}")
await self._publish( await self._publish(
@@ -363,14 +388,7 @@ class TalkingQMQTTService:
) )
return return
payload = { payload = {"msg_id": "011", "status": "failed", "message": "unsupported uuid"}
"msg_id": "011",
"status": "success",
"type": 0,
"params": {
"url": f"http://{settings.server_host}:{settings.server_port}/assets/audio/message_ok.mp3"
}
}
await self._publish(f"device/{device_id}/event_resp", payload) await self._publish(f"device/{device_id}/event_resp", payload)
async def _handle_device_identity_init(self, imei: str, payload: dict): async def _handle_device_identity_init(self, imei: str, payload: dict):

View File

@@ -13,6 +13,7 @@ from services.device_target_cache import device_target_cache
from services.offline_audio_cache import offline_audio_cache from services.offline_audio_cache import offline_audio_cache
from services.target_audio_cache import target_audio_cache from services.target_audio_cache import target_audio_cache
from services.card_service import card_service from services.card_service import card_service
from services.device_target_cache import VOICE_TARGET_KIND_PARENT
from utils.logger import session_logger from utils.logger import session_logger
from handlers.prompt_sound_handler import handle_prompt_sound_request from handlers.prompt_sound_handler import handle_prompt_sound_request
from handlers.session_cleanup_handler import handle_old_session_cleanup from handlers.session_cleanup_handler import handle_old_session_cleanup
@@ -147,29 +148,28 @@ async def handle_binary_message(websocket: WebSocket, device_id: str, serial_num
session_id = session_key[1] session_id = session_key[1]
if packet_type == 1: # 开始包 if packet_type == 1: # 开始包
target_device_id = await device_target_cache.get_target(device_id) voice_target = await device_target_cache.get_voice_target(device_id)
if target_device_id: if voice_target:
# 清除音频缓存 await target_audio_cache.clear_audio_data(voice_target.audio_cache_key)
await target_audio_cache.clear_audio_data(target_device_id)
return None, None return None, None
current_active_session = await handle_start_packet( current_active_session = await handle_start_packet(
device_id, session_id, session_key, session, current_active_session device_id, session_id, session_key, session, current_active_session
) )
elif packet_type == 3: # 中断包 elif packet_type == 3: # 中断包
target_device_id = await device_target_cache.get_target(device_id) voice_target = await device_target_cache.get_voice_target(device_id)
if target_device_id: if voice_target:
return None, None return None, None
current_active_session = await handle_interrupt_packet( current_active_session = await handle_interrupt_packet(
device_id, session_id, session_key, session, current_active_session device_id, session_id, session_key, session, current_active_session
) )
elif packet_type == 2: # 结束包 elif packet_type == 2: # 结束包
target_device_id = await device_target_cache.get_target(device_id) voice_target = await device_target_cache.get_voice_target(device_id)
if target_device_id: if voice_target:
# 处理缓存的音频数据
session_logger.info(device_id, session_id, f"收到结束包,开始处理缓存音频数据") session_logger.info(device_id, session_id, f"收到结束包,开始处理缓存音频数据")
await process_cached_audio(device_id, target_device_id, serial_number) 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) await device_target_cache.remove_target(device_id)
return None, None return None, None
if current_active_session == session_key: if current_active_session == session_key:
@@ -243,20 +243,65 @@ async def handle_interrupt_packet(device_id, session_id, session_key, session, c
async def handle_target_audio_packet(device_id: str, audio_data: bytes): async def handle_target_audio_packet(device_id: str, audio_data: bytes):
"""处理发送给目标设备的音频包""" """处理发送给目标设备的音频包"""
try: try:
target_device_id = await device_target_cache.get_target(device_id) voice_target = await device_target_cache.get_voice_target(device_id)
if not target_device_id: if not voice_target:
session_logger.warning(device_id, "target", "未设置目标设备,无法发送音频") session_logger.warning(device_id, "target", "未设置目标设备,无法发送音频")
return return
# 缓存音频数据 await target_audio_cache.add_audio_data(voice_target.audio_cache_key, audio_data)
await target_audio_cache.add_audio_data(target_device_id, audio_data) if voice_target.kind == VOICE_TARGET_KIND_PARENT:
session_logger.info(device_id, "target", f"已缓存音频数据到目标设备 {target_device_id}") session_logger.info(device_id, "target", "已缓存发给家长的留言音频数据")
else:
session_logger.info(device_id, "target", f"已缓存音频数据到目标设备 {voice_target.target_device_id}")
except Exception as e: except Exception as e:
session_logger.error(device_id, "target", f"处理目标音频包时出错: {e}", exc_info=True) session_logger.error(device_id, "target", f"处理目标音频包时出错: {e}", exc_info=True)
async def process_parent_leave_message(device_id: str, audio_cache_key: str):
"""处理设备发给家长的留言音频并写入家长会话。"""
try:
cached_audio = await target_audio_cache.get_audio_data(audio_cache_key)
if not cached_audio:
session_logger.info(device_id, "parent", "发给家长的留言没有缓存音频数据")
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}"
await im_conversation_service.create_device_parent_leave_message(
device_id=device_id,
media_file_key=audio_url,
media_mime_type="audio/mpeg",
media_size_bytes=len(cached_audio),
ext_json={"source": "device_ws_parent_leave_message"},
)
session_logger.info(device_id, "parent", "发给家长的留言已写入家长会话")
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"
await websocket.send_text(f"PROMPT_SOUND_URL:{success_audio_url}")
session_logger.info(device_id, "device", f"留言成功提示音已发送给设备 {device_id}")
else:
session_logger.warning(device_id, "device", f"设备 {device_id} 不在线,暂不发送留言成功提示音")
except Exception as e:
if isinstance(e, HTTPException):
session_logger.error(device_id, "parent", f"发给家长的留言保存失败返回HTTPException: {e}")
websocket = await connection_manager.get_connection(device_id)
if e.status_code == 404 or e.status_code == 400:
if websocket and websocket.client_state.name == "CONNECTED":
fail_audio_url = f"http://{settings.server_host}:{settings.server_port}/assets/audio/save_audio_fail.mp3"
await websocket.send_text(f"PROMPT_SOUND_URL:{fail_audio_url}")
session_logger.info(device_id, "device", f"留言失败提示音已发送给设备 {device_id}")
else:
session_logger.warning(device_id, "device", f"设备 {device_id} 不在线,暂不发送留言失败提示音")
else:
session_logger.error(device_id, "parent", f"处理家长留言音频时出错: {e}", exc_info=True)
finally:
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):
"""处理缓存的音频数据并发送音频URL""" """处理缓存的音频数据并发送音频URL"""
try: try:

View File

@@ -1,19 +1,51 @@
import asyncio import asyncio
from dataclasses import dataclass
from typing import Dict, Optional from typing import Dict, Optional
VOICE_TARGET_KIND_DEVICE = "device"
VOICE_TARGET_KIND_PARENT = "parent"
@dataclass(frozen=True)
class VoiceTarget:
kind: str
target_device_id: Optional[str] = None
uuid: Optional[str] = None
audio_cache_key: Optional[str] = None
class DeviceTargetCache: class DeviceTargetCache:
def __init__(self): def __init__(self):
self.targets: Dict[str, str] = {} self.targets: Dict[str, VoiceTarget] = {}
self.lock = asyncio.Lock() self.lock = asyncio.Lock()
async def get_target(self, device_id: str) -> Optional[str]: 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
async def get_voice_target(self, device_id: str) -> Optional[VoiceTarget]:
async with self.lock: async with self.lock:
return self.targets.get(device_id) return self.targets.get(device_id)
async def set_target(self, device_id: str, target_device_id: str): async def set_target(self, device_id: str, target_device_id: str):
async with self.lock: async with self.lock:
self.targets[device_id] = target_device_id self.targets[device_id] = VoiceTarget(
kind=VOICE_TARGET_KIND_DEVICE,
target_device_id=target_device_id,
audio_cache_key=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}",
)
async def remove_target(self, device_id: str): async def remove_target(self, device_id: str):
async with self.lock: async with self.lock: