914 lines
37 KiB
Python
914 lines
37 KiB
Python
import asyncio
|
|
import json
|
|
import time
|
|
from datetime import datetime
|
|
from typing import Awaitable, Callable, Dict, Optional
|
|
|
|
import aiomqtt
|
|
|
|
from banban.service.binding import binding_service
|
|
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 banban.service.pending_voice_message import pending_voice_message_service
|
|
from banban.service.sms_notification import sms_notification_service
|
|
from config import settings
|
|
from services.card_service import card_service
|
|
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.device_volume_manager import device_volume_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
|
|
|
|
VOLUME_COMMAND_TIMEOUT_MS = 10 * 1000
|
|
VOLUME_COMMAND_RETENTION_MS = 10 * 60 * 1000
|
|
|
|
|
|
class TalkingQMQTTService:
|
|
_instance = None
|
|
_lock = asyncio.Lock()
|
|
|
|
def __init__(self, config: dict):
|
|
self.broker = config.get("broker", "broker.emqx.io")
|
|
self.port = config.get("port", 1883)
|
|
self.username = config.get("username")
|
|
self.password = config.get("password")
|
|
self.device_prefix = config.get("device_prefix", "TalkingQ")
|
|
self.qos = config.get("qos", 0)
|
|
self.keepalive = config.get("keepalive", 60)
|
|
self.nfc_notice_interval = config.get("nfc_notice_interval", 600)
|
|
|
|
self._client: Optional[aiomqtt.Client] = None
|
|
self._connected = False
|
|
self._connect_lock = asyncio.Lock()
|
|
self._message_task: Optional[asyncio.Task] = None
|
|
self._volume_command_lock = asyncio.Lock()
|
|
self._volume_command_states: Dict[tuple[str, int], dict] = {}
|
|
self._last_volume_command_time_ms = 0
|
|
self._gps_waiter_lock = asyncio.Lock()
|
|
self._gps_waiters: Dict[str, list[asyncio.Future]] = {}
|
|
|
|
self._msg_handlers: Dict[str, Callable[[str, dict], Awaitable[None]]] = {
|
|
"000": self._handle_device_info,
|
|
"001": self._handle_gps_response,
|
|
"002": self._handle_volume_response,
|
|
"003": self._handle_ota_response,
|
|
"004": self._handle_nfc_notice_response,
|
|
"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,
|
|
"012": self._handle_device_identity_init,
|
|
}
|
|
|
|
@classmethod
|
|
async def get_instance(cls, config: dict = None) -> Optional["TalkingQMQTTService"]:
|
|
async with cls._lock:
|
|
if cls._instance is None and config is not None:
|
|
cls._instance = cls(config)
|
|
return cls._instance
|
|
|
|
@classmethod
|
|
async def reset_instance(cls):
|
|
async with cls._lock:
|
|
if cls._instance is not None:
|
|
await cls._instance.disconnect()
|
|
cls._instance = None
|
|
|
|
async def _message_loop(self):
|
|
try:
|
|
async for message in self._client.messages:
|
|
try:
|
|
topic = str(message.topic)
|
|
parts = topic.split("/")
|
|
if not parts or parts[0] != "device":
|
|
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)
|
|
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 exc:
|
|
logger.error("", "", f"mqtt loop failed: {exc}")
|
|
self._connected = False
|
|
|
|
async def _schedule_persistence(self, device_id: str, label: str, coro) -> None:
|
|
async def _runner():
|
|
try:
|
|
await coro
|
|
except Exception as exc:
|
|
logger.error(device_id, "mqtt_persistence", f"{label} failed: {exc}", exc_info=True)
|
|
|
|
await task_manager.create_task(
|
|
_runner(),
|
|
device_id=device_id,
|
|
task_type="persistence",
|
|
)
|
|
|
|
# async def _sync_desired_volume(self, device_id: str, current_level) -> None:
|
|
# if current_level is None:
|
|
# return
|
|
# try:
|
|
# current_level_int = int(current_level)
|
|
# except (TypeError, ValueError):
|
|
# logger.warning(device_id, "volume", f"invalid current volume level: {current_level}")
|
|
# return
|
|
|
|
# desired_level = await device_volume_manager.get_configured_volume(device_id)
|
|
# if desired_level is None or desired_level == current_level_int:
|
|
# return
|
|
|
|
# logger.info(
|
|
# device_id,
|
|
# "volume",
|
|
# f"当前音量 {current_level_int} 与期望音量 {desired_level} 不一致,补发音量指令",
|
|
# )
|
|
# await self.send_volume_command(device_id, desired_level)
|
|
|
|
async def _handle_device_info(self, device_id: str, payload: dict):
|
|
data = payload.get("data", {})
|
|
d_id = data.get("id")
|
|
power = data.get("power")
|
|
signal = data.get("signal")
|
|
version = data.get("version")
|
|
voice = data.get("voice")
|
|
imei = data.get("imei")
|
|
device_type = data.get("device_type")
|
|
latitude = data.get("latitude")
|
|
longitude = data.get("longitude")
|
|
logger.info(
|
|
device_id,
|
|
"",
|
|
(
|
|
f"[设备信息] 设备 {d_id} 信息: 电量={power}, 信号强度={signal}, "
|
|
f"版本号={version}, 音量={voice}, IMEI={imei}, 设备类型={device_type}"
|
|
),
|
|
)
|
|
|
|
await self._schedule_persistence(
|
|
device_id,
|
|
"device_info",
|
|
device_setting_service.insert_or_update(
|
|
device_id=device_id,
|
|
power=data.get("power"),
|
|
signal_strength=data.get("signal"),
|
|
version_str=data.get("version"),
|
|
volume=data.get("voice"),
|
|
imei=str(imei).strip() if imei is not None and str(imei).strip() else None,
|
|
device_type=device_type,
|
|
),
|
|
)
|
|
if latitude is not None and longitude is not None:
|
|
await self._schedule_persistence(
|
|
device_id,
|
|
"device_info_location",
|
|
location_service.report_mqtt_device_location(
|
|
device_id=device_id,
|
|
latitude=latitude,
|
|
longitude=longitude,
|
|
coord_type=data.get("coord_type"),
|
|
accuracy_m=data.get("accuracy_m"),
|
|
altitude_m=data.get("altitude_m"),
|
|
speed_mps=data.get("speed_mps"),
|
|
heading_deg=data.get("heading_deg"),
|
|
source=data.get("source", 0),
|
|
battery_pct=data.get("battery_pct") or data.get("power"),
|
|
),
|
|
)
|
|
# await self._sync_desired_volume(device_id, current_volume)
|
|
await self._publish(f"device/{device_id}/event_resp", {"msg_id": "000", "status": "success"})
|
|
|
|
async def _handle_gps_response(self, device_id: str, payload: dict):
|
|
if payload.get("status") != "success":
|
|
logger.warning(device_id, "", f"[GPS] query failed: {payload}")
|
|
return
|
|
|
|
data = payload.get("data", {})
|
|
raw_device_time = data.get("device_time")
|
|
parsed_device_time = None
|
|
if isinstance(raw_device_time, str) and raw_device_time.strip():
|
|
try:
|
|
parsed_device_time = datetime.fromisoformat(raw_device_time.strip())
|
|
except ValueError:
|
|
parsed_device_time = None
|
|
lat = data.get("latitude")
|
|
lon = data.get("longitude")
|
|
logger.info(device_id, "", f"[GPS] 设备 {device_id} 位置: 纬度={lat}, 经度={lon}")
|
|
|
|
async def _persist_gps_and_notify():
|
|
row = await location_service.report_mqtt_device_location(
|
|
device_id=device_id,
|
|
latitude=data.get("latitude"),
|
|
longitude=data.get("longitude"),
|
|
coord_type=data.get("coord_type"),
|
|
accuracy_m=data.get("accuracy_m"),
|
|
altitude_m=data.get("altitude_m"),
|
|
speed_mps=data.get("speed_mps"),
|
|
heading_deg=data.get("heading_deg"),
|
|
source=data.get("source"),
|
|
battery_pct=data.get("battery_pct"),
|
|
device_time=parsed_device_time,
|
|
)
|
|
await self._notify_gps_waiters(device_id, row)
|
|
|
|
await self._schedule_persistence(device_id, "gps", _persist_gps_and_notify())
|
|
|
|
async def _handle_volume_response(self, device_id: str, payload: dict):
|
|
command_time = self._parse_command_time(payload.get("time"))
|
|
if payload.get("status") != "success":
|
|
await self._mark_volume_command_response(
|
|
device_id=device_id,
|
|
command_time=command_time,
|
|
status="failed",
|
|
response_status=payload.get("status"),
|
|
error=str(payload),
|
|
)
|
|
logger.warning(device_id, "", f"[volume] command failed: {payload}")
|
|
return
|
|
|
|
data = payload.get("data", {})
|
|
current_level = data.get("current_level")
|
|
if current_level is None:
|
|
await self._mark_volume_command_response(
|
|
device_id=device_id,
|
|
command_time=command_time,
|
|
status="failed",
|
|
response_status=payload.get("status"),
|
|
error="missing current_level",
|
|
)
|
|
return
|
|
logger.info(device_id, "", f"[音量] 设备 {device_id} 当前音量: {current_level}")
|
|
await self._mark_volume_command_response(
|
|
device_id=device_id,
|
|
command_time=command_time,
|
|
status="completed",
|
|
current_level=current_level,
|
|
response_status=payload.get("status"),
|
|
error=None,
|
|
)
|
|
|
|
await self._schedule_persistence(
|
|
device_id,
|
|
"volume",
|
|
device_setting_service.insert_or_update(
|
|
device_id=device_id,
|
|
power=None,
|
|
volume=current_level,
|
|
signal_strength=None,
|
|
version_str=None,
|
|
),
|
|
)
|
|
# await self._sync_desired_volume(device_id, current_level)
|
|
|
|
async def _handle_ota_response(self, device_id: str, payload: dict):
|
|
status = payload.get("status")
|
|
data = payload.get("data", {})
|
|
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":
|
|
current = data.get("current_version")
|
|
target = data.get("target_version")
|
|
logger.info(device_id, "", f"[OTA] 设备 {device_id} 已接受升级: {current} -> {target}")
|
|
|
|
await self._schedule_persistence(
|
|
device_id,
|
|
"ota",
|
|
device_firmware_update_manager.update_firmware_update(
|
|
device_id=device_id,
|
|
firmware_version=data.get("target_version") or data.get("version") or data.get("new_version") or "unknown",
|
|
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")
|
|
logger.info(device_id, "", f"[OTA] 设备 {device_id} 升级完成: {new_version}")
|
|
|
|
await self._schedule_persistence(
|
|
device_id,
|
|
"ota",
|
|
device_setting_service.insert_or_update(
|
|
device_id=device_id,
|
|
power=None,
|
|
volume=None,
|
|
signal_strength=None,
|
|
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,
|
|
),
|
|
)
|
|
logger.warning(device_id, "", f"[OTA] command failed: {payload}")
|
|
else:
|
|
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")
|
|
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")
|
|
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)
|
|
result = await binding_service.finalize_nfc_bind(device_id=device_id, card_uuid=nfc_uuid)
|
|
if result is None:
|
|
logger.warning(device_id, "", "[NFC bind] no pending bind session found")
|
|
return
|
|
|
|
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} 休眠时间设置成功")
|
|
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}")
|
|
|
|
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} 发送紧急报警")
|
|
async def _record_alarm_and_notify():
|
|
alarm_id = await device_alarm_service.record_alarm_event(
|
|
device_id=device_id,
|
|
source_msg_id="010",
|
|
resolve_address=False,
|
|
)
|
|
if alarm_id:
|
|
location = await self.query_gps_and_wait_for_location(
|
|
device_id,
|
|
timeout_seconds=settings.alarm_gps_query_timeout_seconds,
|
|
)
|
|
if location and location.get("lat") is not None and location.get("lng") is not None:
|
|
await device_alarm_service.apply_alarm_location(
|
|
alarm_id=alarm_id,
|
|
device_id=device_id,
|
|
location=location,
|
|
)
|
|
else:
|
|
await device_alarm_service.resolve_alarm_current_location(
|
|
alarm_id=alarm_id,
|
|
device_id=device_id,
|
|
)
|
|
sms_notification_service.schedule_alarm_notification(device_id=device_id, alarm_id=alarm_id)
|
|
|
|
await self._schedule_persistence(device_id, "alarm_event", _record_alarm_and_notify())
|
|
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", {})
|
|
nfc_uuid = str(params.get("uuid") or "").strip()
|
|
logger.info(device_id, "", f"[短按留言] 设备 {device_id} 短按发送留言, UUID={nfc_uuid}")
|
|
if nfc_uuid == device_id:
|
|
await device_target_cache.set_parent_target(device_id, nfc_uuid)
|
|
response_payload = {
|
|
"msg_id": "011",
|
|
"status": "success",
|
|
"type": 0,
|
|
"params": {
|
|
"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)
|
|
return
|
|
|
|
media_file_key = str(params.get("media_file_key") or params.get("audio_url") or "").strip()
|
|
if media_file_key:
|
|
try:
|
|
device_identity, _ = 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,
|
|
)
|
|
sms_notification_service.schedule_leave_message_notification(
|
|
device_id=device_id,
|
|
child_name=device_identity.child_name,
|
|
)
|
|
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": f"http://{settings.server_host}:{settings.server_port}/assets/audio/parent_online_zh.mp3"
|
|
},
|
|
},
|
|
)
|
|
return
|
|
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": "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):
|
|
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)
|
|
if not card:
|
|
payload = {
|
|
"msg_id": "005",
|
|
"type": 0,
|
|
"params": {
|
|
"url_1": f"http://{settings.server_host}:{settings.server_port}/assets/audio/error_card.mp3"
|
|
}
|
|
}
|
|
await self._publish(topic, payload)
|
|
return
|
|
|
|
is_owner = await card_service.is_active_for_device(nfc_uuid, device_id)
|
|
if is_owner:
|
|
playback_items = await pending_voice_message_service.get_playback_items(
|
|
target_device_id=device_id,
|
|
)
|
|
if playback_items:
|
|
params = {
|
|
f"url_{k}": item.audio_url
|
|
for k, item in enumerate(playback_items, start=1)
|
|
}
|
|
payload = {"msg_id": "005", "type": 0, "params": params}
|
|
published = await self._publish(topic, payload)
|
|
if published:
|
|
await pending_voice_message_service.mark_delivered(
|
|
[item.pending_id for item in playback_items]
|
|
)
|
|
await offline_audio_cache.clear_audio_urls(device_id)
|
|
return
|
|
|
|
latest_item = await pending_voice_message_service.get_latest_history_playback_item(
|
|
target_device_id=device_id,
|
|
)
|
|
if latest_item:
|
|
payload = {
|
|
"msg_id": "005",
|
|
"type": 0,
|
|
"params": {"url_1": latest_item.audio_url},
|
|
}
|
|
await self._publish(topic, payload)
|
|
logger.info(
|
|
device_id,
|
|
"pending_voice",
|
|
(
|
|
"待收听队列为空,回放最近历史留言: "
|
|
f"im_message_id={latest_item.im_message_id}, "
|
|
f"media_file_key={latest_item.media_file_key}"
|
|
),
|
|
)
|
|
return
|
|
|
|
audio_urls = await offline_audio_cache.pop_audio_urls(device_id)
|
|
if audio_urls:
|
|
params = {f"url_{k}": audio_url for k, audio_url in enumerate(audio_urls, start=1)}
|
|
payload = {"msg_id": "005", "type": 0, "params": params}
|
|
await self._publish(topic, payload)
|
|
await offline_audio_cache.add_audio_url(device_id, audio_urls[-1])
|
|
return
|
|
|
|
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)
|
|
return
|
|
|
|
existing_card = await card_service.get_card_by_uuid(nfc_uuid)
|
|
if existing_card and existing_card.device_id and int(existing_card.status) == 1:
|
|
target_device_id = existing_card.device_id
|
|
else:
|
|
payload = {
|
|
"msg_id": "005",
|
|
"type": 0,
|
|
"params": {
|
|
"url_1": f"http://{settings.server_host}:{settings.server_port}/assets/audio/error_card.mp3"
|
|
},
|
|
}
|
|
await self._publish(topic, payload)
|
|
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:
|
|
if self._connected:
|
|
return
|
|
try:
|
|
self._client = aiomqtt.Client(
|
|
hostname=self.broker,
|
|
port=self.port,
|
|
username=self.username,
|
|
password=self.password,
|
|
identifier=f"talkingq_server_{int(time.time())}",
|
|
keepalive=self.keepalive,
|
|
)
|
|
await self._client.__aenter__()
|
|
self._connected = True
|
|
|
|
await self._client.subscribe("device/+/response", qos=self.qos)
|
|
await self._client.subscribe("device/+/event", qos=self.qos)
|
|
|
|
self._message_task = asyncio.create_task(self._message_loop())
|
|
logger.info("", "", f"mqtt connected: {self.broker}:{self.port}")
|
|
except Exception as exc:
|
|
self._connected = False
|
|
logger.error("", "", f"mqtt connect failed: {exc}")
|
|
|
|
async def disconnect(self):
|
|
if self._message_task and not self._message_task.done():
|
|
self._message_task.cancel()
|
|
try:
|
|
await self._message_task
|
|
except asyncio.CancelledError:
|
|
pass
|
|
self._message_task = None
|
|
|
|
if self._client is not None:
|
|
try:
|
|
await self._client.__aexit__(None, None, None)
|
|
except Exception:
|
|
pass
|
|
self._client = None
|
|
self._connected = False
|
|
|
|
async def _ensure_connected(self):
|
|
if not self._connected:
|
|
await self.connect()
|
|
|
|
async def _publish(self, topic: str, payload: dict) -> bool:
|
|
await self._ensure_connected()
|
|
try:
|
|
await self._client.publish(topic, json.dumps(payload, ensure_ascii=False), qos=self.qos)
|
|
logger.info("", "", f"mqtt publish topic={topic} payload={payload}")
|
|
return True
|
|
except Exception as exc:
|
|
logger.error("", "", f"mqtt publish failed: {exc}")
|
|
return False
|
|
|
|
async def send_gps_query(self, device_id: str) -> str:
|
|
await self._publish(f"device/{device_id}/command", {"msg_id": "001"})
|
|
return "001"
|
|
|
|
async def query_gps_and_wait_for_location(self, device_id: str, *, timeout_seconds: float) -> dict | None:
|
|
timeout = max(0.1, float(timeout_seconds or 0))
|
|
loop = asyncio.get_running_loop()
|
|
future = loop.create_future()
|
|
async with self._gps_waiter_lock:
|
|
self._gps_waiters.setdefault(device_id, []).append(future)
|
|
|
|
try:
|
|
await self.send_gps_query(device_id)
|
|
return await asyncio.wait_for(future, timeout=timeout)
|
|
except asyncio.TimeoutError:
|
|
return None
|
|
finally:
|
|
async with self._gps_waiter_lock:
|
|
waiters = self._gps_waiters.get(device_id)
|
|
if waiters and future in waiters:
|
|
waiters.remove(future)
|
|
if waiters == []:
|
|
self._gps_waiters.pop(device_id, None)
|
|
|
|
async def _notify_gps_waiters(self, device_id: str, row: dict | None) -> None:
|
|
if row is None:
|
|
return
|
|
async with self._gps_waiter_lock:
|
|
waiters = self._gps_waiters.pop(device_id, [])
|
|
|
|
for future in waiters:
|
|
if not future.done():
|
|
future.set_result(dict(row))
|
|
|
|
def _parse_command_time(self, value) -> int | None:
|
|
if value is None:
|
|
return None
|
|
try:
|
|
command_time = int(value)
|
|
except (TypeError, ValueError):
|
|
return None
|
|
return command_time if command_time > 0 else None
|
|
|
|
def _current_utc_timestamp_ms(self) -> int:
|
|
return int(time.time() * 1000)
|
|
|
|
async def _next_volume_command_time(self) -> int:
|
|
async with self._volume_command_lock:
|
|
command_time = max(self._current_utc_timestamp_ms(), self._last_volume_command_time_ms + 1)
|
|
self._last_volume_command_time_ms = command_time
|
|
return command_time
|
|
|
|
async def _record_volume_command(self, *, device_id: str, level: int, command_time: int) -> dict:
|
|
state = {
|
|
"device_id": device_id,
|
|
"msg_id": "002",
|
|
"level": level,
|
|
"time": command_time,
|
|
"status": "sent",
|
|
"current_level": None,
|
|
"response_status": None,
|
|
"error": None,
|
|
"created_at": command_time,
|
|
"updated_at": command_time,
|
|
}
|
|
async with self._volume_command_lock:
|
|
self._volume_command_states[(device_id, command_time)] = dict(state)
|
|
self._cleanup_volume_command_states_locked()
|
|
return state
|
|
|
|
def _cleanup_volume_command_states_locked(self):
|
|
cutoff = self._current_utc_timestamp_ms() - VOLUME_COMMAND_RETENTION_MS
|
|
expired_keys = [
|
|
key
|
|
for key, state in self._volume_command_states.items()
|
|
if int(state.get("updated_at") or state.get("created_at") or 0) < cutoff
|
|
]
|
|
for key in expired_keys:
|
|
self._volume_command_states.pop(key, None)
|
|
|
|
async def _mark_volume_command_response(
|
|
self,
|
|
*,
|
|
device_id: str,
|
|
command_time: int | None,
|
|
status: str,
|
|
current_level=None,
|
|
response_status=None,
|
|
error: str | None,
|
|
) -> None:
|
|
if command_time is None:
|
|
return
|
|
async with self._volume_command_lock:
|
|
state = self._volume_command_states.get((device_id, command_time))
|
|
if not state:
|
|
return
|
|
state.update(
|
|
{
|
|
"status": status,
|
|
"current_level": current_level,
|
|
"response_status": response_status,
|
|
"error": error,
|
|
"updated_at": self._current_utc_timestamp_ms(),
|
|
}
|
|
)
|
|
|
|
async def get_volume_command_status(self, device_id: str, command_time: int) -> dict | None:
|
|
async with self._volume_command_lock:
|
|
self._cleanup_volume_command_states_locked()
|
|
state = self._volume_command_states.get((device_id, command_time))
|
|
if state and state.get("status") == "sent":
|
|
now = self._current_utc_timestamp_ms()
|
|
if now - int(state.get("created_at") or command_time) > VOLUME_COMMAND_TIMEOUT_MS:
|
|
state.update(
|
|
{
|
|
"status": "timeout",
|
|
"error": "device response timeout",
|
|
"updated_at": now,
|
|
}
|
|
)
|
|
return dict(state) if state else None
|
|
|
|
async def send_volume_command(self, device_id: str, level: int) -> dict:
|
|
command_time = await self._next_volume_command_time()
|
|
state = await self._record_volume_command(device_id=device_id, level=level, command_time=command_time)
|
|
published = await self._publish(
|
|
f"device/{device_id}/command",
|
|
{"msg_id": "002", "params": {"level": level}, "time": command_time},
|
|
)
|
|
if not published:
|
|
await self._mark_volume_command_response(
|
|
device_id=device_id,
|
|
command_time=command_time,
|
|
status="failed",
|
|
response_status=None,
|
|
error="publish failed",
|
|
)
|
|
state["status"] = "failed"
|
|
state["error"] = "publish failed"
|
|
state["updated_at"] = self._current_utc_timestamp_ms()
|
|
return state
|
|
|
|
async def send_ota_command(self, device_id: str, url: str, version: str) -> str:
|
|
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:
|
|
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) -> 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:
|
|
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
|
|
}
|
|
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"
|