持久化设备留言待收听状态并支持重启恢复
This commit is contained in:
293
talkingq-url/scripts/simulate_nfc_pending_voice_delivery.py
Normal file
293
talkingq-url/scripts/simulate_nfc_pending_voice_delivery.py
Normal file
@@ -0,0 +1,293 @@
|
||||
#!/usr/bin/env python3
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import asyncio
|
||||
import json
|
||||
import sys
|
||||
import time
|
||||
import uuid
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
import aiomqtt
|
||||
import pymysql
|
||||
from pymysql.cursors import DictCursor
|
||||
|
||||
PROJECT_ROOT = Path(__file__).resolve().parents[1]
|
||||
if str(PROJECT_ROOT) not in sys.path:
|
||||
sys.path.insert(0, str(PROJECT_ROOT))
|
||||
|
||||
from config import settings
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class DeviceCardContext:
|
||||
device_id: str
|
||||
serial_number: str
|
||||
child_id: int
|
||||
card_uuid: str
|
||||
|
||||
|
||||
def parse_args() -> argparse.Namespace:
|
||||
parser = argparse.ArgumentParser(
|
||||
description="Simulate a device NFC listen event and verify pending voice delivery through MQTT."
|
||||
)
|
||||
parser.add_argument("--device-id", default="TalkingQ_XQSN00001001")
|
||||
parser.add_argument("--broker", default=settings.talkingq_mqtt_broker or "127.0.0.1")
|
||||
parser.add_argument("--port", type=int, default=settings.talkingq_mqtt_port or 1883)
|
||||
parser.add_argument("--username", default=settings.talkingq_mqtt_username or None)
|
||||
parser.add_argument("--password", default=settings.talkingq_mqtt_password or None)
|
||||
parser.add_argument("--timeout", type=float, default=10.0)
|
||||
parser.add_argument("--audio-url", default="http://127.0.0.1:8080/assets/audio/welcome.mp3")
|
||||
parser.add_argument("--media-file-key", default="integration/nfc-pending-voice.mp3")
|
||||
parser.add_argument("--keep-row", action="store_true", help="Keep the synthetic pending row after verification.")
|
||||
return parser.parse_args()
|
||||
|
||||
|
||||
def connect_db():
|
||||
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,
|
||||
autocommit=True,
|
||||
)
|
||||
|
||||
|
||||
def ensure_pending_table_exists() -> None:
|
||||
with connect_db() as connection:
|
||||
with connection.cursor() as cursor:
|
||||
cursor.execute(
|
||||
"""
|
||||
SELECT COUNT(*) AS cnt
|
||||
FROM information_schema.tables
|
||||
WHERE table_schema = %s
|
||||
AND table_name = 'device_pending_voice_messages'
|
||||
""",
|
||||
(settings.db_name,),
|
||||
)
|
||||
row = cursor.fetchone()
|
||||
if int(row["cnt"]) == 0:
|
||||
raise RuntimeError(
|
||||
"device_pending_voice_messages table does not exist; start the backend once or run init_db first"
|
||||
)
|
||||
|
||||
|
||||
def load_device_context(device_id: str) -> DeviceCardContext:
|
||||
with connect_db() as connection:
|
||||
with connection.cursor() as cursor:
|
||||
cursor.execute(
|
||||
"""
|
||||
SELECT
|
||||
da.device_id,
|
||||
da.serial_number,
|
||||
db.child_id,
|
||||
c.card_uuid
|
||||
FROM device_auth AS da
|
||||
LEFT JOIN device_bindings AS db
|
||||
ON db.device_id = da.device_id
|
||||
AND db.status = 1
|
||||
LEFT JOIN cards AS c
|
||||
ON c.device_id = da.device_id
|
||||
AND c.status = 1
|
||||
WHERE da.device_id = %s
|
||||
AND da.is_active = 1
|
||||
LIMIT 1
|
||||
""",
|
||||
(device_id,),
|
||||
)
|
||||
row = cursor.fetchone()
|
||||
|
||||
if not row:
|
||||
raise RuntimeError(f"active device_auth row not found for {device_id}")
|
||||
if row["child_id"] is None:
|
||||
raise RuntimeError(f"device {device_id} is not bound to any child")
|
||||
if not row["card_uuid"]:
|
||||
raise RuntimeError(f"device {device_id} does not have an active owner card")
|
||||
return DeviceCardContext(
|
||||
device_id=str(row["device_id"]),
|
||||
serial_number=str(row["serial_number"]),
|
||||
child_id=int(row["child_id"]),
|
||||
card_uuid=str(row["card_uuid"]),
|
||||
)
|
||||
|
||||
|
||||
def insert_synthetic_pending(ctx: DeviceCardContext, *, audio_url: str, media_file_key: str) -> int:
|
||||
synthetic_message_id = int(time.time() * 1000)
|
||||
source = f"integration_nfc_{uuid.uuid4().hex[:10]}"
|
||||
with connect_db() as connection:
|
||||
with connection.cursor() as cursor:
|
||||
cursor.execute(
|
||||
"""
|
||||
INSERT INTO device_pending_voice_messages (
|
||||
target_device_id,
|
||||
sender_device_id,
|
||||
im_message_id,
|
||||
media_file_key,
|
||||
audio_url,
|
||||
source,
|
||||
status
|
||||
) VALUES (%s, %s, %s, %s, %s, %s, 'pending')
|
||||
""",
|
||||
(
|
||||
ctx.device_id,
|
||||
"integration-sender",
|
||||
synthetic_message_id,
|
||||
media_file_key,
|
||||
audio_url,
|
||||
source,
|
||||
),
|
||||
)
|
||||
pending_id = int(cursor.lastrowid)
|
||||
print(
|
||||
json.dumps(
|
||||
{
|
||||
"inserted_pending_id": pending_id,
|
||||
"device_id": ctx.device_id,
|
||||
"im_message_id": synthetic_message_id,
|
||||
"source": source,
|
||||
},
|
||||
ensure_ascii=False,
|
||||
),
|
||||
flush=True,
|
||||
)
|
||||
return pending_id
|
||||
|
||||
|
||||
def fetch_pending_row(pending_id: int) -> dict[str, Any] | None:
|
||||
with connect_db() as connection:
|
||||
with connection.cursor() as cursor:
|
||||
cursor.execute(
|
||||
"""
|
||||
SELECT
|
||||
id,
|
||||
target_device_id,
|
||||
im_message_id,
|
||||
audio_url,
|
||||
source,
|
||||
status,
|
||||
delivery_count,
|
||||
delivered_at
|
||||
FROM device_pending_voice_messages
|
||||
WHERE id = %s
|
||||
""",
|
||||
(pending_id,),
|
||||
)
|
||||
return cursor.fetchone()
|
||||
|
||||
|
||||
async def wait_for_pending_status(
|
||||
pending_id: int,
|
||||
*,
|
||||
expected_status: str,
|
||||
timeout: float,
|
||||
) -> dict[str, Any]:
|
||||
deadline = asyncio.get_running_loop().time() + timeout
|
||||
last_row = None
|
||||
while asyncio.get_running_loop().time() < deadline:
|
||||
last_row = fetch_pending_row(pending_id)
|
||||
if not last_row:
|
||||
raise RuntimeError(f"pending row disappeared unexpectedly: id={pending_id}")
|
||||
if last_row["status"] == expected_status:
|
||||
return last_row
|
||||
await asyncio.sleep(0.2)
|
||||
raise RuntimeError(
|
||||
f"expected pending row status {expected_status}, "
|
||||
f"got {last_row['status'] if last_row else 'missing'}"
|
||||
)
|
||||
|
||||
|
||||
def delete_pending_row(pending_id: int) -> None:
|
||||
with connect_db() as connection:
|
||||
with connection.cursor() as cursor:
|
||||
cursor.execute("DELETE FROM device_pending_voice_messages WHERE id = %s", (pending_id,))
|
||||
|
||||
|
||||
async def wait_for_delivery_response(client: aiomqtt.Client, *, timeout: float) -> dict[str, Any]:
|
||||
deadline = asyncio.get_running_loop().time() + timeout
|
||||
async for message in client.messages:
|
||||
remaining = deadline - asyncio.get_running_loop().time()
|
||||
if remaining <= 0:
|
||||
break
|
||||
try:
|
||||
payload = json.loads(message.payload.decode("utf-8"))
|
||||
except json.JSONDecodeError:
|
||||
continue
|
||||
print(
|
||||
json.dumps(
|
||||
{
|
||||
"received_topic": str(message.topic),
|
||||
"payload": payload,
|
||||
},
|
||||
ensure_ascii=False,
|
||||
),
|
||||
flush=True,
|
||||
)
|
||||
if payload.get("msg_id") == "005" and payload.get("type") == 0:
|
||||
params = payload.get("params") or {}
|
||||
if any(str(key).startswith("url_") for key in params):
|
||||
return payload
|
||||
raise TimeoutError("timed out waiting for msg_id=005 delivery response")
|
||||
|
||||
|
||||
async def run() -> None:
|
||||
args = parse_args()
|
||||
ensure_pending_table_exists()
|
||||
ctx = load_device_context(args.device_id)
|
||||
pending_id = insert_synthetic_pending(
|
||||
ctx,
|
||||
audio_url=args.audio_url,
|
||||
media_file_key=args.media_file_key,
|
||||
)
|
||||
|
||||
try:
|
||||
event_topic = f"device/{ctx.device_id}/event"
|
||||
response_topic = f"device/{ctx.device_id}/event_resp"
|
||||
nfc_payload = {"msg_id": "005", "params": {"uuid": ctx.card_uuid}}
|
||||
async with aiomqtt.Client(
|
||||
hostname=args.broker,
|
||||
port=args.port,
|
||||
username=args.username,
|
||||
password=args.password,
|
||||
) as client:
|
||||
await client.subscribe(response_topic)
|
||||
await client.publish(event_topic, json.dumps(nfc_payload, ensure_ascii=False))
|
||||
print(
|
||||
json.dumps(
|
||||
{
|
||||
"published_topic": event_topic,
|
||||
"payload": nfc_payload,
|
||||
"subscribed_topic": response_topic,
|
||||
},
|
||||
ensure_ascii=False,
|
||||
),
|
||||
flush=True,
|
||||
)
|
||||
response = await asyncio.wait_for(
|
||||
wait_for_delivery_response(client, timeout=args.timeout),
|
||||
timeout=args.timeout,
|
||||
)
|
||||
|
||||
row = await wait_for_pending_status(
|
||||
pending_id,
|
||||
expected_status="delivered",
|
||||
timeout=args.timeout,
|
||||
)
|
||||
print(json.dumps({"pending_after_delivery": row}, ensure_ascii=False, default=str), flush=True)
|
||||
if int(row["delivery_count"]) != 1:
|
||||
raise RuntimeError(f"expected delivery_count 1, got {row['delivery_count']}")
|
||||
params = response.get("params") or {}
|
||||
if args.audio_url not in params.values():
|
||||
raise RuntimeError(f"expected delivered url {args.audio_url}, got {params}")
|
||||
finally:
|
||||
if not args.keep_row:
|
||||
delete_pending_row(pending_id)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
asyncio.run(run())
|
||||
Reference in New Issue
Block a user