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