From 46cc5272f31a6c117b2b7ab95afd64d83c9222ea Mon Sep 17 00:00:00 2001 From: HycJack <772403255@qq.com> Date: Fri, 8 May 2026 17:33:03 +0800 Subject: [PATCH 1/8] =?UTF-8?q?add=20=E5=91=8A=E8=AD=A6=E5=92=8C=E8=81=94?= =?UTF-8?q?=E8=B0=83=E9=97=AE=E9=A2=98=E4=BF=AE=E6=94=B9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- talkingq-url/banban/dao/device.py | 4 + talkingq-url/banban/dao/device_alarm.py | 86 ++++++++ talkingq-url/banban/routers/devices.py | 181 +++++++++++++++- talkingq-url/banban/service/device.py | 49 ++++- talkingq-url/banban/service/device_alarm.py | 47 +++++ talkingq-url/banban/service/device_setting.py | 39 +++- talkingq-url/database/models.py | 16 ++ talkingq-url/handlers/mqtt_handler.py | 194 +++++++++--------- 8 files changed, 516 insertions(+), 100 deletions(-) create mode 100644 talkingq-url/banban/dao/device_alarm.py create mode 100644 talkingq-url/banban/service/device_alarm.py diff --git a/talkingq-url/banban/dao/device.py b/talkingq-url/banban/dao/device.py index 517884d..73852e1 100644 --- a/talkingq-url/banban/dao/device.py +++ b/talkingq-url/banban/dao/device.py @@ -77,6 +77,10 @@ class DeviceDAO(BaseDAO): db.device_id, db.child_id, c.child_name, + ds.sleep_mode, + ds.disable_time_start, + ds.disable_time_end, + ds.timezone, ds.power, ds.volume, ds.`signal` AS signal_strength, diff --git a/talkingq-url/banban/dao/device_alarm.py b/talkingq-url/banban/dao/device_alarm.py new file mode 100644 index 0000000..ea7faa5 --- /dev/null +++ b/talkingq-url/banban/dao/device_alarm.py @@ -0,0 +1,86 @@ +from collections.abc import Mapping +from typing import Any + +from sqlalchemy import text + +from banban.dao import BaseDAO + + +class DeviceAlarmDAO(BaseDAO): + async def create( + self, + *, + device_id: str, + owner_user_id: int | None, + child_id: int | None, + source_msg_id: str = "010", + ) -> int: + result = await self.execute( + """ + INSERT INTO device_alarm_events ( + device_id, owner_user_id, child_id, source_msg_id, created_at + ) + VALUES ( + :device_id, :owner_user_id, :child_id, :source_msg_id, CURRENT_TIMESTAMP + ) + """, + { + "device_id": device_id, + "owner_user_id": owner_user_id, + "child_id": child_id, + "source_msg_id": source_msg_id, + }, + ) + await self.commit() + return int(result.lastrowid) + + async def get_active_binding_context(self, *, device_id: str) -> Mapping[str, Any] | None: + result = await self.execute( + text( + """ + SELECT owner_user_id, child_id + FROM device_bindings + WHERE device_id = :device_id + AND status = 1 + LIMIT 1 + """ + ), + {"device_id": device_id}, + ) + return result.mappings().first() + + async def list_by_device( + self, + *, + device_id: str, + owner_user_id: int, + limit: int, + ) -> list[Mapping[str, Any]]: + result = await self.execute( + text( + """ + SELECT + dae.alarm_id, + dae.device_id, + dae.owner_user_id, + dae.child_id, + dae.source_msg_id, + dae.created_at, + c.child_name + FROM device_alarm_events AS dae + LEFT JOIN children AS c + ON c.child_id = dae.child_id + AND c.status = 1 + WHERE dae.device_id = :device_id + AND dae.owner_user_id = :owner_user_id + ORDER BY dae.created_at DESC, dae.alarm_id DESC + LIMIT :limit + """ + ), + { + "device_id": device_id, + "owner_user_id": owner_user_id, + "limit": limit, + }, + ) + return list(result.mappings().all()) diff --git a/talkingq-url/banban/routers/devices.py b/talkingq-url/banban/routers/devices.py index edcdd40..95b5406 100644 --- a/talkingq-url/banban/routers/devices.py +++ b/talkingq-url/banban/routers/devices.py @@ -1,6 +1,6 @@ import logging from collections.abc import Mapping -from datetime import datetime +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 @@ -15,6 +15,7 @@ try: DeviceLocationTrajectoryItem, DeviceLocationTrajectoryResponse, ) + from banban.service.device_alarm import device_alarm_service from banban.service.location import location_service from banban.service.device import device_service except ModuleNotFoundError: @@ -24,6 +25,7 @@ except ModuleNotFoundError: DeviceLocationTrajectoryItem, DeviceLocationTrajectoryResponse, ) + from banban.service.device_alarm import device_alarm_service from banban.service.location import location_service from banban.service.device import device_service @@ -53,6 +55,10 @@ class DeviceStatusResponse(BaseModel): device_id: str child_id: int | None = None child_name: str | None = None + sleep_mode: int | None = None + disable_time_start: str | None = None + disable_time_end: str | None = None + timezone: str | None = None power: int | None = None volume: int | None = None signal: int | None = None @@ -72,6 +78,20 @@ class DeviceStatusResponse(BaseModel): location_updated_at: datetime | None = None +class DeviceAlarmItem(BaseModel): + alarm_id: int + device_id: str + child_id: int | None = None + child_name: str | None = None + source_msg_id: str + created_at: datetime + + +class DeviceAlarmListResponse(BaseModel): + items: list[DeviceAlarmItem] + total: int + + class DeviceVolumeUpdateRequest(BaseModel): level: int = Field(ge=0, le=100) @@ -82,6 +102,31 @@ class DeviceVolumeUpdateResponse(BaseModel): msg_id: str +class DeviceSleepScheduleUpdateRequest(BaseModel): + start: str = Field(..., pattern=r"^\d{2}:\d{2}$") + end: str = Field(..., pattern=r"^\d{2}:\d{2}$") + timezone: str = Field(default="Asia/Shanghai", min_length=1, max_length=32) + + +class DeviceSleepScheduleResponse(BaseModel): + device_id: str + sleep_mode: int + start: str | None = None + end: str | None = None + timezone: str + msg_id: str | None = None + + +class DeviceRemoteSleepWakeRequest(BaseModel): + switch: str = Field(..., pattern=r"^(on|off)$") + + +class DeviceRemoteSleepWakeResponse(BaseModel): + device_id: str + switch: str + msg_id: str + + @@ -140,11 +185,31 @@ def _row_to_trajectory_item(row: Mapping, *, child_name: str | None) -> DeviceLo ) +def _format_time_value(value: object) -> str | None: + if value is None: + return None + if isinstance(value, time): + return value.strftime("%H:%M") + text_value = str(value) + return text_value[:5] if len(text_value) >= 5 else text_value + + +def _parse_time_value(value: str, field_name: str) -> time: + try: + return datetime.strptime(value, "%H:%M").time() + except ValueError as exc: + raise HTTPException(status_code=422, detail=f"{field_name} must use HH:MM format") from exc + + def _row_to_device_status_response(row: Mapping) -> DeviceStatusResponse: return DeviceStatusResponse( device_id=str(row["device_id"]), child_id=int(row["child_id"]) if row["child_id"] is not None else None, child_name=row.get("child_name"), + sleep_mode=row.get("sleep_mode"), + disable_time_start=_format_time_value(row.get("disable_time_start")), + disable_time_end=_format_time_value(row.get("disable_time_end")), + timezone=row.get("timezone"), power=row["power"], volume=row["volume"], signal=row["signal_strength"], @@ -165,6 +230,17 @@ def _row_to_device_status_response(row: Mapping) -> DeviceStatusResponse: ) +def _row_to_alarm_item(row: Mapping) -> DeviceAlarmItem: + return DeviceAlarmItem( + alarm_id=int(row["alarm_id"]), + device_id=str(row["device_id"]), + child_id=int(row["child_id"]) if row["child_id"] is not None else None, + child_name=row.get("child_name"), + source_msg_id=str(row["source_msg_id"]), + created_at=row["created_at"], + ) + + @router.get("/{device_id}/messages", response_model=DeviceMessageListResponse) async def list_device_messages( device_id: str, @@ -226,6 +302,36 @@ async def get_device_status( return _row_to_device_status_response(row) +@router.get("/{device_id}/alarms", response_model=DeviceAlarmListResponse) +async def list_device_alarms( + device_id: str, + request: Request, + limit: int = Query(default=20, ge=1, le=100), + current_user_id: int = Depends(get_current_user_id), +) -> DeviceAlarmListResponse: + await device_service.ensure_device_access(device_id=device_id, user_id=current_user_id) + rows = await device_alarm_service.list_device_alarms( + device_id=device_id, + owner_user_id=current_user_id, + limit=limit, + ) + + logger.info( + "device alarms fetched", + extra={ + "event": "device_alarm_list", + "request_id": getattr(request.state, "request_id", None), + "user_id": current_user_id, + "device_id": device_id, + "returned_count": len(rows), + }, + ) + return DeviceAlarmListResponse( + items=[_row_to_alarm_item(row) for row in rows], + total=len(rows), + ) + + @router.post("/{device_id}/volume", response_model=DeviceVolumeUpdateResponse) async def set_device_volume( device_id: str, @@ -253,6 +359,77 @@ async def set_device_volume( return DeviceVolumeUpdateResponse(device_id=device_id, level=payload.level, msg_id=msg_id) +@router.post("/{device_id}/sleep-schedule", response_model=DeviceSleepScheduleResponse) +async def set_device_sleep_schedule( + device_id: str, + payload: DeviceSleepScheduleUpdateRequest, + request: Request, + current_user_id: int = Depends(get_current_user_id), +) -> DeviceSleepScheduleResponse: + start_time = _parse_time_value(payload.start, "start") + end_time = _parse_time_value(payload.end, "end") + msg_id = await device_service.set_sleep_schedule( + device_id=device_id, + user_id=current_user_id, + start=start_time, + end=end_time, + timezone=payload.timezone, + ) + + logger.info( + "device sleep schedule command sent", + extra={ + "event": "device_sleep_schedule_set", + "request_id": getattr(request.state, "request_id", None), + "user_id": current_user_id, + "device_id": device_id, + "start": payload.start, + "end": payload.end, + "timezone": payload.timezone, + "msg_id": msg_id, + }, + ) + return DeviceSleepScheduleResponse( + device_id=device_id, + sleep_mode=1, + start=payload.start, + end=payload.end, + timezone=payload.timezone, + msg_id=msg_id, + ) + + +@router.post("/{device_id}/sleep-wake", response_model=DeviceRemoteSleepWakeResponse) +async def set_device_remote_sleep_wake( + device_id: str, + payload: DeviceRemoteSleepWakeRequest, + request: Request, + current_user_id: int = Depends(get_current_user_id), +) -> DeviceRemoteSleepWakeResponse: + msg_id = await device_service.set_remote_sleep_wake( + device_id=device_id, + user_id=current_user_id, + switch=payload.switch, + ) + + logger.info( + "device remote sleep wake command sent", + extra={ + "event": "device_remote_sleep_wake_set", + "request_id": getattr(request.state, "request_id", None), + "user_id": current_user_id, + "device_id": device_id, + "switch": payload.switch, + "msg_id": msg_id, + }, + ) + return DeviceRemoteSleepWakeResponse( + device_id=device_id, + switch=payload.switch, + msg_id=msg_id, + ) + + @router.get("/{device_id}/location", response_model=DeviceLocationCurrentResponse) async def get_current_device_location( @@ -358,4 +535,4 @@ async def get_device_location_trajectory( total=len(rows), start_at=start_at, end_at=end_at, - ) \ No newline at end of file + ) diff --git a/talkingq-url/banban/service/device.py b/talkingq-url/banban/service/device.py index 8e63add..135ca0c 100644 --- a/talkingq-url/banban/service/device.py +++ b/talkingq-url/banban/service/device.py @@ -1,10 +1,12 @@ from collections.abc import Mapping -from typing import Any, List +from datetime import time +from typing import Any, List, Literal from services.database_service_base import DatabaseServiceBase from fastapi import HTTPException from banban.dao.device import DeviceDAO +from banban.service.device_setting import device_setting_service class DeviceService(DatabaseServiceBase): @@ -68,6 +70,51 @@ class DeviceService(DatabaseServiceBase): raise HTTPException(status_code=503, detail="MQTT 服务未初始化") return await service.send_volume_command(device_id, level) + async def set_sleep_schedule( + self, + *, + device_id: str, + user_id: int, + start: time, + end: time, + timezone: str, + ) -> str: + await self.ensure_device_access(device_id=device_id, user_id=user_id) + + from handlers.mqtt_handler import TalkingQMQTTService + + service = await TalkingQMQTTService.get_instance() + if service is None: + raise HTTPException(status_code=503, detail="MQTT 服务未初始化") + + start_text = start.strftime("%H:%M") + end_text = end.strftime("%H:%M") + msg_id = await service.send_sleep_schedule_command(device_id, start_text, end_text) + await device_setting_service.upsert_sleep_schedule( + device_id=device_id, + sleep_mode=1, + disable_time_start=start, + disable_time_end=end, + timezone=timezone, + ) + return msg_id + + async def set_remote_sleep_wake( + self, + *, + device_id: str, + user_id: int, + switch: Literal["on", "off"], + ) -> str: + await self.ensure_device_access(device_id=device_id, user_id=user_id) + + from handlers.mqtt_handler import TalkingQMQTTService + + service = await TalkingQMQTTService.get_instance() + if service is None: + raise HTTPException(status_code=503, detail="MQTT 服务未初始化") + return await service.send_remote_sleep_wake_command(device_id, switch) + # 创建全局 DeviceService 实例 device_service = DeviceService() diff --git a/talkingq-url/banban/service/device_alarm.py b/talkingq-url/banban/service/device_alarm.py new file mode 100644 index 0000000..efabfdb --- /dev/null +++ b/talkingq-url/banban/service/device_alarm.py @@ -0,0 +1,47 @@ +from collections.abc import Mapping +from typing import Any + +from banban.dao.device_alarm import DeviceAlarmDAO +from services.database_service_base import DatabaseServiceBase + + +class DeviceAlarmService(DatabaseServiceBase): + def __init__(self): + super().__init__(service_name="device_alarm_service") + + async def record_alarm_event(self, *, device_id: str, source_msg_id: str = "010") -> int | None: + db_session = await self.get_session() + try: + dao = DeviceAlarmDAO(db_session) + binding = await dao.get_active_binding_context(device_id=device_id) + if not binding: + return None + return await dao.create( + device_id=device_id, + owner_user_id=int(binding["owner_user_id"]) if binding.get("owner_user_id") is not None else None, + child_id=int(binding["child_id"]) if binding.get("child_id") is not None else None, + source_msg_id=source_msg_id, + ) + finally: + await db_session.close() + + async def list_device_alarms( + self, + *, + device_id: str, + owner_user_id: int, + limit: int, + ) -> list[Mapping[str, Any]]: + db_session = await self.get_session() + try: + dao = DeviceAlarmDAO(db_session) + return await dao.list_by_device( + device_id=device_id, + owner_user_id=owner_user_id, + limit=limit, + ) + finally: + await db_session.close() + + +device_alarm_service = DeviceAlarmService() diff --git a/talkingq-url/banban/service/device_setting.py b/talkingq-url/banban/service/device_setting.py index 6e0a289..ec87791 100644 --- a/talkingq-url/banban/service/device_setting.py +++ b/talkingq-url/banban/service/device_setting.py @@ -93,7 +93,14 @@ class DeviceSettingService(DatabaseServiceBase): finally: await db_session.close() - async def insert_or_update(self, device_id: str, power: int, volume: int, signal_strength: int, version_str: str) -> None: + async def insert_or_update( + self, + device_id: str, + power: Optional[int], + volume: Optional[int], + signal_strength: Optional[int], + version_str: Optional[str], + ) -> None: try: current_row = await self.get_setting_by_device_id(device_id=device_id) if current_row: @@ -103,5 +110,33 @@ class DeviceSettingService(DatabaseServiceBase): finally: pass + async def upsert_sleep_schedule( + self, + *, + device_id: str, + sleep_mode: int, + disable_time_start: time, + disable_time_end: time, + timezone: str, + ) -> None: + current_row = await self.get_setting_by_device_id(device_id=device_id) + if current_row: + await self.update_setting( + device_id=device_id, + sleep_mode=sleep_mode, + disable_time_start=disable_time_start, + disable_time_end=disable_time_end, + timezone=timezone, + ) + return + + await self.create_setting( + device_id=device_id, + sleep_mode=sleep_mode, + disable_time_start=disable_time_start, + disable_time_end=disable_time_end, + timezone=timezone, + ) + # 创建全局 DeviceSettingService 实例 -device_setting_service = DeviceSettingService() \ No newline at end of file +device_setting_service = DeviceSettingService() diff --git a/talkingq-url/database/models.py b/talkingq-url/database/models.py index 4147097..8b141e6 100644 --- a/talkingq-url/database/models.py +++ b/talkingq-url/database/models.py @@ -267,6 +267,22 @@ class DeviceSetting(Base): onupdate=datetime.utcnow, ) + +class DeviceAlarmEvent(Base): + __tablename__ = "device_alarm_events" + __table_args__ = ( + Index("idx_device_alarm_device_created", "device_id", "created_at"), + Index("idx_device_alarm_owner_created", "owner_user_id", "created_at"), + Index("idx_device_alarm_child_created", "child_id", "created_at"), + ) + + alarm_id: Mapped[int] = mapped_column(BigInteger, primary_key=True, autoincrement=True) + device_id: Mapped[str] = mapped_column(String(64), nullable=False) + owner_user_id: Mapped[Optional[int]] = mapped_column(Integer) + child_id: Mapped[Optional[int]] = mapped_column(Integer) + source_msg_id: Mapped[str] = mapped_column(String(8), server_default=text("'010'")) + created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow) + class IMConversation(Base): __tablename__ = "im_conversations" __table_args__ = ( diff --git a/talkingq-url/handlers/mqtt_handler.py b/talkingq-url/handlers/mqtt_handler.py index b396205..a5801e1 100644 --- a/talkingq-url/handlers/mqtt_handler.py +++ b/talkingq-url/handlers/mqtt_handler.py @@ -1,9 +1,15 @@ +import asyncio import json import time -import asyncio -from typing import Optional, Dict, Callable, Awaitable -from banban.service.device_setting import device_setting_service +from datetime import datetime +from typing import Awaitable, Callable, Dict, Optional + +import aiomqtt + from banban.service.binding import BindingService +from banban.service.device_alarm import device_alarm_service +from banban.service.device_setting import device_setting_service +from banban.service.location import location_service from config import settings from services.card_service import card_service from services.offline_audio_cache import offline_audio_cache @@ -12,6 +18,8 @@ from banban.service.location import location_service from database.models import ChildLocationCurrent from datetime import datetime from services.device_target_cache import device_target_cache +from services.offline_audio_cache import offline_audio_cache +from services.task_manager import task_manager from utils.logger import session_logger as logger from services.task_manager import task_manager @@ -69,30 +77,28 @@ class TalkingQMQTTService: try: topic = str(message.topic) parts = topic.split("/") - if parts[0] == "device": - device_id = parts[1] if len(parts) >= 2 else "unknown" + if not parts or parts[0] != "device": + continue - if not device_id.startswith(f"{self.device_prefix}_"): - continue + device_id = parts[1] if len(parts) >= 2 else "unknown" + if not device_id.startswith(f"{self.device_prefix}_"): + continue - payload = json.loads(message.payload.decode("utf-8")) - - msg_id = payload.get("msg_id") - - handler = self._msg_handlers.get(msg_id) - if handler: - await handler(device_id, payload) - else: - logger.warning(device_id, "", f"未知 msg_id: {msg_id}, 设备: {device_id}") - - except json.JSONDecodeError as e: - logger.error("", "", f"消息解析失败: {e}") - except Exception as e: - logger.error("", "", f"消息处理异常: {e}") + payload = json.loads(message.payload.decode("utf-8")) + msg_id = payload.get("msg_id") + handler = self._msg_handlers.get(msg_id) + if handler: + await handler(device_id, payload) + else: + logger.warning(device_id, "", f"unknown msg_id={msg_id}") + except json.JSONDecodeError as exc: + logger.error("", "", f"invalid mqtt payload: {exc}") + except Exception as exc: + logger.error("", "", f"mqtt message handling failed: {exc}") except asyncio.CancelledError: pass - except Exception as e: - logger.error("", "", f"消息循环异常: {e}") + except Exception as exc: + logger.error("", "", f"mqtt loop failed: {exc}") self._connected = False async def _schedule_persistence(self, device_id: str, label: str, coro) -> None: @@ -109,7 +115,6 @@ class TalkingQMQTTService: ) async def _handle_device_info(self, device_id: str, payload: dict): - # status = payload.get("status") data = payload.get("data", {}) await self._schedule_persistence( device_id, @@ -199,17 +204,15 @@ class TalkingQMQTTService: async def _handle_nfc_notice_response(self, device_id: str, payload: dict): status = payload.get("status") - if status == "success": - logger.info(device_id, "", f"[NFC通知] 设备 {device_id} 已收到留言提示") - else: - logger.warning(device_id, "", f"[NFC通知] 设备 {device_id} 留言提示失败: {payload}") + if status != "success": + logger.warning(device_id, "", f"[NFC notice] command failed: {payload}") async def _handle_nfc_listen_report(self, device_id: str, payload: dict): params = payload.get("params", {}) nfc_uuid = params.get("uuid") logger.info(device_id, "", f"[NFC收听] 设备 {device_id} 请求收听留言, UUID={nfc_uuid}") await self._send_nfc_listen_response(device_id, nfc_uuid) - + async def _handle_bind_response(self, device_id: str, payload: dict): params = payload status = params.get("status") @@ -223,7 +226,8 @@ class TalkingQMQTTService: if result is None: logger.warning(device_id, "", "[NFC bind] no pending bind session found") return - logger.info(device_id, "", f"[NFC绑定] 设备 {device_id} 请求绑定卡片, UUID={nfc_uuid}, result={result}") + + logger.info(device_id, "", f"[NFC bind] bind completed, uuid={nfc_uuid}, result={result}") async def _handle_open_response(self, device_id: str, payload: dict): params = payload @@ -237,6 +241,25 @@ class TalkingQMQTTService: status = payload.get("status") if status == "success": logger.info(device_id, "", f"[定时休眠] 设备 {device_id} 休眠时间设置成功") + data = payload.get("data", {}) or {} + start = data.get("start") + end = data.get("end") + timezone = data.get("timezone") or "Asia/Shanghai" + if start and end: + try: + await self._schedule_persistence( + device_id, + "sleep_schedule", + device_setting_service.upsert_sleep_schedule( + device_id=device_id, + sleep_mode=1, + disable_time_start=datetime.strptime(start, "%H:%M").time(), + disable_time_end=datetime.strptime(end, "%H:%M").time(), + timezone=timezone, + ), + ) + except ValueError: + logger.warning(device_id, "", f"[定时休眠] 无法解析设备返回的时间: {payload}") else: logger.warning(device_id, "", f"[定时休眠] 设备 {device_id} 休眠时间设置失败: {payload}") @@ -249,6 +272,11 @@ class TalkingQMQTTService: async def _handle_alarm_report(self, device_id: str, payload: dict): logger.info(device_id, "", f"[告警] 设备 {device_id} 发送紧急报警") + await self._schedule_persistence( + device_id, + "alarm_event", + device_alarm_service.record_alarm_event(device_id=device_id, source_msg_id="010"), + ) await self._publish(f"device/{device_id}/event_resp", {"msg_id": "010", "status": "success"}) async def _handle_short_press_message(self, device_id: str, payload: dict): @@ -268,7 +296,6 @@ class TalkingQMQTTService: topic = f"device/{device_id}/event_resp" card = await card_service.get_card_by_uuid(nfc_uuid) if not card: - logger.warning("", "", f"[NFC收听] 设备 {device_id} 没有找到卡片{nfc_uuid},无法发送留言提示") payload = { "msg_id": "005", "type": 0, @@ -291,7 +318,8 @@ class TalkingQMQTTService: } } await self._publish(topic, payload) - return + return + if has_pending: audio_urls = await offline_audio_cache.get_audio_urls(device_id) # 53D92B6DA20001 测试卡片 @@ -314,49 +342,38 @@ class TalkingQMQTTService: } } else: - params = {} - for k, audio_url in enumerate(audio_urls, start=1): - params[f"url_{k}"] = audio_url - payload = { - "msg_id": "005", - "type": 0, - "params": params - } + 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) await self._publish(topic, payload) - else: - payload = { - "msg_id": "005", - "type": 0, - "params": { - "url_1": f"http://{settings.server_host}:{settings.server_port}/assets/audio/no_message.mp3" - } - } - await self._publish(topic, payload) - else: - # 检查卡片是否存在 - existing_card = await card_service.get_card_by_uuid(nfc_uuid) - - if existing_card: - # 卡片已存在,使用卡片绑定的设备ID作为目标设备ID - target_device_id = existing_card.device_id - logger.info(device_id, "card", f"卡片已存在,绑定的设备ID: {target_device_id}") - else: - # 卡片不存在,创建新卡片并绑定到当前设备 - new_card = await card_service.activate_card(nfc_uuid, device_id) - logger.info(device_id, "card", f"新卡片{nfc_uuid}已创建并激活,绑定到设备: {device_id}") return - - # 设置目标设备 - await device_target_cache.set_target(device_id, target_device_id) + payload = { "msg_id": "005", - "type": 1, + "type": 0, "params": { - "url_1": f"http://{settings.server_host}:{settings.server_port}/assets/audio/welcome.mp3" - } + "url_1": f"http://{settings.server_host}:{settings.server_port}/assets/audio/no_message.mp3" + }, } await self._publish(topic, payload) + return + + existing_card = await card_service.get_card_by_uuid(nfc_uuid) + if existing_card: + target_device_id = existing_card.device_id + else: + await card_service.activate_card(nfc_uuid, device_id) + return + + await device_target_cache.set_target(device_id, target_device_id) + payload = { + "msg_id": "005", + "type": 1, + "params": { + "url_1": f"http://{settings.server_host}:{settings.server_port}/assets/audio/welcome.mp3" + }, + } + await self._publish(topic, payload) async def connect(self): async with self._connect_lock: @@ -373,19 +390,15 @@ class TalkingQMQTTService: ) await self._client.__aenter__() self._connected = True - logger.system_info("", f"TalkingQ MQTT 已连接: {self.broker}:{self.port}") await self._client.subscribe("device/+/response", qos=self.qos) - logger.system_info("", "已订阅: device/+/response") - await self._client.subscribe("device/+/event", qos=self.qos) - logger.system_info("", "已订阅: device/+/event") self._message_task = asyncio.create_task(self._message_loop()) - logger.info("", "", "TalkingQ MQTT 服务启动中...") - except Exception as e: + logger.info("", "", f"mqtt connected: {self.broker}:{self.port}") + except Exception as exc: self._connected = False - logger.error("", "", f"TalkingQ MQTT 连接失败: {e}") + logger.error("", "", f"mqtt connect failed: {exc}") async def disconnect(self): if self._message_task and not self._message_task.done(): @@ -412,35 +425,26 @@ class TalkingQMQTTService: await self._ensure_connected() try: await self._client.publish(topic, json.dumps(payload, ensure_ascii=False), qos=self.qos) - logger.info("", "", f"下发命令 -> {topic}: {payload}") - except Exception as e: - logger.error("", "", f"命令下发失败: {e}") + logger.info("", "", f"mqtt publish topic={topic} payload={payload}") + except Exception as exc: + logger.error("", "", f"mqtt publish failed: {exc}") async def send_gps_query(self, device_id: str) -> str: - topic = f"device/{device_id}/command" - payload = {"msg_id": "001"} - await self._publish(topic, payload) + await self._publish(f"device/{device_id}/command", {"msg_id": "001"}) return "001" async def send_volume_command(self, device_id: str, level: int) -> str: - topic = f"device/{device_id}/command" - payload = { - "msg_id": "002", - "params": {"level": level} - } - await self._publish(topic, payload) + await self._publish( + f"device/{device_id}/command", + {"msg_id": "002", "params": {"level": level}}, + ) return "002" async def send_ota_command(self, device_id: str, url: str, version: str) -> str: - topic = f"device/{device_id}/command" - payload = { - "msg_id": "003", - "params": { - "url": url, - "version": version, - } - } - await self._publish(topic, payload) + await self._publish( + f"device/{device_id}/command", + {"msg_id": "003", "params": {"url": url, "version": version}}, + ) return "003" async def send_nfc_notice(self, device_id: str, url: str) -> str: From be4d2765574896d1d6748f706fc7e7bcf0813997 Mon Sep 17 00:00:00 2001 From: HycJack <772403255@qq.com> Date: Sat, 9 May 2026 09:46:06 +0800 Subject: [PATCH 2/8] =?UTF-8?q?=E5=90=88=E5=85=A5=E5=B0=8F=E7=A8=8B?= =?UTF-8?q?=E5=BA=8F=E4=BF=AE=E6=94=B9=EF=BC=8C=E6=96=B0=E5=A2=9E=E5=91=8A?= =?UTF-8?q?=E8=AD=A6=E5=8A=9F=E8=83=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- talkingq-url/banban/dao/im.py | 46 +++++ talkingq-url/banban/routers/devices.py | 63 ++++++ talkingq-url/banban/service/device.py | 97 +++++++++ talkingq-url/banban/service/im.py | 72 +++++++ talkingq-url/database/models.py | 20 ++ talkingq-url/handlers/mqtt_handler.py | 133 ++++++++++-- talkingq-url/scripts/generate_bind_qr.py | 8 +- .../scripts/import_device_imei_mapping.py | 189 ++++++++++++++++++ .../services/device_identity_initializer.py | 129 ++++++++++++ .../services/device_update_manager.py | 71 ++++++- 10 files changed, 800 insertions(+), 28 deletions(-) create mode 100644 talkingq-url/scripts/import_device_imei_mapping.py create mode 100644 talkingq-url/services/device_identity_initializer.py diff --git a/talkingq-url/banban/dao/im.py b/talkingq-url/banban/dao/im.py index 137c737..4b792a7 100644 --- a/talkingq-url/banban/dao/im.py +++ b/talkingq-url/banban/dao/im.py @@ -18,6 +18,14 @@ class DeviceIdentity: child_name: str | None +@dataclass(frozen=True) +class DeviceOwnerIdentity: + device_id: str + child_id: int + child_name: str | None + owner_user_id: int + + @dataclass(frozen=True) class ConversationMessageCreateResult: idempotent: bool @@ -98,6 +106,44 @@ class ImDAO(BaseDAO): child_id=int(row["child_id"]), child_name=row["child_name"], ) + + async def get_bound_device_owner_identity(self, *, device_id: str) -> DeviceOwnerIdentity: + row = ( + await self.execute( + text( + """ + SELECT + da.device_id, + db.owner_user_id, + db.child_id, + c.child_name + FROM device_auth AS da + JOIN device_bindings AS db + ON db.device_id = da.device_id + AND db.status = 1 + LEFT JOIN children AS c + ON c.child_id = db.child_id + AND c.status = 1 + WHERE da.device_id = :device_id + AND da.is_active = 1 + LIMIT 1 + """ + ), + {"device_id": device_id}, + ) + ).mappings().first() + if not row: + from fastapi import HTTPException, status + raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="invalid device credentials") + if row["child_id"] is None: + from fastapi import HTTPException + raise HTTPException(status_code=404, detail="device not bound to a child") + return DeviceOwnerIdentity( + device_id=str(row["device_id"]), + child_id=int(row["child_id"]), + child_name=row["child_name"], + owner_user_id=int(row["owner_user_id"]), + ) async def get_device_by_id(self, *, device_id: str) -> DeviceIdentity: row = ( diff --git a/talkingq-url/banban/routers/devices.py b/talkingq-url/banban/routers/devices.py index 95b5406..d8336db 100644 --- a/talkingq-url/banban/routers/devices.py +++ b/talkingq-url/banban/routers/devices.py @@ -127,6 +127,21 @@ class DeviceRemoteSleepWakeResponse(BaseModel): msg_id: str +class DeviceFirmwareStatusResponse(BaseModel): + device_id: str + current_version: str | None = None + latest_version: str | None = None + update_available: bool + can_update: bool + update_status: str + progress: float + target_version: str | None = None + updated_at: datetime | None = None + + +class DeviceFirmwareUpdateResponse(DeviceFirmwareStatusResponse): + msg_id: str + @@ -430,6 +445,54 @@ async def set_device_remote_sleep_wake( ) +@router.get("/{device_id}/firmware", response_model=DeviceFirmwareStatusResponse) +async def get_device_firmware_status( + device_id: str, + request: Request, + current_user_id: int = Depends(get_current_user_id), +) -> DeviceFirmwareStatusResponse: + result = await device_service.get_firmware_status( + device_id=device_id, + user_id=current_user_id, + ) + logger.info( + "device firmware status fetched", + extra={ + "event": "device_firmware_status", + "request_id": getattr(request.state, "request_id", None), + "user_id": current_user_id, + "device_id": device_id, + "update_available": result["update_available"], + "update_status": result["update_status"], + }, + ) + return DeviceFirmwareStatusResponse(**result) + + +@router.post("/{device_id}/firmware/update", response_model=DeviceFirmwareUpdateResponse) +async def start_device_firmware_update( + device_id: str, + request: Request, + current_user_id: int = Depends(get_current_user_id), +) -> DeviceFirmwareUpdateResponse: + result = await device_service.start_firmware_update( + device_id=device_id, + user_id=current_user_id, + ) + logger.info( + "device firmware update command sent", + extra={ + "event": "device_firmware_update", + "request_id": getattr(request.state, "request_id", None), + "user_id": current_user_id, + "device_id": device_id, + "target_version": result["target_version"], + "msg_id": result["msg_id"], + }, + ) + return DeviceFirmwareUpdateResponse(**result) + + @router.get("/{device_id}/location", response_model=DeviceLocationCurrentResponse) async def get_current_device_location( diff --git a/talkingq-url/banban/service/device.py b/talkingq-url/banban/service/device.py index 135ca0c..6c28ee3 100644 --- a/talkingq-url/banban/service/device.py +++ b/talkingq-url/banban/service/device.py @@ -7,6 +7,8 @@ from fastapi import HTTPException from banban.dao.device import DeviceDAO from banban.service.device_setting import device_setting_service +from services.device_update_manager import device_firmware_update_manager +from services.system_config_manager import system_config_manager class DeviceService(DatabaseServiceBase): @@ -115,6 +117,101 @@ class DeviceService(DatabaseServiceBase): raise HTTPException(status_code=503, detail="MQTT 服务未初始化") return await service.send_remote_sleep_wake_command(device_id, switch) + def _compare_versions(self, current_version: str | None, latest_version: str | None) -> bool: + current = (current_version or "").strip() + latest = (latest_version or "").strip() + if not latest: + return False + if not current or current in {"unknown", "0.0.0"}: + return True + + try: + current_parts = [int(part) for part in current.split(".")] + latest_parts = [int(part) for part in latest.split(".")] + except ValueError: + return current != latest + + max_len = max(len(current_parts), len(latest_parts)) + current_parts.extend([0] * (max_len - len(current_parts))) + latest_parts.extend([0] * (max_len - len(latest_parts))) + return latest_parts > current_parts + + async def get_firmware_status( + self, + *, + device_id: str, + user_id: int, + ) -> dict[str, Any]: + status_row = await self.get_device_status(device_id=device_id, user_id=user_id) + current_version = status_row.get("version") + + latest_version_config = await system_config_manager.get_config("latest_firmware_version") + firmware_url_config = await system_config_manager.get_config("update_firmware_url") + latest_version = latest_version_config.config_value if latest_version_config else None + firmware_url = firmware_url_config.config_value if firmware_url_config else None + update_available = self._compare_versions(current_version, latest_version) + + update_record = await device_firmware_update_manager.get_firmware_update_dict(device_id) + update_status = update_record.get("update_status") if update_record else "idle" + progress = update_record.get("progress") if update_record else 0.0 + target_version = update_record.get("firmware_version") if update_record else None + updated_at = update_record.get("updated_at") if update_record else None + + return { + "device_id": device_id, + "current_version": current_version, + "latest_version": latest_version, + "update_available": update_available, + "update_status": update_status or "idle", + "progress": float(progress or 0.0), + "target_version": target_version, + "updated_at": updated_at, + "can_update": bool(update_available and latest_version and firmware_url), + } + + async def start_firmware_update( + self, + *, + device_id: str, + user_id: int, + ) -> dict[str, Any]: + firmware_status = await self.get_firmware_status(device_id=device_id, user_id=user_id) + if not firmware_status.get("update_available"): + raise HTTPException(status_code=409, detail="device firmware is already up to date") + + latest_version = firmware_status.get("latest_version") + if not latest_version: + raise HTTPException(status_code=404, detail="latest firmware version not configured") + + firmware_url_config = await system_config_manager.get_config("update_firmware_url") + firmware_url = firmware_url_config.config_value if firmware_url_config else None + if not firmware_url: + raise HTTPException(status_code=404, detail="firmware url not configured") + + from handlers.mqtt_handler import TalkingQMQTTService + + service = await TalkingQMQTTService.get_instance() + if service is None: + raise HTTPException(status_code=503, detail="MQTT 服务未初始化") + + await device_firmware_update_manager.update_firmware_update( + device_id=device_id, + firmware_version=latest_version, + update_status="sent", + progress=0.0, + ) + msg_id = await service.send_ota_command(device_id, firmware_url, latest_version) + + return { + **firmware_status, + "latest_version": latest_version, + "target_version": latest_version, + "update_status": "sent", + "progress": 0.0, + "msg_id": msg_id, + "can_update": bool(firmware_status.get("can_update")), + } + # 创建全局 DeviceService 实例 device_service = DeviceService() diff --git a/talkingq-url/banban/service/im.py b/talkingq-url/banban/service/im.py index f8b732d..9b11433 100644 --- a/talkingq-url/banban/service/im.py +++ b/talkingq-url/banban/service/im.py @@ -71,6 +71,12 @@ def build_device_audio_client_msg_id(*, device_id: str, target_device_id: str, a return f"device-audio-{digest[:32]}" +def build_device_parent_leave_message_client_msg_id(*, device_id: str, media_file_key: str) -> str: + raw = f"{device_id}|parent|{media_file_key}" + digest = hashlib.sha256(raw.encode("utf-8")).hexdigest() + return f"device-parent-{digest[:32]}" + + def normalize_content_json(value: Any) -> dict[str, Any] | None: if value is None: return None @@ -409,6 +415,72 @@ class ImService(DatabaseServiceBase): finally: await db_session.close() + async def create_device_parent_leave_message( + self, + *, + device_id: str, + media_file_key: str, + media_duration_ms: int | None = None, + media_mime_type: str | None = None, + media_size_bytes: int | None = None, + media_transcript_text: str | None = None, + client_msg_id: str | None = None, + ext_json: dict[str, Any] | None = None, + ) -> tuple[DeviceIdentity, ConversationMessageCreateResult]: + normalized_media_file_key = str(media_file_key or "").strip() + if not normalized_media_file_key: + raise HTTPException(status_code=400, detail="media_file_key is required") + + db_session = await self.get_session() + try: + dao = ImDAO(db_session) + owner_identity = await dao.get_bound_device_owner_identity(device_id=device_id) + device_identity = DeviceIdentity( + device_id=owner_identity.device_id, + child_id=owner_identity.child_id, + child_name=owner_identity.child_name, + ) + if ext_json: + resolved_ext_json = dict(ext_json) + else: + resolved_ext_json = {} + resolved_ext_json.update( + { + "message_kind": "leave_message", + "source": "device_mqtt_011", + "source_device_id": device_id, + } + ) + + payload = DeviceMessageCreateRequest( + conversation_type=PARENT_CHILD_CONVERSATION_TYPE, + parent_user_id=owner_identity.owner_user_id, + content_type=2, + media_file_key=normalized_media_file_key, + media_duration_ms=media_duration_ms, + media_mime_type=(media_mime_type or "").strip() or "audio/mpeg", + media_size_bytes=media_size_bytes, + media_transcript_text=(media_transcript_text or "").strip() or None, + client_msg_id=(client_msg_id or "").strip() + or build_device_parent_leave_message_client_msg_id( + device_id=device_id, + media_file_key=normalized_media_file_key, + ), + ext_json=resolved_ext_json, + ) + + result = await self._create_device_message_with_payload( + dao=dao, + device_identity=device_identity, + payload=payload, + ) + return device_identity, result + except Exception: + await db_session.rollback() + raise + finally: + await db_session.close() + async def assert_child_exists(self, *, child_id: int) -> Mapping[str, Any]: db_session = await self.get_session() try: diff --git a/talkingq-url/database/models.py b/talkingq-url/database/models.py index 8b141e6..c9eb83b 100644 --- a/talkingq-url/database/models.py +++ b/talkingq-url/database/models.py @@ -103,10 +103,30 @@ class DeviceAuth(Base): {'mysql_charset': 'utf8mb4', 'mysql_collate': 'utf8mb4_unicode_ci'} ) + +class DeviceImeiMapping(Base): + __tablename__ = "device_imei_mapping" + id = Column(Integer, primary_key=True, autoincrement=True) + imei = Column(String(64), nullable=False, unique=True, index=True) + device_id = Column(String(64), nullable=False, unique=True, index=True) + serial_number = Column(String(64), nullable=False) + status = Column(String(32), nullable=False, server_default=text("'pending'")) + activated_at = Column(DateTime, nullable=True) + created_at = Column(DateTime, nullable=False, server_default=func.now()) + updated_at = Column(DateTime, nullable=False, server_default=func.now(), onupdate=func.now()) + + __table_args__ = ( + Index("idx_device_imei_mapping_status", "status"), + {'mysql_charset': 'utf8mb4', 'mysql_collate': 'utf8mb4_unicode_ci'} + ) + + class DeviceFirmwareUpdate(Base): __tablename__ = "device_firmware_update" id = Column(Integer, primary_key=True, autoincrement=True) device_id = Column(String(64), nullable=False, unique=True, index=True) + serial_number = Column(String(64), nullable=False, default="") + mac_address = Column(String(512), nullable=True) firmware_version = Column(String(64), nullable=False) update_status = Column(String(32), nullable=False, default="success") # 更新状态,如 updating/success/failed created_at = Column(DateTime, nullable=False, server_default=func.now()) diff --git a/talkingq-url/handlers/mqtt_handler.py b/talkingq-url/handlers/mqtt_handler.py index a5801e1..a45481c 100644 --- a/talkingq-url/handlers/mqtt_handler.py +++ b/talkingq-url/handlers/mqtt_handler.py @@ -9,19 +9,19 @@ import aiomqtt from banban.service.binding import BindingService from banban.service.device_alarm import device_alarm_service from banban.service.device_setting import device_setting_service +from banban.service.im import im_service from banban.service.location import location_service from config import settings from services.card_service import card_service -from services.offline_audio_cache import offline_audio_cache -import aiomqtt -from banban.service.location import location_service -from database.models import ChildLocationCurrent -from datetime import datetime from services.device_target_cache import device_target_cache +from services.device_identity_initializer import ( + DeviceIdentityInitializationError, + device_identity_initializer, +) +from services.device_update_manager import device_firmware_update_manager from services.offline_audio_cache import offline_audio_cache from services.task_manager import task_manager from utils.logger import session_logger as logger -from services.task_manager import task_manager class TalkingQMQTTService: _instance = None @@ -55,6 +55,7 @@ class TalkingQMQTTService: "009": self._handle_remote_sleep_wake_response, "010": self._handle_alarm_report, "011": self._handle_short_press_message, + "012": self._handle_device_identity_init, } @classmethod @@ -80,12 +81,16 @@ class TalkingQMQTTService: if not parts or parts[0] != "device": continue - device_id = parts[1] if len(parts) >= 2 else "unknown" - if not device_id.startswith(f"{self.device_prefix}_"): - continue - payload = json.loads(message.payload.decode("utf-8")) msg_id = payload.get("msg_id") + + device_id = parts[1] if len(parts) >= 2 else "unknown" + topic_kind = parts[2] if len(parts) >= 3 else "" + if msg_id == "012" and topic_kind != "event": + continue + if msg_id != "012" and not device_id.startswith(f"{self.device_prefix}_"): + continue + handler = self._msg_handlers.get(msg_id) if handler: await handler(device_id, payload) @@ -185,7 +190,36 @@ class TalkingQMQTTService: async def _handle_ota_response(self, device_id: str, payload: dict): status = payload.get("status") data = payload.get("data", {}) - if status == "success": + progress = data.get("progress") + try: + progress_value = float(progress) if progress is not None else None + except (TypeError, ValueError): + progress_value = None + + if status == "accepted": + await self._schedule_persistence( + device_id, + "ota", + device_firmware_update_manager.update_firmware_update( + device_id=device_id, + firmware_version=data.get("version") or data.get("new_version") or "unknown", + update_status="accepted", + progress=progress_value if progress_value is not None else 0.0, + ), + ) + elif status == "updating": + await self._schedule_persistence( + device_id, + "ota", + device_firmware_update_manager.update_firmware_update( + device_id=device_id, + firmware_version=data.get("version") or data.get("new_version") or "updating", + update_status="updating", + progress=progress_value, + ), + ) + elif status == "success": + new_version = data.get("new_version") or data.get("version") await self._schedule_persistence( device_id, "ota", @@ -194,13 +228,33 @@ class TalkingQMQTTService: power=None, volume=None, signal_strength=None, - version_str=data.get("new_version"), + version_str=new_version, + ), + ) + await self._schedule_persistence( + device_id, + "ota", + device_firmware_update_manager.update_firmware_update( + device_id=device_id, + firmware_version=new_version or "unknown", + update_status="success", + progress=100.0, + ), + ) + elif status == "failed": + await self._schedule_persistence( + device_id, + "ota", + device_firmware_update_manager.update_firmware_update( + device_id=device_id, + firmware_version=data.get("version") or data.get("new_version") or "unknown", + update_status="failed", + progress=progress_value, ), ) - elif status != "accepted": logger.warning(device_id, "", f"[OTA] command failed: {payload}") else: - logger.warning(device_id, "", f"[OTA] 设备 {device_id} 升级异常: {payload}") + logger.warning(device_id, "", f"[OTA] unknown status: {payload}") async def _handle_nfc_notice_response(self, device_id: str, payload: dict): status = payload.get("status") @@ -283,8 +337,35 @@ class TalkingQMQTTService: params = payload.get("params", {}) nfc_uuid = params.get("uuid") logger.info(device_id, "", f"[短按留言] 设备 {device_id} 短按发送留言, UUID={nfc_uuid}") + media_file_key = str(params.get("media_file_key") or params.get("audio_url") or "").strip() + if media_file_key: + try: + await im_service.create_device_parent_leave_message( + device_id=device_id, + media_file_key=media_file_key, + media_duration_ms=params.get("media_duration_ms"), + media_mime_type=params.get("media_mime_type"), + media_size_bytes=params.get("media_size_bytes"), + media_transcript_text=params.get("media_transcript_text"), + client_msg_id=params.get("client_msg_id"), + ext_json=params.get("ext_json") if isinstance(params.get("ext_json"), dict) else None, + ) + logger.info(device_id, "", f"[短按留言] 设备 {device_id} 留言已写入家长会话") + except Exception as exc: + logger.warning(device_id, "", f"[短按留言] 设备 {device_id} 留言写入失败: {exc}") + await self._publish( + f"device/{device_id}/event_resp", + { + "msg_id": "011", + "status": "failed", + "message": str(exc), + }, + ) + return + payload = { "msg_id": "011", + "status": "success", "type": 0, "params": { "url": f"http://{settings.server_host}:{settings.server_port}/assets/audio/message_ok.mp3" @@ -292,6 +373,30 @@ class TalkingQMQTTService: } await self._publish(f"device/{device_id}/event_resp", payload) + async def _handle_device_identity_init(self, imei: str, payload: dict): + try: + result = await device_identity_initializer.initialize_by_imei(imei) + except DeviceIdentityInitializationError as exc: + logger.warning(imei, "", f"[设备初始化] IMEI初始化失败: {exc}") + await self._publish( + f"device/{imei}/event_resp", + { + "msg_id": "012", + "status": "failed", + "message": str(exc), + }, + ) + return + + response_payload = { + "msg_id": "012", + "params": { + "device_id": result.device_id, + "device_sn": result.serial_number, + }, + } + await self._publish(f"device/{imei}/event_resp", response_payload) + async def _send_nfc_listen_response(self, device_id: str, nfc_uuid: str): topic = f"device/{device_id}/event_resp" card = await card_service.get_card_by_uuid(nfc_uuid) diff --git a/talkingq-url/scripts/generate_bind_qr.py b/talkingq-url/scripts/generate_bind_qr.py index d56f4d1..0c63869 100644 --- a/talkingq-url/scripts/generate_bind_qr.py +++ b/talkingq-url/scripts/generate_bind_qr.py @@ -21,7 +21,7 @@ from qrcode.main import QRCode DEFAULT_OUTPUT_DIR = SCRIPT_DIR.parent / "output" / "qr" -SERIAL_PREFIX = "TalkingQ-" +SERIAL_PREFIXES = ("TalkingQ-", "TQ_") SAFE_NAME_RE = re.compile(r"[^A-Za-z0-9._-]+") @@ -32,7 +32,7 @@ def parse_args() -> argparse.Namespace: parser.add_argument("device_id", help="Device ID written into the QR payload.") parser.add_argument( "serial_number", - help=f"Device serial number. It must start with {SERIAL_PREFIX!r}.", + help=f"Device serial number. It must start with one of {SERIAL_PREFIXES!r}.", ) parser.add_argument( "-o", @@ -56,8 +56,8 @@ def validate_inputs(device_id: str, serial_number: str, box_size: int) -> tuple[ raise ValueError("device_id cannot be empty") if not normalized_serial_number: raise ValueError("serial_number cannot be empty") - if not normalized_serial_number.startswith(SERIAL_PREFIX): - raise ValueError(f"serial_number must start with {SERIAL_PREFIX}") + if not normalized_serial_number.startswith(SERIAL_PREFIXES): + raise ValueError(f"serial_number must start with one of {SERIAL_PREFIXES}") if box_size <= 0: raise ValueError("box_size must be greater than 0") diff --git a/talkingq-url/scripts/import_device_imei_mapping.py b/talkingq-url/scripts/import_device_imei_mapping.py new file mode 100644 index 0000000..f6d9d1a --- /dev/null +++ b/talkingq-url/scripts/import_device_imei_mapping.py @@ -0,0 +1,189 @@ +#!/usr/bin/env python3 +from __future__ import annotations + +import argparse +import csv +import sys +from pathlib import Path + +PROJECT_ROOT = Path(__file__).resolve().parents[1] +if str(PROJECT_ROOT) not in sys.path: + sys.path.insert(0, str(PROJECT_ROOT)) + +import pymysql +from pymysql.cursors import DictCursor + +from config import settings + + +SERIAL_NUMBER_COLUMNS = ("serial_number", "device_sn") + +CREATE_TABLE_SQL = """ +CREATE TABLE IF NOT EXISTS device_imei_mapping ( + id INT NOT NULL AUTO_INCREMENT, + imei VARCHAR(64) NOT NULL, + device_id VARCHAR(64) NOT NULL, + serial_number VARCHAR(64) NOT NULL, + status VARCHAR(32) NOT NULL DEFAULT 'pending', + activated_at DATETIME NULL, + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + PRIMARY KEY (id), + UNIQUE INDEX imei_UNIQUE (imei ASC), + UNIQUE INDEX device_id_UNIQUE (device_id ASC), + INDEX idx_device_imei_mapping_status (status ASC) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci +""" + + +def parse_args() -> argparse.Namespace: + parser = argparse.ArgumentParser( + description="Import IMEI to device identity mappings from a CSV file." + ) + parser.add_argument("csv_path", help="CSV file with imei, device_id, serial_number columns.") + parser.add_argument( + "--dry-run", + action="store_true", + help="Validate the CSV without writing to the database.", + ) + return parser.parse_args() + + +def normalize_row(row: dict[str, str], row_number: int) -> dict[str, str]: + serial_number = "" + for column in SERIAL_NUMBER_COLUMNS: + value = str(row.get(column) or "").strip() + if value: + serial_number = value + break + + normalized = { + "imei": str(row.get("imei") or "").strip(), + "device_id": str(row.get("device_id") or "").strip(), + "serial_number": serial_number, + } + missing = [key for key, value in normalized.items() if not value] + if missing: + raise ValueError(f"row {row_number}: missing required columns: {', '.join(missing)}") + return normalized + + +def read_rows(csv_path: Path) -> list[dict[str, str]]: + with csv_path.open("r", encoding="utf-8-sig", newline="") as fp: + reader = csv.DictReader(fp) + fieldnames = set(reader.fieldnames or []) + missing = [column for column in ("imei", "device_id") if column not in fieldnames] + if not any(column in fieldnames for column in SERIAL_NUMBER_COLUMNS): + missing.append("serial_number/device_sn") + if missing: + raise ValueError(f"CSV missing required columns: {', '.join(missing)}") + + rows: list[dict[str, str]] = [] + seen_imei: set[str] = set() + seen_device_id: set[str] = set() + for row_number, row in enumerate(reader, start=2): + normalized = normalize_row(row, row_number) + if normalized["imei"] in seen_imei: + raise ValueError(f"row {row_number}: duplicate imei in CSV: {normalized['imei']}") + if normalized["device_id"] in seen_device_id: + raise ValueError(f"row {row_number}: duplicate device_id in CSV: {normalized['device_id']}") + seen_imei.add(normalized["imei"]) + seen_device_id.add(normalized["device_id"]) + rows.append(normalized) + return rows + + +def get_connection(): + return pymysql.connect( + host=settings.db_host, + port=settings.db_port, + user=settings.db_user, + password=settings.db_password, + database=settings.db_name, + charset="utf8mb4", + cursorclass=DictCursor, + ) + + +def import_rows(rows: list[dict[str, str]]) -> int: + conn = get_connection() + try: + with conn.cursor() as cur: + cur.execute(CREATE_TABLE_SQL) + for row in rows: + cur.execute( + """ + SELECT imei, device_id, serial_number, status + FROM device_imei_mapping + WHERE imei = %s OR device_id = %s + """, + (row["imei"], row["device_id"]), + ) + existing_rows = cur.fetchall() + if len(existing_rows) > 1: + raise ValueError( + f"conflicting mapping: imei={row['imei']} device_id={row['device_id']}" + ) + + if not existing_rows: + cur.execute( + """ + INSERT INTO device_imei_mapping (imei, device_id, serial_number, status) + VALUES (%s, %s, %s, 'pending') + """, + (row["imei"], row["device_id"], row["serial_number"]), + ) + continue + + existing = existing_rows[0] + if existing["imei"] != row["imei"]: + raise ValueError( + f"device_id already mapped to another imei: {row['device_id']}" + ) + + is_activated = str(existing["status"]) == "activated" + changed_identity = ( + existing["device_id"] != row["device_id"] + or existing["serial_number"] != row["serial_number"] + ) + if is_activated and changed_identity: + raise ValueError(f"activated imei cannot be remapped: {row['imei']}") + + cur.execute( + """ + UPDATE device_imei_mapping + SET device_id = %s, + serial_number = %s, + status = IF(status = 'activated', status, 'pending') + WHERE imei = %s + """, + (row["device_id"], row["serial_number"], row["imei"]), + ) + conn.commit() + return len(rows) + except Exception: + conn.rollback() + raise + finally: + conn.close() + + +def main() -> int: + args = parse_args() + csv_path = Path(args.csv_path).expanduser().resolve() + try: + rows = read_rows(csv_path) + if args.dry_run: + print(f"validated {len(rows)} rows from {csv_path}") + return 0 + imported = import_rows(rows) + except Exception as exc: + print(f"Error: {exc}", file=sys.stderr) + return 1 + + print(f"imported {imported} rows into device_imei_mapping") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/talkingq-url/services/device_identity_initializer.py b/talkingq-url/services/device_identity_initializer.py new file mode 100644 index 0000000..8e32481 --- /dev/null +++ b/talkingq-url/services/device_identity_initializer.py @@ -0,0 +1,129 @@ +from __future__ import annotations + +from dataclasses import dataclass + +from sqlalchemy import text + +from services.database_service_base import DatabaseServiceBase +from utils.logger import session_logger + + +class DeviceIdentityInitializationError(ValueError): + pass + + +@dataclass(frozen=True) +class DeviceIdentityInitializationResult: + imei: str + device_id: str + serial_number: str + activated: bool + + +class DeviceIdentityInitializer(DatabaseServiceBase): + def __init__(self): + super().__init__(service_name="device_identity_initializer") + + async def initialize_by_imei(self, imei: str) -> DeviceIdentityInitializationResult: + normalized_imei = str(imei or "").strip() + if not normalized_imei: + raise DeviceIdentityInitializationError("imei is required") + + db_session = await self.get_session() + try: + mapping_result = await db_session.execute( + text( + """ + SELECT imei, device_id, serial_number, status + FROM device_imei_mapping + WHERE imei = :imei + LIMIT 1 + """ + ), + {"imei": normalized_imei}, + ) + mapping = mapping_result.mappings().first() + if mapping is None: + raise DeviceIdentityInitializationError("imei mapping not found") + + device_id = str(mapping["device_id"]).strip() + serial_number = str(mapping["serial_number"]).strip() + if not device_id or not serial_number: + raise DeviceIdentityInitializationError("imei mapping is incomplete") + + device_auth_result = await db_session.execute( + text( + """ + SELECT device_id, serial_number + FROM device_auth + WHERE device_id = :device_id + LIMIT 1 + """ + ), + {"device_id": device_id}, + ) + device_auth = device_auth_result.mappings().first() + if device_auth is None: + await db_session.execute( + text( + """ + INSERT INTO device_auth (device_id, serial_number, is_active) + VALUES (:device_id, :serial_number, 1) + """ + ), + {"device_id": device_id, "serial_number": serial_number}, + ) + elif str(device_auth["serial_number"]) != serial_number: + raise DeviceIdentityInitializationError("device_auth serial_number conflict") + else: + await db_session.execute( + text( + """ + UPDATE device_auth + SET is_active = 1 + WHERE device_id = :device_id + """ + ), + {"device_id": device_id}, + ) + + await db_session.execute( + text( + """ + UPDATE device_imei_mapping + SET status = 'activated', + activated_at = COALESCE(activated_at, CURRENT_TIMESTAMP) + WHERE imei = :imei + """ + ), + {"imei": normalized_imei}, + ) + await db_session.commit() + + session_logger.info( + device_id, + "device_identity_initializer", + f"IMEI identity initialized: imei={normalized_imei}", + ) + return DeviceIdentityInitializationResult( + imei=normalized_imei, + device_id=device_id, + serial_number=serial_number, + activated=str(mapping["status"]) == "activated", + ) + except DeviceIdentityInitializationError: + await db_session.rollback() + raise + except Exception as exc: + await db_session.rollback() + session_logger.error( + normalized_imei, + "device_identity_initializer", + f"initialize identity failed: {exc}", + ) + raise + finally: + await db_session.close() + + +device_identity_initializer = DeviceIdentityInitializer() diff --git a/talkingq-url/services/device_update_manager.py b/talkingq-url/services/device_update_manager.py index 074a77e..d88b511 100644 --- a/talkingq-url/services/device_update_manager.py +++ b/talkingq-url/services/device_update_manager.py @@ -1,6 +1,8 @@ from typing import Dict, Optional import time -from sqlalchemy import select, update, insert, delete +from collections.abc import Mapping + +from sqlalchemy import select, update, insert, delete, text from database.models import DeviceFirmwareUpdate from services.database_service_base import DatabaseServiceBase from utils.logger import session_logger @@ -13,7 +15,31 @@ class DeviceFirmwareUpdateManager(DatabaseServiceBase): self.cache_timestamps = {} # 记录缓存更新时间 self.max_cache_size = 1000 # 最大缓存条目数 - async def create_firmware_update(self, device_id: str, firmware_version: str, update_status: str = "success"): + async def _get_serial_number(self, db_session, device_id: str) -> str: + result = await db_session.execute( + text("SELECT serial_number FROM device_auth WHERE device_id = :device_id LIMIT 1"), + {"device_id": device_id}, + ) + row = result.mappings().first() + if row and row.get("serial_number"): + return str(row["serial_number"]) + return "" + + def _row_to_dict(self, row) -> dict: + if row is None: + return {} + if isinstance(row, Mapping): + return dict(row) + return { + "device_id": row.device_id, + "firmware_version": row.firmware_version, + "update_status": row.update_status, + "progress": row.progress, + "created_at": row.created_at, + "updated_at": row.updated_at, + } + + async def create_firmware_update(self, device_id: str, firmware_version: str, update_status: str = "success", progress: float = 0.0): """新增固件更新记录""" await self._init_database() db_session = await self.get_session() @@ -29,16 +55,18 @@ class DeviceFirmwareUpdateManager(DatabaseServiceBase): .values( firmware_version=firmware_version, update_status=update_status, - progress=0.0 + progress=progress ) ) await db_session.execute(update_stmt) else: + serial_number = await self._get_serial_number(db_session, device_id) insert_stmt = insert(DeviceFirmwareUpdate).values( device_id=device_id, + serial_number=serial_number, firmware_version=firmware_version, update_status=update_status, - progress=0.0 + progress=progress ) await db_session.execute(insert_stmt) @@ -48,7 +76,7 @@ class DeviceFirmwareUpdateManager(DatabaseServiceBase): "device_id": device_id, "firmware_version": firmware_version, "update_status": update_status, - "progress": 0.0 + "progress": progress }) return True @@ -79,7 +107,13 @@ class DeviceFirmwareUpdateManager(DatabaseServiceBase): finally: await db_session.close() - async def update_firmware_update(self, device_id: str, firmware_version: str, update_status: str) -> bool: + async def update_firmware_update( + self, + device_id: str, + firmware_version: str, + update_status: str, + progress: Optional[float] = None, + ) -> bool: """更新设备固件信息(支持部分字段更新)""" await self._init_database() db_session = await self.get_session() @@ -88,6 +122,8 @@ class DeviceFirmwareUpdateManager(DatabaseServiceBase): "firmware_version": firmware_version, "update_status": update_status } + if progress is not None: + update_values["progress"] = progress query = select(DeviceFirmwareUpdate).where(DeviceFirmwareUpdate.device_id == device_id) result = await db_session.execute(query) @@ -101,10 +137,13 @@ class DeviceFirmwareUpdateManager(DatabaseServiceBase): ) await db_session.execute(update_stmt) else: + serial_number = await self._get_serial_number(db_session, device_id) insert_stmt = insert(DeviceFirmwareUpdate).values( device_id=device_id, + serial_number=serial_number, firmware_version=firmware_version, - update_status=update_status + update_status=update_status, + progress=progress if progress is not None else 0.0, ) await db_session.execute(insert_stmt) @@ -131,8 +170,12 @@ class DeviceFirmwareUpdateManager(DatabaseServiceBase): await db_session.execute(update_stmt) await db_session.commit() - if device_id in self.update_cache: - self.update_cache[device_id].progress = progress + cached_update = self.update_cache.get(device_id) + if isinstance(cached_update, dict): + cached_update["progress"] = progress + elif cached_update is not None: + cached_update.progress = progress + if cached_update is not None: self.cache_timestamps[device_id] = time.time() session_logger.info(device_id, "firmware_update", f"更新进度已更新: {progress:.1f}%") @@ -184,8 +227,10 @@ class DeviceFirmwareUpdateManager(DatabaseServiceBase): ) await db_session.execute(update_stmt) else: + serial_number = await self._get_serial_number(db_session, device_id) insert_stmt = insert(DeviceFirmwareUpdate).values( device_id=device_id, + serial_number=serial_number, firmware_version=firmware_version, update_status="success" # 默认状态为成功 ) @@ -264,4 +309,10 @@ class DeviceFirmwareUpdateManager(DatabaseServiceBase): if device_id in self.cache_timestamps: del self.cache_timestamps[device_id] -device_firmware_update_manager = DeviceFirmwareUpdateManager() \ No newline at end of file + async def get_firmware_update_dict(self, device_id: str) -> Optional[dict]: + record = await self.get_firmware_update(device_id) + if record is None: + return None + return self._row_to_dict(record) + +device_firmware_update_manager = DeviceFirmwareUpdateManager() From edd1a2c4c2314d7ac658ad7b22425165197e5329 Mon Sep 17 00:00:00 2001 From: HycJack <772403255@qq.com> Date: Sat, 9 May 2026 11:49:22 +0800 Subject: [PATCH 3/8] =?UTF-8?q?=E6=94=AF=E6=8C=81=E8=AE=BE=E5=A4=87?= =?UTF-8?q?=E7=9F=AD=E6=8C=89=E5=90=91=E5=AE=B6=E9=95=BF=E7=95=99=E8=A8=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- talkingq-url/banban/service/im.py | 2 + talkingq-url/config.py | 4 + talkingq-url/handlers/mqtt_handler.py | 36 ++++++--- .../handlers/websocket_message_handler.py | 81 ++++++++++++++----- talkingq-url/services/device_target_cache.py | 38 ++++++++- 5 files changed, 131 insertions(+), 30 deletions(-) diff --git a/talkingq-url/banban/service/im.py b/talkingq-url/banban/service/im.py index 9b11433..72cf2da 100644 --- a/talkingq-url/banban/service/im.py +++ b/talkingq-url/banban/service/im.py @@ -140,6 +140,8 @@ async def present_message_item( ) -> ChildConversationMessageItem: item = row_to_message_item(row) if item.content_type == 2 and item.media_file_key: + if item.media_file_key.startswith(("http://", "https://")): + return item try: item.media_file_key = await audio_storage.get_audio_url(item.media_file_key) except MessageAudioStorageError: diff --git a/talkingq-url/config.py b/talkingq-url/config.py index 7b175e9..0f3227e 100644 --- a/talkingq-url/config.py +++ b/talkingq-url/config.py @@ -68,6 +68,10 @@ class Settings(BaseSettings): 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_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") client_api_key: str = Field(default="", validation_alias="CLIENT_API_KEY") diff --git a/talkingq-url/handlers/mqtt_handler.py b/talkingq-url/handlers/mqtt_handler.py index a45481c..b705894 100644 --- a/talkingq-url/handlers/mqtt_handler.py +++ b/talkingq-url/handlers/mqtt_handler.py @@ -335,8 +335,21 @@ class TalkingQMQTTService: async def _handle_short_press_message(self, device_id: str, payload: dict): 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}") + 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://101.35.224.118: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() if media_file_key: try: @@ -351,6 +364,18 @@ class TalkingQMQTTService: ext_json=params.get("ext_json") if isinstance(params.get("ext_json"), dict) else None, ) 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://101.35.224.118:8080/assets/audio/message_ok.mp3" + }, + }, + ) + return except Exception as exc: logger.warning(device_id, "", f"[短按留言] 设备 {device_id} 留言写入失败: {exc}") await self._publish( @@ -363,14 +388,7 @@ class TalkingQMQTTService: ) return - payload = { - "msg_id": "011", - "status": "success", - "type": 0, - "params": { - "url": f"http://{settings.server_host}:{settings.server_port}/assets/audio/message_ok.mp3" - } - } + payload = {"msg_id": "011", "status": "failed", "message": "unsupported uuid"} await self._publish(f"device/{device_id}/event_resp", payload) async def _handle_device_identity_init(self, imei: str, payload: dict): diff --git a/talkingq-url/handlers/websocket_message_handler.py b/talkingq-url/handlers/websocket_message_handler.py index 35cb06c..4454776 100644 --- a/talkingq-url/handlers/websocket_message_handler.py +++ b/talkingq-url/handlers/websocket_message_handler.py @@ -4,7 +4,7 @@ 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 save_audio_file +from handlers.audio_file_handler import message_audio_storage_service, save_audio_file from services.audio_session import audio_session_manager from services.interrupt_handler import interrupt_handler from services.task_manager import task_manager @@ -13,6 +13,7 @@ from services.device_target_cache import device_target_cache from services.offline_audio_cache import offline_audio_cache from services.target_audio_cache import target_audio_cache from services.card_service import card_service +from services.device_target_cache import VOICE_TARGET_KIND_PARENT from utils.logger import session_logger from handlers.prompt_sound_handler import handle_prompt_sound_request 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] if packet_type == 1: # 开始包 - target_device_id = await device_target_cache.get_target(device_id) - if target_device_id: - # 清除音频缓存 - await target_audio_cache.clear_audio_data(target_device_id) + 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 ) elif packet_type == 3: # 中断包 - target_device_id = await device_target_cache.get_target(device_id) - if target_device_id: + 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: # 结束包 - target_device_id = await device_target_cache.get_target(device_id) - if target_device_id: - # 处理缓存的音频数据 + voice_target = await device_target_cache.get_voice_target(device_id) + if voice_target: 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) return None, None 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): """处理发送给目标设备的音频包""" 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", "未设置目标设备,无法发送音频") return - # 缓存音频数据 - await target_audio_cache.add_audio_data(target_device_id, audio_data) - session_logger.info(device_id, "target", f"已缓存音频数据到目标设备 {target_device_id}") + await target_audio_cache.add_audio_data(voice_target.audio_cache_key, audio_data) + 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}") except Exception as e: 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): """处理缓存的音频数据并发送音频URL""" try: diff --git a/talkingq-url/services/device_target_cache.py b/talkingq-url/services/device_target_cache.py index 1f3b1c9..5eac15d 100644 --- a/talkingq-url/services/device_target_cache.py +++ b/talkingq-url/services/device_target_cache.py @@ -1,19 +1,51 @@ import asyncio +from dataclasses import dataclass 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: def __init__(self): - self.targets: Dict[str, str] = {} + self.targets: Dict[str, VoiceTarget] = {} self.lock = asyncio.Lock() 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: return self.targets.get(device_id) async def set_target(self, device_id: str, target_device_id: str): 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 with self.lock: @@ -26,4 +58,4 @@ class DeviceTargetCache: # 单例实例 -device_target_cache = DeviceTargetCache() \ No newline at end of file +device_target_cache = DeviceTargetCache() From 83bb79a8d0a067814bfe67b5599686d636afbb44 Mon Sep 17 00:00:00 2001 From: HycJack <772403255@qq.com> Date: Sat, 9 May 2026 16:50:19 +0800 Subject: [PATCH 4/8] =?UTF-8?q?=E4=BF=AE=E6=94=B9=E9=9F=B3=E9=A2=91?= =?UTF-8?q?=E6=96=87=E4=BB=B6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- talkingq-url/assets/audio/parent_online_zh.mp3 | Bin 0 -> 13905 bytes talkingq-url/handlers/mqtt_handler.py | 4 ++-- talkingq-url/test/minimax_tts_more.py | 13 ++++++++----- 3 files changed, 10 insertions(+), 7 deletions(-) create mode 100644 talkingq-url/assets/audio/parent_online_zh.mp3 diff --git a/talkingq-url/assets/audio/parent_online_zh.mp3 b/talkingq-url/assets/audio/parent_online_zh.mp3 new file mode 100644 index 0000000000000000000000000000000000000000..43dda78611f569ecc62f6b3b2dd20b4dac5dde90 GIT binary patch literal 13905 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 ZUw;?f?HP{68fLQqcea literal 0 HcmV?d00001 diff --git a/talkingq-url/handlers/mqtt_handler.py b/talkingq-url/handlers/mqtt_handler.py index b705894..16b3b9d 100644 --- a/talkingq-url/handlers/mqtt_handler.py +++ b/talkingq-url/handlers/mqtt_handler.py @@ -344,7 +344,7 @@ class TalkingQMQTTService: "status": "success", "type": 0, "params": { - "url": "http://101.35.224.118:8080/assets/audio/message_ok.mp3" + "url": "http://101.35.224.118:8080/assets/audio/parent_online_zh.mp3" }, } await self._publish(f"device/{device_id}/event_resp", response_payload) @@ -371,7 +371,7 @@ class TalkingQMQTTService: "status": "success", "type": 0, "params": { - "url": "http://101.35.224.118:8080/assets/audio/message_ok.mp3" + "url": "http://101.35.224.118:8080/assets/audio/parent_online_zh.mp3" }, }, ) diff --git a/talkingq-url/test/minimax_tts_more.py b/talkingq-url/test/minimax_tts_more.py index f4511c3..c7a3418 100644 --- a/talkingq-url/test/minimax_tts_more.py +++ b/talkingq-url/test/minimax_tts_more.py @@ -115,11 +115,14 @@ async def test_minimax_tts(): # "have_rest":{ # "zh": "小憩一下,待会儿见。" # } - "bind_nfc_ready":{ - "zh": "现在开始刷卡绑定设备吧。" - }, - "bind_nfc_finish":{ - "zh": "卡片绑定成功。" + # "bind_nfc_ready":{ + # "zh": "现在开始刷卡绑定设备吧。" + # }, + # "bind_nfc_finish":{ + # "zh": "卡片绑定成功。" + # } + "parent_online": { + "zh": "你好,你的家长已在线请留言" } } From a7f3b4f8ddc86ac78c1286f1277af05a8be4a796 Mon Sep 17 00:00:00 2001 From: HycJack <772403255@qq.com> Date: Sat, 9 May 2026 16:50:42 +0800 Subject: [PATCH 5/8] =?UTF-8?q?=E6=8F=90=E4=BA=A4=E5=B0=8F=E7=A8=8B?= =?UTF-8?q?=E5=BA=8F=E5=90=88=E5=85=A5=E4=BB=A3=E7=A0=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- banban-mini/src/app.config.ts | 1 + .../src/components/system-banner/index.scss | 119 +++++++++ .../src/components/system-banner/index.tsx | 44 +++ .../system-banner/use-system-banner.tsx | 191 +++++++++++++ banban-mini/src/pages/bind/index.tsx | 4 + banban-mini/src/pages/chat/detail/index.scss | 7 +- banban-mini/src/pages/chat/detail/index.tsx | 7 +- banban-mini/src/pages/chat/index.tsx | 3 + banban-mini/src/pages/device/index.scss | 207 ++++++++++++++ banban-mini/src/pages/device/index.tsx | 196 +++++++++++++- banban-mini/src/pages/location/index.tsx | 3 + .../src/pages/sleep-schedule/index.config.ts | 5 + .../src/pages/sleep-schedule/index.scss | 252 ++++++++++++++++++ .../src/pages/sleep-schedule/index.tsx | 222 +++++++++++++++ banban-mini/src/pages/sleep/index.scss | 98 +++++++ banban-mini/src/pages/sleep/index.tsx | 108 +++++++- banban-mini/src/services/chat.ts | 5 +- banban-mini/src/services/device.ts | 145 +++++++++- banban-mini/src/services/system-banner.ts | 175 ++++++++++++ 19 files changed, 1762 insertions(+), 30 deletions(-) create mode 100644 banban-mini/src/components/system-banner/index.scss create mode 100644 banban-mini/src/components/system-banner/index.tsx create mode 100644 banban-mini/src/components/system-banner/use-system-banner.tsx create mode 100644 banban-mini/src/pages/sleep-schedule/index.config.ts create mode 100644 banban-mini/src/pages/sleep-schedule/index.scss create mode 100644 banban-mini/src/pages/sleep-schedule/index.tsx create mode 100644 banban-mini/src/services/system-banner.ts diff --git a/banban-mini/src/app.config.ts b/banban-mini/src/app.config.ts index 0a712f8..2d74c7b 100644 --- a/banban-mini/src/app.config.ts +++ b/banban-mini/src/app.config.ts @@ -7,6 +7,7 @@ export default defineAppConfig({ "pages/chat/detail/index", "pages/location/index", "pages/sleep/index", + "pages/sleep-schedule/index", ], window: { backgroundTextStyle: "light", diff --git a/banban-mini/src/components/system-banner/index.scss b/banban-mini/src/components/system-banner/index.scss new file mode 100644 index 0000000..1faa6da --- /dev/null +++ b/banban-mini/src/components/system-banner/index.scss @@ -0,0 +1,119 @@ +.system-banner { + position: fixed; + left: 24px; + right: 24px; + z-index: 9999; + box-sizing: border-box; + opacity: 0; + pointer-events: none; + transform: translateY(-140%); + transition: transform 0.28s ease, opacity 0.28s ease; +} + +.system-banner-card, +.system-banner-icon, +.system-banner-icon-text, +.system-banner-body, +.system-banner-header, +.system-banner-title, +.system-banner-time, +.system-banner-summary, +.system-banner-detail, +.system-banner-close, +.system-banner-close-text { + box-sizing: border-box; +} + +.system-banner.is-visible { + opacity: 1; + pointer-events: auto; + transform: translateY(0); +} + +.system-banner-card { + display: flex; + align-items: flex-start; + gap: 18px; + padding: 22px 22px 20px; + border-radius: 24px; + background: linear-gradient(180deg, rgba(255, 250, 244, 0.98) 0%, rgba(255, 243, 226, 0.98) 100%); + border: 1px solid rgba(255, 166, 77, 0.36); + box-shadow: 0 18px 36px rgba(102, 70, 24, 0.18); +} + +.system-banner-icon { + width: 64px; + height: 64px; + flex-shrink: 0; + border-radius: 20px; + background: linear-gradient(180deg, #ff9d3f 0%, #ff7e33 100%); + display: flex; + align-items: center; + justify-content: center; + box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.3); +} + +.system-banner-icon-text { + font-size: 34px; + font-weight: 700; + color: #fff; + line-height: 1; +} + +.system-banner-body { + flex: 1; + min-width: 0; +} + +.system-banner-header { + display: flex; + align-items: center; + justify-content: space-between; + gap: 16px; +} + +.system-banner-title { + font-size: 28px; + font-weight: 700; + color: #2f2213; +} + +.system-banner-time { + flex-shrink: 0; + font-size: 22px; + color: #8e6b43; +} + +.system-banner-summary { + margin-top: 6px; + display: block; + font-size: 30px; + font-weight: 600; + color: #3f2f1c; + line-height: 1.35; +} + +.system-banner-detail { + margin-top: 6px; + display: block; + font-size: 24px; + color: #7a6143; + line-height: 1.4; +} + +.system-banner-close { + width: 40px; + height: 40px; + flex-shrink: 0; + border-radius: 20px; + display: flex; + align-items: center; + justify-content: center; + background: rgba(255, 255, 255, 0.52); +} + +.system-banner-close-text { + font-size: 30px; + line-height: 1; + color: #8a6b48; +} diff --git a/banban-mini/src/components/system-banner/index.tsx b/banban-mini/src/components/system-banner/index.tsx new file mode 100644 index 0000000..f570bb9 --- /dev/null +++ b/banban-mini/src/components/system-banner/index.tsx @@ -0,0 +1,44 @@ +import { View, Text } from '@tarojs/components' +import { SystemBannerNotification } from '@/services/system-banner' +import './index.scss' + +interface SystemBannerProps { + banner: SystemBannerNotification | null + visible: boolean + top: number + onPress: () => void + onClose: (event?: { stopPropagation?: () => void }) => void +} + +export default function SystemBanner(props: SystemBannerProps) { + const { banner, visible, top, onPress, onClose } = props + + if (!banner) return null + + return ( + + + + ! + + + + + {banner.title} + {banner.timeLabel} + + {banner.summary} + {banner.detail} + + + + × + + + + ) +} diff --git a/banban-mini/src/components/system-banner/use-system-banner.tsx b/banban-mini/src/components/system-banner/use-system-banner.tsx new file mode 100644 index 0000000..01c99dd --- /dev/null +++ b/banban-mini/src/components/system-banner/use-system-banner.tsx @@ -0,0 +1,191 @@ +import { useEffect, useRef, useState } from 'react' +import Taro, { useDidHide, useDidShow } from '@tarojs/taro' +import { getToken } from '@/services/session' +import { pollSystemBannerNotifications, SystemBannerNotification } from '@/services/system-banner' +import SystemBanner from './index' + +const BANNER_AUTO_HIDE_MS = 4500 +const BANNER_TRANSITION_MS = 280 +const POLL_INTERVAL_MS = 5000 + +export function useSystemBanner() { + const [activeBanner, setActiveBanner] = useState(null) + const [bannerQueue, setBannerQueue] = useState([]) + const [bannerVisible, setBannerVisible] = useState(false) + const [bannerTop, setBannerTop] = useState(24) + const pollTimerRef = useRef | null>(null) + const hideTimerRef = useRef | null>(null) + const clearBannerTimerRef = useRef | null>(null) + const isPollingRef = useRef(false) + const currentBannerRef = useRef(null) + const queuedBannerKeysRef = useRef>(new Set()) + + useEffect(() => { + currentBannerRef.current = activeBanner + }, [activeBanner]) + + useEffect(() => { + try { + const systemInfo = Taro.getSystemInfoSync() + const statusBarHeight = Number(systemInfo.statusBarHeight || 0) + setBannerTop(Math.max(24, statusBarHeight + 12)) + } catch { + setBannerTop(24) + } + }, []) + + const clearHideTimer = () => { + if (hideTimerRef.current) { + clearTimeout(hideTimerRef.current) + hideTimerRef.current = null + } + } + + const clearBannerTimer = () => { + if (clearBannerTimerRef.current) { + clearTimeout(clearBannerTimerRef.current) + clearBannerTimerRef.current = null + } + } + + const finishBannerDismiss = () => { + clearBannerTimer() + clearBannerTimerRef.current = setTimeout(() => { + setActiveBanner(null) + }, BANNER_TRANSITION_MS) + } + + const dismissActiveBanner = () => { + if (!currentBannerRef.current) return + clearHideTimer() + setBannerVisible(false) + finishBannerDismiss() + } + + const resetBannerState = () => { + clearHideTimer() + clearBannerTimer() + queuedBannerKeysRef.current.clear() + setBannerVisible(false) + setActiveBanner(null) + setBannerQueue([]) + } + + const enqueueNotifications = (items: SystemBannerNotification[]) => { + if (items.length === 0) return + + setBannerQueue((currentQueue) => { + const nextQueue = [...currentQueue] + const currentBannerKey = currentBannerRef.current?.key + + items.forEach((item) => { + if (item.key === currentBannerKey) return + if (queuedBannerKeysRef.current.has(item.key)) return + queuedBannerKeysRef.current.add(item.key) + nextQueue.push(item) + }) + + return nextQueue + }) + } + + const runNotificationPoll = async () => { + if (isPollingRef.current) return + + if (!getToken()) { + resetBannerState() + return + } + + isPollingRef.current = true + try { + const notifications = await pollSystemBannerNotifications() + enqueueNotifications(notifications) + } catch (error) { + console.error('[system-banner] poll failed:', error) + } finally { + isPollingRef.current = false + } + } + + const startPolling = () => { + if (pollTimerRef.current) return + + void runNotificationPoll() + pollTimerRef.current = setInterval(() => { + void runNotificationPoll() + }, POLL_INTERVAL_MS) + } + + const stopPolling = () => { + if (!pollTimerRef.current) return + clearInterval(pollTimerRef.current) + pollTimerRef.current = null + } + + useDidShow(() => { + startPolling() + }) + + useDidHide(() => { + stopPolling() + }) + + useEffect(() => { + if (activeBanner || bannerQueue.length === 0) return + + const [nextBanner, ...restQueue] = bannerQueue + queuedBannerKeysRef.current.delete(nextBanner.key) + setBannerQueue(restQueue) + setActiveBanner(nextBanner) + setBannerVisible(true) + }, [activeBanner, bannerQueue]) + + useEffect(() => { + if (!activeBanner) return + + clearHideTimer() + clearBannerTimer() + setBannerVisible(true) + + hideTimerRef.current = setTimeout(() => { + setBannerVisible(false) + finishBannerDismiss() + }, BANNER_AUTO_HIDE_MS) + + return () => { + clearHideTimer() + } + }, [activeBanner]) + + useEffect(() => { + return () => { + stopPolling() + clearHideTimer() + clearBannerTimer() + } + }, []) + + const handleBannerClick = () => { + const currentBanner = currentBannerRef.current + if (!currentBanner) return + + dismissActiveBanner() + void Taro.switchTab({ url: currentBanner.route }) + } + + const handleBannerClose = (event?: { stopPropagation?: () => void }) => { + event?.stopPropagation?.() + dismissActiveBanner() + } + + return ( + + ) +} diff --git a/banban-mini/src/pages/bind/index.tsx b/banban-mini/src/pages/bind/index.tsx index 003246a..3c36ad9 100644 --- a/banban-mini/src/pages/bind/index.tsx +++ b/banban-mini/src/pages/bind/index.tsx @@ -17,6 +17,7 @@ import { resolveChildSelection, setSelectedChildId as setStoredSelectedChildId, } from '@/services/child' +import { useSystemBanner } from '@/components/system-banner/use-system-banner' import './index.scss' @@ -82,6 +83,7 @@ function parseBindingPayload(rawValue: string): { deviceId: string; serialNumber } export default function Bind() { + const systemBanner = useSystemBanner() const [loading, setLoading] = useState(true) const [submitting, setSubmitting] = useState(false) const [isScanning, setIsScanning] = useState(false) @@ -333,6 +335,7 @@ export default function Bind() { 加载中... + {systemBanner} ) } @@ -480,6 +483,7 @@ export default function Bind() { 点击发送绑卡指令,然后去设备上贴自己的卡完成确认 + {systemBanner} ) } diff --git a/banban-mini/src/pages/chat/detail/index.scss b/banban-mini/src/pages/chat/detail/index.scss index 4ab5329..1e3a449 100644 --- a/banban-mini/src/pages/chat/detail/index.scss +++ b/banban-mini/src/pages/chat/detail/index.scss @@ -175,7 +175,7 @@ } .audio-bubble { - min-width: 220px; + min-width: 144px; display: flex; align-items: center; gap: 14px; @@ -191,11 +191,6 @@ line-height: 1; } -.audio-bubble-text { - flex: 1; - font-size: 28px; -} - .audio-bubble-duration { font-size: 24px; opacity: 0.8; diff --git a/banban-mini/src/pages/chat/detail/index.tsx b/banban-mini/src/pages/chat/detail/index.tsx index 60ac3b5..0346222 100644 --- a/banban-mini/src/pages/chat/detail/index.tsx +++ b/banban-mini/src/pages/chat/detail/index.tsx @@ -10,6 +10,7 @@ import { ConversationSource, PeerKind, } from '../../../services/chat' +import { useSystemBanner } from '@/components/system-banner/use-system-banner' import './index.scss' function normalizePeerKind(value?: string): PeerKind { @@ -53,6 +54,7 @@ function formatAudioDuration(durationMs?: number | null): string { } export default function ChatDetail() { + const systemBanner = useSystemBanner() const router = useRouter() const params = router.params @@ -322,13 +324,9 @@ export default function ChatDetail() { const renderMessageBody = (msg: ChatMessage) => { if (msg.contentType === 2 && msg.mediaUrl) { const isPlaying = playingMessageId === msg.id - const isLeaveMessage = msg.extJson?.message_kind === 'leave_message' return ( handlePlayAudio(msg)}> {isPlaying ? '[]' : '>'} - - {msg.mediaTranscriptText || (isLeaveMessage ? '点击收听留言' : '点击播放语音')} - {formatAudioDuration(msg.mediaDurationMs)} ) @@ -421,6 +419,7 @@ export default function ChatDetail() { )} + {systemBanner} ) } diff --git a/banban-mini/src/pages/chat/index.tsx b/banban-mini/src/pages/chat/index.tsx index d6f1a11..3e08ae7 100644 --- a/banban-mini/src/pages/chat/index.tsx +++ b/banban-mini/src/pages/chat/index.tsx @@ -3,9 +3,11 @@ import { useState } from 'react' 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 './index.scss' export default function Chat() { + const systemBanner = useSystemBanner() const [conversations, setConversations] = useState([]) const [loading, setLoading] = useState(true) @@ -97,6 +99,7 @@ export default function Chat() { ))} )} + {systemBanner} ) } diff --git a/banban-mini/src/pages/device/index.scss b/banban-mini/src/pages/device/index.scss index 80464f7..0545c35 100644 --- a/banban-mini/src/pages/device/index.scss +++ b/banban-mini/src/pages/device/index.scss @@ -164,11 +164,21 @@ justify-content: space-between; padding: 24px; + &.sleep-item:active { + background: #F8F9FA; + } + &.volume-item { flex-direction: column; align-items: stretch; padding: 20px 24px 24px; } + + &.quick-sleep-item { + flex-direction: column; + align-items: stretch; + padding: 20px 24px 24px; + } } .control-left { @@ -220,6 +230,14 @@ color: #FF8C42; } +.control-link { + flex-shrink: 0; + margin-left: 16px; + font-size: 26px; + font-weight: 700; + color: #FF8C42; +} + .divider { height: 1px; margin: 0 24px; @@ -236,6 +254,195 @@ } } +.quick-sleep-header { + display: flex; + align-items: center; + margin-bottom: 18px; +} + +.quick-sleep-actions { + display: flex; + gap: 16px; +} + +.quick-sleep-btn { + flex: 1; + display: flex; + align-items: center; + justify-content: center; + height: 82px; + border-radius: 18px; + + &.sleep { + background: #1F2937; + } + + &.wake { + background: #FF8C42; + } + + &.disabled { + opacity: 0.65; + } +} + +.quick-sleep-btn-text { + font-size: 28px; + font-weight: 700; + color: #FFFFFF; +} + +.alarm-card { + margin: 0 24px 24px; + border-radius: 20px; + background: #FFFFFF; + box-shadow: 0 2px 12px rgba(0, 0, 0, 0.04); + overflow: hidden; +} + +.alarm-header { + padding: 24px 24px 18px; + background: linear-gradient(135deg, #FFF1F2 0%, #FFE4E6 100%); +} + +.alarm-title { + display: block; + font-size: 30px; + font-weight: 700; + color: #9F1239; +} + +.alarm-subtitle { + display: block; + margin-top: 8px; + font-size: 24px; + line-height: 1.6; + color: #BE123C; +} + +.alarm-body { + padding: 6px 24px 10px; +} + +.alarm-row { + display: flex; + align-items: center; + justify-content: space-between; + padding: 18px 0; + border-bottom: 1px solid #F7E4E8; + + &:last-child { + border-bottom: none; + } +} + +.alarm-label { + font-size: 28px; + color: #666666; +} + +.alarm-value { + max-width: 60%; + font-size: 28px; + text-align: right; + word-break: break-all; + color: #1A1A1A; +} + +.alarm-empty { + padding: 24px; +} + +.alarm-empty-text { + font-size: 26px; + line-height: 1.7; + color: #666666; +} + +.alarm-history { + padding: 0 24px 20px; + border-top: 1px solid #F7E4E8; +} + +.alarm-history-header { + display: flex; + align-items: center; + justify-content: space-between; + padding: 22px 0 18px; +} + +.alarm-history-title { + font-size: 28px; + font-weight: 700; + color: #1A1A1A; +} + +.alarm-history-count { + font-size: 24px; + color: #9CA3AF; +} + +.alarm-history-item { + padding: 18px 0; + border-top: 1px solid #F5F5F5; + + &:first-of-type { + border-top: none; + } +} + +.alarm-history-main { + display: flex; + align-items: center; + justify-content: space-between; + gap: 24px; +} + +.alarm-history-time { + flex: 1; + min-width: 0; + font-size: 26px; + font-weight: 600; + color: #1F2937; +} + +.alarm-history-type { + flex-shrink: 0; + font-size: 24px; + font-weight: 600; + color: #BE123C; +} + +.alarm-history-meta { + display: flex; + align-items: center; + justify-content: space-between; + gap: 20px; + margin-top: 10px; +} + +.alarm-history-child, +.alarm-history-device { + flex: 1; + min-width: 0; + font-size: 24px; + color: #6B7280; + word-break: break-all; +} + +.alarm-history-device { + text-align: right; +} + +.alarm-history-empty { + padding: 8px 0 4px; +} + +.alarm-history-empty-text { + font-size: 24px; + color: #9CA3AF; +} + .volume-slider { display: flex; align-items: center; diff --git a/banban-mini/src/pages/device/index.tsx b/banban-mini/src/pages/device/index.tsx index 287b175..20bdeb8 100644 --- a/banban-mini/src/pages/device/index.tsx +++ b/banban-mini/src/pages/device/index.tsx @@ -1,10 +1,11 @@ -import { View, Text, Slider, Image, Switch } from '@tarojs/components' +import { View, Text, Slider, Image } from '@tarojs/components' import { useState } from 'react' import Taro, { useDidShow } from '@tarojs/taro' import { getToken } from '@/services/auth' import { Binding, loadCurrentChildBindingContext } from '@/services/binding' import { Child } from '@/services/child' -import { DeviceStatus, getDeviceStatus, setDeviceVolume } from '@/services/device' +import { DeviceAlarmItem, DeviceStatus, getDeviceAlarms, getDeviceStatus, setDeviceRemoteSleepWake, setDeviceVolume } from '@/services/device' +import { useSystemBanner } from '@/components/system-banner/use-system-banner' import './index.scss' function formatTime(value?: string | null): string { @@ -39,14 +40,53 @@ function getSignalLabel(value?: number | null): string { return '弱' } +function formatSleepRange(status: DeviceStatus | null): string { + const start = status?.disable_time_start?.trim() + const end = status?.disable_time_end?.trim() + if (!start || !end) return '未设置' + return `${start}-${end}` +} + +function formatAlarmTime(value?: string | null): string { + if (!value) return '--' + const date = new Date(value) + if (Number.isNaN(date.getTime())) return value + const month = `${date.getMonth() + 1}`.padStart(2, '0') + const day = `${date.getDate()}`.padStart(2, '0') + const hour = `${date.getHours()}`.padStart(2, '0') + const minute = `${date.getMinutes()}`.padStart(2, '0') + const second = `${date.getSeconds()}`.padStart(2, '0') + return `${month}-${day} ${hour}:${minute}:${second}` +} + +function getAlarmTypeLabel(value?: string | null): string { + const normalized = String(value || '').trim() + if (!normalized) return '--' + if (normalized === '010') return '紧急告警' + return normalized +} + +function getAlarmChildLabel(alarm: DeviceAlarmItem | null, fallbackChildName?: string | null): string { + const alarmChildName = String(alarm?.child_name || '').trim() + if (alarmChildName) return alarmChildName + + const fallbackName = String(fallbackChildName || '').trim() + if (fallbackName) return fallbackName + + if (alarm?.child_id) return `儿童 ${alarm.child_id}` + return '未关联儿童' +} + export default function Device() { + const systemBanner = useSystemBanner() const [binding, setBinding] = useState(null) const [child, setChild] = useState(null) const [deviceStatus, setDeviceStatus] = useState(null) + const [deviceAlarms, setDeviceAlarms] = useState([]) const [isLoading, setIsLoading] = useState(true) - const [sleepEnabled, setSleepEnabled] = useState(true) const [isDetailExpanded, setIsDetailExpanded] = useState(false) const [isSavingVolume, setIsSavingVolume] = useState(false) + const [sleepWakePending, setSleepWakePending] = useState<'on' | 'off' | null>(null) const [volumeValue, setVolumeValue] = useState(0) useDidShow(() => { @@ -66,11 +106,16 @@ export default function Device() { setBinding(context.currentBinding) if (context.currentBinding?.device_id) { - const nextStatus = await getDeviceStatus(context.currentBinding.device_id) + const [nextStatus, nextAlarms] = await Promise.all([ + getDeviceStatus(context.currentBinding.device_id), + getDeviceAlarms(context.currentBinding.device_id, 20), + ]) setDeviceStatus(nextStatus) + setDeviceAlarms(nextAlarms.items || []) setVolumeValue(nextStatus?.volume ?? 0) } else { setDeviceStatus(null) + setDeviceAlarms([]) setVolumeValue(0) } } catch (error: any) { @@ -84,13 +129,8 @@ export default function Device() { } } - const handleSleepToggle = (e: any) => { - const nextValue = Boolean(e.detail?.value) - setSleepEnabled(nextValue) - Taro.showToast({ - title: nextValue ? '休眠已开启' : '休眠已关闭', - icon: 'none', - }) + const handleOpenSleepSchedule = () => { + Taro.navigateTo({ url: '/pages/sleep-schedule/index' }) } const handleToggleDetail = () => { @@ -126,12 +166,33 @@ export default function Device() { } } + const handleRemoteSleepWake = async (switchValue: 'on' | 'off') => { + if (!binding?.device_id || sleepWakePending) return + + setSleepWakePending(switchValue) + try { + await setDeviceRemoteSleepWake(switchValue, binding.device_id) + Taro.showToast({ + title: switchValue === 'off' ? '休眠指令已发送' : '唤醒指令已发送', + icon: 'success', + }) + } catch (error: any) { + Taro.showToast({ + title: error?.message || '操作失败,请重试', + icon: 'none', + }) + } finally { + setSleepWakePending(null) + } + } + if (isLoading) { return ( 加载中... + {systemBanner} ) } @@ -153,6 +214,7 @@ export default function Device() { 去添加孩子 + {systemBanner} ) } @@ -184,6 +246,7 @@ export default function Device() { 去绑定设备 + {systemBanner} ) } @@ -196,6 +259,10 @@ export default function Device() { const versionLabel = deviceStatus?.version || '--' const coordinateLabel = formatCoordinates(deviceStatus) const volumeLabel = `${volumeValue}%` + const sleepRangeLabel = formatSleepRange(deviceStatus) + const latestAlarm = deviceAlarms[0] || null + const historyAlarms = latestAlarm ? deviceAlarms.slice(1) : [] + const latestAlarmChildName = getAlarmChildLabel(latestAlarm, childName) return ( @@ -234,17 +301,53 @@ export default function Device() { 设备控制 - + 定时休眠 - 开启后进入休眠状态 + {sleepRangeLabel} + + + 设置 + + + + + + + + + + + + 立即休眠/唤醒 + + {sleepWakePending === 'off' + ? '正在发送休眠指令...' + : sleepWakePending === 'on' + ? '正在发送唤醒指令...' + : '立即控制当前设备'} + + + + + + handleRemoteSleepWake('off')} + > + 立即休眠 + + handleRemoteSleepWake('on')} + > + 立即唤醒 - @@ -291,6 +394,70 @@ export default function Device() { + + + 最近告警 + + {latestAlarm ? '已收到设备主动上报的告警,可继续查看以往记录' : '暂时没有收到新的设备告警'} + + + + {latestAlarm ? ( + + + + 告警时间 + {formatAlarmTime(latestAlarm.created_at)} + + + 来源儿童 + {latestAlarmChildName} + + + 来源设备 + {latestAlarm.device_id} + + + 上报类型 + {getAlarmTypeLabel(latestAlarm.source_msg_id)} + + + + + + 以往告警 + 最近 {historyAlarms.length} 条 + + + {historyAlarms.length > 0 ? ( + historyAlarms.map((alarm) => ( + + + {formatAlarmTime(alarm.created_at)} + {getAlarmTypeLabel(alarm.source_msg_id)} + + + + 来源儿童:{getAlarmChildLabel(alarm, childName)} + + {alarm.device_id} + + + )) + ) : ( + + 还没有更早的告警记录。 + + )} + + + ) : ( + + 设备触发长按告警后,会在这里显示最近一条记录。 + + )} + + @@ -329,6 +496,7 @@ export default function Device() { )} + {systemBanner} ) } diff --git a/banban-mini/src/pages/location/index.tsx b/banban-mini/src/pages/location/index.tsx index 6f46f7f..fa4a36c 100644 --- a/banban-mini/src/pages/location/index.tsx +++ b/banban-mini/src/pages/location/index.tsx @@ -9,6 +9,7 @@ import { DeviceLocation, DeviceTrajectoryPoint, } from '@/services/location' +import { useSystemBanner } from '@/components/system-banner/use-system-banner' import './index.scss' type TrajectoryMode = 'current' | 'today' | 'recent' @@ -117,6 +118,7 @@ function getTrajectoryMarkerId(index: number): number { } export default function Location() { + const systemBanner = useSystemBanner() const [loading, setLoading] = useState(true) const [deviceLocation, setDeviceLocation] = useState(null) const [trajectory, setTrajectory] = useState([]) @@ -492,6 +494,7 @@ export default function Location() { )} + {systemBanner} ) } diff --git a/banban-mini/src/pages/sleep-schedule/index.config.ts b/banban-mini/src/pages/sleep-schedule/index.config.ts new file mode 100644 index 0000000..f914a9e --- /dev/null +++ b/banban-mini/src/pages/sleep-schedule/index.config.ts @@ -0,0 +1,5 @@ +export default definePageConfig({ + navigationBarTitleText: '定时休眠', + navigationBarBackgroundColor: '#F5F7FA', + navigationBarTextStyle: 'black', +}) diff --git a/banban-mini/src/pages/sleep-schedule/index.scss b/banban-mini/src/pages/sleep-schedule/index.scss new file mode 100644 index 0000000..505be44 --- /dev/null +++ b/banban-mini/src/pages/sleep-schedule/index.scss @@ -0,0 +1,252 @@ +.sleep-page { + min-height: 100%; + background: #F5F7FA; + padding-bottom: 160px; + padding-bottom: calc(160px + constant(safe-area-inset-bottom)); + padding-bottom: calc(160px + env(safe-area-inset-bottom)); +} + +.page-header { + padding: 80px 32px 24px; +} + +.page-title { + display: block; + font-size: 48px; + line-height: 1.2; + font-weight: 700; + color: #1A1A1A; +} + +.page-subtitle { + display: block; + margin-top: 14px; + font-size: 28px; + line-height: 1.6; + color: #666666; +} + +.hero-card { + margin: 0 24px 28px; + padding: 32px 28px; + border-radius: 28px; + background: linear-gradient(135deg, #1F2937 0%, #334155 100%); + display: flex; + align-items: center; + justify-content: space-between; +} + +.hero-copy { + flex: 1; + min-width: 0; +} + +.hero-label { + display: block; + font-size: 24px; + color: rgba(255, 255, 255, 0.68); +} + +.hero-value { + display: block; + margin-top: 10px; + font-size: 34px; + line-height: 1.3; + font-weight: 700; + color: #FFFFFF; + word-break: break-all; +} + +.hero-detail { + display: block; + margin-top: 12px; + font-size: 26px; + line-height: 1.6; + color: rgba(255, 255, 255, 0.88); +} + +.hero-icon-wrap { + display: flex; + align-items: center; + justify-content: center; + width: 104px; + height: 104px; + margin-left: 20px; + border-radius: 28px; + background: rgba(255, 255, 255, 0.1); +} + +.hero-icon { + width: 52px; + height: 52px; +} + +.section-title { + display: block; + margin: 0 24px 18px; + font-size: 32px; + font-weight: 700; + color: #1A1A1A; +} + +.settings-card, +.summary-card, +.tips-card, +.empty-card { + margin: 0 24px 24px; + border-radius: 22px; + background: #FFFFFF; + box-shadow: 0 2px 12px rgba(0, 0, 0, 0.05); +} + +.settings-card, +.summary-card, +.tips-card { + overflow: hidden; +} + +.setting-row, +.summary-row { + display: flex; + align-items: center; + justify-content: space-between; + padding: 28px 24px; +} + +.setting-row:active { + background: #F8F9FA; +} + +.setting-label, +.summary-label { + display: block; + font-size: 30px; + font-weight: 600; + color: #1A1A1A; +} + +.setting-hint { + display: block; + margin-top: 8px; + font-size: 24px; + line-height: 1.6; + color: #7A7A7A; +} + +.setting-value, +.summary-value { + max-width: 42%; + font-size: 30px; + font-weight: 700; + text-align: right; + color: #FF8C42; +} + +.summary-card .summary-value { + color: #1A1A1A; + font-size: 28px; + font-weight: 500; +} + +.divider { + height: 1px; + margin: 0 24px; + background: #F0F0F0; +} + +.tips-card { + padding: 28px 24px; + background: #FFF7ED; +} + +.tips-title { + display: block; + margin-bottom: 12px; + font-size: 28px; + font-weight: 700; + color: #C2410C; +} + +.tips-text { + display: block; + font-size: 26px; + line-height: 1.7; + color: #9A3412; +} + +.tips-text + .tips-text { + margin-top: 8px; +} + +.save-btn { + position: fixed; + left: 24px; + right: 24px; + bottom: 32px; + bottom: calc(32px + constant(safe-area-inset-bottom)); + bottom: calc(32px + env(safe-area-inset-bottom)); + height: 92px; + border-radius: 22px; + background: #FF8C42; + display: flex; + align-items: center; + justify-content: center; + box-shadow: 0 10px 24px rgba(255, 140, 66, 0.28); +} + +.save-btn.disabled { + opacity: 0.72; +} + +.save-btn-text { + font-size: 30px; + font-weight: 700; + color: #FFFFFF; +} + +.empty-card { + padding: 32px 28px; +} + +.empty-title { + display: block; + font-size: 32px; + font-weight: 700; + color: #1A1A1A; +} + +.empty-desc { + display: block; + margin-top: 12px; + font-size: 27px; + line-height: 1.7; + color: #666666; +} + +.primary-btn { + margin-top: 28px; + height: 84px; + border-radius: 18px; + background: #FF8C42; + display: flex; + align-items: center; + justify-content: center; +} + +.primary-btn-text { + font-size: 28px; + font-weight: 700; + color: #FFFFFF; +} + +.loading { + min-height: 100%; + display: flex; + align-items: center; + justify-content: center; +} + +.loading text { + font-size: 32px; + color: #999999; +} diff --git a/banban-mini/src/pages/sleep-schedule/index.tsx b/banban-mini/src/pages/sleep-schedule/index.tsx new file mode 100644 index 0000000..ef4b5b3 --- /dev/null +++ b/banban-mini/src/pages/sleep-schedule/index.tsx @@ -0,0 +1,222 @@ +import { View, Text, Picker, Image } from '@tarojs/components' +import { useState } from 'react' +import Taro, { useDidShow } from '@tarojs/taro' +import { getToken } from '@/services/auth' +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 './index.scss' + +function formatSleepRange(status: DeviceStatus | null): string { + const start = status?.disable_time_start?.trim() + const end = status?.disable_time_end?.trim() + if (!start || !end) return '未设置' + return `${start}-${end}` +} + +function formatUpdatedAt(value?: string | null): string { + if (!value) return '--' + const date = new Date(value) + if (Number.isNaN(date.getTime())) return value + const month = `${date.getMonth() + 1}`.padStart(2, '0') + const day = `${date.getDate()}`.padStart(2, '0') + const hour = `${date.getHours()}`.padStart(2, '0') + const minute = `${date.getMinutes()}`.padStart(2, '0') + return `${month}-${day} ${hour}:${minute}` +} + +export default function SleepSchedule() { + const systemBanner = useSystemBanner() + const [isLoading, setIsLoading] = useState(true) + const [isSaving, setIsSaving] = useState(false) + const [binding, setBinding] = useState(null) + const [child, setChild] = useState(null) + const [deviceStatus, setDeviceStatus] = useState(null) + const [startTime, setStartTime] = useState('22:00') + const [endTime, setEndTime] = useState('07:00') + + useDidShow(() => { + void loadData() + }) + + const loadData = async () => { + if (!getToken()) { + Taro.reLaunch({ url: '/pages/login/index' }) + return + } + + setIsLoading(true) + try { + const context = await loadCurrentChildBindingContext() + setChild(context.currentChild) + setBinding(context.currentBinding) + + if (context.currentBinding?.device_id) { + const nextStatus = await getDeviceStatus(context.currentBinding.device_id) + setDeviceStatus(nextStatus) + setStartTime(nextStatus?.disable_time_start || '22:00') + setEndTime(nextStatus?.disable_time_end || '07:00') + } else { + setDeviceStatus(null) + setStartTime('22:00') + setEndTime('07:00') + } + } catch (error: any) { + console.error('[sleep-schedule] load failed:', error) + Taro.showToast({ + title: error?.message || '加载失败,请稍后重试', + icon: 'none', + }) + } finally { + setIsLoading(false) + } + } + + const handleSave = async () => { + if (!binding?.device_id) { + Taro.showToast({ title: '当前还没有绑定设备', icon: 'none' }) + return + } + if (startTime === endTime) { + Taro.showToast({ title: '开始和结束时间不能相同', icon: 'none' }) + return + } + + setIsSaving(true) + try { + const result = await setDeviceSleepSchedule(startTime, endTime, deviceStatus?.timezone || 'Asia/Shanghai', binding.device_id) + setDeviceStatus((current) => ({ + ...(current || { device_id: binding.device_id }), + ...current, + sleep_mode: result.sleep_mode, + disable_time_start: result.start || startTime, + disable_time_end: result.end || endTime, + timezone: result.timezone, + })) + Taro.showToast({ title: '休眠时间已发送', icon: 'success' }) + } catch (error: any) { + Taro.showToast({ + title: error?.message || '设置失败,请重试', + icon: 'none', + }) + } finally { + setIsSaving(false) + } + } + + if (isLoading) { + return ( + + + 加载中... + + {systemBanner} + + ) + } + + if (!child) { + return ( + + + 定时休眠 + 先选择一个孩子,再为对应设备设置休眠时间 + + + 还没有当前孩子 + 请先到管理页添加并选择孩子。 + + {systemBanner} + + ) + } + + if (!binding) { + return ( + + + 定时休眠 + {child.child_name} 还没有绑定设备 + + + 当前没有可设置的设备 + 先去首页绑定设备,再回来设置休眠时间段。 + Taro.navigateTo({ url: '/pages/bind/index' })}> + 去绑定设备 + + + {systemBanner} + + ) + } + + const currentRange = formatSleepRange(deviceStatus) + const timezoneText = deviceStatus?.timezone || 'Asia/Shanghai' + const childName = child.child_name || '当前孩子' + + return ( + + + 定时休眠 + 每天在设定时段内,设备将进入不可启动状态 + + + + + 当前设备 + {binding.device_id} + {childName} 的当前休眠时段:{currentRange} + + + + + + + 时间设置 + + setStartTime(event.detail.value)}> + + + 开始时间 + 设备从这个时刻开始进入休眠 + + {startTime} + + + + + + setEndTime(event.detail.value)}> + + + 结束时间 + 到这个时刻后恢复正常使用 + + {endTime} + + + + + + + 生效时区 + {timezoneText} + + + 当前配置 + {currentRange} + + + 最近更新时间 + {formatUpdatedAt(deviceStatus?.settings_updated_at)} + + + + + {isSaving ? '发送中...' : '保存休眠时间'} + + {systemBanner} + + ) +} diff --git a/banban-mini/src/pages/sleep/index.scss b/banban-mini/src/pages/sleep/index.scss index 50d5fa8..6074d2a 100644 --- a/banban-mini/src/pages/sleep/index.scss +++ b/banban-mini/src/pages/sleep/index.scss @@ -194,6 +194,104 @@ word-break: break-all; } +.system-update-card { + margin: 0 24px 24px; + border-radius: 20px; + background: #FFFFFF; + box-shadow: 0 2px 12px rgba(0, 0, 0, 0.04); + overflow: hidden; + + &.disabled { + opacity: 0.72; + } +} + +.system-update-header { + display: flex; + align-items: center; + justify-content: space-between; + gap: 20px; + padding: 24px; + background: #F0FDF4; +} + +.system-update-heading { + min-width: 0; +} + +.system-update-title { + display: block; + font-size: 30px; + font-weight: 700; + color: #14532D; +} + +.system-update-subtitle { + display: block; + margin-top: 8px; + font-size: 24px; + line-height: 1.5; + color: #15803D; +} + +.system-update-action { + display: flex; + align-items: center; + justify-content: center; + flex-shrink: 0; + min-width: 132px; + height: 64px; + padding: 0 24px; + border-radius: 16px; + background: #16A34A; + + &.disabled { + background: #CBD5E1; + } +} + +.system-update-action-text { + font-size: 26px; + font-weight: 700; + color: #FFFFFF; +} + +.system-update-body { + padding: 6px 24px 20px; +} + +.system-update-row { + display: flex; + align-items: center; + justify-content: space-between; + gap: 24px; + padding: 18px 0; + border-bottom: 1px solid #F3F4F6; + + &:last-child { + border-bottom: none; + } +} + +.system-update-label { + flex-shrink: 0; + font-size: 26px; + color: #6B7280; +} + +.system-update-value { + min-width: 0; + font-size: 28px; + font-weight: 600; + text-align: right; + word-break: break-all; + color: #1F2937; + + &.highlight { + color: #15803D; + } +} + .menu-item.disabled { opacity: 0.45; } diff --git a/banban-mini/src/pages/sleep/index.tsx b/banban-mini/src/pages/sleep/index.tsx index 7918355..62d654f 100644 --- a/banban-mini/src/pages/sleep/index.tsx +++ b/banban-mini/src/pages/sleep/index.tsx @@ -10,6 +10,8 @@ import { unbindDevice, } from '@/services/binding' import { Child, clearSelectedChildId, createChild, setSelectedChildId, updateChild } from '@/services/child' +import { DeviceFirmwareStatus, getDeviceFirmwareStatus, startDeviceFirmwareUpdate } from '@/services/device' +import { useSystemBanner } from '@/components/system-banner/use-system-banner' import './index.scss' interface MenuItem { @@ -22,11 +24,15 @@ interface MenuItem { } export default function Sleep() { + const systemBanner = useSystemBanner() const [loading, setLoading] = useState(true) const [children, setChildren] = useState([]) const [bindings, setBindings] = useState([]) const [binding, setBinding] = useState(null) const [currentChild, setCurrentChild] = useState(null) + const [firmwareStatus, setFirmwareStatus] = useState(null) + const [isLoadingFirmware, setIsLoadingFirmware] = useState(false) + const [isUpdatingFirmware, setIsUpdatingFirmware] = useState(false) const [showModal, setShowModal] = useState(false) const [showChildModal, setShowChildModal] = useState(false) const [modalType, setModalType] = useState<'add' | 'edit'>('add') @@ -47,11 +53,13 @@ export default function Sleep() { setLoading(true) try { const context = await loadCurrentChildBindingContext() + const nextBinding = (context.currentBinding as BindingListItem | null) || null setChildren(context.children) setBindings(context.bindings as BindingListItem[]) setCurrentChild(context.currentChild) - setBinding((context.currentBinding as BindingListItem | null) || null) + setBinding(nextBinding) setParentInfo(Taro.getStorageSync('userInfo') || {}) + void loadFirmwareStatus(nextBinding?.device_id) } catch (error: any) { console.error('[manage] load failed:', error) Taro.showToast({ @@ -65,6 +73,30 @@ export default function Sleep() { const currentChildName = currentChild?.child_name || '未设置' + const loadFirmwareStatus = async (deviceId?: string | null) => { + const normalizedDeviceId = String(deviceId || '').trim() + if (!normalizedDeviceId) { + setFirmwareStatus(null) + setIsLoadingFirmware(false) + return + } + + setIsLoadingFirmware(true) + try { + const nextFirmwareStatus = await getDeviceFirmwareStatus(normalizedDeviceId) + setFirmwareStatus(nextFirmwareStatus) + } catch (error: any) { + console.error('[manage] firmware load failed:', error) + setFirmwareStatus(null) + Taro.showToast({ + title: error?.message || '系统更新状态加载失败', + icon: 'none', + }) + } finally { + setIsLoadingFirmware(false) + } + } + const handleOpenModal = (type: 'add' | 'edit', child?: Child) => { setModalType(type) setChildName(child?.child_name || '') @@ -88,6 +120,7 @@ export default function Sleep() { } setCurrentChild(child) setBinding(nextBinding) + void loadFirmwareStatus(nextBinding?.device_id) setShowChildModal(false) Taro.showToast({ title: '已切换当前孩子', @@ -128,6 +161,37 @@ export default function Sleep() { } } + const handleStartFirmwareUpdate = () => { + if (!binding?.device_id || !firmwareStatus?.can_update || isLoadingFirmware || isUpdatingFirmware) return + + Taro.showModal({ + title: '系统更新', + content: `确定要更新当前设备 ${binding.device_id} 吗?`, + confirmText: '更新', + confirmColor: '#16A34A', + success: async (result) => { + if (!result.confirm || !binding?.device_id) return + + setIsUpdatingFirmware(true) + try { + const nextFirmwareStatus = await startDeviceFirmwareUpdate(binding.device_id) + setFirmwareStatus(nextFirmwareStatus) + Taro.showToast({ + title: '更新指令已发送', + icon: 'success', + }) + } catch (error: any) { + Taro.showToast({ + title: error?.message || '更新失败,请重试', + icon: 'none', + }) + } finally { + setIsUpdatingFirmware(false) + } + }, + }) + } + const handleUnbind = () => { if (!binding) return @@ -215,11 +279,23 @@ export default function Sleep() { 加载中... + {systemBanner} ) } const parentDisplayName = parentInfo.nickname?.trim() || '家长' + const currentFirmwareLabel = firmwareStatus?.current_version || '--' + const latestFirmwareLabel = firmwareStatus?.latest_version || '--' + const canUpdateFirmware = Boolean(binding?.device_id && firmwareStatus?.can_update && !isLoadingFirmware && !isUpdatingFirmware) + const firmwareActionText = isLoadingFirmware ? '查询中' : isUpdatingFirmware ? '发送中' : '更新' + const firmwareSubtitle = !binding?.device_id + ? '绑定设备后可查看系统版本' + : isLoadingFirmware + ? '正在查询当前设备版本' + : firmwareStatus?.update_available + ? '发现可更新版本' + : '当前设备版本状态' const menuItems: MenuItem[] = [ { icon: require('../../assets/tab-icons/orange-robot.png'), @@ -296,6 +372,35 @@ export default function Sleep() { + + + + 系统更新 + {firmwareSubtitle} + + + {firmwareActionText} + + + + + 当前设备 + {binding?.device_id || '未绑定'} + + + 当前版本 + {currentFirmwareLabel} + + + 最新版本 + {latestFirmwareLabel} + + + + {menuItems.map((item, index) => ( @@ -396,6 +501,7 @@ export default function Sleep() { )} + {systemBanner} ) } diff --git a/banban-mini/src/services/chat.ts b/banban-mini/src/services/chat.ts index 3d8b68f..757da1c 100644 --- a/banban-mini/src/services/chat.ts +++ b/banban-mini/src/services/chat.ts @@ -211,10 +211,7 @@ function formatImPreview( ): string { if (item.content_type === 1) return item.content_text || '文本消息' if (item.content_type === 2) { - if (item.ext_json?.message_kind === 'leave_message') { - return item.media_transcript_text || '[留言]' - } - return item.media_transcript_text || '[语音]' + return '' } if (item.content_type === 3) return '[图片]' if (item.content_type === 4) { diff --git a/banban-mini/src/services/device.ts b/banban-mini/src/services/device.ts index c7e875a..ff78d1e 100644 --- a/banban-mini/src/services/device.ts +++ b/banban-mini/src/services/device.ts @@ -5,6 +5,10 @@ export interface DeviceStatus { device_id: string child_id?: number | null child_name?: string | null + sleep_mode?: number | null + disable_time_start?: string | null + disable_time_end?: string | null + timezone?: string | null power?: number | null volume?: number | null signal?: number | null @@ -24,12 +28,77 @@ export interface DeviceStatus { location_updated_at?: string | null } +export interface DeviceAlarmItem { + alarm_id: number + device_id: string + child_id?: number | null + child_name?: string | null + source_msg_id: string + created_at: string +} + +export interface DeviceAlarmListResponse { + items: DeviceAlarmItem[] + total: number +} + export interface DeviceVolumeUpdateResponse { device_id: string level: number msg_id: string } +export interface DeviceSleepScheduleUpdateResponse { + device_id: string + sleep_mode: number + start?: string | null + end?: string | null + timezone: string + msg_id?: string | null +} + +export interface DeviceRemoteSleepWakeResponse { + device_id: string + switch: 'on' | 'off' + msg_id: string +} + +export interface DeviceFirmwareStatus { + device_id: string + current_version?: string | null + latest_version?: string | null + update_available: boolean + can_update: boolean + update_status: string + progress: number + target_version?: string | null + updated_at?: string | null +} + +export interface DeviceFirmwareUpdateResponse extends DeviceFirmwareStatus { + msg_id: string +} + +function normalizeTimeValue(value?: string | null): string | null { + if (value === null || value === undefined) return null + const trimmed = String(value).trim() + if (!trimmed) return null + + const matched = trimmed.match(/^(\d{1,2}):(\d{2})(?::\d{2})?:?$/) + if (!matched) return trimmed.replace(/:+$/, '') + + const hour = matched[1].padStart(2, '0') + return `${hour}:${matched[2]}` +} + +function normalizeDeviceStatus(status: DeviceStatus): DeviceStatus { + return { + ...status, + disable_time_start: normalizeTimeValue(status.disable_time_start), + disable_time_end: normalizeTimeValue(status.disable_time_end), + } +} + async function resolveDeviceId(deviceId?: string): Promise { const normalizedDeviceId = String(deviceId || '').trim() if (normalizedDeviceId) return normalizedDeviceId @@ -42,7 +111,8 @@ export async function getDeviceStatus(deviceId?: string): Promise(`/banban/devices/${resolvedDeviceId}/status`) + const result = await request(`/banban/devices/${resolvedDeviceId}/status`) + return normalizeDeviceStatus(result) } catch (error: any) { if (error?.status === 404) return null throw error @@ -60,3 +130,76 @@ export async function setDeviceVolume(level: number, deviceId?: string): Promise data: { level }, }) } + +export async function setDeviceSleepSchedule( + start: string, + end: string, + timezone = 'Asia/Shanghai', + deviceId?: string +): Promise { + const resolvedDeviceId = await resolveDeviceId(deviceId) + if (!resolvedDeviceId) { + throw new Error('当前没有可用设备') + } + + const result = await request(`/banban/devices/${resolvedDeviceId}/sleep-schedule`, { + method: 'POST', + data: { start, end, timezone }, + }) + + return { + ...result, + start: normalizeTimeValue(result.start), + end: normalizeTimeValue(result.end), + } +} + +export async function setDeviceRemoteSleepWake( + switchValue: 'on' | 'off', + deviceId?: string +): Promise { + const resolvedDeviceId = await resolveDeviceId(deviceId) + if (!resolvedDeviceId) { + throw new Error('当前没有可用设备') + } + + return request(`/banban/devices/${resolvedDeviceId}/sleep-wake`, { + method: 'POST', + data: { switch: switchValue }, + }) +} + +export async function getDeviceFirmwareStatus(deviceId?: string): Promise { + const resolvedDeviceId = await resolveDeviceId(deviceId) + if (!resolvedDeviceId) return null + + try { + return await request(`/banban/devices/${resolvedDeviceId}/firmware`) + } catch (error: any) { + if (error?.status === 404) return null + throw error + } +} + +export async function startDeviceFirmwareUpdate(deviceId?: string): Promise { + const resolvedDeviceId = await resolveDeviceId(deviceId) + if (!resolvedDeviceId) { + throw new Error('当前没有可用设备') + } + + return request(`/banban/devices/${resolvedDeviceId}/firmware/update`, { + method: 'POST', + }) +} + +export async function getDeviceAlarms( + deviceId?: string, + limit = 20 +): Promise { + const resolvedDeviceId = await resolveDeviceId(deviceId) + if (!resolvedDeviceId) { + return { items: [], total: 0 } + } + + return request(`/banban/devices/${resolvedDeviceId}/alarms?limit=${limit}`) +} diff --git a/banban-mini/src/services/system-banner.ts b/banban-mini/src/services/system-banner.ts new file mode 100644 index 0000000..1d45b0f --- /dev/null +++ b/banban-mini/src/services/system-banner.ts @@ -0,0 +1,175 @@ +import Taro from '@tarojs/taro' +import { Binding, getBindings } from './binding' +import { DeviceAlarmItem, getDeviceAlarms } from './device' + +const LAST_SEEN_ALARM_MAP_KEY = 'systemBannerLastSeenAlarmMap' +const DEVICE_ALARM_FETCH_LIMIT = 20 +const LAST_POLL_TIME_KEY = 'systemBannerLastPollTime' + +type LastSeenAlarmMap = Record + +export interface SystemBannerNotification { + key: string + title: string + summary: string + detail: string + timeLabel: string + route: string + alarmId: number + deviceId: string +} + +function readLastSeenAlarmMap(): LastSeenAlarmMap { + const storedValue = Taro.getStorageSync(LAST_SEEN_ALARM_MAP_KEY) + if (!storedValue || typeof storedValue !== 'object') return {} + return storedValue as LastSeenAlarmMap +} + +function writeLastSeenAlarmMap(map: LastSeenAlarmMap) { + Taro.setStorageSync(LAST_SEEN_ALARM_MAP_KEY, map) +} + +function readLastPollTime(): number { + const value = Number(Taro.getStorageSync(LAST_POLL_TIME_KEY) || 0) + return Number.isFinite(value) ? value : 0 +} + +function writeLastPollTime(value: number) { + Taro.setStorageSync(LAST_POLL_TIME_KEY, value) +} + +function setDeviceAlarmSeen(deviceId: string, alarmId: number, map: LastSeenAlarmMap) { + map[deviceId] = alarmId +} + +function hasDeviceAlarmBaseline(deviceId: string, map: LastSeenAlarmMap): boolean { + return Object.prototype.hasOwnProperty.call(map, deviceId) +} + +function getAlarmChildLabel(alarm: DeviceAlarmItem, fallbackChildName?: string | null): string { + const childName = String(alarm.child_name || '').trim() + if (childName) return childName + + const fallbackName = String(fallbackChildName || '').trim() + if (fallbackName) return fallbackName + + if (alarm.child_id) return `儿童 ${alarm.child_id}` + return '当前孩子' +} + +function getAlarmTypeLabel(sourceMsgId?: string | null): string { + const normalizedValue = String(sourceMsgId || '').trim() + if (normalizedValue === '010') return '紧急告警' + if (!normalizedValue) return '设备提醒' + return normalizedValue +} + +function formatBannerTime(value?: string | null): string { + if (!value) return '' + const date = new Date(value) + if (Number.isNaN(date.getTime())) return '' + const hour = `${date.getHours()}`.padStart(2, '0') + const minute = `${date.getMinutes()}`.padStart(2, '0') + return `${hour}:${minute}` +} + +function buildDeviceAlarmNotification( + alarm: DeviceAlarmItem, + fallbackChildName?: string | null +): SystemBannerNotification { + const childLabel = getAlarmChildLabel(alarm, fallbackChildName) + const alarmTypeLabel = getAlarmTypeLabel(alarm.source_msg_id) + + return { + key: `device-alarm-${alarm.device_id}-${alarm.alarm_id}`, + title: '系统消息', + summary: `${childLabel} 的伴伴触发了${alarmTypeLabel}`, + detail: `设备 ${alarm.device_id}`, + timeLabel: formatBannerTime(alarm.created_at), + route: '/pages/device/index', + alarmId: alarm.alarm_id, + deviceId: alarm.device_id, + } +} + +function sortAlarmsByCreatedAt(left: DeviceAlarmItem, right: DeviceAlarmItem): number { + const leftTime = new Date(left.created_at).getTime() + const rightTime = new Date(right.created_at).getTime() + return leftTime - rightTime +} + +export async function pollSystemBannerNotifications(): Promise { + const bindingResponse = await getBindings(undefined, 100) + const bindings = bindingResponse.items || [] + + if (bindings.length === 0) return [] + + const lastSeenAlarmMap = readLastSeenAlarmMap() + const previousPollTime = readLastPollTime() + const currentPollTime = Date.now() + let hasMapChanged = false + + const alarmResults = await Promise.allSettled( + bindings.map(async (binding) => ({ + binding, + response: await getDeviceAlarms(binding.device_id, DEVICE_ALARM_FETCH_LIMIT), + })) + ) + + const nextNotifications: Array<{ alarm: DeviceAlarmItem; binding: Binding }> = [] + + alarmResults.forEach((result) => { + if (result.status !== 'fulfilled') return + + const { binding, response } = result.value + const deviceId = String(binding.device_id || '').trim() + if (!deviceId) return + + const alarms = response.items || [] + const latestAlarmId = alarms[0]?.alarm_id || 0 + const hasBaseline = hasDeviceAlarmBaseline(deviceId, lastSeenAlarmMap) + const knownAlarmId = Number(lastSeenAlarmMap[deviceId] || 0) + + // Establish a baseline on first poll so old history does not flood the user. + if (!hasBaseline) { + setDeviceAlarmSeen(deviceId, latestAlarmId, lastSeenAlarmMap) + hasMapChanged = true + return + } + + if (latestAlarmId < knownAlarmId) { + setDeviceAlarmSeen(deviceId, latestAlarmId, lastSeenAlarmMap) + hasMapChanged = true + return + } + + if (latestAlarmId === knownAlarmId) { + return + } + + setDeviceAlarmSeen(deviceId, latestAlarmId, lastSeenAlarmMap) + hasMapChanged = true + + alarms + .filter((alarm) => { + if (alarm.alarm_id <= knownAlarmId) return false + if (previousPollTime <= 0) return true + + const createdAt = new Date(alarm.created_at).getTime() + if (Number.isNaN(createdAt)) return true + return createdAt >= previousPollTime + }) + .forEach((alarm) => { + nextNotifications.push({ alarm, binding }) + }) + }) + + if (hasMapChanged) { + writeLastSeenAlarmMap(lastSeenAlarmMap) + } + writeLastPollTime(currentPollTime) + + return nextNotifications + .sort((left, right) => sortAlarmsByCreatedAt(left.alarm, right.alarm)) + .map(({ alarm, binding }) => buildDeviceAlarmNotification(alarm, binding.child_name)) +} From 8305bfcf2ccb447af537a9e57acf9e66b139cb0d Mon Sep 17 00:00:00 2001 From: HycJack <772403255@qq.com> Date: Sat, 9 May 2026 17:06:27 +0800 Subject: [PATCH 6/8] =?UTF-8?q?=E4=BF=AE=E6=94=B9=E7=BB=99=E6=9C=8D?= =?UTF-8?q?=E5=8A=A1=E7=95=99=E8=A8=80=E7=9A=84uuid=E5=8C=B9=E9=85=8D?= =?UTF-8?q?=E8=A7=84=E5=88=99?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .DS_Store | Bin 8196 -> 14340 bytes .gitignore | 2 ++ talkingq-url/config.py | 4 ---- talkingq-url/handlers/mqtt_handler.py | 2 +- 4 files changed, 3 insertions(+), 5 deletions(-) diff --git a/.DS_Store b/.DS_Store index fcd63ead39f7de06150bb2d6d357ade5f578b10c..eb333705c7fe6bf054ed558531e50a49954592b8 100644 GIT binary patch literal 14340 zcmeGjYitz9`TN#}>}DY>juRkZd*?VjLe9aL7%)joj?FZVUjJENXFpb^s75GN??W^ZL^leZC2TaY)ni<phH z=U7dwkM(&*>~7K>m!Kp)e$lMRl?&UhUG~Gosx|9wIWa?-*(5j1m-d$o*T|T9->9J# zMmDG2tZr$BH9Tl&x>Yc?8@eOS#F*4;rE~g;rrC2YyL_&y)?!y$87O9qokgRtTWJ+V z-bahtDk(?Atr{`8&em9qnpdN?s0&1K3oGw(bV_-#yhx2nN?Po7iDllzVmU4;uGG83 zu*#$z@={UJq&_p9(M{gka`^^Pxk>6TagKRmvwWi{4Um>|#Ei?!l4`f8yeZzwI_^ok zh4p#esthTqt`@~XcO>oT>qjl!sZjKYqNheGXC$jMqhLFpG;VHD)y=G_m71n&#Pnse z=QOD)wNF%z6OCe6C8I#bK*}Ldbj8-hTxM|qt%*hD4-No48Mh|ddA_+)T!w5BQRyx# z32Wh2$ir?p0C&Md@H8BOXW%Hj4=3R>_#ONS{tREi*YFQ)#0#+*=i)qEfD7>|ycVy+ zCAbv3um{)S%{Yiz)NnfvqlrZ<;V#^bKgBz6AMVEk_-nime}fO;gLnuJ;}iHKzJRad zYxp|8fhX~EJcVE2m-rQajeip)p-Gr4sKO6~1;RpMk#M!JLby>_E!-ju3PZvO=Rhd0 zZm_MIGhsL7gWc3E<+zKj?v-++j~qnb737@C14v(_!v&Xmt^(-!Uhy^7DjPC#>mQ7V1g{Y8&{E~Z@`V1!d~2lLzu=4*}aJt=5Z%F zxCi&*&u|QXPF8;q@5Eo>-FPoq{{3Y8hwu@66d%K<@CZJO&*Af)54?*X;-~oA8mnGc zZPobKdRCqDUHC5h==NRj9D8GBeSB^Fg7|Fk^CJ_BTg#hKHnczwE2SLEw5?&nVIHak zT~!nNXOyxVB*+eVUkkZ^` zTZObi^Q=ceD-NM-?I$C)N0WBZ(p--=#VbTfl7^Jod-lYa$79isC3|DhJ@I(w(pa>! zbNSxAl6Xn$;u|&&?J#qey`M2m>{MnPwh{1$1C%)hGF{?OoUTutK`z3PTMKBs&D8yR$K4ye@phk z7oR!q^vWsS>Igu zWa$+?hXfA5Z-8fm?^oWGz61>=Sa~(Z7kD9Xu9f^Bn*Zy}GU54u;=YHOOc+85gc1lP z5K17Fz562QjpRCkhUD!&ERBC)r2QHnpI z$Z%e!$O3(PB$Rm1@d$d)@hHQd<8^vS<%H@I?33UahxA3^`+o@NyG!sNCByT-rw^8} TvtQuxJ3Rl>f2IBZpa1_2>S|em delta 462 zcmZoEXmOBWU|?W$DortDU;r^WfEYvza8E20o2Vx_*}#NF6v$@)5sW}_hMaW6;N<+= zf{ho=*e55Cboctu97{_9n_Yz#k zjys~tr{I+@$bi@c)Wg7F^nqcrfs)i@1tnhQm9ORj*=ih>>=l^|Ktq8XnaNd3T*v}a z{K(oBTs};8P}P{ss$9xpY-+BfplfVA`J|%E}lU3BbnRoi~Pj*z5ncSzw!?tUO zNFLX*$qUr9V9X0@+DyI^fF{UJmQr6a`JuS`#L1GAH3WEA7@W`kpEyBA1lh03vsrS2 zfzE=6Bm9UYE`aPf25$uhpjw`?;G(>o{JeCaZy7h=;?ZRmWCnYkL4q4dyMiKRQBx8AV diff --git a/.gitignore b/.gitignore index 36b13f1..b249263 100644 --- a/.gitignore +++ b/.gitignore @@ -174,3 +174,5 @@ cython_debug/ # PyPI configuration file .pypirc +banban_server/ +talkingq-url-bak/ diff --git a/talkingq-url/config.py b/talkingq-url/config.py index 0f3227e..7b175e9 100644 --- a/talkingq-url/config.py +++ b/talkingq-url/config.py @@ -68,10 +68,6 @@ class Settings(BaseSettings): 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_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") client_api_key: str = Field(default="", validation_alias="CLIENT_API_KEY") diff --git a/talkingq-url/handlers/mqtt_handler.py b/talkingq-url/handlers/mqtt_handler.py index 16b3b9d..1726cd7 100644 --- a/talkingq-url/handlers/mqtt_handler.py +++ b/talkingq-url/handlers/mqtt_handler.py @@ -337,7 +337,7 @@ class TalkingQMQTTService: params = payload.get("params", {}) nfc_uuid = str(params.get("uuid") or "").strip() logger.info(device_id, "", f"[短按留言] 设备 {device_id} 短按发送留言, UUID={nfc_uuid}") - if nfc_uuid == settings.talkingq_parent_message_uuid: + if nfc_uuid == device_id: await device_target_cache.set_parent_target(device_id, nfc_uuid) response_payload = { "msg_id": "011", From 847755c56a94c6db2b01e2d9015ec742d9f0dcb6 Mon Sep 17 00:00:00 2001 From: HycJack <772403255@qq.com> Date: Sun, 10 May 2026 20:45:11 +0800 Subject: [PATCH 7/8] =?UTF-8?q?=E4=BF=AE=E5=A4=8D=E5=AE=B6=E9=95=BF?= =?UTF-8?q?=E7=BB=99=E8=AE=BE=E5=A4=87=E7=95=99=E8=A8=80=E9=97=AE=E9=A2=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- talkingq-url/banban/service/im.py | 15 +++++++++++++-- talkingq-url/handlers/mqtt_handler.py | 4 ++-- .../handlers/websocket_message_handler.py | 1 - 3 files changed, 15 insertions(+), 5 deletions(-) diff --git a/talkingq-url/banban/service/im.py b/talkingq-url/banban/service/im.py index 72cf2da..fa69068 100644 --- a/talkingq-url/banban/service/im.py +++ b/talkingq-url/banban/service/im.py @@ -4,11 +4,11 @@ import json from collections.abc import Mapping from pathlib import Path 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.message_audio_storage import MessageAudioStorageService, MessageAudioStorageError - +from banban.service.binding import BindingService try: from banban.dao.im import ImDAO, DeviceIdentity, ConversationMessageCreateResult from banban.schemas.im import ( @@ -19,6 +19,9 @@ 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 + PARENT_PARTICIPANT_TYPE = 1 @@ -286,6 +289,14 @@ class ImService(DatabaseServiceBase): child_id=child_id, payload=payload, ) + 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) + except Exception: + session_logger.error(device.device_id, "audio", f"failed to get 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: try: await self.audio_storage.delete_audio(stored_audio.file_key) diff --git a/talkingq-url/handlers/mqtt_handler.py b/talkingq-url/handlers/mqtt_handler.py index 1726cd7..d7e0e30 100644 --- a/talkingq-url/handlers/mqtt_handler.py +++ b/talkingq-url/handlers/mqtt_handler.py @@ -344,7 +344,7 @@ class TalkingQMQTTService: "status": "success", "type": 0, "params": { - "url": "http://101.35.224.118:8080/assets/audio/parent_online_zh.mp3" + "url": f"http://{settings.server_host}:{settings.server_port}/assets/audio/parent_online_zh.mp3" }, } await self._publish(f"device/{device_id}/event_resp", response_payload) @@ -371,7 +371,7 @@ class TalkingQMQTTService: "status": "success", "type": 0, "params": { - "url": "http://101.35.224.118:8080/assets/audio/parent_online_zh.mp3" + "url": f"http://{settings.server_host}:{settings.server_port}/assets/audio/parent_online_zh.mp3" }, }, ) diff --git a/talkingq-url/handlers/websocket_message_handler.py b/talkingq-url/handlers/websocket_message_handler.py index 4454776..4030538 100644 --- a/talkingq-url/handlers/websocket_message_handler.py +++ b/talkingq-url/handlers/websocket_message_handler.py @@ -19,7 +19,6 @@ from handlers.prompt_sound_handler import handle_prompt_sound_request from handlers.session_cleanup_handler import handle_old_session_cleanup from config import settings from banban.service.im import im_service as im_conversation_service -from handlers.audio_file_handler import message_audio_storage_service, save_audio_file from fastapi import HTTPException async def handle_websocket_messages(websocket: WebSocket, device_id: str, serial_number: str): From e1271f6c5b62244b6fb5a83e76c2ce63f0ca54e8 Mon Sep 17 00:00:00 2001 From: stu2not Date: Mon, 11 May 2026 15:18:22 +0800 Subject: [PATCH 8/8] =?UTF-8?q?=E4=BF=AE=E5=A4=8D=E8=AE=BE=E5=A4=87?= =?UTF-8?q?=E7=BB=99=E5=AE=B6=E9=95=BF=E7=95=99=E8=A8=80=E9=9F=B3=E9=A2=91?= =?UTF-8?q?=E4=BA=91=E5=AD=98=E5=82=A8=E9=93=BE=E8=B7=AF?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../banban/service/device_voice_archive.py | 4 +- .../banban/service/message_audio_storage.py | 3 ++ talkingq-url/handlers/audio_file_handler.py | 39 ++++++++++-------- .../handlers/websocket_message_handler.py | 41 +++++++++++++++---- talkingq-url/utils/audio_format.py | 33 +++++++++++++++ 5 files changed, 93 insertions(+), 27 deletions(-) create mode 100644 talkingq-url/utils/audio_format.py diff --git a/talkingq-url/banban/service/device_voice_archive.py b/talkingq-url/banban/service/device_voice_archive.py index 89a1b97..9b5b17a 100644 --- a/talkingq-url/banban/service/device_voice_archive.py +++ b/talkingq-url/banban/service/device_voice_archive.py @@ -140,10 +140,10 @@ class DeviceVoiceArchiveService(DatabaseServiceBase): ), ) stored_audio = await message_audio_storage_service.upload_audio( - sender_device_id=sender_device_id, - receiver_device_id=receiver_device_id, + device_id=sender_device_id, content=archive_audio_data, content_type=prepared_audio.mime_type, + extension=prepared_audio.archive_format, ) session_logger.info( sender_device_id, diff --git a/talkingq-url/banban/service/message_audio_storage.py b/talkingq-url/banban/service/message_audio_storage.py index 6d66373..c340d6a 100644 --- a/talkingq-url/banban/service/message_audio_storage.py +++ b/talkingq-url/banban/service/message_audio_storage.py @@ -142,3 +142,6 @@ class MessageAudioStorageService: ContentType=content_type, EnableMD5=False, ) + + +message_audio_storage_service = MessageAudioStorageService() diff --git a/talkingq-url/handlers/audio_file_handler.py b/talkingq-url/handlers/audio_file_handler.py index 9d19932..46cdd43 100644 --- a/talkingq-url/handlers/audio_file_handler.py +++ b/talkingq-url/handlers/audio_file_handler.py @@ -1,22 +1,27 @@ -from banban.service.message_audio_storage import MessageAudioStorageService +from banban.service.message_audio_storage import StoredMessageAudio, message_audio_storage_service from utils.logger import session_logger -message_audio_storage_service = MessageAudioStorageService() - - -# async def save_audio_file(audio_data: bytes, device_id: str) -> str: -# """Upload device audio to COS and return its object key.""" -# try: -# stored = await message_audio_storage_service.upload_audio( -# device_id=device_id, -# content=audio_data, -# ) -# session_logger.info(device_id, "audio", f"audio uploaded to COS: {stored.file_key}") -# return stored.file_key -# except Exception as e: -# session_logger.error(device_id, "audio", f"failed to store audio: {e}", exc_info=True) -# raise +async def upload_message_audio( + audio_data: bytes, + device_id: str, + *, + content_type: str = "audio/mpeg", + extension: str = "mp3", +) -> StoredMessageAudio: + """Upload message audio to COS and return its storage metadata.""" + try: + stored = await message_audio_storage_service.upload_audio( + device_id=device_id, + content=audio_data, + content_type=content_type, + extension=extension, + ) + session_logger.info(device_id, "audio", f"audio uploaded to COS: {stored.file_key}") + return stored + except Exception as e: + session_logger.error(device_id, "audio", f"failed to store audio: {e}", exc_info=True) + raise import os @@ -54,4 +59,4 @@ async def save_audio_file(audio_data: bytes, device_id: str) -> str: return filepath except Exception as e: session_logger.error(device_id, "audio", f"保存音频文件时出错: {e}", exc_info=True) - raise \ No newline at end of file + raise diff --git a/talkingq-url/handlers/websocket_message_handler.py b/talkingq-url/handlers/websocket_message_handler.py index 4030538..d53b950 100644 --- a/talkingq-url/handlers/websocket_message_handler.py +++ b/talkingq-url/handlers/websocket_message_handler.py @@ -4,7 +4,7 @@ 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 +from handlers.audio_file_handler import message_audio_storage_service, save_audio_file, upload_message_audio from services.audio_session import audio_session_manager from services.interrupt_handler import interrupt_handler from services.task_manager import task_manager @@ -19,8 +19,19 @@ from handlers.prompt_sound_handler import handle_prompt_sound_request from handlers.session_cleanup_handler import handle_old_session_cleanup from config import settings from banban.service.im import im_service as im_conversation_service +from utils.audio_format import detect_audio_format, wrap_pcm_as_wav from fastapi import HTTPException + +def prepare_message_audio(audio_data: bytes) -> tuple[bytes, str, str, str]: + source_format = detect_audio_format(audio_data) + if source_format == "wav": + return audio_data, "audio/wav", "wav", source_format + if source_format == "mp3": + return audio_data, "audio/mpeg", "mp3", source_format + return wrap_pcm_as_wav(audio_data), "audio/wav", "wav", source_format + + async def handle_websocket_messages(websocket: WebSocket, device_id: str, serial_number: str): """ 处理WebSocket连接中的所有消息 @@ -266,16 +277,30 @@ async def process_parent_leave_message(device_id: str, audio_cache_key: str): 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}" + archive_audio, media_mime_type, extension, source_format = prepare_message_audio(cached_audio) + stored_audio = await upload_message_audio( + archive_audio, + device_id, + content_type=media_mime_type, + extension=extension, + ) 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"}, + media_file_key=stored_audio.file_key, + media_mime_type=media_mime_type, + media_size_bytes=len(archive_audio), + ext_json={ + "source": "device_ws_parent_leave_message", + "storage": "cos", + "source_format": source_format, + "archive_format": extension, + }, + ) + session_logger.info( + device_id, + "parent", + f"发给家长的留言已上传COS并写入家长会话: {stored_audio.file_key}", ) - session_logger.info(device_id, "parent", "发给家长的留言已写入家长会话") websocket = await connection_manager.get_connection(device_id) if websocket and websocket.client_state.name == "CONNECTED": diff --git a/talkingq-url/utils/audio_format.py b/talkingq-url/utils/audio_format.py new file mode 100644 index 0000000..8468df8 --- /dev/null +++ b/talkingq-url/utils/audio_format.py @@ -0,0 +1,33 @@ +import io +import wave + + +DEFAULT_SAMPLE_RATE = 16000 +DEFAULT_CHANNELS = 1 +DEFAULT_SAMPLE_WIDTH = 2 + + +def detect_audio_format(audio_data: bytes) -> str: + if len(audio_data) >= 12 and audio_data[:4] == b"RIFF" and audio_data[8:12] == b"WAVE": + return "wav" + if audio_data.startswith(b"ID3"): + return "mp3" + if len(audio_data) >= 2 and audio_data[0] == 0xFF and (audio_data[1] & 0xE0) == 0xE0: + return "mp3" + return "pcm_s16le_16k_mono" + + +def wrap_pcm_as_wav( + audio_data: bytes, + *, + sample_rate: int = DEFAULT_SAMPLE_RATE, + channels: int = DEFAULT_CHANNELS, + sample_width: int = DEFAULT_SAMPLE_WIDTH, +) -> bytes: + wav_buffer = io.BytesIO() + with wave.open(wav_buffer, "wb") as wav_file: + wav_file.setnchannels(channels) + wav_file.setsampwidth(sample_width) + wav_file.setframerate(sample_rate) + wav_file.writeframes(audio_data) + return wav_buffer.getvalue()