294 lines
9.7 KiB
Python
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())
|