Files
banban/talkingq-url/scripts/simulate_nfc_pending_voice_delivery.py

294 lines
9.7 KiB
Python

#!/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())