Merge branch 'main' into test-clean

This commit is contained in:
stu2not
2026-05-07 09:32:00 +08:00
84 changed files with 1945 additions and 171 deletions

View File

@@ -5,7 +5,7 @@ module.exports = {
defineConstants: {
__APP_ENV__: '"development"',
// Update this to your LAN backend when testing on a real device.
__API_BASE_URL__: '"http://192.168.10.36:8001"',
__API_BASE_URL__: '"https://banban.api.talkingq.com"',
},
mini: {},
h5: {}

View File

@@ -5,7 +5,7 @@ module.exports = {
defineConstants: {
__APP_ENV__: '"production"',
// Replace with the production backend before release builds.
__API_BASE_URL__: '"http://192.168.10.36:8001"',
__API_BASE_URL__: '"https://banban.api.talkingq.com"',
},
mini: {},
h5: {}

View File

@@ -8,7 +8,7 @@ export interface AppConfig {
export const APP_CONFIG: AppConfig = {
appEnv: typeof __APP_ENV__ === 'undefined' ? 'development' : __APP_ENV__,
apiBaseUrl: typeof __API_BASE_URL__ === 'undefined' ? 'http://127.0.0.1:8001' : __API_BASE_URL__,
apiBaseUrl: typeof __API_BASE_URL__ === 'undefined' ? 'https://banban.api.talkingq.com' : __API_BASE_URL__,
}
export const API_BASE_URL = APP_CONFIG.apiBaseUrl

View File

@@ -88,7 +88,7 @@ export default function Device() {
const nextValue = Boolean(e.detail?.value)
setSleepEnabled(nextValue)
Taro.showToast({
title: nextValue ? '休眠展示已开启' : '休眠展示已关闭',
title: nextValue ? '休眠已开启' : '休眠已关闭',
icon: 'none',
})
}
@@ -241,7 +241,7 @@ export default function Device() {
</View>
<View className='control-info'>
<Text className='control-name'></Text>
<Text className='control-detail'></Text>
<Text className='control-detail'></Text>
</View>
</View>
<Switch checked={sleepEnabled} onChange={handleSleepToggle} color='#FF8C42' />

View File

@@ -375,7 +375,7 @@ export default function Location() {
</View>
) : (
<>
<View className='mode-switch'>
{/* <View className='mode-switch'>
{MODE_OPTIONS.map((item) => (
<View
key={item.key}
@@ -388,7 +388,7 @@ export default function Location() {
<Text className='mode-chip-text'>{item.label}</Text>
</View>
))}
</View>
</View> */}
<View className='map-section'>
<Map

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

View File

@@ -1,11 +1,13 @@
import logging
from collections.abc import Mapping
from datetime import datetime
from services.connection_manager import connection_manager
from fastapi import APIRouter, Depends, HTTPException, Query, Request
from pydantic import BaseModel, Field
from sqlalchemy import text
import asyncio
from datetime import timedelta
from handlers.mqtt_handler import TalkingQMQTTService
try:
from banban.security import get_current_user_id
from banban.schemas.location import (
@@ -251,17 +253,40 @@ async def set_device_volume(
return DeviceVolumeUpdateResponse(device_id=device_id, level=payload.level, msg_id=msg_id)
@router.get("/{device_id}/location", response_model=DeviceLocationCurrentResponse)
async def get_current_device_location(
device_id: str,
request: Request,
current_user_id: int = Depends(get_current_user_id),
) -> DeviceLocationCurrentResponse:
# 每次进入小程序地图页面获取最新位置坐标等待设备上报GPS数据再查询数据库坐标
service = await TalkingQMQTTService.get_instance()
if service is None:
raise HTTPException(status_code=503, detail="MQTT 服务未初始化")
await service.send_gps_query(device_id)
try:
# 每隔1s 获取一次GPS数据最多3次
# 先判断设备是否在线不在线直接提示设备没有在线通过websocket判断
target_websocket = await connection_manager.get_connection(device_id)
if not target_websocket or target_websocket.client_state.name != "CONNECTED":
raise HTTPException(status_code=408, detail="设备未在线")
i=0
while i<1:
try:
row = await location_service.get_device_current_location(
device_id=device_id,
user_id=current_user_id,
)
except Exception as e:
await asyncio.sleep(1)
continue
if row is None:
await asyncio.sleep(1)
else:
if row["updated_at"] is not None \
and (datetime.now() - row['updated_at']) <= timedelta(seconds=10):
logger.info(
"device current location fetched",
extra={
@@ -274,6 +299,27 @@ async def get_current_device_location(
)
return _row_to_current_location_response(row)
i+=1
if row is None:
raise HTTPException(status_code=408, detail="GPS数据上报超时")
logger.info(
"device location reported",
extra={
"event": "device_location_report",
"request_id": getattr(request.state, "request_id", None),
"device_id": device_id,
"child_id": int(row["child_id"]),
"lat": float(row["lat"]),
"lng": float(row["lng"]),
},
)
except Exception as e:
raise HTTPException(status_code=408, detail=f"GPS数据上报失败: {e}")
@router.get("/{device_id}/trajectory", response_model=DeviceLocationTrajectoryResponse)
async def get_device_location_trajectory(

View File

@@ -10,13 +10,16 @@ from banban.schemas.mqtt_models import (
NFCListenRequest,
NFCUnreadRequest,
NFCBindRequest,
SleepScheduleRequest,
RemoteSleepWakeRequest,
OpenCommandRequest,
CommandResponse,
DeviceResponseData,
DeviceStatusResponse,
)
from fastapi import Depends
from handlers.mqtt_handler import TalkingQMQTTService
from banban.security import get_current_user_id
# from banban.security import get_current_user_id
@@ -35,7 +38,7 @@ async def _get_service() -> TalkingQMQTTService:
@router.post("/gps", response_model=CommandResponse, summary="GPS位置查询")
async def query_gps(
req: GPSQueryRequest,
current_user_id: int = Depends(get_current_user_id)
# current_user_id: int = Depends(get_current_user_id)
):
service = await _get_service()
msg_id = await service.send_gps_query(req.device_id)
@@ -45,7 +48,7 @@ async def query_gps(
@router.post("/volume", response_model=CommandResponse, summary="音量调节")
async def set_volume(
req: VolumeRequest,
current_user_id: int = Depends(get_current_user_id)
# current_user_id: int = Depends(get_current_user_id)
):
service = await _get_service()
msg_id = await service.send_volume_command(req.device_id, req.level)
@@ -55,7 +58,7 @@ async def set_volume(
@router.post("/ota", response_model=CommandResponse, summary="OTA升级")
async def start_ota(
req: OTARequest,
current_user_id: int = Depends(get_current_user_id)
# current_user_id: int = Depends(get_current_user_id)
):
service = await _get_service()
msg_id = await service.send_ota_command(req.device_id, req.url, req.version)
@@ -65,32 +68,32 @@ async def start_ota(
@router.post("/nfc/notice", response_model=CommandResponse, summary="NFC留言下发提示")
async def send_nfc_notice(
req: NFCNoticeRequest,
current_user_id: int = Depends(get_current_user_id)
# current_user_id: int = Depends(get_current_user_id)
):
service = await _get_service()
msg_id = await service.send_nfc_notice(req.device_id, req.url)
return CommandResponse(msg_id=msg_id, device_id=req.device_id)
@router.post("/nfc/listen", response_model=CommandResponse, summary="NFC收听留言")
async def nfc_listen(
req: NFCListenRequest,
current_user_id: int = Depends(get_current_user_id)
):
service = await _get_service()
topic = f"device/{req.device_id}/event"
payload = {
"msg_id": "005",
"params": {"uuid": req.uuid}
}
await service._publish(topic, payload)
return CommandResponse(msg_id="005", device_id=req.device_id)
# @router.post("/nfc/listen", response_model=CommandResponse, summary="NFC收听留言")
# async def nfc_listen(
# req: NFCListenRequest,
# # current_user_id: int = Depends(get_current_user_id)
# ):
# service = await _get_service()
# topic = f"device/{req.device_id}/event"
# payload = {
# "msg_id": "005",
# "params": {"uuid": req.uuid}
# }
# await service._publish(topic, payload)
# return CommandResponse(msg_id="005", device_id=req.device_id)
@router.post("/nfc/unread", response_model=CommandResponse, summary="设置NFC未读留言")
async def set_nfc_unread(
req: NFCUnreadRequest,
current_user_id: int = Depends(get_current_user_id)
# current_user_id: int = Depends(get_current_user_id)
):
await offline_audio_cache.add_audio_url(req.device_id, req.url)
return CommandResponse(msg_id="", device_id=req.device_id, message="已设置未读留言")
@@ -99,7 +102,7 @@ async def set_nfc_unread(
@router.delete("/nfc/unread/{device_id}", response_model=CommandResponse, summary="清除NFC未读留言定时推送")
async def clear_nfc_unread(
device_id: str,
current_user_id: int = Depends(get_current_user_id)
# current_user_id: int = Depends(get_current_user_id)
):
await offline_audio_cache.clear_audio_urls(device_id)
return CommandResponse(msg_id="", device_id=device_id, message="已清除未读留言")
@@ -108,14 +111,43 @@ async def clear_nfc_unread(
@router.post("/nfc/bind", response_model=CommandResponse, summary="NFC绑定卡片")
async def bind_nfc(
req: NFCBindRequest,
current_user_id: int = Depends(get_current_user_id)
# current_user_id: int = Depends(get_current_user_id)
):
# 扫码绑定卡片需要发送NFC绑定命令
service = await _get_service()
msg_id = await service.send_bind_nfc_command(req.device_id, req.uuid)
return CommandResponse(msg_id=msg_id, device_id=req.device_id)
@router.post("/sleep/schedule", response_model=CommandResponse, summary="设置定时休眠")
async def set_sleep_schedule(
req: SleepScheduleRequest,
# current_user_id: int = Depends(get_current_user_id)
):
service = await _get_service()
msg_id = await service.send_sleep_schedule_command(req.device_id, req.start, req.end)
return CommandResponse(msg_id=msg_id, device_id=req.device_id)
@router.post("/sleep/remote", response_model=CommandResponse, summary="远程休眠唤醒")
async def remote_sleep_wake(
req: RemoteSleepWakeRequest,
# current_user_id: int = Depends(get_current_user_id)
):
service = await _get_service()
msg_id = await service.send_remote_sleep_wake_command(req.device_id, req.switch)
return CommandResponse(msg_id=msg_id, device_id=req.device_id)
@router.post("/device/open", response_model=CommandResponse, summary="设备开关控制")
async def control_device_open(
req: OpenCommandRequest,
# current_user_id: int = Depends(get_current_user_id)
):
service = await _get_service()
msg_id = await service.send_open_command(req.device_id, req.open_type, req.is_open)
return CommandResponse(msg_id=msg_id, device_id=req.device_id)
# @router.get("/nfc/unread", summary="查询所有未读留言设备列表")
# async def get_nfc_unread_devices():
# service = await _get_service()

View File

@@ -37,6 +37,23 @@ class NFCBindRequest(BaseModel):
uuid: str = Field(..., description="NFC卡片UUID")
class SleepScheduleRequest(BaseModel):
device_id: str = Field(..., description="设备ID")
start: str = Field(..., description="休眠开始时间,格式 HH:MM如 22:00")
end: str = Field(..., description="休眠结束时间,格式 HH:MM如 07:00")
class RemoteSleepWakeRequest(BaseModel):
device_id: str = Field(..., description="设备ID")
switch: str = Field(..., description="开关状态on=唤醒off=休眠")
class OpenCommandRequest(BaseModel):
device_id: str = Field(..., description="设备ID")
open_type: int = Field(..., ge=0, le=2, description="0=查询状态1=控制打开2=控制关闭")
is_open: Optional[int] = Field(None, description="open_type=1时1=打开0=关闭")
class CommandResponse(BaseModel):
code: int = 0
message: str = "success"

View File

@@ -1,6 +1,12 @@
import asyncio
from dataclasses import dataclass
from datetime import UTC, datetime
from datetime import datetime
try:
from datetime import UTC # Python 3.11+
except ImportError:
from datetime import timezone
UTC = timezone.utc # Python 3.10 及更早版本
from urllib.parse import urlparse
from uuid import uuid4

View File

@@ -267,7 +267,6 @@ class DeviceSetting(Base):
onupdate=datetime.utcnow,
)
class IMConversation(Base):
__tablename__ = "im_conversations"
__table_args__ = (

View File

@@ -1,80 +1,117 @@
services:
app:
build: .
# mysql:
# build:
# context: .
# dockerfile: Dockerfile-mysql
# ports:
# - "13306:3306" # 修改为 13306 或其他未使用的端口
# networks:
# - app-network
# volumes:
# - mysql_data:/var/lib/mysql # 持久化数据
# healthcheck:
# test: ["CMD", "mysqladmin", "ping", "-h", "localhost", "-u", "root", "-padmin123"]
# interval: 10s
# timeout: 5s
# retries: 5
# start_period: 30s
# redis:
# image: redis:7.2-alpine
# ports:
# - "6379:6379"
# networks:
# - app-network
# volumes:
# - redis_data:/data
# healthcheck:
# test: ["CMD", "redis-cli", "ping"]
# interval: 5s
# timeout: 3s
# retries: 5
# start_period: 10s
# command: redis-server --appendonly yes
# node:
# build:
# context: .
# dockerfile: Dockerfile-node
# depends_on:
# mysql:
# condition: service_healthy
# ports:
# - "8084:8084"
# networks:
# - app-network
# environment:
# - API_URL=http://server:8091
# server:
# build:
# context: .
# dockerfile: Dockerfile-server
# # 添加构建缓存设置
# cache_from:
# - eclipse-temurin:21-jre
# depends_on:
# mysql:
# condition: service_healthy
# redis:
# condition: service_healthy
# networks:
# - app-network
# environment:
# SPRING_DATASOURCE_URL: "jdbc:mysql://mysql:3306/talkingq?allowPublicKeyRetrieval=true&useSSL=false&serverTimezone=UTC"
# SPRING_DATASOURCE_USERNAME: "root"
# SPRING_DATASOURCE_PASSWORD: "admin123"
# SPRING_DATA_REDIS_HOST: "redis"
# SPRING_DATA_REDIS_PORT: "6379"
# SPRING_DATA_REDIS_PASSWORD: ""
# volumes:
# - maven_repo:/root/.m2/repository # 持久化Maven仓库
# restart: on-failure # 添加重启策略,如果启动失败会自动重试
nginx:
image: nginx:stable-alpine
# depends_on:
# server:
# condition: service_started
ports:
- "8080:8080"
env_file:
- .env
- "80:80"
- "443:443"
networks:
- app-network
volumes:
- ./nginx.conf:/etc/nginx/conf.d/default.conf:ro
- ./logs/nginx:/var/log/nginx
- ./certbot/conf:/etc/letsencrypt
- ./certbot/www:/var/www/certbot
- ./web:/usr/share/nginx/html
- /etc/timezone:/etc/timezone:ro
- /etc/localtime:/etc/localtime:ro
environment:
TALKINGQ_MQTT_BROKER: mqtt
TALKINGQ_MQTT_PORT: 1883
volumes:
- ./assets:/app/assets
- ./logs:/app/logs
depends_on:
mqtt:
condition: service_started
mysql:
condition: service_healthy
restart: unless-stopped
networks:
- talkingq-network
- TZ=Asia/Shanghai
command: "/bin/sh -c 'while :; do sleep 6h & wait $${!}; nginx -s reload; done & nginx -g \"daemon off;\"'"
backend_app:
build: ../backend-v0.1
ports:
- "8001:8001"
env_file:
- ../backend-v0.1/.env
volumes:
- ../backend-v0.1/app/assets:/app/assets
- ../backend-v0.1/app/logs:/app/logs
depends_on:
mysql:
condition: service_healthy
restart: unless-stopped
certbot:
image: certbot/certbot
networks:
- talkingq-network
mysql:
image: mysql:8.0
command: --default-authentication-plugin=caching_sha2_password
restart: unless-stopped
- app-network
volumes:
- ./certbot/conf:/etc/letsencrypt
- ./certbot/www:/var/www/certbot
- /etc/timezone:/etc/timezone:ro
- /etc/localtime:/etc/localtime:ro
environment:
MYSQL_ROOT_PASSWORD: "${MYSQL_ROOT_PASSWORD}"
MYSQL_DATABASE: talkingq
MYSQL_USER: talkingq
MYSQL_PASSWORD: "${MYSQL_PASSWORD}"
TZ: Asia/Shanghai
volumes:
- mysql-data:/var/lib/mysql
- ./mysql/init:/docker-entrypoint-initdb.d
- ./mysql/conf.d:/etc/mysql/conf.d
healthcheck:
test: ["CMD", "mysqladmin", "ping", "-h", "localhost", "-u", "talkingq", "-p${MYSQL_PASSWORD}"]
interval: 10s
timeout: 5s
retries: 5
networks:
- talkingq-network
mqtt:
image: eclipse-mosquitto:2
ports:
- "1883:1883"
volumes:
- ./mqtt/mosquitto.conf:/mosquitto/config/mosquitto.conf:ro
- mqtt-data:/mosquitto/data
- mqtt-log:/mosquitto/log
restart: unless-stopped
networks:
- talkingq-network
volumes:
mysql-data:
mqtt-data:
mqtt-log:
- TZ=Asia/Shanghai
entrypoint: "/bin/sh -c 'trap exit TERM; while :; do certbot renew; sleep 12h & wait $${!}; done;'"
networks:
talkingq-network:
app-network:
driver: bridge
# volumes:
# mysql_data: # 定义持久化卷
# maven_repo: # 持久化Maven仓库
# vosk_models: # 持久化Vosk模型
# redis_data: #redis持久卷

View File

@@ -0,0 +1,291 @@
device/TalkingQ_xxx/command # 服务器下发命令(下行)
device/TalkingQ_xxx/response # 设备响应(上行)
device/TalkingQ_xxx/event # 设备主动事件
device/TalkingQ_xxx/event_resp # 事件返回
消息格式JSON
device/TalkingQ_xxx/event
0.设备定时10分钟上报信息
{
"msg_id": "000",
"data": {
"id":"TalkingQ_xxx",
"power": 70, 每个间隔10从10-100
"signal": 3, 信号强度1是最弱的 5是最强
"voice":60, 每个间隔20从40-100
"version":"v1.0"
}
}
服务器返回
device/TalkingQ_xxx/event_resp
{
"msg_id": "000",
"status": "success"
}
1. GPS位置查询
device/TalkingQ_xxx/command
服务器下发:
{
"msg_id": "001"
}
device/TalkingQ_xxx/response
4G模块响应
{
"msg_id": "001",
"status": "success",
"data": {
"latitude": 39.9042,
"longitude": 116.4074
}
}
2. 音量调节
device/TalkingQ_xxx/command
服务器下发:
{
"msg_id": "002",
"params": {
"level": 70
}
}
device/TalkingQ_xxx/response
4G模块响应
{
"msg_id": "002",
"status": "success",
"data": {
"current_level": 70
}
}
3. OTA升级
device/TalkingQ_xxx/command
服务器下发:
{
"msg_id": "003",
"params": {
"url": "http://47.113.99.138:7050/EG800AKCN_91LC-beta1-20260430170621-fota.bin",
"version": "2.1.0"
}
}
device/TalkingQ_xxx/response
4G模块响应立即确认
{
"msg_id": "003",
"status": "accepted",
"data": {
"current_version": "2.0.5",
"target_version": "2.1.0"
}
}
升级完成
{
"msg_id": "003",
"status": "success",
"data": {
"new_version": "2.1.0"
}
}
4、NFC留言下发提示
device/TalkingQ_xxx/command
如果设备在线且有未读留言服务器定时10分钟下发一次
{
"msg_id": "004",
"params": {
"url": "http://xxx.mp3" //您有新的留言请注意查收
}
}
device/TalkingQ_xxx/response
4G模块响应
{
"msg_id": "004",
"status": "success",
}
5、发送或收听留言
device/TalkingQ_xxx/event
NFC触发设备端上报
{
"msg_id": "005",
"params": {
"uuid": "53C12B6DA20001"
}
}
//如果不是本机本地UUID则下发“你好你的朋友已在线请留言”
//如果本机本地UUID但没有留言"你好,已没可读留言"
服务器下发
device/TalkingQ_xxx/event_resp
{
"msg_id": "005",
"status": "success",
"type": 0, //0代表收听留言 1 代表需要发送留言
"params": {
"url_1": "http://xxx.mp3"
"url_2": "http://xxx.mp3"
}
}
{
"msg_id": "005",
"type": 0,
"params":
{
"url_1": "http://175.24.73.253:8080/assets/audio/no_message.mp3"
}
}
6、NFC绑定uuid
device/TalkingQ_xxx/command
{
"msg_id": "006",
}
device/TalkingQ_xxx/response
4G模块响应
{
"msg_id": "006",
"status": "success",
"data": {
"uuid": "xxxx"
}
}
7、开关状态
device/TalkingQ_xxx/command
{
"msg_id": "007",
"type": 0 , //0查询状态 1 控制打开 2控制关闭
}
device/TalkingQ_xxx/response
4G模块响应
{
"msg_id": "007",
"status": "success",
"data": {
"status": "on" //on或off 返回当前状态
}
}
8、定时休眠
device/TalkingQ_xxx/command
{
"msg_id": "008",
"params": {
"start": "22:00" , //设置时和分
"end": "07:00" //设置时和分
}
}
device/TalkingQ_xxx/response
4G模块响应
{
"msg_id": "008",
"status": "success"
}
9、远程休眠和唤醒
device/TalkingQ_xxx/command
{
"msg_id": "009",
"params": {
"switch": "on" , // on 唤醒 off 休眠
}
}
device/TalkingQ_xxx/response
4G模块响应
{
"msg_id": "009",
"status": "success"
}
10、额外按键长按发送告警信息
device/TalkingQ_xxx/event
{
"msg_id": "010" //发送告警
}
device/TalkingQ_xxx/event_resp
4G模块响应
{
"msg_id": "010",
"status": "success"
}
11、额外按键短按发送留言给家长
device/TalkingQ_xxx/event
{
"msg_id": "011",
"params": {
"uuid": "TalkingQ_xxx"
}
}
device/TalkingQ_xxx/event_resp
{
"msg_id": "011",
"type": 0,
"params":
{
"url": "http://175.24.73.253:8080/assets/audio/message_ok.mp3"
}
}
TalkingQ_XQSN00001003 TQ_XQSN000000001003
53C12B6DA20001
53D92B6DA20001
53DA2B6DA20001
set id TalkingQ_XQSN00001003
set sn TQ_XQSN000000001003
TalkingQ_XQSN00001004 TQ_XQSN000000001004
53E12B6DA20001
53D12B6DA20001
53D22B6DA20001
TalkingQ_XQSN00001005 TQ_XQSN000000001005
53C22B6DA20001
1000000189ABCDEFGHIJKLMNOPQRSTUV
1000000089ABCDEFGHIJKLMNOPQRSTUV
1、额外按钮
短按 直接与父母留言
长按 播报紧急报警系统
2、休眠时间段设置功能
在设定时间内,设备不可启动
设定时间过后,可以正常使用
3、远程一键休眠和一键唤醒
mqtt地址
http://175.24.73.253:18083/#/login?to=/clients
admin
emqx_DPWQyk
{
"device_id": "TalkingQ_XQSN00001005",
"start": "22:00",
"end": "07:00"
}
{
"device_id": "TalkingQ_XQSN00001005",
"url": "http://175.24.73.253:80/assets/audio/test_zh.mp3"
}

View File

@@ -0,0 +1,295 @@
device/imei/command # 服务器下发命令(下行)
device/imei/response # 设备响应(上行)
device/imei/event # 设备主动事件
device/imei/event_resp # 事件返回
消息格式JSON
device/imei/event
0.设备定时10分钟上报信息
{
"msg_id": "000",
"data": {
"id":"TalkingQ_xxx",
"power": 70, 每个间隔10从10-100
"signal": 3, 信号强度1是最弱的 5是最强
"voice":60, 每个间隔20从40-100
"version":"v1.0"
}
}
服务器返回
device/imei/event_resp
{
"msg_id": "000",
"status": "success"
}
1. GPS位置查询
device/imei/command
服务器下发:
{
"msg_id": "001"
}
device/imei/response
4G模块响应
{
"msg_id": "001",
"status": "success",
"data": {
"latitude": 39.9042,
"longitude": 116.4074
}
}
2. 音量调节
device/imei/command
服务器下发:
{
"msg_id": "002",
"params": {
"level": 70
}
}
device/imei/response
4G模块响应
{
"msg_id": "002",
"status": "success",
"data": {
"current_level": 70
}
}
3. OTA升级
device/imei/command
服务器下发:
{
"msg_id": "003",
"params": {
"url": "http://47.113.99.138:7050/EG800AKCN_91LC-beta1-20260501205451-fota.bin",
"version": "2.1.0"
}
}
device/imei/response
4G模块响应立即确认
{
"msg_id": "003",
"status": "accepted",
"data": {
"current_version": "2.0.5",
"target_version": "2.1.0"
}
}
升级完成
{
"msg_id": "003",
"status": "success",
"data": {
"new_version": "2.1.0"
}
}
4、NFC留言下发提示
device/imei/command
如果设备在线且有未读留言服务器定时10分钟下发一次
{
"msg_id": "004",
"params": {
"url": "http://xxx.mp3" //您有新的留言请注意查收
}
}
device/imei/response
4G模块响应
{
"msg_id": "004",
"status": "success",
}
5、发送或收听留言
device/imei/event
NFC触发设备端上报
{
"msg_id": "005",
"params": {
"uuid": "53C12B6DA20001"
}
}
//如果不是本机本地UUID则下发“你好你的朋友已在线请留言”
//如果本机本地UUID但没有留言"你好,已没可读留言"
服务器下发
device/imei/event_resp
{
"msg_id": "005",
"status": "success",
"type": 0, //0代表收听留言 1 代表需要发送留言
"params": {
"url_1": "http://xxx.mp3"
"url_2": "http://xxx.mp3"
}
}
{
"msg_id": "005",
"type": 0,
"params":
{
"url_1": "http://175.24.73.253:8080/assets/audio/no_message.mp3"
}
}
6、NFC绑定uuid
device/imei/command
{
"msg_id": "006"
}
device/imei/response
4G模块响应
{
"msg_id": "006",
"status": "success",
"data": {
"uuid": "xxxx"
}
}
7、开关状态
//0查询状态 1 控制打开 2控制关闭
device/imei/command
{
"msg_id": "007",
"type": 0
}
device/imei/response
4G模块响应
{
"msg_id": "007",
"status": "success",
"data": {
"status": "on" //on或off 返回当前状态
}
}
8、定时休眠
device/imei/command
{
"msg_id": "008",
"params": {
"start": "22:00" , //设置时和分
"end": "07:00" //设置时和分
}
}
device/imei/response
4G模块响应
{
"msg_id": "008",
"status": "success"
}
9、远程休眠和唤醒
device/imei/command
{
"msg_id": "009",
"params": {
"switch": "on" // on 唤醒 off 休眠
}
}
device/imei/response
4G模块响应
{
"msg_id": "009",
"status": "success"
}
10、额外按键长按发送告警信息
device/imei/event
{
"msg_id": "010" //发送告警
}
device/imei/event_resp
4G模块响应
{
"msg_id": "010",
"status": "success"
}
11、额外按键短按发送留言给家长
device/imei/event
{
"msg_id": "011",
"params": {
"uuid": "TalkingQ_xxx"
}
}
device/imei/event_resp
{
"msg_id": "011",
"type": 0,
"params":
{
"url": "http://175.24.73.253:8080/assets/audio/message_ok.mp3"
}
}
12、增加后台自动下发DeviceID和SN流程
设备端上报
device/imei/event
{
"msg_id": "012"
}
服务器下发
device/imei/event_resp
{
"msg_id": "012",
"params":
{
"url": "175.24.73.253:80",
"device_id":"TalkingQ_XQSN00001003",
"device_sn":"TQ_XQSN000000001003",
}
}
TalkingQ_XQSN00001003 TQ_XQSN000000001003
53C12B6DA20001
53D92B6DA20001
53DA2B6DA20001
set id TalkingQ_XQSN00001003
set sn TQ_XQSN000000001003
TalkingQ_XQSN00001004 TQ_XQSN000000001004
53E12B6DA20001
53D12B6DA20001
53D22B6DA20001
TalkingQ_XQSN00001005 TQ_XQSN000000001005
53C22B6DA20001
1000000189ABCDEFGHIJKLMNOPQRSTUV
1000000089ABCDEFGHIJKLMNOPQRSTUV
1、额外按钮
短按 直接与父母留言
长按 播报紧急报警系统
2、休眠时间段设置功能
在设定时间内,设备不可启动
设定时间过后,可以正常使用
3、远程一键休眠和一键唤醒
mqtt地址
http://175.24.73.253:18083/#/login?to=/clients
admin
emqx_DPWQyk

View File

@@ -5,15 +5,53 @@ 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
import os
import uuid
from config import settings
from utils.logger import session_logger
# from utils.audio_denoiser import reduce_background_noise
async def save_audio_file(audio_data: bytes, device_id: str) -> str:
"""Upload device audio to COS and return its object key."""
"""
保存音频数据到 assets/audio 目录
Args:
audio_data: 音频二进制数据
device_id: 设备ID
Returns:
音频文件的相对路径
"""
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
audio_dir = os.path.join(settings.assets_dir, "audio")
os.makedirs(audio_dir, exist_ok=True)
filename = f"{device_id}_{uuid.uuid4().hex[:8]}.mp3"
filepath = os.path.join(audio_dir, filename)
with open(filepath, 'wb') as f:
f.write(audio_data)
# relative_path = f"assets/audio/{filename}"
session_logger.info(device_id, "audio", f"音频文件已保存: {filepath}")
# reduce_background_noise(filepath, relative_path,noise_path='assets/audio/noise_sample.wav',normalize_volume=True)
return filepath
except Exception as e:
session_logger.error(device_id, "audio", f"failed to store audio: {e}", exc_info=True)
session_logger.error(device_id, "audio", f"保存音频文件时出错: {e}", exc_info=True)
raise

View File

@@ -11,11 +11,16 @@ 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
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.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
@@ -45,6 +50,10 @@ class TalkingQMQTTService:
"005": self._handle_nfc_listen_report,
"006": self._handle_bind_response,
"007": self._handle_open_response,
"008": self._handle_sleep_schedule_response,
"009": self._handle_remote_sleep_wake_response,
"010": self._handle_alarm_report,
"011": self._handle_short_press_message,
}
@classmethod
@@ -189,6 +198,8 @@ class TalkingQMQTTService:
)
elif status != "accepted":
logger.warning(device_id, "", f"[OTA] command failed: {payload}")
else:
logger.warning(device_id, "", f"[OTA] 设备 {device_id} 升级异常: {payload}")
async def _handle_nfc_notice_response(self, device_id: str, payload: dict):
status = payload.get("status")
@@ -202,19 +213,13 @@ class TalkingQMQTTService:
await self._send_nfc_listen_response(device_id, nfc_uuid)
async def _handle_bind_response(self, device_id: str, payload: dict):
params = payload.get("params", {})
data = payload.get("data", {})
status = payload.get("status") or params.get("status")
logger.info(device_id, "", f"[NFC bind] device={device_id} status={status}")
if status not in (None, "success", "accepted"):
logger.warning(device_id, "", f"[NFC bind] bind failed: {payload}")
return
nfc_uuid = data.get("uuid") or params.get("uuid")
if not nfc_uuid:
logger.warning(device_id, "", f"[NFC bind] missing uuid: {payload}")
return
params = payload
status = params.get("status")
logger.info(device_id, "", f"[NFC绑定] 设备 {device_id} 请求绑定卡片返回状态: {status}")
data = params.get("data", {})
nfc_uuid = data.get("uuid")
# 卡片与设备绑定
# await card_service.activate_card(device_id=device_id, card_uuid=nfc_uuid)
service = BindingService()
result = await service.finalize_nfc_bind(device_id=device_id, card_uuid=nfc_uuid)
if result is None:
@@ -224,8 +229,43 @@ class TalkingQMQTTService:
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
status = params.get("status")
logger.info(device_id, "", f"[设备状态] 设备 {device_id} 请求返回设备状态: {status}")
data = params.get("data", {})
device_status = data.get("status")
logger.info(device_id, "", f"[设备状态] 设备 {device_id} 请求设备状态: {device_status}")
async def _handle_sleep_schedule_response(self, device_id: str, payload: dict):
status = payload.get("status")
if status == "success":
logger.info(device_id, "", f"[定时休眠] 设备 {device_id} 休眠时间设置成功")
else:
logger.warning(device_id, "", f"[定时休眠] 设备 {device_id} 休眠时间设置失败: {payload}")
async def _handle_remote_sleep_wake_response(self, device_id: str, payload: dict):
status = payload.get("status")
if status == "success":
logger.info(device_id, "", f"[远程休眠唤醒] 设备 {device_id} 操作成功")
else:
logger.warning(device_id, "", f"[远程休眠唤醒] 设备 {device_id} 操作失败: {payload}")
async def _handle_alarm_report(self, device_id: str, payload: dict):
logger.info(device_id, "", f"[告警] 设备 {device_id} 发送紧急报警")
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):
params = payload.get("params", {})
logger.info(device_id, "", f"[device open] response={params}")
nfc_uuid = params.get("uuid")
logger.info(device_id, "", f"[短按留言] 设备 {device_id} 短按发送留言, UUID={nfc_uuid}")
payload = {
"msg_id": "011",
"type": 0,
"params": {
"url": f"http://{settings.server_host}:{settings.server_port}/assets/audio/message_ok.mp3"
}
}
await self._publish(f"device/{device_id}/event_resp", payload)
async def _send_nfc_listen_response(self, device_id: str, nfc_uuid: str):
topic = f"device/{device_id}/event_resp"
@@ -244,7 +284,7 @@ class TalkingQMQTTService:
is_owner = await card_service.check_card_ownership(nfc_uuid, device_id)
if is_owner:
has_pending = await offline_audio_cache.has_pending_audio(device_id)
if nfc_uuid == "53D92B6DA20001":
if nfc_uuid == "53C22B6DA20001":
payload = {
"msg_id": "005",
"type": 0,
@@ -257,6 +297,17 @@ class TalkingQMQTTService:
if has_pending:
audio_urls = await offline_audio_cache.get_audio_urls(device_id)
# 53D92B6DA20001 测试卡片
if nfc_uuid == "53C22B6DA20001":
payload = {
"msg_id": "005",
"type": 0,
"params": {
"url_1": f"http://{settings.server_host}:{settings.server_port}/assets/audio/TalkingQ_XQSN00001005_2f654480.mp3"
}
}
await self._publish(topic, payload)
return
if len(audio_urls) == 0:
payload = {
"msg_id": "005",
@@ -372,25 +423,69 @@ class TalkingQMQTTService:
return "003"
async def send_nfc_notice(self, device_id: str, url: str) -> str:
await self._publish(
f"device/{device_id}/command",
{"msg_id": "004", "params": {"url_1": url}},
)
topic = f"device/{device_id}/command"
payload = {
"msg_id": "004",
"params": {"url": url}
}
await self._publish(topic, payload)
return "004"
async def send_bind_nfc_command(self, device_id: str, uuid: Optional[str] = None) -> str:
del uuid
await self._publish(f"device/{device_id}/command", {"msg_id": "006"})
async def send_bind_nfc_command(self, device_id: str, uuid: str) -> str:
topic = f"device/{device_id}/command"
payload = {
"msg_id": "006",
"params": {
"url": f"http://{settings.server_host}:{settings.server_port}/assets/audio/bind_nfc_ready_zh.mp3"
}
}
await self._publish(topic, payload)
return "006"
async def send_open_command(self, device_id: str, open_type: int, is_open: int) -> str:
if open_type not in [0, 1]:
raise ValueError("open_type must be 0 or 1")
topic = f"device/{device_id}/command"
if open_type not in [0, 1, 2]:
raise ValueError("open_type must be 0, 1 or 2")
if open_type == 0:
payload = {"msg_id": "007", "type": open_type}
else:
payload = {"msg_id": "007", "type": open_type, "status": is_open}
await self._publish(f"device/{device_id}/command", payload)
payload = {
"msg_id": "007",
"type": open_type
}
elif open_type == 1:
payload = {
"msg_id": "007",
"type": open_type
}
elif open_type == 2:
payload = {
"msg_id": "007",
"type": open_type
}
await self._publish(topic, payload)
return "007"
async def send_sleep_schedule_command(self, device_id: str, start: str, end: str) -> str:
topic = f"device/{device_id}/command"
payload = {
"msg_id": "008",
"params": {
"start": start,
"end": end
}
}
await self._publish(topic, payload)
return "008"
async def send_remote_sleep_wake_command(self, device_id: str, switch: str) -> str:
if switch not in ["on", "off"]:
raise ValueError("switch must be 'on' or 'off'")
topic = f"device/{device_id}/command"
payload = {
"msg_id": "009",
"params": {
"switch": switch
}
}
await self._publish(topic, payload)
return "009"

View File

@@ -18,6 +18,8 @@ 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):
"""
@@ -266,13 +268,13 @@ async def process_cached_audio(device_id: str, target_device_id: str, serial_num
# 保存音频文件
audio_file_key = await save_audio_file(cached_audio, device_id)
audio_url = f"http://{settings.server_host}:{settings.server_port}/{audio_file_key}"
# 将音频URL保存到数据库 im_conversation和im_message
await im_conversation_service.create_device_message(device_id=device_id, serial_number=serial_number, target_device_id=target_device_id, audio_url=audio_file_key)
try:
audio_url = await message_audio_storage_service.get_audio_url(audio_file_key)
except Exception:
audio_url = audio_file_key
# await im_conversation_service.create_device_message(device_id=device_id, serial_number=serial_number, target_device_id=target_device_id, audio_url=audio_file_key)
# try:
# audio_url = await message_audio_storage_service.get_audio_url(audio_file_key)
# except Exception:
# audio_url = audio_file_key
# 发送URL给目标设备
# target_websocket = await connection_manager.get_connection(target_device_id)
# if target_websocket and target_websocket.client_state.name == "CONNECTED":
@@ -293,7 +295,7 @@ async def process_cached_audio(device_id: str, target_device_id: str, serial_num
await websocket.send_text(f"PROMPT_SOUND_URL:{success_audio_url}")
session_logger.info(device_id, "device", f"留言已收到音频URL给设备 {device_id}")
else:
session_logger.warning(device_id, "device", f"设备 {device_id} 不在线发送留言已收到音频URL失败")
session_logger.warning(device_id, "device", f"设备 {device_id} 不在线,暂不发送留言已收到音频URL")
# # # 目标设备不在线,保存到离线缓存
# await offline_audio_cache.add_audio_url(target_device_id, audio_url)
# session_logger.warning(device_id, "target", f"目标设备 {target_device_id} 不在线保存音频URL到离线缓存")
@@ -305,6 +307,18 @@ async def process_cached_audio(device_id: str, target_device_id: str, serial_num
# else:
# session_logger.warning(device_id, "device", f"设备 {device_id} 不在线,发送留言成功音频失败")
except Exception as e:
# 处理HTTPException异常
if isinstance(e, HTTPException):
session_logger.error(device_id, "target", f"保存音频文件时出错返回HTTPException: {e}")
websocket = await connection_manager.get_connection(device_id)
if e.status_code == 404 or e.status_code == 400:
if websocket and websocket.client_state.name == "CONNECTED":
success_audio_url = f"http://{settings.server_host}:{settings.server_port}/assets/audio/save_audio_fail.mp3"
await websocket.send_text(f"PROMPT_SOUND_URL:{success_audio_url}")
session_logger.info(device_id, "device", f"留言已收到音频URL给设备 {device_id}")
else:
session_logger.warning(device_id, "device", f"设备 {device_id} 不在线暂不发送留言已收到音频URL")
else:
session_logger.error(device_id, "target", f"处理缓存音频时出错: {e}", exc_info=True)
finally:
# 清除缓存

View File

@@ -0,0 +1,65 @@
#!/bin/bash
if ! docker compose version >/dev/null 2>&1; then
echo 'Error: docker compose is not installed.' >&2
exit 1
fi
domains=(banban.api.talkingq.com)
rsa_key_size=4096
data_path="./certbot"
email="admin2345@talkingq.com" # 修改为您的邮箱地址
staging=0 # 设置为1表示测试模式0表示生产模式
if [ -d "$data_path" ]; then
read -p "已存在证书数据。是否继续? (y/N) " decision
if [ "$decision" != "Y" ] && [ "$decision" != "y" ]; then
exit
fi
fi
echo "### 创建证书目录结构 ###"
sudo mkdir -p "$data_path/conf/live/$domains"
sudo mkdir -p "$data_path/www"
echo "### 创建临时自签名证书 ###"
path="/etc/letsencrypt/live/$domains"
sudo mkdir -p "$data_path/conf/live/$domains"
sudo docker compose run --rm --entrypoint "\
openssl req -x509 -nodes -newkey rsa:$rsa_key_size -days 1\
-keyout '$path/privkey.pem' \
-out '$path/fullchain.pem' \
-subj '/CN=localhost'" certbot
echo "### 启动nginx ###"
sudo docker compose up --force-recreate -d nginx
echo "### 删除临时证书 ###"
sudo docker compose run --rm --entrypoint "\
rm -Rf /etc/letsencrypt/live/$domains && \
rm -Rf /etc/letsencrypt/archive/$domains && \
rm -Rf /etc/letsencrypt/renewal/$domains.conf" certbot
echo "### 申请Let's Encrypt证书 ###"
domain_args=""
for domain in "${domains[@]}"; do
domain_args="$domain_args -d $domain"
done
# 选择是否使用staging环境
case "$staging" in
0) staging_arg="";;
1) staging_arg="--staging";;
esac
sudo docker compose run --rm --entrypoint "\
certbot certonly --webroot -w /var/www/certbot \
$staging_arg \
$domain_args \
--email $email \
--rsa-key-size $rsa_key_size \
--agree-tos \
--force-renewal" certbot
echo "### 重启nginx ###"
sudo docker compose exec nginx nginx -s reload

View File

@@ -87,6 +87,7 @@ async def lifespan(app: FastAPI):
session_logger.system_info("shutdown", "后台清理任务已取消")
from services.audio_session import audio_session_manager
sessions_to_close = await audio_session_manager.get_all_sessions()
for session_key, session in sessions_to_close:

114
talkingq-url/nginx.conf Normal file
View File

@@ -0,0 +1,114 @@
# 默认服务器配置已移至HTTPS服务器块
# 添加JavaScript MIME类型映射
# types {
# application/javascript js mjs ts;
# text/javascript js mjs ts;
# }
server {
listen 80;
server_name banban.api.talkingq.com;
# location = /msUqvicTHS.txt {
# root /usr/share/nginx/html;
# }
# location / {
# root /usr/share/nginx/html;
# }
# HTTP 跳转到 HTTPS
location / {
return 301 https://$host$request_uri;
}
# Let's Encrypt HTTP-01 校验
location /.well-known/acme-challenge/ {
root /var/www/certbot;
}
}
server {
listen 443 ssl;
server_name banban.api.talkingq.com;
ssl_certificate /etc/letsencrypt/live/banban.api.talkingq.com/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/banban.api.talkingq.com/privkey.pem;
ssl_protocols TLSv1.2 TLSv1.3;
ssl_ciphers HIGH:!aNULL:!MD5;
access_log /var/log/nginx/access.log;
error_log /var/log/nginx/error.log;
location = /msUqvicTHS.txt {
root /usr/share/nginx/html;
}
# 将API请求转发到Java后端服务
location / {
proxy_pass http://175.24.73.253:80;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header X-Original-URI $request_uri;
proxy_set_header Authorization $http_authorization;
add_header Cache-Control "no-store, no-cache, must-revalidate";
expires -1;
}
# # 将API请求转发到Java后端服务
# location /api/ {
# proxy_pass http://server:8091;
# proxy_set_header Host $host;
# proxy_set_header X-Real-IP $remote_addr;
# proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
# proxy_set_header X-Forwarded-Proto $scheme;
# proxy_set_header X-Original-URI $request_uri;
# add_header Cache-Control "no-store, no-cache, must-revalidate";
# expires -1;
# }
# # 静态资源处理
# location ~* \.(css|js|png|jpg|jpeg|gif|ico|svg|woff|woff2|ttf|eot|mp3|mp4|avi|pdf|txt)$ {
# proxy_pass http://node:8084;
# proxy_set_header Host $host;
# proxy_set_header X-Real-IP $remote_addr;
# proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
# proxy_set_header X-Forwarded-Proto $scheme;
# expires 1d;
# proxy_buffering off;
# add_header Cache-Control "public, max-age=86400";
# }
# # Vite特定的虚拟资源处理
# location ~ ^(/@id|/@vite|/node_modules) {
# proxy_pass http://node:8084;
# proxy_set_header Host $host;
# proxy_set_header X-Real-IP $remote_addr;
# proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
# proxy_set_header X-Forwarded-Proto $scheme;
# add_header Cache-Control "no-store, no-cache, must-revalidate";
# expires -1;
# }
# # WebSocket支持Vite HMR
# location / {
# proxy_pass http://node:8084;
# proxy_set_header Host $host;
# proxy_buffering off;
# proxy_set_header X-Real-IP $remote_addr;
# proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
# proxy_set_header X-Forwarded-Proto $scheme;
# proxy_set_header X-Original-URI $request_uri;
# add_header Cache-Control "no-store, no-cache, must-revalidate";
# expires -1;
# # WebSocket配置
# proxy_http_version 1.1;
# proxy_set_header Upgrade $http_upgrade;
# proxy_set_header Connection "upgrade";
# proxy_read_timeout 86400;
# }
error_page 500 502 503 504 /50x.html;
location = /50x.html {
root /usr/share/nginx/html;
}
}

View File

@@ -20,7 +20,7 @@ setuptools==69.5.1
pycld2
aiomqtt>=2.0.0
apscheduler>=3.10.0
PyJWT==2.10.1
httpx==0.28.1
cos-python-sdk-v5==1.9.41
PyJWT==2.10.1

View File

@@ -0,0 +1,431 @@
import os
import asyncio
import threading
import tkinter as tk
from tkinter import ttk, messagebox, filedialog
from pathlib import Path
from dotenv import load_dotenv
import aiohttp
import aiofiles
import binascii
import json
import logging
import subprocess
import platform
logging.basicConfig(
level=logging.INFO, format="%(asctime)s - %(name)s - %(levelname)s - %(message)s"
)
logger = logging.getLogger("minimax_tts_gui")
class MiniMaxTTSGUI:
def __init__(self, root):
self.root = root
self.root.title("MiniMax TTS 工具")
self.root.geometry("750x700")
self.current_audio_file = None
self.synthesis_in_progress = False
self.text_entries = []
self.config_collapsed = False
self.load_env()
self.setup_ui()
def load_env(self):
env_path = os.path.join(os.path.dirname(os.path.abspath(__file__)), "minimax.env")
if os.path.exists(env_path):
load_dotenv(env_path)
logger.info(f"已加载环境变量文件: {env_path}")
else:
logger.warning(f"环境变量文件不存在: {env_path}")
def setup_ui(self):
main_frame = ttk.Frame(self.root, padding="10")
main_frame.grid(row=0, column=0, sticky=(tk.W, tk.E, tk.N, tk.S))
self.root.columnconfigure(0, weight=1)
self.root.rowconfigure(0, weight=1)
main_frame.columnconfigure(1, weight=1)
row = 0
config_header_frame = ttk.Frame(main_frame)
config_header_frame.grid(row=row, column=0, columnspan=2, sticky=(tk.W, tk.E), pady=(0, 5))
ttk.Label(config_header_frame, text="配置信息", font=("Arial", 12, "bold")).pack(side=tk.LEFT)
self.toggle_btn = ttk.Button(config_header_frame, text="", width=3, command=self.toggle_config)
self.toggle_btn.pack(side=tk.RIGHT)
row += 1
self.config_frame = ttk.Frame(main_frame)
self.config_frame.grid(row=row, column=0, columnspan=2, sticky=(tk.W, tk.E), pady=(0, 10))
config_row = 0
ttk.Label(self.config_frame, text="API Key:").grid(row=config_row, column=0, sticky=tk.W, pady=5)
self.api_key_var = tk.StringVar(value=os.getenv("MINIMAX_API_KEY", ""))
ttk.Entry(self.config_frame, textvariable=self.api_key_var, width=60).grid(row=config_row, column=1, sticky=(tk.W, tk.E), pady=5)
config_row += 1
ttk.Label(self.config_frame, text="Group ID:").grid(row=config_row, column=0, sticky=tk.W, pady=5)
self.group_id_var = tk.StringVar(value=os.getenv("MINIMAX_GROUP_ID", "1915292410024300630"))
ttk.Entry(self.config_frame, textvariable=self.group_id_var, width=60).grid(row=config_row, column=1, sticky=(tk.W, tk.E), pady=5)
config_row += 1
ttk.Label(self.config_frame, text="Base URL:").grid(row=config_row, column=0, sticky=tk.W, pady=5)
self.base_url_var = tk.StringVar(value=os.getenv("MINIMAX_BASE_URL", "https://api.minimax.chat/v1/t2a_v2"))
ttk.Entry(self.config_frame, textvariable=self.base_url_var, width=60).grid(row=config_row, column=1, sticky=(tk.W, tk.E), pady=5)
config_row += 1
ttk.Label(self.config_frame, text="音色 ID:").grid(row=config_row, column=0, sticky=tk.W, pady=5)
self.voice_id_var = tk.StringVar(value=os.getenv("MINIMAX_VOICE_ID", "cartoon-boy-01"))
ttk.Entry(self.config_frame, textvariable=self.voice_id_var, width=60).grid(row=config_row, column=1, sticky=(tk.W, tk.E), pady=5)
config_row += 1
self.config_frame.columnconfigure(1, weight=1)
row += 1
ttk.Separator(main_frame, orient='horizontal').grid(row=row, column=0, columnspan=2, sticky=(tk.W, tk.E), pady=10)
row += 1
text_list_header = ttk.Frame(main_frame)
text_list_header.grid(row=row, column=0, columnspan=2, sticky=(tk.W, tk.E), pady=(0, 5))
ttk.Label(text_list_header, text="文本列表", font=("Arial", 12, "bold")).pack(side=tk.LEFT)
add_btn = ttk.Button(text_list_header, text="", width=3, command=self.add_text_entry)
add_btn.pack(side=tk.RIGHT)
row += 1
self.text_list_container = ttk.Frame(main_frame)
self.text_list_container.grid(row=row, column=0, columnspan=2, sticky=(tk.W, tk.E, tk.N, tk.S), pady=5)
self.text_list_container.columnconfigure(1, weight=1)
self.add_text_entry()
row += 1
ttk.Separator(main_frame, orient='horizontal').grid(row=row, column=0, columnspan=2, sticky=(tk.W, tk.E), pady=10)
row += 1
ttk.Label(main_frame, text="合成参数", font=("Arial", 12, "bold")).grid(row=row, column=0, columnspan=2, sticky=tk.W, pady=(0, 10))
row += 1
ttk.Label(main_frame, text="输出目录:").grid(row=row, column=0, sticky=tk.W, pady=5)
self.output_dir_var = tk.StringVar(value="assets")
ttk.Entry(main_frame, textvariable=self.output_dir_var, width=50).grid(row=row, column=1, sticky=(tk.W, tk.E), pady=5)
row += 1
ttk.Label(main_frame, text="语速:").grid(row=row, column=0, sticky=tk.W, pady=5)
self.speed_var = tk.DoubleVar(value=1.0)
ttk.Scale(main_frame, from_=0.5, to=2.0, variable=self.speed_var, orient=tk.HORIZONTAL, length=300).grid(row=row, column=1, sticky=tk.W, pady=5)
ttk.Label(main_frame, textvariable=self.speed_var).grid(row=row, column=1, sticky=tk.E, pady=5)
row += 1
ttk.Label(main_frame, text="音量:").grid(row=row, column=0, sticky=tk.W, pady=5)
self.vol_var = tk.DoubleVar(value=1.0)
ttk.Scale(main_frame, from_=0.5, to=2.0, variable=self.vol_var, orient=tk.HORIZONTAL, length=300).grid(row=row, column=1, sticky=tk.W, pady=5)
ttk.Label(main_frame, textvariable=self.vol_var).grid(row=row, column=1, sticky=tk.E, pady=5)
row += 1
ttk.Label(main_frame, text="音调:").grid(row=row, column=0, sticky=tk.W, pady=5)
self.pitch_var = tk.IntVar(value=0)
ttk.Scale(main_frame, from_=-10, to=10, variable=self.pitch_var, orient=tk.HORIZONTAL, length=300).grid(row=row, column=1, sticky=tk.W, pady=5)
ttk.Label(main_frame, textvariable=self.pitch_var).grid(row=row, column=1, sticky=tk.E, pady=5)
row += 1
button_frame = ttk.Frame(main_frame)
button_frame.grid(row=row, column=0, columnspan=2, pady=15)
self.synthesize_btn = ttk.Button(button_frame, text="开始合成", command=self.start_synthesis)
self.synthesize_btn.pack(side=tk.LEFT, padx=5)
self.play_btn = ttk.Button(button_frame, text="播放最后一个", command=self.play_audio, state=tk.DISABLED)
self.play_btn.pack(side=tk.LEFT, padx=5)
self.open_folder_btn = ttk.Button(button_frame, text="打开输出目录", command=self.open_output_folder)
self.open_folder_btn.pack(side=tk.LEFT, padx=5)
row += 1
ttk.Separator(main_frame, orient='horizontal').grid(row=row, column=0, columnspan=2, sticky=(tk.W, tk.E), pady=10)
row += 1
ttk.Label(main_frame, text="日志", font=("Arial", 12, "bold")).grid(row=row, column=0, columnspan=2, sticky=tk.W, pady=(0, 10))
row += 1
self.log_text = tk.Text(main_frame, height=8, width=70, state=tk.DISABLED)
self.log_text.grid(row=row, column=0, columnspan=2, sticky=(tk.W, tk.E, tk.N, tk.S), pady=5)
log_scrollbar = ttk.Scrollbar(main_frame, orient=tk.VERTICAL, command=self.log_text.yview)
log_scrollbar.grid(row=row, column=2, sticky=(tk.N, tk.S))
self.log_text['yscrollcommand'] = log_scrollbar.set
main_frame.rowconfigure(row, weight=1)
def toggle_config(self):
self.config_collapsed = not self.config_collapsed
if self.config_collapsed:
self.config_frame.grid_remove()
self.toggle_btn.config(text="")
else:
self.config_frame.grid()
self.toggle_btn.config(text="")
def add_text_entry(self):
entry_frame = ttk.Frame(self.text_list_container)
entry_row = len(self.text_entries)
ttk.Label(entry_frame, text=f"#{entry_row + 1}").grid(row=0, column=0, sticky=tk.W, padx=2)
ttk.Label(entry_frame, text="文件名:").grid(row=0, column=1, sticky=tk.W, padx=2)
filename_var = tk.StringVar(value=f"text_{entry_row + 1}")
filename_entry = ttk.Entry(entry_frame, textvariable=filename_var, width=15)
filename_entry.grid(row=0, column=2, sticky=tk.W, padx=2)
ttk.Label(entry_frame, text="文本:").grid(row=0, column=3, sticky=tk.W, padx=2)
text_var = tk.StringVar(value="")
text_entry = ttk.Entry(entry_frame, textvariable=text_var, width=35)
text_entry.grid(row=0, column=4, sticky=(tk.W, tk.E), padx=2)
remove_btn = ttk.Button(entry_frame, text="", width=3, command=lambda: self.remove_text_entry(entry_frame))
remove_btn.grid(row=0, column=5, sticky=tk.W, padx=2)
entry_frame.columnconfigure(4, weight=1)
entry_frame.grid(row=entry_row, column=0, columnspan=2, sticky=(tk.W, tk.E), pady=3)
self.text_entries.append({
'frame': entry_frame,
'filename_var': filename_var,
'text_var': text_var
})
def remove_text_entry(self, frame):
if len(self.text_entries) <= 1:
messagebox.showwarning("提示", "至少保留一个文本条目")
return
for i, entry in enumerate(self.text_entries):
if entry['frame'] == frame:
frame.destroy()
self.text_entries.pop(i)
self.reorder_entries()
break
def reorder_entries(self):
for i, entry in enumerate(self.text_entries):
entry['frame'].grid(row=i, column=0, columnspan=2, sticky=(tk.W, tk.E), pady=3)
for widget in entry['frame'].winfo_children():
if isinstance(widget, ttk.Label) and widget.cget("text").startswith("#"):
widget.config(text=f"#{i + 1}")
break
def log_message(self, message):
self.log_text.config(state=tk.NORMAL)
self.log_text.insert(tk.END, message + "\n")
self.log_text.see(tk.END)
self.log_text.config(state=tk.DISABLED)
logger.info(message)
def start_synthesis(self):
if self.synthesis_in_progress:
messagebox.showwarning("提示", "合成正在进行中,请稍候...")
return
text_list = []
for entry in self.text_entries:
text = entry['text_var'].get().strip()
filename = entry['filename_var'].get().strip()
if text:
text_list.append({
'text': text,
'filename': filename or "output"
})
if not text_list:
messagebox.showwarning("提示", "请输入要合成的文本")
return
api_key = self.api_key_var.get().strip()
group_id = self.group_id_var.get().strip()
if not api_key or not group_id:
messagebox.showwarning("提示", "请填写 API Key 和 Group ID")
return
self.synthesis_in_progress = True
self.synthesize_btn.config(state=tk.DISABLED)
self.play_btn.config(state=tk.DISABLED)
thread = threading.Thread(target=self.run_batch_synthesis, args=(text_list,))
thread.daemon = True
thread.start()
def run_batch_synthesis(self, text_list):
try:
output_dir = Path(self.output_dir_var.get())
output_dir.mkdir(parents=True, exist_ok=True)
self.log_message(f"开始批量合成 {len(text_list)} 个文本...")
loop = asyncio.new_event_loop()
asyncio.set_event_loop(loop)
success_count = 0
last_file = None
for i, item in enumerate(text_list):
output_file = output_dir / f"{item['filename']}.mp3"
self.log_message(f"[{i+1}/{len(text_list)}] 正在合成: {item['text'][:30]}...")
result = loop.run_until_complete(
self.synthesize_speech(
text=item['text'],
output_file=str(output_file),
api_key=self.api_key_var.get(),
group_id=self.group_id_var.get(),
base_url=self.base_url_var.get(),
voice_id=self.voice_id_var.get(),
speed=self.speed_var.get(),
vol=self.vol_var.get(),
pitch=self.pitch_var.get()
)
)
if result:
success_count += 1
last_file = result
self.log_message(f" ✓ 成功: {result}")
else:
self.log_message(f" ✗ 失败")
loop.close()
if success_count > 0:
self.current_audio_file = last_file
self.log_message(f"\n批量合成完成!成功 {success_count}/{len(text_list)}")
self.root.after(0, lambda: self.play_btn.config(state=tk.NORMAL))
self.root.after(0, lambda: messagebox.showinfo(
"合成完成",
f"批量合成完成!\n成功 {success_count}/{len(text_list)}\n输出目录: {output_dir.resolve()}"
))
else:
self.log_message("所有合成都失败了")
self.root.after(0, lambda: messagebox.showerror("错误", "所有合成都失败了,请查看日志"))
except Exception as e:
self.log_message(f"批量合成出错: {str(e)}")
self.root.after(0, lambda: messagebox.showerror("错误", f"批量合成出错: {str(e)}"))
finally:
self.synthesis_in_progress = False
self.root.after(0, lambda: self.synthesize_btn.config(state=tk.NORMAL))
async def synthesize_speech(self, text, output_file, api_key, group_id, base_url, voice_id, speed, vol, pitch):
headers = {
"Authorization": f"Bearer {api_key}",
"Content-Type": "application/json"
}
payload = {
"model": "speech-02-turbo",
"text": text,
"stream": True,
"voice_setting": {
"voice_id": voice_id,
"speed": speed,
"vol": vol,
"pitch": pitch
},
"audio_setting": {
"sample_rate": 16000,
"bitrate": 32000,
"format": "mp3",
"channel": 1
}
}
url = f"{base_url}?GroupId={group_id}"
try:
audio_buffer = bytearray()
async with aiohttp.ClientSession() as session:
async with session.post(url, json=payload, headers=headers) as response:
if response.status != 200:
error_text = await response.text()
self.log_message(f" 请求失败: {response.status}, {error_text}")
return None
async for line in response.content:
line = line.strip()
if line.startswith(b'data:'):
try:
json_str = line[5:].decode('utf-8').strip()
if not json_str:
continue
data_json = json.loads(json_str)
if "data" in data_json and "audio" in data_json["data"]:
status = data_json["data"].get("status", 1)
audio_hex = data_json["data"]["audio"]
if status == 1:
if audio_hex:
audio_binary = binascii.unhexlify(audio_hex)
audio_buffer.extend(audio_binary)
elif status == 2:
break
except json.JSONDecodeError as e:
self.log_message(f" JSON解析失败: {e}")
except Exception as e:
self.log_message(f" 处理响应出错: {str(e)}")
if len(audio_buffer) == 0:
self.log_message(" 音频缓冲区为空")
return None
async with aiofiles.open(output_file, 'wb') as f:
await f.write(audio_buffer)
return output_file
except Exception as e:
self.log_message(f" 合成失败: {str(e)}")
return None
def play_audio(self):
if not self.current_audio_file or not os.path.exists(self.current_audio_file):
messagebox.showwarning("提示", "请先合成音频")
return
try:
if platform.system() == "Darwin":
subprocess.run(["open", self.current_audio_file])
elif platform.system() == "Windows":
os.startfile(self.current_audio_file)
else:
subprocess.run(["xdg-open", self.current_audio_file])
self.log_message(f"正在播放: {self.current_audio_file}")
except Exception as e:
messagebox.showerror("错误", f"播放失败: {str(e)}")
def open_output_folder(self):
output_dir = self.output_dir_var.get()
if not os.path.exists(output_dir):
os.makedirs(output_dir, exist_ok=True)
try:
if platform.system() == "Darwin":
subprocess.run(["open", output_dir])
elif platform.system() == "Windows":
os.startfile(output_dir)
else:
subprocess.run(["xdg-open", output_dir])
except Exception as e:
messagebox.showerror("错误", f"打开目录失败: {str(e)}")
if __name__ == "__main__":
root = tk.Tk()
app = MiniMaxTTSGUI(root)
root.mainloop()

View File

@@ -0,0 +1,260 @@
import os
import asyncio
import aiohttp
import aiofiles
import binascii
import json
import logging
from pathlib import Path
from dotenv import load_dotenv
logging.basicConfig(
level=logging.INFO, format="%(asctime)s - %(name)s - %(levelname)s - %(message)s"
)
logger = logging.getLogger("minimax_tts_test")
# 加载环境变量
env_path = os.path.join(os.path.dirname(os.path.abspath(__file__)), "minimax.env")
if os.path.exists(env_path):
load_dotenv(env_path)
logger.info(f"已加载环境变量文件: {env_path}")
else:
logger.warning(f"环境变量文件不存在: {env_path}")
async def test_minimax_tts():
# 获取配置
api_key = os.getenv("MINIMAX_API_KEY", "")
group_id = os.getenv("MINIMAX_GROUP_ID", "")
base_url = os.getenv("MINIMAX_BASE_URL", "https://api.minimax.chat/v1/t2a_v2")
voice_id = os.getenv("MINIMAX_VOICE_ID", "cartoon-boy-01")
if not api_key or not group_id:
logger.error("缺少必要的配置: MINIMAX_API_KEY 或 MINIMAX_GROUP_ID")
return
# 定义中英文短语对应表
phrases_dict = {
# "upgrading": {
# "zh": "升级中,请勿断电",
# "en": "Upgrading, please do not power off"
# },
# "upgrade_success": {
# "zh": "升级成功,正在重启设备",
# "en": "Upgrade successful, restarting device"
# },
# "upgrade_failed": {
# "zh": "升级失败,正在重启设备",
# "en": "Upgrade failed, restarting device"
# },
# "low_energy": {
# "zh": "感觉没能量了,罢工",
# "en": "Feeling out of energy, going on strike"
# },
# "network_connected": {
# "zh": "连上网络开始上班了",
# "en": "Connected to network, starting work"
# },
# "network_lost": {
# "zh": "没网了,下班了",
# "en": "No network, time to get off work"
# },
# "volume_down": {
# "zh": "音量已调小一点",
# "en": "Volume turned down a bit"
# },
# "max_volume": {
# "zh": "音量已最大",
# "en": "Volume is at maximum"
# },
# "volume_up": {
# "zh": "音量已调大一点",
# "en": "Volume turned up a bit"
# },
# "wakeup": {
# "zh": "我起床了",
# "en": "I'm awake now"
# },
# "enter_network_config": {
# "zh": "进入配网",
# "en": "Entering network configuration"
# },
# "exit_network_config": {
# "zh": "退出配网",
# "en": "Exiting network configuration"
# }
# "error_card":{
# "zh": "你好,卡片有误,请使用本人卡片收听留言"
# },
# "no_message":{
# "zh": "你好,已没可读留言"
# },
# "new_message":{
# "zh": "您有新的留言请注意查收"
# }
# "test":{
# "zh": "橙子我们今天去动物园玩吧?"
# }
# "welcome":{
# "zh": "你好,小朋友,想要和你的好朋友说些什么呢?"
# }
# "tts_error":{
# "zh": "没听到你的声音喔,本轮对话结束"
# },
# "message_ok":{
# "zh": "留言已收到"
# }
# "audio_save_fail":{
# "zh": "留言已收到,但是无法发送。"
# }
# "network_ok":{
# "zh": "已连上网络,可以按键或刷卡跟我对话喔。"
# },
# "network_connect":{
# "zh": "系统启动中,正在等待网络连接。"
# },
# "have_rest":{
# "zh": "小憩一下,待会儿见。"
# }
"bind_nfc_ready":{
"zh": "现在开始刷卡绑定设备吧。"
},
"bind_nfc_finish":{
"zh": "卡片绑定成功。"
}
}
# 创建输出目录
assets_dir = os.getenv("ASSETS_DIR", "assets")
output_dir = Path(assets_dir) / (voice_id + "_tts")
output_dir.mkdir(parents=True, exist_ok=True)
logger.info(f"开始测试MiniMax TTS服务音色: {voice_id}")
# 遍历所有短语进行合成
for phrase_key, phrases in phrases_dict.items():
for lang, text in phrases.items():
await synthesize_speech(
text=text,
language=lang,
file_prefix=f"{phrase_key}_{lang}",
output_dir=output_dir,
api_key=api_key,
group_id=group_id,
base_url=base_url,
voice_id=voice_id
)
async def synthesize_speech(text, language, file_prefix, output_dir, api_key, group_id, base_url, voice_id):
"""合成单个语音文件"""
output_file = output_dir / f"{file_prefix}.mp3"
headers = {
"Authorization": f"Bearer {api_key}",
"Content-Type": "application/json"
}
# 语言映射
lang_mapping = {
"zh": "Chinese",
# "en": "English",
# "fr": "French",
# "de": "German",
# "es": "Spanish",
# "yue": "Chinese,Yue"
}
payload = {
"model": "speech-02-turbo",
"text": text,
"stream": True,
"voice_setting": {
"voice_id": voice_id,
"speed": 1.0,
"vol": 1.0,
"pitch": 0
},
"audio_setting": {
"sample_rate": 16000,
"bitrate": 32000,
"format": "mp3",
"channel": 1
},
"language_boost": lang_mapping.get(language, "auto")
}
url = f"{base_url}?GroupId={group_id}"
try:
logger.info(f"开始合成语音: [{language}] {text}")
audio_buffer = bytearray()
async with aiohttp.ClientSession() as session:
async with session.post(url, json=payload, headers=headers) as response:
if response.status != 200:
error_text = await response.text()
logger.error(f"MiniMax TTS请求失败: {response.status}, {error_text}")
return None
logger.info("开始接收MiniMax TTS流式响应")
line_count = 0
async for line in response.content:
line_count += 1
logger.debug(f"收到第{line_count}行原始数据: {line}")
line = line.strip() # 去除换行符
if line.startswith(b'data:'):
try:
# 解析JSON数据
json_str = line[5:].decode('utf-8').strip()
if not json_str:
logger.debug("空的JSON字符串跳过")
continue
data_json = json.loads(json_str)
if "data" in data_json and "audio" in data_json["data"]:
status = data_json["data"].get("status", 1)
audio_hex = data_json["data"]["audio"]
if status == 1: # 只处理status=1(合成中)的音频数据忽略status=2(合成结束)的汇总数据
if audio_hex:
try:
audio_binary = binascii.unhexlify(audio_hex)
audio_buffer.extend(audio_binary)
except binascii.Error as e:
logger.error(f"音频数据解码失败: {e}")
elif status == 2: # 合成结束
logger.info("MiniMax TTS流式合成完成")
break
else:
logger.warning(f"响应中没有音频数据: {data_json}")
except json.JSONDecodeError as e:
logger.error(f"JSON解析失败: {e}, 原始数据: {line}")
except Exception as e:
logger.error(f"处理MiniMax TTS流式响应出错: {str(e)}")
else:
logger.debug(f"非data行跳过: {line}")
# 检查音频缓冲区大小
if len(audio_buffer) == 0:
logger.error("音频缓冲区为空,合成可能失败")
return None
logger.info(f"音频合成完成,总大小: {len(audio_buffer)} 字节")
# 保存音频文件
async with aiofiles.open(output_file, 'wb') as f:
await f.write(audio_buffer)
logger.info(f"TTS合成成功! 保存音频到: {output_file}")
return str(output_file)
except Exception as e:
logger.error(f"MiniMax TTS合成失败: {str(e)}")
return None
if __name__ == "__main__":
asyncio.run(test_minimax_tts())

View File

@@ -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()