From 326e4bac28a88b2cfff1b617904c479f5e82de92 Mon Sep 17 00:00:00 2001 From: HycJack <772403255@qq.com> Date: Wed, 6 May 2026 03:33:28 +0800 Subject: [PATCH] add test-clean code merge --- talkingq-url/banban/dao/binding.py | 16 + talkingq-url/banban/dao/child.py | 29 +- talkingq-url/banban/dao/device.py | 56 +- talkingq-url/banban/dao/im.py | 17 +- talkingq-url/banban/dao/location.py | 20 +- talkingq-url/banban/routers/devices.py | 112 +++- talkingq-url/banban/routers/im.py | 60 +- talkingq-url/banban/routers/wechat_auth.py | 20 +- talkingq-url/banban/service/binding.py | 12 + talkingq-url/banban/service/device.py | 32 +- .../banban/service/device_voice_archive.py | 427 +++++++++++++ talkingq-url/banban/service/im.py | 86 +++ talkingq-url/banban/service/location.py | 60 +- .../banban/service/message_audio_storage.py | 9 + talkingq-url/database/models.py | 7 +- talkingq-url/handlers/mqtt_handler.py | 128 ++-- talkingq-url/requirements.txt | 2 +- talkingq-url/scripts/_vendor/qrcode/LICENSE | 48 ++ talkingq-url/scripts/_vendor/qrcode/LUT.py | 223 +++++++ .../scripts/_vendor/qrcode/__init__.py | 3 + talkingq-url/scripts/_vendor/qrcode/base.py | 313 ++++++++++ .../scripts/_vendor/qrcode/constants.py | 5 + .../scripts/_vendor/qrcode/exceptions.py | 2 + .../scripts/_vendor/qrcode/image/__init__.py | 1 + .../scripts/_vendor/qrcode/image/base.py | 2 + .../scripts/_vendor/qrcode/image/pure.py | 5 + talkingq-url/scripts/_vendor/qrcode/main.py | 541 ++++++++++++++++ talkingq-url/scripts/_vendor/qrcode/util.py | 584 ++++++++++++++++++ talkingq-url/scripts/generate_bind_qr.py | 149 +++++ .../scripts/simulate_device_voice_exchange.py | 348 +++++++++++ talkingq-url/utils/ audio_format.py | 33 + 31 files changed, 3275 insertions(+), 75 deletions(-) create mode 100644 talkingq-url/banban/service/device_voice_archive.py create mode 100644 talkingq-url/scripts/_vendor/qrcode/LICENSE create mode 100644 talkingq-url/scripts/_vendor/qrcode/LUT.py create mode 100644 talkingq-url/scripts/_vendor/qrcode/__init__.py create mode 100644 talkingq-url/scripts/_vendor/qrcode/base.py create mode 100644 talkingq-url/scripts/_vendor/qrcode/constants.py create mode 100644 talkingq-url/scripts/_vendor/qrcode/exceptions.py create mode 100644 talkingq-url/scripts/_vendor/qrcode/image/__init__.py create mode 100644 talkingq-url/scripts/_vendor/qrcode/image/base.py create mode 100644 talkingq-url/scripts/_vendor/qrcode/image/pure.py create mode 100644 talkingq-url/scripts/_vendor/qrcode/main.py create mode 100644 talkingq-url/scripts/_vendor/qrcode/util.py create mode 100644 talkingq-url/scripts/generate_bind_qr.py create mode 100644 talkingq-url/scripts/simulate_device_voice_exchange.py create mode 100644 talkingq-url/utils/ audio_format.py diff --git a/talkingq-url/banban/dao/binding.py b/talkingq-url/banban/dao/binding.py index 73d5383..aa9b192 100644 --- a/talkingq-url/banban/dao/binding.py +++ b/talkingq-url/banban/dao/binding.py @@ -34,6 +34,20 @@ class BindingDAO(BaseDAO): ) ).mappings().first() + async def get_active_binding_by_device(self, device_id: str) -> Optional[Mapping]: + return ( + await self.execute( + """ + SELECT id, device_id, owner_user_id, child_id, status + FROM device_bindings + WHERE device_id = :device_id + AND status = 1 + LIMIT 1 + """, + {"device_id": device_id}, + ) + ).mappings().first() + async def _clear_child_from_binding(self, binding_row: Mapping[str, object]) -> None: row_id = int(binding_row["id"]) status = int(binding_row["status"]) @@ -372,6 +386,8 @@ class BindingDAO(BaseDAO): row = await self.get_by_device(device_id=device_id, user_id=user_id) if not row: return False + if row["child_id"] is not None: + return False await self._upsert_parent_child_relation(user_id=user_id, child_id=child_id) await self._bind_device(device_id=device_id, user_id=user_id, child_id=child_id) diff --git a/talkingq-url/banban/dao/child.py b/talkingq-url/banban/dao/child.py index 8a24221..71233ac 100644 --- a/talkingq-url/banban/dao/child.py +++ b/talkingq-url/banban/dao/child.py @@ -90,18 +90,17 @@ class ChildDAO(BaseDAO): await self.commit() async def has_access(self, child_id: int, user_id: int) -> bool: - return ( - await self.execute( - """ - SELECT 1 - FROM children AS c - JOIN parent_child_relations AS pcr - ON pcr.child_id = c.child_id - WHERE c.child_id = :child_id - AND pcr.user_id = :user_id - AND c.status = 1 - AND pcr.status = 1 - """, - {"child_id": child_id, "user_id": user_id}, - ) - ).scalar_one_or_none() is not None + result = await self.execute( + """ + SELECT 1 + FROM children AS c + JOIN parent_child_relations AS pcr + ON pcr.child_id = c.child_id + WHERE c.child_id = :child_id + AND pcr.user_id = :user_id + AND c.status = 1 + AND pcr.status = 1 + """, + {"child_id": child_id, "user_id": user_id}, + ) + return result.scalar_one_or_none() is not None diff --git a/talkingq-url/banban/dao/device.py b/talkingq-url/banban/dao/device.py index 91373ec..517884d 100644 --- a/talkingq-url/banban/dao/device.py +++ b/talkingq-url/banban/dao/device.py @@ -60,4 +60,58 @@ class DeviceDAO(BaseDAO): ), params, ) - return result.mappings().all() \ No newline at end of file + return result.mappings().all() + + async def get_device_status( + self, + *, + device_id: str, + user_id: int, + ) -> Mapping[str, Any]: + from fastapi import HTTPException + + result = await self.execute( + text( + """ + SELECT + db.device_id, + db.child_id, + c.child_name, + ds.power, + ds.volume, + ds.`signal` AS signal_strength, + ds.`version` AS version, + ds.updated_at AS settings_updated_at, + cl.coord_type, + cl.lat, + cl.lng, + cl.accuracy_m, + cl.altitude_m, + cl.speed_mps, + cl.heading_deg, + cl.source, + cl.battery_pct, + cl.device_time, + cl.server_time, + cl.updated_at AS location_updated_at + FROM device_bindings AS db + LEFT JOIN children AS c + ON c.child_id = db.child_id + AND c.status = 1 + LEFT JOIN device_settings AS ds + ON ds.device_id = db.device_id + LEFT JOIN child_location_current AS cl + ON cl.child_id = db.child_id + AND cl.device_id = db.device_id + WHERE db.device_id = :device_id + AND db.owner_user_id = :user_id + AND db.status = 1 + LIMIT 1 + """ + ), + {"device_id": device_id, "user_id": user_id}, + ) + row = result.mappings().first() + if row is None: + raise HTTPException(status_code=404, detail="device not found") + return row diff --git a/talkingq-url/banban/dao/im.py b/talkingq-url/banban/dao/im.py index dcc0447..137c737 100644 --- a/talkingq-url/banban/dao/im.py +++ b/talkingq-url/banban/dao/im.py @@ -178,11 +178,18 @@ class ImDAO(BaseDAO): participant_b_id = str(high_id) return participant_a_id, participant_b_id, f"{participant_a_id}:{participant_b_id}" - async def _build_preview(self, content_type: int, content_text: str | None) -> str: + async def _build_preview( + self, + content_type: int, + content_text: str | None, + ext_json: dict[str, Any] | None = None, + ) -> str: if content_type == 1: return (content_text or "").strip()[:255] if content_type == 2: - return "[audio]" + if (ext_json or {}).get("message_kind") == "leave_message": + return "[留言]" + return "[语音]" if content_type == 3: return "[image]" return "[json]" @@ -239,7 +246,11 @@ class ImDAO(BaseDAO): return conversation_id, True now_sql = "CURRENT_TIMESTAMP(3)" - preview = await self._build_preview(payload.content_type, payload.content_text) + preview = await self._build_preview( + payload.content_type, + payload.content_text, + payload.ext_json, + ) try: conversation_row = await self._get_conversation_by_id(conversation_id=conversation_id, lock=True) diff --git a/talkingq-url/banban/dao/location.py b/talkingq-url/banban/dao/location.py index b00f79a..cd9b484 100644 --- a/talkingq-url/banban/dao/location.py +++ b/talkingq-url/banban/dao/location.py @@ -18,6 +18,21 @@ class ParentDeviceAccess: class LocationDAO(BaseDAO): + async def get_active_binding_by_device(self, *, device_id: str) -> Mapping[str, Any] | None: + result = await self.execute( + text( + """ + SELECT child_id + FROM device_bindings + WHERE device_id = :device_id + AND status = 1 + LIMIT 1 + """ + ), + {"device_id": device_id}, + ) + return result.mappings().first() + async def assert_parent_device_access(self, *, device_id: str, user_id: int) -> ParentDeviceAccess: from fastapi import HTTPException, status result = await self.execute( @@ -321,8 +336,7 @@ class LocationDAO(BaseDAO): await self.execute(text(update_current_sql), params) await self.commit() - async def get_device_current_location_by_device_id(self, *, device_id: str) -> Mapping[str, Any]: - # sql 查询 + async def get_current_location_by_device_id(self, *, device_id: str) -> Mapping[str, Any]: result = await self.execute( text( f""" @@ -348,4 +362,4 @@ class LocationDAO(BaseDAO): ), {"device_id": device_id}, ) - return result.mappings().first() \ No newline at end of file + return result.mappings().first() diff --git a/talkingq-url/banban/routers/devices.py b/talkingq-url/banban/routers/devices.py index 142e19f..edcdd40 100644 --- a/talkingq-url/banban/routers/devices.py +++ b/talkingq-url/banban/routers/devices.py @@ -3,7 +3,7 @@ from collections.abc import Mapping from datetime import datetime from services.connection_manager import connection_manager from fastapi import APIRouter, Depends, HTTPException, Query, Request -from pydantic import BaseModel +from pydantic import BaseModel, Field from sqlalchemy import text import asyncio from datetime import timedelta @@ -49,6 +49,39 @@ class DeviceMessageListResponse(BaseModel): next_cursor: int | None = None +class DeviceStatusResponse(BaseModel): + device_id: str + child_id: int | None = None + child_name: str | None = None + power: int | None = None + volume: int | None = None + signal: int | None = None + version: str | None = None + settings_updated_at: datetime | None = None + coord_type: str | None = None + lat: float | None = None + lng: float | None = None + accuracy_m: int | None = None + altitude_m: float | None = None + speed_mps: float | None = None + heading_deg: int | None = None + source: int | None = None + battery_pct: int | None = None + device_time: datetime | None = None + server_time: datetime | None = None + location_updated_at: datetime | None = None + + +class DeviceVolumeUpdateRequest(BaseModel): + level: int = Field(ge=0, le=100) + + +class DeviceVolumeUpdateResponse(BaseModel): + device_id: str + level: int + msg_id: str + + @@ -107,6 +140,31 @@ def _row_to_trajectory_item(row: Mapping, *, child_name: str | None) -> DeviceLo ) +def _row_to_device_status_response(row: Mapping) -> DeviceStatusResponse: + return DeviceStatusResponse( + device_id=str(row["device_id"]), + child_id=int(row["child_id"]) if row["child_id"] is not None else None, + child_name=row.get("child_name"), + power=row["power"], + volume=row["volume"], + signal=row["signal_strength"], + version=row["version"], + settings_updated_at=row["settings_updated_at"], + coord_type=row["coord_type"], + lat=float(row["lat"]) if row["lat"] is not None else None, + lng=float(row["lng"]) if row["lng"] is not None else None, + accuracy_m=row["accuracy_m"], + altitude_m=float(row["altitude_m"]) if row["altitude_m"] is not None else None, + speed_mps=float(row["speed_mps"]) if row["speed_mps"] is not None else None, + heading_deg=row["heading_deg"], + source=int(row["source"]) if row["source"] is not None else None, + battery_pct=row["battery_pct"], + device_time=row["device_time"], + server_time=row["server_time"], + location_updated_at=row["location_updated_at"], + ) + + @router.get("/{device_id}/messages", response_model=DeviceMessageListResponse) async def list_device_messages( device_id: str, @@ -144,6 +202,58 @@ async def list_device_messages( ) +@router.get("/{device_id}/status", response_model=DeviceStatusResponse) +async def get_device_status( + device_id: str, + request: Request, + current_user_id: int = Depends(get_current_user_id), +) -> DeviceStatusResponse: + row = await device_service.get_device_status( + device_id=device_id, + user_id=current_user_id, + ) + + logger.info( + "device status fetched", + extra={ + "event": "device_status", + "request_id": getattr(request.state, "request_id", None), + "user_id": current_user_id, + "device_id": device_id, + "child_id": row["child_id"], + }, + ) + return _row_to_device_status_response(row) + + +@router.post("/{device_id}/volume", response_model=DeviceVolumeUpdateResponse) +async def set_device_volume( + device_id: str, + payload: DeviceVolumeUpdateRequest, + request: Request, + current_user_id: int = Depends(get_current_user_id), +) -> DeviceVolumeUpdateResponse: + msg_id = await device_service.set_device_volume( + device_id=device_id, + user_id=current_user_id, + level=payload.level, + ) + + logger.info( + "device volume command sent", + extra={ + "event": "device_volume_set", + "request_id": getattr(request.state, "request_id", None), + "user_id": current_user_id, + "device_id": device_id, + "level": payload.level, + "msg_id": msg_id, + }, + ) + return DeviceVolumeUpdateResponse(device_id=device_id, level=payload.level, msg_id=msg_id) + + + @router.get("/{device_id}/location", response_model=DeviceLocationCurrentResponse) async def get_current_device_location( device_id: str, diff --git a/talkingq-url/banban/routers/im.py b/talkingq-url/banban/routers/im.py index 958973d..c89d2ba 100644 --- a/talkingq-url/banban/routers/im.py +++ b/talkingq-url/banban/routers/im.py @@ -3,7 +3,7 @@ import logging from collections.abc import Mapping from typing import Any -from fastapi import APIRouter, Depends, HTTPException, Query, Request, Response, status +from fastapi import APIRouter, Depends, File, Form, HTTPException, Query, Request, Response, UploadFile, status from sqlalchemy import text try: @@ -442,6 +442,64 @@ async def create_child_message_for_parent( ) +@router.post( + "/{child_id}/voice-message", + response_model=ConversationMessageCreateResponse, + status_code=status.HTTP_201_CREATED, +) +async def create_child_voice_message_for_parent( + child_id: int, + request: Request, + response: Response, + file: UploadFile = File(...), + duration_ms: int = Form(..., ge=0), + client_msg_id: str = Form(..., min_length=1, max_length=64), + transcript_text: str | None = Form(default=None, max_length=1000), + current_user_id: int = Depends(get_current_user_id), +) -> ConversationMessageCreateResponse: + try: + content = await file.read() + result = await im_service.create_parent_child_voice_message( + parent_user_id=current_user_id, + child_id=child_id, + filename=file.filename, + content_type=file.content_type, + content=content, + media_duration_ms=duration_ms, + media_transcript_text=transcript_text, + client_msg_id=client_msg_id, + ext_json={ + "message_kind": "leave_message", + "source": "parent_weapp_voice", + }, + ) + finally: + await file.close() + + if result.idempotent: + response.status_code = status.HTTP_200_OK + + logger.info( + "parent child voice message created", + extra={ + "event": "parent_child_voice_message_create", + "request_id": getattr(request.state, "request_id", None), + "user_id": current_user_id, + "child_id": child_id, + "conversation_id": result.conversation_id, + "idempotent": result.idempotent, + }, + ) + + return ConversationMessageCreateResponse( + idempotent=result.idempotent, + conversation_id=result.conversation_id, + conversation_type=result.conversation_type, + conversation_type_name=result.conversation_type_name, + message=result.message, + ) + + @router.get( "/{child_id}/conversations/{conversation_id}/messages", response_model=ChildConversationMessageListResponse, diff --git a/talkingq-url/banban/routers/wechat_auth.py b/talkingq-url/banban/routers/wechat_auth.py index 78ee56e..9f4548e 100644 --- a/talkingq-url/banban/routers/wechat_auth.py +++ b/talkingq-url/banban/routers/wechat_auth.py @@ -32,6 +32,14 @@ class LoginResponse(BaseModel): token_type: str = "bearer" expires_in: int user_id: int + nickname: str | None = None + + +def _normalize_nickname(value: str | None) -> str | None: + if value is None: + return None + normalized = value.strip() + return normalized or None @router.post("/login", response_model=LoginResponse) @@ -56,20 +64,26 @@ async def login( ) raise HTTPException(status_code=exc.status_code, detail=str(exc)) from exc + normalized_nickname = _normalize_nickname(payload.nickname) parent_service = ParentService() parent = await parent_service.create( openid=wechat_session.openid, unionid=wechat_session.unionid, - nickname=payload.nickname, + nickname=normalized_nickname, avatar_url=payload.avatar_url, ) user_id = int(parent["user_id"]) access_token, expires_in = create_access_token(user_id=user_id) logger.info("wechat login succeeded", extra={"event": "wechat_login_succeeded", "user_id": user_id}) - return LoginResponse(access_token=access_token, expires_in=expires_in, user_id=user_id) + return LoginResponse( + access_token=access_token, + expires_in=expires_in, + user_id=user_id, + nickname=parent.get("nickname"), + ) @router.post("/logout") async def logout(request: Request): - return {"message": "logged out"} \ No newline at end of file + return {"message": "logged out"} diff --git a/talkingq-url/banban/service/binding.py b/talkingq-url/banban/service/binding.py index cc7b364..338de38 100644 --- a/talkingq-url/banban/service/binding.py +++ b/talkingq-url/banban/service/binding.py @@ -24,6 +24,13 @@ class BindingService(DatabaseServiceBase): def __init__(self): super().__init__(service_name="binding_service") + async def _ensure_device_unbound(self, db_session, device_id: str) -> None: + dao = BindingDAO(db_session) + active_binding = await dao.get_active_binding_by_device(device_id) + if active_binding is None: + return + raise BindingError("device is already bound, unbind it before binding again", status_code=409) + async def _ensure_bindable_device(self, db_session, device_id: str, serial_number: str) -> None: dao = BindingDAO(db_session) row = await dao.get_device_auth(device_id) @@ -50,6 +57,7 @@ class BindingService(DatabaseServiceBase): db_session = await self.get_session() try: await self._ensure_bindable_device(db_session, device_id, serial_number) + await self._ensure_device_unbound(db_session, device_id) dao = BindingDAO(db_session) bind_token, expires_at = await dao.start_bind(user_id, device_id, child_id) await db_session.commit() @@ -188,6 +196,7 @@ class BindingService(DatabaseServiceBase): db_session = await self.get_session() try: await self._ensure_bindable_device(db_session, device_id, serial_number) + await self._ensure_device_unbound(db_session, device_id) dao = BindingDAO(db_session) await dao.direct_bind(device_id, child_id, user_id) await db_session.commit() @@ -201,6 +210,9 @@ class BindingService(DatabaseServiceBase): dao = BindingDAO(db_session) ok = await dao.set_binding_child(device_id=device_id, child_id=child_id, user_id=user_id) if not ok: + active_binding = await dao.get_active_binding_by_device(device_id) + if active_binding and active_binding["child_id"] is not None: + raise BindingError("device is already bound, unbind it before binding again", status_code=409) raise ValueError("binding not found") await db_session.commit() return {"device_id": device_id, "child_id": child_id} diff --git a/talkingq-url/banban/service/device.py b/talkingq-url/banban/service/device.py index 571444a..8e63add 100644 --- a/talkingq-url/banban/service/device.py +++ b/talkingq-url/banban/service/device.py @@ -2,6 +2,7 @@ from collections.abc import Mapping from typing import Any, List from services.database_service_base import DatabaseServiceBase +from fastapi import HTTPException from banban.dao.device import DeviceDAO @@ -38,6 +39,35 @@ class DeviceService(DatabaseServiceBase): finally: await db_session.close() + async def get_device_status( + self, + *, + device_id: str, + user_id: int, + ) -> Mapping[str, Any]: + db_session = await self.get_session() + try: + dao = DeviceDAO(db_session) + return await dao.get_device_status(device_id=device_id, user_id=user_id) + finally: + await db_session.close() + + async def set_device_volume( + self, + *, + device_id: str, + user_id: int, + level: int, + ) -> str: + await self.ensure_device_access(device_id=device_id, user_id=user_id) + + from handlers.mqtt_handler import TalkingQMQTTService + + service = await TalkingQMQTTService.get_instance() + if service is None: + raise HTTPException(status_code=503, detail="MQTT 服务未初始化") + return await service.send_volume_command(device_id, level) + # 创建全局 DeviceService 实例 -device_service = DeviceService() \ No newline at end of file +device_service = DeviceService() diff --git a/talkingq-url/banban/service/device_voice_archive.py b/talkingq-url/banban/service/device_voice_archive.py new file mode 100644 index 0000000..89a1b97 --- /dev/null +++ b/talkingq-url/banban/service/device_voice_archive.py @@ -0,0 +1,427 @@ +import asyncio +import os +import shutil +from dataclasses import dataclass +from uuid import uuid4 + +from sqlalchemy import text + +from banban.dao.im import ImDAO +from banban.schemas.im import DeviceMessageCreateRequest +from banban.service.message_audio_storage import ( + MessageAudioStorageError, + message_audio_storage_service, +) +from config import settings +from services.database_service_base import DatabaseServiceBase +from utils.audio_format import ( + DEFAULT_CHANNELS, + DEFAULT_SAMPLE_RATE, + DEFAULT_SAMPLE_WIDTH, + detect_audio_format, + wrap_pcm_as_wav, +) +from utils.logger import session_logger + + +CHILD_PARTICIPANT_TYPE = 2 +CHILD_PEER_CONVERSATION_TYPE = 1 + + +@dataclass(frozen=True) +class PreparedArchiveAudio: + filepath: str + mime_type: str + size_bytes: int + source_format: str + archive_format: str + + +class DeviceVoiceArchiveService(DatabaseServiceBase): + def __init__(self) -> None: + super().__init__(service_name="device_voice_archive") + + async def archive_peer_voice_message( + self, + *, + sender_device_id: str, + receiver_device_id: str, + local_audio_path: str, + ) -> bool: + archive_id = f"voice_archive:{uuid4().hex[:12]}" + session_logger.info( + sender_device_id, + archive_id, + ( + "开始归档设备语音消息: " + f"sender_device_id={sender_device_id}, receiver_device_id={receiver_device_id}, " + f"local_audio_path={local_audio_path}" + ), + ) + + if not local_audio_path or not os.path.exists(local_audio_path): + session_logger.warning( + sender_device_id, + archive_id, + f"跳过语音归档: 本地音频文件不存在, local_audio_path={local_audio_path}", + ) + return False + + prepared_audio = None + db_session = await self.get_session() + try: + prepared_audio = await self._prepare_archive_audio( + sender_device_id=sender_device_id, + archive_id=archive_id, + local_audio_path=local_audio_path, + ) + if prepared_audio is None: + return False + + dao = ImDAO(db_session) + sender_identity = await self._get_child_identity_by_device_id( + db_session=db_session, + device_id=sender_device_id, + ) + receiver_identity = await self._get_child_identity_by_device_id( + db_session=db_session, + device_id=receiver_device_id, + ) + + if sender_identity is None: + session_logger.warning( + sender_device_id, + archive_id, + f"跳过语音归档: 发送设备未绑定有效 child, device_id={sender_device_id}", + ) + return False + if receiver_identity is None: + session_logger.warning( + sender_device_id, + archive_id, + f"跳过语音归档: 接收设备未绑定有效 child, device_id={receiver_device_id}", + ) + return False + if sender_identity["child_id"] == receiver_identity["child_id"]: + session_logger.warning( + sender_device_id, + archive_id, + ( + "跳过语音归档: 发送和接收设备映射到了同一个 child, " + f"child_id={sender_identity['child_id']}" + ), + ) + return False + + session_logger.info( + sender_device_id, + archive_id, + ( + "设备绑定解析完成: " + f"sender_child_id={sender_identity['child_id']}, " + f"sender_child_name={sender_identity['child_name'] or 'unknown'}, " + f"receiver_child_id={receiver_identity['child_id']}, " + f"receiver_child_name={receiver_identity['child_name'] or 'unknown'}" + ), + ) + + with open(prepared_audio.filepath, "rb") as archive_file: + archive_audio_data = archive_file.read() + + session_logger.info( + sender_device_id, + archive_id, + ( + "开始上传语音到 COS: " + f"bucket={settings.cos_bucket_message or 'unset'}, " + f"media_mime_type={prepared_audio.mime_type}, " + f"audio_size_bytes={prepared_audio.size_bytes}, " + f"archive_format={prepared_audio.archive_format}" + ), + ) + stored_audio = await message_audio_storage_service.upload_audio( + sender_device_id=sender_device_id, + receiver_device_id=receiver_device_id, + content=archive_audio_data, + content_type=prepared_audio.mime_type, + ) + session_logger.info( + sender_device_id, + archive_id, + f"COS 上传成功: file_key={stored_audio.file_key}", + ) + + participant_a_id, participant_b_id, pair_key = dao._build_child_peer_pair( + int(sender_identity["child_id"]), + int(receiver_identity["child_id"]), + ) + client_msg_id = f"device_voice_{uuid4().hex[:24]}" + payload = DeviceMessageCreateRequest( + conversation_type=CHILD_PEER_CONVERSATION_TYPE, + peer_child_id=int(receiver_identity["child_id"]), + content_type=2, + media_file_key=stored_audio.file_key, + media_mime_type=prepared_audio.mime_type, + media_size_bytes=prepared_audio.size_bytes, + client_msg_id=client_msg_id, + ext_json={ + "archive_source": "device_voice", + "sender_device_id": sender_device_id, + "receiver_device_id": receiver_device_id, + "local_audio_path": local_audio_path, + "archive_format": prepared_audio.archive_format, + "source_format": prepared_audio.source_format, + }, + ) + session_logger.info( + sender_device_id, + archive_id, + ( + "开始写入 IM 消息: " + f"conversation_type={CHILD_PEER_CONVERSATION_TYPE}, " + f"pair_key={pair_key}, client_msg_id={client_msg_id}" + ), + ) + conversation_id, idempotent = await dao.create_message( + conversation_type=CHILD_PEER_CONVERSATION_TYPE, + participant_a_type=CHILD_PARTICIPANT_TYPE, + participant_a_id=participant_a_id, + participant_b_type=CHILD_PARTICIPANT_TYPE, + participant_b_id=participant_b_id, + pair_key=pair_key, + sender_type=CHILD_PARTICIPANT_TYPE, + sender_id=str(sender_identity["child_id"]), + receiver_type=CHILD_PARTICIPANT_TYPE, + receiver_id=str(receiver_identity["child_id"]), + sender_name_snapshot=sender_identity["child_name"], + sender_avatar_snapshot=None, + receiver_name_snapshot=receiver_identity["child_name"], + receiver_avatar_snapshot=None, + payload=payload, + ) + message_row = await dao._get_message_by_conversation_client_id( + conversation_id=conversation_id, + client_msg_id=client_msg_id, + ) + session_logger.info( + sender_device_id, + archive_id, + ( + "IM 消息写入完成: " + f"conversation_id={conversation_id}, " + f"message_id={message_row['id'] if message_row else 'unknown'}, " + f"seq={message_row['seq'] if message_row else 'unknown'}, " + f"idempotent={idempotent}" + ), + ) + session_logger.info( + sender_device_id, + archive_id, + ( + "设备语音归档成功: " + f"sender_device_id={sender_device_id}, " + f"receiver_device_id={receiver_device_id}, " + f"conversation_id={conversation_id}, " + f"file_key={stored_audio.file_key}, " + f"archive_format={prepared_audio.archive_format}" + ), + ) + return True + except MessageAudioStorageError as exc: + session_logger.error( + sender_device_id, + archive_id, + f"设备语音归档失败: COS 上传异常: {exc}", + ) + return False + except Exception as exc: + session_logger.error( + sender_device_id, + archive_id, + f"设备语音归档失败: {exc}", + exc_info=True, + ) + return False + finally: + if prepared_audio and prepared_audio.archive_format == "mp3" and prepared_audio.filepath != local_audio_path: + if os.path.exists(prepared_audio.filepath): + os.remove(prepared_audio.filepath) + await db_session.close() + + async def _prepare_archive_audio( + self, + *, + sender_device_id: str, + archive_id: str, + local_audio_path: str, + ) -> PreparedArchiveAudio | None: + with open(local_audio_path, "rb") as local_file: + local_audio_data = local_file.read() + + source_format = detect_audio_format(local_audio_data) + local_size = len(local_audio_data) + session_logger.info( + sender_device_id, + archive_id, + ( + "异步归档开始判断音频格式: " + f"local_audio_path={local_audio_path}, source_format={source_format}, " + f"local_size_bytes={local_size}" + ), + ) + + if source_format == "mp3": + session_logger.info( + sender_device_id, + archive_id, + "异步归档判断结果: 已是 MP3,无需转码", + ) + return PreparedArchiveAudio( + filepath=local_audio_path, + mime_type="audio/mpeg", + size_bytes=local_size, + source_format=source_format, + archive_format="mp3", + ) + + wav_path = f"{local_audio_path}.archive.wav" + mp3_path = f"{local_audio_path}.archive.mp3" + try: + if source_format == "wav": + with open(wav_path, "wb") as wav_file: + wav_file.write(local_audio_data) + session_logger.info( + sender_device_id, + archive_id, + f"异步归档检测到 WAV,准备转 MP3: wav_path={wav_path}", + ) + else: + wrapped_wav = wrap_pcm_as_wav( + local_audio_data, + sample_rate=DEFAULT_SAMPLE_RATE, + channels=DEFAULT_CHANNELS, + sample_width=DEFAULT_SAMPLE_WIDTH, + ) + with open(wav_path, "wb") as wav_file: + wav_file.write(wrapped_wav) + session_logger.info( + sender_device_id, + archive_id, + ( + "异步归档将原始字节封装为 WAV: " + f"wav_path={wav_path}, sample_rate={DEFAULT_SAMPLE_RATE}, " + f"channels={DEFAULT_CHANNELS}, sample_width={DEFAULT_SAMPLE_WIDTH}" + ), + ) + + await self._convert_to_mp3( + sender_device_id=sender_device_id, + archive_id=archive_id, + source_path=wav_path, + target_path=mp3_path, + ) + mp3_size = os.path.getsize(mp3_path) + session_logger.info( + sender_device_id, + archive_id, + ( + "异步归档转码完成: " + f"mp3_path={mp3_path}, mp3_size_bytes={mp3_size}, " + f"source_format={source_format}" + ), + ) + return PreparedArchiveAudio( + filepath=mp3_path, + mime_type="audio/mpeg", + size_bytes=mp3_size, + source_format=source_format, + archive_format="mp3", + ) + finally: + if os.path.exists(wav_path): + os.remove(wav_path) + + async def _convert_to_mp3( + self, + *, + sender_device_id: str, + archive_id: str, + source_path: str, + target_path: str, + ) -> None: + ffmpeg_path = shutil.which("ffmpeg") + if not ffmpeg_path: + raise RuntimeError("ffmpeg not found in PATH") + + command = [ + ffmpeg_path, + "-y", + "-loglevel", + "error", + "-i", + source_path, + "-codec:a", + "libmp3lame", + "-b:a", + "32k", + target_path, + ] + session_logger.info( + sender_device_id, + archive_id, + f"异步归档开始转码为 MP3: command={' '.join(command)}", + ) + process = await asyncio.create_subprocess_exec( + *command, + stdout=asyncio.subprocess.PIPE, + stderr=asyncio.subprocess.PIPE, + ) + stdout, stderr = await process.communicate() + if process.returncode != 0: + stderr_text = stderr.decode("utf-8", errors="ignore").strip() + raise RuntimeError( + f"ffmpeg convert failed, returncode={process.returncode}, stderr={stderr_text}" + ) + session_logger.info( + sender_device_id, + archive_id, + ( + "异步归档 FFmpeg 转码完成: " + f"source_path={source_path}, target_path={target_path}, " + f"ffmpeg_stdout={stdout.decode('utf-8', errors='ignore').strip() or 'empty'}" + ), + ) + + async def _get_child_identity_by_device_id(self, *, db_session, device_id: str): + result = await db_session.execute( + text( + """ + SELECT + da.device_id, + db.child_id, + c.child_name + FROM device_auth AS da + LEFT JOIN device_bindings AS db + ON db.device_id = da.device_id + AND db.status = 1 + LEFT JOIN children AS c + ON c.child_id = db.child_id + AND c.status = 1 + WHERE da.device_id = :device_id + AND da.is_active = 1 + LIMIT 1 + """ + ), + {"device_id": device_id}, + ) + row = result.mappings().first() + if not row or row["child_id"] is None: + return None + return { + "device_id": str(row["device_id"]), + "child_id": int(row["child_id"]), + "child_name": row["child_name"], + } + + +device_voice_archive_service = DeviceVoiceArchiveService() diff --git a/talkingq-url/banban/service/im.py b/talkingq-url/banban/service/im.py index 74d3b86..f8b732d 100644 --- a/talkingq-url/banban/service/im.py +++ b/talkingq-url/banban/service/im.py @@ -2,6 +2,7 @@ from dataclasses import dataclass import hashlib import json from collections.abc import Mapping +from pathlib import Path from typing import Any from fastapi import HTTPException @@ -36,6 +37,25 @@ PARTICIPANT_TYPE_NAMES = { CHILD_PARTICIPANT_TYPE: "child", } +_AUDIO_CONTENT_TYPE_TO_EXT = { + "audio/mpeg": "mp3", + "audio/mp3": "mp3", + "audio/aac": "aac", + "audio/wav": "wav", + "audio/x-wav": "wav", + "audio/x-m4a": "m4a", + "audio/mp4": "m4a", + "audio/webm": "webm", + "application/octet-stream": "mp3", +} +_AUDIO_EXTENSION_ALIASES = { + ".mp3": "mp3", + ".aac": "aac", + ".m4a": "m4a", + ".wav": "wav", + ".webm": "webm", +} + def conversation_type_name(conversation_type: int) -> str: return CONVERSATION_TYPE_NAMES.get(conversation_type, f"unknown_{conversation_type}") @@ -66,6 +86,20 @@ def normalize_content_json(value: Any) -> dict[str, Any] | None: return None +def normalize_audio_extension(*, filename: str | None, content_type: str | None) -> tuple[str, str]: + normalized_content_type = (content_type or "").strip().lower() or "audio/mpeg" + if normalized_content_type in _AUDIO_CONTENT_TYPE_TO_EXT: + return _AUDIO_CONTENT_TYPE_TO_EXT[normalized_content_type], normalized_content_type + + suffix = Path(filename or "").suffix.lower() + if suffix in _AUDIO_EXTENSION_ALIASES: + extension = _AUDIO_EXTENSION_ALIASES[suffix] + fallback_content_type = "audio/m4a" if extension == "m4a" else f"audio/{extension}" + return extension, fallback_content_type + + raise HTTPException(status_code=415, detail="unsupported audio file type") + + def row_to_message_item(row: Mapping[str, Any]) -> ChildConversationMessageItem: return ChildConversationMessageItem( id=int(row["id"]), @@ -201,6 +235,58 @@ class ImService(DatabaseServiceBase): finally: await db_session.close() + async def create_parent_child_voice_message( + self, + *, + parent_user_id: int, + child_id: int, + filename: str | None, + content_type: str | None, + content: bytes, + media_duration_ms: int | None, + media_transcript_text: str | None, + client_msg_id: str, + ext_json: dict[str, Any] | None = None, + ) -> ConversationMessageCreateResult: + if not content: + raise HTTPException(status_code=400, detail="audio file is empty") + + extension, normalized_content_type = normalize_audio_extension( + filename=filename, + content_type=content_type, + ) + stored_audio = await self.audio_storage.upload_audio( + device_id=f"parent-{parent_user_id}", + content=content, + content_type=normalized_content_type, + extension=extension, + ) + payload = ParentChildMessageCreateRequest( + content_type=2, + media_file_key=stored_audio.file_key, + media_duration_ms=media_duration_ms, + media_mime_type=normalized_content_type, + media_size_bytes=len(content), + media_transcript_text=(media_transcript_text or "").strip() or None, + client_msg_id=client_msg_id, + ext_json=ext_json, + ) + + try: + result = await self.create_parent_child_message( + parent_user_id=parent_user_id, + child_id=child_id, + payload=payload, + ) + except Exception: + try: + await self.audio_storage.delete_audio(stored_audio.file_key) + except MessageAudioStorageError: + pass + raise + + return result + async def _create_device_message_with_payload( self, *, diff --git a/talkingq-url/banban/service/location.py b/talkingq-url/banban/service/location.py index 73a6739..a60b5a8 100644 --- a/talkingq-url/banban/service/location.py +++ b/talkingq-url/banban/service/location.py @@ -89,7 +89,7 @@ class LocationService(DatabaseServiceBase): db_session = await self.get_session() try: dao = LocationDAO(db_session) - current_row = await dao.get_device_current_location_by_device_id(device_id=device_id) + current_row = await dao.get_current_location_by_device_id(device_id=device_id) if current_row: await dao.update(device_id=device_id, latitude=location.lat, longitude=location.lng) # 更新成功加到历史记录表 @@ -98,5 +98,63 @@ class LocationService(DatabaseServiceBase): finally: await db_session.close() + async def report_mqtt_device_location( + self, + *, + device_id: str, + latitude: float | None, + longitude: float | None, + coord_type: str | None = None, + accuracy_m: int | None = None, + altitude_m: float | None = None, + speed_mps: float | None = None, + heading_deg: int | None = None, + source: int | None = None, + battery_pct: int | None = None, + device_time: datetime | None = None, + ) -> Mapping[str, Any] | None: + if latitude is None or longitude is None: + return None + + @dataclass(frozen=True) + class _MQTTLocationPayload: + coord_type: str + lat: float + lng: float + accuracy_m: int | None + altitude_m: float | None + speed_mps: float | None + heading_deg: int | None + source: int + battery_pct: int | None + device_time: datetime + + db_session = await self.get_session() + try: + dao = LocationDAO(db_session) + binding_row = await dao.get_active_binding_by_device(device_id=device_id) + if not binding_row or binding_row["child_id"] is None: + return None + + payload = _MQTTLocationPayload( + coord_type=(coord_type or "gcj02").strip() or "gcj02", + lat=float(latitude), + lng=float(longitude), + accuracy_m=accuracy_m, + altitude_m=altitude_m, + speed_mps=speed_mps, + heading_deg=heading_deg, + source=source if source is not None else 0, + battery_pct=battery_pct, + device_time=device_time or datetime.now(), + ) + return await dao.report_device_location( + device_id=device_id, + child_id=int(binding_row["child_id"]), + payload=payload, + ) + finally: + await db_session.close() + # 创建全局 LocationService 实例 location_service = LocationService() diff --git a/talkingq-url/banban/service/message_audio_storage.py b/talkingq-url/banban/service/message_audio_storage.py index af86e76..6d66373 100644 --- a/talkingq-url/banban/service/message_audio_storage.py +++ b/talkingq-url/banban/service/message_audio_storage.py @@ -119,6 +119,15 @@ class MessageAudioStorageService: Expired=settings.cos_avatar_url_expire_seconds, ) + async def delete_audio(self, file_key_or_url: str) -> None: + self._assert_ready() + normalized_key = self._normalize_file_key(file_key_or_url) + await asyncio.to_thread( + self._get_client().delete_object, + Bucket=settings.cos_bucket_message, + Key=normalized_key, + ) + def _upload_audio_sync( self, *, diff --git a/talkingq-url/database/models.py b/talkingq-url/database/models.py index f900c29..4147097 100644 --- a/talkingq-url/database/models.py +++ b/talkingq-url/database/models.py @@ -256,10 +256,10 @@ class DeviceSetting(Base): timezone: Mapped[str] = mapped_column(String(32), server_default=text("'Asia/Shanghai'")) volume: Mapped[Optional[int]] = mapped_column(Integer) brightness: Mapped[Optional[int]] = mapped_column(Integer) - disable_weekdays: Mapped[Optional[str]] = mapped_column(String(32)) power: Mapped[Optional[int]] = mapped_column(Integer) - signal_strength: Mapped[Optional[int]] = mapped_column(Integer) - version_str: Mapped[Optional[str]] = mapped_column(String(64)) + signal: Mapped[Optional[int]] = mapped_column("signal", Integer) + version: Mapped[Optional[str]] = mapped_column("version", String(64)) + disable_weekdays: Mapped[Optional[str]] = mapped_column(String(32)) created_at: Mapped[Optional[datetime]] = mapped_column(DateTime, server_default=text("CURRENT_TIMESTAMP")) updated_at: Mapped[Optional[datetime]] = mapped_column( DateTime, @@ -267,7 +267,6 @@ class DeviceSetting(Base): onupdate=datetime.utcnow, ) - class IMConversation(Base): __tablename__ = "im_conversations" __table_args__ = ( diff --git a/talkingq-url/handlers/mqtt_handler.py b/talkingq-url/handlers/mqtt_handler.py index eaf5a53..dac29cf 100644 --- a/talkingq-url/handlers/mqtt_handler.py +++ b/talkingq-url/handlers/mqtt_handler.py @@ -10,10 +10,10 @@ from services.offline_audio_cache import offline_audio_cache import aiomqtt from banban.service.location import location_service from database.models import ChildLocationCurrent - +from datetime import datetime from services.device_target_cache import device_target_cache from utils.logger import session_logger as logger - +from services.task_manager import task_manager class TalkingQMQTTService: _instance = None @@ -95,59 +95,105 @@ class TalkingQMQTTService: logger.error("", "", f"消息循环异常: {e}") 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 _handle_device_info(self, device_id: str, payload: dict): # status = payload.get("status") data = payload.get("data", {}) - # if status == "success": - d_id = data.get("id") - power = data.get("power") - signal = data.get("signal") - version = data.get("version") - voice = data.get("voice") - logger.info(device_id, "", f"[设备信息] 设备 {d_id} 信息: 电量={power}, 信号强度={signal}, 版本号={version}, 音量={voice}") - # 插入到数据库 - await device_setting_service.insert_or_update(device_id=device_id, power=power, signal_strength=signal, version_str=version, volume=voice) - # else: - # logger.warning(device_id, "", f"[设备信息] 设备 {device_id} 查询失败: {payload}") + 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"), + ), + ) 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): - status = payload.get("status") + if payload.get("status") != "success": + logger.warning(device_id, "", f"[GPS] query failed: {payload}") + return + data = payload.get("data", {}) - if status == "success": - lat = data.get("latitude") - lon = data.get("longitude") - logger.info(device_id, "", f"[GPS] 设备 {device_id} 位置: 纬度={lat}, 经度={lon}") - # 插入到数据库 - location = ChildLocationCurrent(device_id=device_id, lat=lat, lon=lon) - await location_service.insert_or_update(device_id=device_id, location=location) - else: - logger.warning(device_id, "", f"[GPS] 设备 {device_id} 查询失败: {payload}") + 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 + + await self._schedule_persistence( + device_id, + "gps", + 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, + ), + ) async def _handle_volume_response(self, device_id: str, payload: dict): - status = payload.get("status") - data = payload.get("data", {}) - if status == "success": - level = data.get("current_level") - logger.info(device_id, "", f"[音量] 设备 {device_id} 当前音量: {level}") - # 更新设备音量 - await device_setting_service.insert_or_update(device_id=device_id, volume=level) + if payload.get("status") != "success": + logger.warning(device_id, "", f"[volume] command failed: {payload}") + return - else: - logger.warning(device_id, "", f"[音量] 设备 {device_id} 调节失败: {payload}") + data = payload.get("data", {}) + current_level = data.get("current_level") + if current_level is None: + return + 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, + ), + ) async def _handle_ota_response(self, device_id: str, payload: dict): status = payload.get("status") data = payload.get("data", {}) - if status == "accepted": - current = data.get("current_version") - target = data.get("target_version") - logger.info(device_id, "", f"[OTA] 设备 {device_id} 已接受升级: {current} -> {target}") - elif status == "success": - new_ver = data.get("new_version") - logger.info(device_id, "", f"[OTA] 设备 {device_id} 升级完成: {new_ver}") - # 更新设备版本号 - await device_setting_service.insert_or_update(device_id=device_id, version_str=new_ver) + if status == "success": + 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=data.get("new_version"), + ), + ) + elif status != "accepted": + logger.warning(device_id, "", f"[OTA] command failed: {payload}") else: logger.warning(device_id, "", f"[OTA] 设备 {device_id} 升级异常: {payload}") diff --git a/talkingq-url/requirements.txt b/talkingq-url/requirements.txt index c9f9a0e..cf6ea49 100644 --- a/talkingq-url/requirements.txt +++ b/talkingq-url/requirements.txt @@ -20,7 +20,7 @@ setuptools==69.5.1 pycld2 aiomqtt>=2.0.0 apscheduler>=3.10.0 - +PyJWT==2.10.1 httpx==0.28.1 cos-python-sdk-v5==1.9.41 pytest==8.3.4 diff --git a/talkingq-url/scripts/_vendor/qrcode/LICENSE b/talkingq-url/scripts/_vendor/qrcode/LICENSE new file mode 100644 index 0000000..bb4b0c7 --- /dev/null +++ b/talkingq-url/scripts/_vendor/qrcode/LICENSE @@ -0,0 +1,48 @@ +Copyright (c) 2011, Lincoln Loop +All rights reserved. + +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions are met: + + * Redistributions of source code must retain the above copyright notice, + this list of conditions and the following disclaimer. + * Redistributions in binary form must reproduce the above copyright notice, + this list of conditions and the following disclaimer in the documentation + and/or other materials provided with the distribution. + * Neither the package name nor the names of its contributors may be + used to endorse or promote products derived from this software without + specific prior written permission. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND +ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED +WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE +DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE LIABLE FOR +ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES +(INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; +LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON +ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT +(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS +SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + + +------------------------------------------------------------------------------- + + +Original text and license from the pyqrnative package where this was forked +from (http://code.google.com/p/pyqrnative): + +#Ported from the Javascript library by Sam Curren +# +#QRCode for Javascript +#http://d-project.googlecode.com/svn/trunk/misc/qrcode/js/qrcode.js +# +#Copyright (c) 2009 Kazuhiko Arase +# +#URL: http://www.d-project.com/ +# +#Licensed under the MIT license: +# http://www.opensource.org/licenses/mit-license.php +# +# The word "QR Code" is registered trademark of +# DENSO WAVE INCORPORATED +# http://www.denso-wave.com/qrcode/faqpatent-e.html diff --git a/talkingq-url/scripts/_vendor/qrcode/LUT.py b/talkingq-url/scripts/_vendor/qrcode/LUT.py new file mode 100644 index 0000000..115892f --- /dev/null +++ b/talkingq-url/scripts/_vendor/qrcode/LUT.py @@ -0,0 +1,223 @@ +# Store all kinds of lookup table. + + +# # generate rsPoly lookup table. + +# from qrcode import base + +# def create_bytes(rs_blocks): +# for r in range(len(rs_blocks)): +# dcCount = rs_blocks[r].data_count +# ecCount = rs_blocks[r].total_count - dcCount +# rsPoly = base.Polynomial([1], 0) +# for i in range(ecCount): +# rsPoly = rsPoly * base.Polynomial([1, base.gexp(i)], 0) +# return ecCount, rsPoly + +# rsPoly_LUT = {} +# for version in range(1,41): +# for error_correction in range(4): +# rs_blocks_list = base.rs_blocks(version, error_correction) +# ecCount, rsPoly = create_bytes(rs_blocks_list) +# rsPoly_LUT[ecCount]=rsPoly.num +# print(rsPoly_LUT) + +# Result. Usage: input: ecCount, output: Polynomial.num +# e.g. rsPoly = base.Polynomial(LUT.rsPoly_LUT[ecCount], 0) +rsPoly_LUT = { + 7: [1, 127, 122, 154, 164, 11, 68, 117], + 10: [1, 216, 194, 159, 111, 199, 94, 95, 113, 157, 193], + 13: [1, 137, 73, 227, 17, 177, 17, 52, 13, 46, 43, 83, 132, 120], + 15: [1, 29, 196, 111, 163, 112, 74, 10, 105, 105, 139, 132, 151, 32, 134, 26], + 16: [1, 59, 13, 104, 189, 68, 209, 30, 8, 163, 65, 41, 229, 98, 50, 36, 59], + 17: [1, 119, 66, 83, 120, 119, 22, 197, 83, 249, 41, 143, 134, 85, 53, 125, 99, 79], + 18: [ + 1, + 239, + 251, + 183, + 113, + 149, + 175, + 199, + 215, + 240, + 220, + 73, + 82, + 173, + 75, + 32, + 67, + 217, + 146, + ], + 20: [ + 1, + 152, + 185, + 240, + 5, + 111, + 99, + 6, + 220, + 112, + 150, + 69, + 36, + 187, + 22, + 228, + 198, + 121, + 121, + 165, + 174, + ], + 22: [ + 1, + 89, + 179, + 131, + 176, + 182, + 244, + 19, + 189, + 69, + 40, + 28, + 137, + 29, + 123, + 67, + 253, + 86, + 218, + 230, + 26, + 145, + 245, + ], + 24: [ + 1, + 122, + 118, + 169, + 70, + 178, + 237, + 216, + 102, + 115, + 150, + 229, + 73, + 130, + 72, + 61, + 43, + 206, + 1, + 237, + 247, + 127, + 217, + 144, + 117, + ], + 26: [ + 1, + 246, + 51, + 183, + 4, + 136, + 98, + 199, + 152, + 77, + 56, + 206, + 24, + 145, + 40, + 209, + 117, + 233, + 42, + 135, + 68, + 70, + 144, + 146, + 77, + 43, + 94, + ], + 28: [ + 1, + 252, + 9, + 28, + 13, + 18, + 251, + 208, + 150, + 103, + 174, + 100, + 41, + 167, + 12, + 247, + 56, + 117, + 119, + 233, + 127, + 181, + 100, + 121, + 147, + 176, + 74, + 58, + 197, + ], + 30: [ + 1, + 212, + 246, + 77, + 73, + 195, + 192, + 75, + 98, + 5, + 70, + 103, + 177, + 22, + 217, + 138, + 51, + 181, + 246, + 72, + 25, + 18, + 46, + 228, + 74, + 216, + 195, + 11, + 106, + 130, + 150, + ], +} diff --git a/talkingq-url/scripts/_vendor/qrcode/__init__.py b/talkingq-url/scripts/_vendor/qrcode/__init__.py new file mode 100644 index 0000000..98be578 --- /dev/null +++ b/talkingq-url/scripts/_vendor/qrcode/__init__.py @@ -0,0 +1,3 @@ +from .main import QRCode + +__all__ = ["QRCode"] diff --git a/talkingq-url/scripts/_vendor/qrcode/base.py b/talkingq-url/scripts/_vendor/qrcode/base.py new file mode 100644 index 0000000..20f81f6 --- /dev/null +++ b/talkingq-url/scripts/_vendor/qrcode/base.py @@ -0,0 +1,313 @@ +from typing import NamedTuple +from qrcode import constants + +EXP_TABLE = list(range(256)) + +LOG_TABLE = list(range(256)) + +for i in range(8): + EXP_TABLE[i] = 1 << i + +for i in range(8, 256): + EXP_TABLE[i] = ( + EXP_TABLE[i - 4] ^ EXP_TABLE[i - 5] ^ EXP_TABLE[i - 6] ^ EXP_TABLE[i - 8] + ) + +for i in range(255): + LOG_TABLE[EXP_TABLE[i]] = i + +RS_BLOCK_OFFSET = { + constants.ERROR_CORRECT_L: 0, + constants.ERROR_CORRECT_M: 1, + constants.ERROR_CORRECT_Q: 2, + constants.ERROR_CORRECT_H: 3, +} + +RS_BLOCK_TABLE = ( + # L + # M + # Q + # H + # 1 + (1, 26, 19), + (1, 26, 16), + (1, 26, 13), + (1, 26, 9), + # 2 + (1, 44, 34), + (1, 44, 28), + (1, 44, 22), + (1, 44, 16), + # 3 + (1, 70, 55), + (1, 70, 44), + (2, 35, 17), + (2, 35, 13), + # 4 + (1, 100, 80), + (2, 50, 32), + (2, 50, 24), + (4, 25, 9), + # 5 + (1, 134, 108), + (2, 67, 43), + (2, 33, 15, 2, 34, 16), + (2, 33, 11, 2, 34, 12), + # 6 + (2, 86, 68), + (4, 43, 27), + (4, 43, 19), + (4, 43, 15), + # 7 + (2, 98, 78), + (4, 49, 31), + (2, 32, 14, 4, 33, 15), + (4, 39, 13, 1, 40, 14), + # 8 + (2, 121, 97), + (2, 60, 38, 2, 61, 39), + (4, 40, 18, 2, 41, 19), + (4, 40, 14, 2, 41, 15), + # 9 + (2, 146, 116), + (3, 58, 36, 2, 59, 37), + (4, 36, 16, 4, 37, 17), + (4, 36, 12, 4, 37, 13), + # 10 + (2, 86, 68, 2, 87, 69), + (4, 69, 43, 1, 70, 44), + (6, 43, 19, 2, 44, 20), + (6, 43, 15, 2, 44, 16), + # 11 + (4, 101, 81), + (1, 80, 50, 4, 81, 51), + (4, 50, 22, 4, 51, 23), + (3, 36, 12, 8, 37, 13), + # 12 + (2, 116, 92, 2, 117, 93), + (6, 58, 36, 2, 59, 37), + (4, 46, 20, 6, 47, 21), + (7, 42, 14, 4, 43, 15), + # 13 + (4, 133, 107), + (8, 59, 37, 1, 60, 38), + (8, 44, 20, 4, 45, 21), + (12, 33, 11, 4, 34, 12), + # 14 + (3, 145, 115, 1, 146, 116), + (4, 64, 40, 5, 65, 41), + (11, 36, 16, 5, 37, 17), + (11, 36, 12, 5, 37, 13), + # 15 + (5, 109, 87, 1, 110, 88), + (5, 65, 41, 5, 66, 42), + (5, 54, 24, 7, 55, 25), + (11, 36, 12, 7, 37, 13), + # 16 + (5, 122, 98, 1, 123, 99), + (7, 73, 45, 3, 74, 46), + (15, 43, 19, 2, 44, 20), + (3, 45, 15, 13, 46, 16), + # 17 + (1, 135, 107, 5, 136, 108), + (10, 74, 46, 1, 75, 47), + (1, 50, 22, 15, 51, 23), + (2, 42, 14, 17, 43, 15), + # 18 + (5, 150, 120, 1, 151, 121), + (9, 69, 43, 4, 70, 44), + (17, 50, 22, 1, 51, 23), + (2, 42, 14, 19, 43, 15), + # 19 + (3, 141, 113, 4, 142, 114), + (3, 70, 44, 11, 71, 45), + (17, 47, 21, 4, 48, 22), + (9, 39, 13, 16, 40, 14), + # 20 + (3, 135, 107, 5, 136, 108), + (3, 67, 41, 13, 68, 42), + (15, 54, 24, 5, 55, 25), + (15, 43, 15, 10, 44, 16), + # 21 + (4, 144, 116, 4, 145, 117), + (17, 68, 42), + (17, 50, 22, 6, 51, 23), + (19, 46, 16, 6, 47, 17), + # 22 + (2, 139, 111, 7, 140, 112), + (17, 74, 46), + (7, 54, 24, 16, 55, 25), + (34, 37, 13), + # 23 + (4, 151, 121, 5, 152, 122), + (4, 75, 47, 14, 76, 48), + (11, 54, 24, 14, 55, 25), + (16, 45, 15, 14, 46, 16), + # 24 + (6, 147, 117, 4, 148, 118), + (6, 73, 45, 14, 74, 46), + (11, 54, 24, 16, 55, 25), + (30, 46, 16, 2, 47, 17), + # 25 + (8, 132, 106, 4, 133, 107), + (8, 75, 47, 13, 76, 48), + (7, 54, 24, 22, 55, 25), + (22, 45, 15, 13, 46, 16), + # 26 + (10, 142, 114, 2, 143, 115), + (19, 74, 46, 4, 75, 47), + (28, 50, 22, 6, 51, 23), + (33, 46, 16, 4, 47, 17), + # 27 + (8, 152, 122, 4, 153, 123), + (22, 73, 45, 3, 74, 46), + (8, 53, 23, 26, 54, 24), + (12, 45, 15, 28, 46, 16), + # 28 + (3, 147, 117, 10, 148, 118), + (3, 73, 45, 23, 74, 46), + (4, 54, 24, 31, 55, 25), + (11, 45, 15, 31, 46, 16), + # 29 + (7, 146, 116, 7, 147, 117), + (21, 73, 45, 7, 74, 46), + (1, 53, 23, 37, 54, 24), + (19, 45, 15, 26, 46, 16), + # 30 + (5, 145, 115, 10, 146, 116), + (19, 75, 47, 10, 76, 48), + (15, 54, 24, 25, 55, 25), + (23, 45, 15, 25, 46, 16), + # 31 + (13, 145, 115, 3, 146, 116), + (2, 74, 46, 29, 75, 47), + (42, 54, 24, 1, 55, 25), + (23, 45, 15, 28, 46, 16), + # 32 + (17, 145, 115), + (10, 74, 46, 23, 75, 47), + (10, 54, 24, 35, 55, 25), + (19, 45, 15, 35, 46, 16), + # 33 + (17, 145, 115, 1, 146, 116), + (14, 74, 46, 21, 75, 47), + (29, 54, 24, 19, 55, 25), + (11, 45, 15, 46, 46, 16), + # 34 + (13, 145, 115, 6, 146, 116), + (14, 74, 46, 23, 75, 47), + (44, 54, 24, 7, 55, 25), + (59, 46, 16, 1, 47, 17), + # 35 + (12, 151, 121, 7, 152, 122), + (12, 75, 47, 26, 76, 48), + (39, 54, 24, 14, 55, 25), + (22, 45, 15, 41, 46, 16), + # 36 + (6, 151, 121, 14, 152, 122), + (6, 75, 47, 34, 76, 48), + (46, 54, 24, 10, 55, 25), + (2, 45, 15, 64, 46, 16), + # 37 + (17, 152, 122, 4, 153, 123), + (29, 74, 46, 14, 75, 47), + (49, 54, 24, 10, 55, 25), + (24, 45, 15, 46, 46, 16), + # 38 + (4, 152, 122, 18, 153, 123), + (13, 74, 46, 32, 75, 47), + (48, 54, 24, 14, 55, 25), + (42, 45, 15, 32, 46, 16), + # 39 + (20, 147, 117, 4, 148, 118), + (40, 75, 47, 7, 76, 48), + (43, 54, 24, 22, 55, 25), + (10, 45, 15, 67, 46, 16), + # 40 + (19, 148, 118, 6, 149, 119), + (18, 75, 47, 31, 76, 48), + (34, 54, 24, 34, 55, 25), + (20, 45, 15, 61, 46, 16), +) + + +def glog(n): + if n < 1: # pragma: no cover + raise ValueError(f"glog({n})") + return LOG_TABLE[n] + + +def gexp(n): + return EXP_TABLE[n % 255] + + +class Polynomial: + def __init__(self, num, shift): + if not num: # pragma: no cover + raise Exception(f"{len(num)}/{shift}") + + offset = 0 + for offset in range(len(num)): + if num[offset] != 0: + break + + self.num = num[offset:] + [0] * shift + + def __getitem__(self, index): + return self.num[index] + + def __iter__(self): + return iter(self.num) + + def __len__(self): + return len(self.num) + + def __mul__(self, other): + num = [0] * (len(self) + len(other) - 1) + + for i, item in enumerate(self): + for j, other_item in enumerate(other): + num[i + j] ^= gexp(glog(item) + glog(other_item)) + + return Polynomial(num, 0) + + def __mod__(self, other): + difference = len(self) - len(other) + if difference < 0: + return self + + ratio = glog(self[0]) - glog(other[0]) + + num = [ + item ^ gexp(glog(other_item) + ratio) + for item, other_item in zip(self, other) + ] + if difference: + num.extend(self[-difference:]) + + # recursive call + return Polynomial(num, 0) % other + + +class RSBlock(NamedTuple): + total_count: int + data_count: int + + +def rs_blocks(version, error_correction): + if error_correction not in RS_BLOCK_OFFSET: # pragma: no cover + raise Exception( + "bad rs block @ version: %s / error_correction: %s" + % (version, error_correction) + ) + offset = RS_BLOCK_OFFSET[error_correction] + rs_block = RS_BLOCK_TABLE[(version - 1) * 4 + offset] + + blocks = [] + + for i in range(0, len(rs_block), 3): + count, total_count, data_count = rs_block[i : i + 3] + for _ in range(count): + blocks.append(RSBlock(total_count, data_count)) + + return blocks diff --git a/talkingq-url/scripts/_vendor/qrcode/constants.py b/talkingq-url/scripts/_vendor/qrcode/constants.py new file mode 100644 index 0000000..385dda0 --- /dev/null +++ b/talkingq-url/scripts/_vendor/qrcode/constants.py @@ -0,0 +1,5 @@ +# QR error correct levels +ERROR_CORRECT_L = 1 +ERROR_CORRECT_M = 0 +ERROR_CORRECT_Q = 3 +ERROR_CORRECT_H = 2 diff --git a/talkingq-url/scripts/_vendor/qrcode/exceptions.py b/talkingq-url/scripts/_vendor/qrcode/exceptions.py new file mode 100644 index 0000000..b37bd30 --- /dev/null +++ b/talkingq-url/scripts/_vendor/qrcode/exceptions.py @@ -0,0 +1,2 @@ +class DataOverflowError(Exception): + pass diff --git a/talkingq-url/scripts/_vendor/qrcode/image/__init__.py b/talkingq-url/scripts/_vendor/qrcode/image/__init__.py new file mode 100644 index 0000000..1a0c4cb --- /dev/null +++ b/talkingq-url/scripts/_vendor/qrcode/image/__init__.py @@ -0,0 +1 @@ +# Minimal package marker for the vendored qrcode runtime used by generate_bind_qr.py. diff --git a/talkingq-url/scripts/_vendor/qrcode/image/base.py b/talkingq-url/scripts/_vendor/qrcode/image/base.py new file mode 100644 index 0000000..4e15d79 --- /dev/null +++ b/talkingq-url/scripts/_vendor/qrcode/image/base.py @@ -0,0 +1,2 @@ +class BaseImage: + pass diff --git a/talkingq-url/scripts/_vendor/qrcode/image/pure.py b/talkingq-url/scripts/_vendor/qrcode/image/pure.py new file mode 100644 index 0000000..074d049 --- /dev/null +++ b/talkingq-url/scripts/_vendor/qrcode/image/pure.py @@ -0,0 +1,5 @@ +from .base import BaseImage + + +class PyPNGImage(BaseImage): + pass diff --git a/talkingq-url/scripts/_vendor/qrcode/main.py b/talkingq-url/scripts/_vendor/qrcode/main.py new file mode 100644 index 0000000..152c97b --- /dev/null +++ b/talkingq-url/scripts/_vendor/qrcode/main.py @@ -0,0 +1,541 @@ +import sys +from bisect import bisect_left +from typing import ( + Generic, + NamedTuple, + Optional, + TypeVar, + cast, + overload, + Literal, +) + +from qrcode import constants, exceptions, util +from qrcode.image.base import BaseImage +from qrcode.image.pure import PyPNGImage + +ModulesType = list[list[Optional[bool]]] +# Cache modules generated just based on the QR Code version +precomputed_qr_blanks: dict[int, ModulesType] = {} + + +def make(data=None, **kwargs): + qr = QRCode(**kwargs) + qr.add_data(data) + return qr.make_image() + + +def _check_box_size(size): + if int(size) <= 0: + raise ValueError(f"Invalid box size (was {size}, expected larger than 0)") + + +def _check_border(size): + if int(size) < 0: + raise ValueError( + "Invalid border value (was %s, expected 0 or larger than that)" % size + ) + + +def _check_mask_pattern(mask_pattern): + if mask_pattern is None: + return + if not isinstance(mask_pattern, int): + raise TypeError( + f"Invalid mask pattern (was {type(mask_pattern)}, expected int)" + ) + if mask_pattern < 0 or mask_pattern > 7: + raise ValueError(f"Mask pattern should be in range(8) (got {mask_pattern})") + + +def copy_2d_array(x): + return [row[:] for row in x] + + +class ActiveWithNeighbors(NamedTuple): + NW: bool + N: bool + NE: bool + W: bool + me: bool + E: bool + SW: bool + S: bool + SE: bool + + def __bool__(self) -> bool: + return self.me + + +GenericImage = TypeVar("GenericImage", bound=BaseImage) +GenericImageLocal = TypeVar("GenericImageLocal", bound=BaseImage) + + +class QRCode(Generic[GenericImage]): + modules: ModulesType + _version: Optional[int] = None + + def __init__( + self, + version=None, + error_correction=constants.ERROR_CORRECT_M, + box_size=10, + border=4, + image_factory: Optional[type[GenericImage]] = None, + mask_pattern=None, + ): + _check_box_size(box_size) + _check_border(border) + self.version = version + self.error_correction = int(error_correction) + self.box_size = int(box_size) + # Spec says border should be at least four boxes wide, but allow for + # any (e.g. for producing printable QR codes). + self.border = int(border) + self.mask_pattern = mask_pattern + self.image_factory = image_factory + if image_factory is not None: + assert issubclass(image_factory, BaseImage) + self.clear() + + @property + def version(self) -> int: + if self._version is None: + self.best_fit() + return cast(int, self._version) + + @version.setter + def version(self, value) -> None: + if value is not None: + value = int(value) + util.check_version(value) + self._version = value + + @property + def mask_pattern(self): + return self._mask_pattern + + @mask_pattern.setter + def mask_pattern(self, pattern): + _check_mask_pattern(pattern) + self._mask_pattern = pattern + + def clear(self): + """ + Reset the internal data. + """ + self.modules = [[]] + self.modules_count = 0 + self.data_cache = None + self.data_list = [] + + def add_data(self, data, optimize=20): + """ + Add data to this QR Code. + + :param optimize: Data will be split into multiple chunks to optimize + the QR size by finding to more compressed modes of at least this + length. Set to ``0`` to avoid optimizing at all. + """ + if isinstance(data, util.QRData): + self.data_list.append(data) + elif optimize: + self.data_list.extend(util.optimal_data_chunks(data, minimum=optimize)) + else: + self.data_list.append(util.QRData(data)) + self.data_cache = None + + def make(self, fit=True): + """ + Compile the data into a QR Code array. + + :param fit: If ``True`` (or if a size has not been provided), find the + best fit for the data to avoid data overflow errors. + """ + if fit or (self.version is None): + self.best_fit(start=self.version) + if self.mask_pattern is None: + self.makeImpl(False, self.best_mask_pattern()) + else: + self.makeImpl(False, self.mask_pattern) + + def makeImpl(self, test, mask_pattern): + self.modules_count = self.version * 4 + 17 + + if self.version in precomputed_qr_blanks: + self.modules = copy_2d_array(precomputed_qr_blanks[self.version]) + else: + self.modules = [ + [None] * self.modules_count for i in range(self.modules_count) + ] + self.setup_position_probe_pattern(0, 0) + self.setup_position_probe_pattern(self.modules_count - 7, 0) + self.setup_position_probe_pattern(0, self.modules_count - 7) + self.setup_position_adjust_pattern() + self.setup_timing_pattern() + + precomputed_qr_blanks[self.version] = copy_2d_array(self.modules) + + self.setup_type_info(test, mask_pattern) + + if self.version >= 7: + self.setup_type_number(test) + + if self.data_cache is None: + self.data_cache = util.create_data( + self.version, self.error_correction, self.data_list + ) + self.map_data(self.data_cache, mask_pattern) + + def setup_position_probe_pattern(self, row, col): + for r in range(-1, 8): + if row + r <= -1 or self.modules_count <= row + r: + continue + + for c in range(-1, 8): + if col + c <= -1 or self.modules_count <= col + c: + continue + + if ( + (0 <= r <= 6 and c in {0, 6}) + or (0 <= c <= 6 and r in {0, 6}) + or (2 <= r <= 4 and 2 <= c <= 4) + ): + self.modules[row + r][col + c] = True + else: + self.modules[row + r][col + c] = False + + def best_fit(self, start=None): + """ + Find the minimum size required to fit in the data. + """ + if start is None: + start = 1 + util.check_version(start) + + # Corresponds to the code in util.create_data, except we don't yet know + # version, so optimistically assume start and check later + mode_sizes = util.mode_sizes_for_version(start) + buffer = util.BitBuffer() + for data in self.data_list: + buffer.put(data.mode, 4) + buffer.put(len(data), mode_sizes[data.mode]) + data.write(buffer) + + needed_bits = len(buffer) + self.version = bisect_left( + util.BIT_LIMIT_TABLE[self.error_correction], needed_bits, start + ) + if self.version == 41: + raise exceptions.DataOverflowError() + + # Now check whether we need more bits for the mode sizes, recursing if + # our guess was too low + if mode_sizes is not util.mode_sizes_for_version(self.version): + self.best_fit(start=self.version) + return self.version + + def best_mask_pattern(self): + """ + Find the most efficient mask pattern. + """ + min_lost_point = 0 + pattern = 0 + + for i in range(8): + self.makeImpl(True, i) + + lost_point = util.lost_point(self.modules) + + if i == 0 or min_lost_point > lost_point: + min_lost_point = lost_point + pattern = i + + return pattern + + def print_tty(self, out=None): + """ + Output the QR Code only using TTY colors. + + If the data has not been compiled yet, make it first. + """ + if out is None: + import sys + + out = sys.stdout + + if not out.isatty(): + raise OSError("Not a tty") + + if self.data_cache is None: + self.make() + + modcount = self.modules_count + out.write("\x1b[1;47m" + (" " * (modcount * 2 + 4)) + "\x1b[0m\n") + for r in range(modcount): + out.write("\x1b[1;47m \x1b[40m") + for c in range(modcount): + if self.modules[r][c]: + out.write(" ") + else: + out.write("\x1b[1;47m \x1b[40m") + out.write("\x1b[1;47m \x1b[0m\n") + out.write("\x1b[1;47m" + (" " * (modcount * 2 + 4)) + "\x1b[0m\n") + out.flush() + + def print_ascii(self, out=None, tty=False, invert=False): + """ + Output the QR Code using ASCII characters. + + :param tty: use fixed TTY color codes (forces invert=True) + :param invert: invert the ASCII characters (solid <-> transparent) + """ + if out is None: + out = sys.stdout + + if tty and not out.isatty(): + raise OSError("Not a tty") + + if self.data_cache is None: + self.make() + + modcount = self.modules_count + codes = [bytes((code,)).decode("cp437") for code in (255, 223, 220, 219)] + if tty: + invert = True + if invert: + codes.reverse() + + def get_module(x, y) -> int: + if invert and self.border and max(x, y) >= modcount + self.border: + return 1 + if min(x, y) < 0 or max(x, y) >= modcount: + return 0 + return cast(int, self.modules[x][y]) + + for r in range(-self.border, modcount + self.border, 2): + if tty: + if not invert or r < modcount + self.border - 1: + out.write("\x1b[48;5;232m") # Background black + out.write("\x1b[38;5;255m") # Foreground white + for c in range(-self.border, modcount + self.border): + pos = get_module(r, c) + (get_module(r + 1, c) << 1) + out.write(codes[pos]) + if tty: + out.write("\x1b[0m") + out.write("\n") + out.flush() + + @overload + def make_image( + self, image_factory: Literal[None] = None, **kwargs + ) -> GenericImage: ... + + @overload + def make_image( + self, image_factory: type[GenericImageLocal] = None, **kwargs + ) -> GenericImageLocal: ... + + def make_image(self, image_factory=None, **kwargs): + """ + Make an image from the QR Code data. + + If the data has not been compiled yet, make it first. + """ + # allow embeded_ parameters with typos for backwards compatibility + if ( + kwargs.get("embedded_image_path") + or kwargs.get("embedded_image") + or kwargs.get("embeded_image_path") + or kwargs.get("embeded_image") + ) and self.error_correction != constants.ERROR_CORRECT_H: + raise ValueError( + "Error correction level must be ERROR_CORRECT_H if an embedded image is provided" + ) + _check_box_size(self.box_size) + if self.data_cache is None: + self.make() + + if image_factory is not None: + assert issubclass(image_factory, BaseImage) + else: + image_factory = self.image_factory + if image_factory is None: + from qrcode.image.pil import Image, PilImage + + # Use PIL by default if available, otherwise use PyPNG. + image_factory = PilImage if Image else PyPNGImage + + im = image_factory( + self.border, + self.modules_count, + self.box_size, + qrcode_modules=self.modules, + **kwargs, + ) + + if im.needs_drawrect: + for r in range(self.modules_count): + for c in range(self.modules_count): + if im.needs_context: + im.drawrect_context(r, c, qr=self) + elif self.modules[r][c]: + im.drawrect(r, c) + if im.needs_processing: + im.process() + + return im + + # return true if and only if (row, col) is in the module + def is_constrained(self, row: int, col: int) -> bool: + return ( + row >= 0 + and row < len(self.modules) + and col >= 0 + and col < len(self.modules[row]) + ) + + def setup_timing_pattern(self): + for r in range(8, self.modules_count - 8): + if self.modules[r][6] is not None: + continue + self.modules[r][6] = r % 2 == 0 + + for c in range(8, self.modules_count - 8): + if self.modules[6][c] is not None: + continue + self.modules[6][c] = c % 2 == 0 + + def setup_position_adjust_pattern(self): + pos = util.pattern_position(self.version) + + for i in range(len(pos)): + row = pos[i] + + for j in range(len(pos)): + col = pos[j] + + if self.modules[row][col] is not None: + continue + + for r in range(-2, 3): + for c in range(-2, 3): + if ( + r == -2 + or r == 2 + or c == -2 + or c == 2 + or (r == 0 and c == 0) + ): + self.modules[row + r][col + c] = True + else: + self.modules[row + r][col + c] = False + + def setup_type_number(self, test): + bits = util.BCH_type_number(self.version) + + for i in range(18): + mod = not test and ((bits >> i) & 1) == 1 + self.modules[i // 3][i % 3 + self.modules_count - 8 - 3] = mod + + for i in range(18): + mod = not test and ((bits >> i) & 1) == 1 + self.modules[i % 3 + self.modules_count - 8 - 3][i // 3] = mod + + def setup_type_info(self, test, mask_pattern): + data = (self.error_correction << 3) | mask_pattern + bits = util.BCH_type_info(data) + + # vertical + for i in range(15): + mod = not test and ((bits >> i) & 1) == 1 + + if i < 6: + self.modules[i][8] = mod + elif i < 8: + self.modules[i + 1][8] = mod + else: + self.modules[self.modules_count - 15 + i][8] = mod + + # horizontal + for i in range(15): + mod = not test and ((bits >> i) & 1) == 1 + + if i < 8: + self.modules[8][self.modules_count - i - 1] = mod + elif i < 9: + self.modules[8][15 - i - 1 + 1] = mod + else: + self.modules[8][15 - i - 1] = mod + + # fixed module + self.modules[self.modules_count - 8][8] = not test + + def map_data(self, data, mask_pattern): + inc = -1 + row = self.modules_count - 1 + bitIndex = 7 + byteIndex = 0 + + mask_func = util.mask_func(mask_pattern) + + data_len = len(data) + + for col in range(self.modules_count - 1, 0, -2): + if col <= 6: + col -= 1 + + col_range = (col, col - 1) + + while True: + for c in col_range: + if self.modules[row][c] is None: + dark = False + + if byteIndex < data_len: + dark = ((data[byteIndex] >> bitIndex) & 1) == 1 + + if mask_func(row, c): + dark = not dark + + self.modules[row][c] = dark + bitIndex -= 1 + + if bitIndex == -1: + byteIndex += 1 + bitIndex = 7 + + row += inc + + if row < 0 or self.modules_count <= row: + row -= inc + inc = -inc + break + + def get_matrix(self): + """ + Return the QR Code as a multidimensional array, including the border. + + To return the array without a border, set ``self.border`` to 0 first. + """ + if self.data_cache is None: + self.make() + + if not self.border: + return self.modules + + width = len(self.modules) + self.border * 2 + code = [[False] * width] * self.border + x_border = [False] * self.border + for module in self.modules: + code.append(x_border + cast(list[bool], module) + x_border) + code += [[False] * width] * self.border + + return code + + def active_with_neighbors(self, row: int, col: int) -> ActiveWithNeighbors: + context: list[bool] = [] + for r in range(row - 1, row + 2): + for c in range(col - 1, col + 2): + context.append(self.is_constrained(r, c) and bool(self.modules[r][c])) + return ActiveWithNeighbors(*context) diff --git a/talkingq-url/scripts/_vendor/qrcode/util.py b/talkingq-url/scripts/_vendor/qrcode/util.py new file mode 100644 index 0000000..fe25548 --- /dev/null +++ b/talkingq-url/scripts/_vendor/qrcode/util.py @@ -0,0 +1,584 @@ +import math +import re + +from qrcode import LUT, base, exceptions +from qrcode.base import RSBlock + +# QR encoding modes. +MODE_NUMBER = 1 << 0 +MODE_ALPHA_NUM = 1 << 1 +MODE_8BIT_BYTE = 1 << 2 +MODE_KANJI = 1 << 3 + +# Encoding mode sizes. +MODE_SIZE_SMALL = { + MODE_NUMBER: 10, + MODE_ALPHA_NUM: 9, + MODE_8BIT_BYTE: 8, + MODE_KANJI: 8, +} +MODE_SIZE_MEDIUM = { + MODE_NUMBER: 12, + MODE_ALPHA_NUM: 11, + MODE_8BIT_BYTE: 16, + MODE_KANJI: 10, +} +MODE_SIZE_LARGE = { + MODE_NUMBER: 14, + MODE_ALPHA_NUM: 13, + MODE_8BIT_BYTE: 16, + MODE_KANJI: 12, +} + +ALPHA_NUM = b"0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZ $%*+-./:" +RE_ALPHA_NUM = re.compile(b"^[" + re.escape(ALPHA_NUM) + rb"]*\Z") + +# The number of bits for numeric delimited data lengths. +NUMBER_LENGTH = {3: 10, 2: 7, 1: 4} + +PATTERN_POSITION_TABLE = [ + [], + [6, 18], + [6, 22], + [6, 26], + [6, 30], + [6, 34], + [6, 22, 38], + [6, 24, 42], + [6, 26, 46], + [6, 28, 50], + [6, 30, 54], + [6, 32, 58], + [6, 34, 62], + [6, 26, 46, 66], + [6, 26, 48, 70], + [6, 26, 50, 74], + [6, 30, 54, 78], + [6, 30, 56, 82], + [6, 30, 58, 86], + [6, 34, 62, 90], + [6, 28, 50, 72, 94], + [6, 26, 50, 74, 98], + [6, 30, 54, 78, 102], + [6, 28, 54, 80, 106], + [6, 32, 58, 84, 110], + [6, 30, 58, 86, 114], + [6, 34, 62, 90, 118], + [6, 26, 50, 74, 98, 122], + [6, 30, 54, 78, 102, 126], + [6, 26, 52, 78, 104, 130], + [6, 30, 56, 82, 108, 134], + [6, 34, 60, 86, 112, 138], + [6, 30, 58, 86, 114, 142], + [6, 34, 62, 90, 118, 146], + [6, 30, 54, 78, 102, 126, 150], + [6, 24, 50, 76, 102, 128, 154], + [6, 28, 54, 80, 106, 132, 158], + [6, 32, 58, 84, 110, 136, 162], + [6, 26, 54, 82, 110, 138, 166], + [6, 30, 58, 86, 114, 142, 170], +] + +G15 = (1 << 10) | (1 << 8) | (1 << 5) | (1 << 4) | (1 << 2) | (1 << 1) | (1 << 0) +G18 = ( + (1 << 12) + | (1 << 11) + | (1 << 10) + | (1 << 9) + | (1 << 8) + | (1 << 5) + | (1 << 2) + | (1 << 0) +) +G15_MASK = (1 << 14) | (1 << 12) | (1 << 10) | (1 << 4) | (1 << 1) + +PAD0 = 0xEC +PAD1 = 0x11 + + +# Precompute bit count limits, indexed by error correction level and code size +def _data_count(block): + return block.data_count + + +BIT_LIMIT_TABLE = [ + [0] + + [ + 8 * sum(map(_data_count, base.rs_blocks(version, error_correction))) + for version in range(1, 41) + ] + for error_correction in range(4) +] + + +def BCH_type_info(data): + d = data << 10 + while BCH_digit(d) - BCH_digit(G15) >= 0: + d ^= G15 << (BCH_digit(d) - BCH_digit(G15)) + + return ((data << 10) | d) ^ G15_MASK + + +def BCH_type_number(data): + d = data << 12 + while BCH_digit(d) - BCH_digit(G18) >= 0: + d ^= G18 << (BCH_digit(d) - BCH_digit(G18)) + return (data << 12) | d + + +def BCH_digit(data): + digit = 0 + while data != 0: + digit += 1 + data >>= 1 + return digit + + +def pattern_position(version): + return PATTERN_POSITION_TABLE[version - 1] + + +def mask_func(pattern): + """ + Return the mask function for the given mask pattern. + """ + if pattern == 0: # 000 + return lambda i, j: (i + j) % 2 == 0 + if pattern == 1: # 001 + return lambda i, j: i % 2 == 0 + if pattern == 2: # 010 + return lambda i, j: j % 3 == 0 + if pattern == 3: # 011 + return lambda i, j: (i + j) % 3 == 0 + if pattern == 4: # 100 + return lambda i, j: (math.floor(i / 2) + math.floor(j / 3)) % 2 == 0 + if pattern == 5: # 101 + return lambda i, j: (i * j) % 2 + (i * j) % 3 == 0 + if pattern == 6: # 110 + return lambda i, j: ((i * j) % 2 + (i * j) % 3) % 2 == 0 + if pattern == 7: # 111 + return lambda i, j: ((i * j) % 3 + (i + j) % 2) % 2 == 0 + raise TypeError("Bad mask pattern: " + pattern) # pragma: no cover + + +def mode_sizes_for_version(version): + if version < 10: + return MODE_SIZE_SMALL + elif version < 27: + return MODE_SIZE_MEDIUM + else: + return MODE_SIZE_LARGE + + +def length_in_bits(mode, version): + if mode not in (MODE_NUMBER, MODE_ALPHA_NUM, MODE_8BIT_BYTE, MODE_KANJI): + raise TypeError(f"Invalid mode ({mode})") # pragma: no cover + + check_version(version) + + return mode_sizes_for_version(version)[mode] + + +def check_version(version): + if version < 1 or version > 40: + raise ValueError(f"Invalid version (was {version}, expected 1 to 40)") + + +def lost_point(modules): + modules_count = len(modules) + + lost_point = 0 + + lost_point = _lost_point_level1(modules, modules_count) + lost_point += _lost_point_level2(modules, modules_count) + lost_point += _lost_point_level3(modules, modules_count) + lost_point += _lost_point_level4(modules, modules_count) + + return lost_point + + +def _lost_point_level1(modules, modules_count): + lost_point = 0 + + modules_range = range(modules_count) + container = [0] * (modules_count + 1) + + for row in modules_range: + this_row = modules[row] + previous_color = this_row[0] + length = 0 + for col in modules_range: + if this_row[col] == previous_color: + length += 1 + else: + if length >= 5: + container[length] += 1 + length = 1 + previous_color = this_row[col] + if length >= 5: + container[length] += 1 + + for col in modules_range: + previous_color = modules[0][col] + length = 0 + for row in modules_range: + if modules[row][col] == previous_color: + length += 1 + else: + if length >= 5: + container[length] += 1 + length = 1 + previous_color = modules[row][col] + if length >= 5: + container[length] += 1 + + lost_point += sum( + container[each_length] * (each_length - 2) + for each_length in range(5, modules_count + 1) + ) + + return lost_point + + +def _lost_point_level2(modules, modules_count): + lost_point = 0 + + modules_range = range(modules_count - 1) + for row in modules_range: + this_row = modules[row] + next_row = modules[row + 1] + # use iter() and next() to skip next four-block. e.g. + # d a f if top-right a != b bottom-right, + # c b e then both abcd and abef won't lost any point. + modules_range_iter = iter(modules_range) + for col in modules_range_iter: + top_right = this_row[col + 1] + if top_right != next_row[col + 1]: + # reduce 33.3% of runtime via next(). + # None: raise nothing if there is no next item. + next(modules_range_iter, None) + elif top_right != this_row[col]: + continue + elif top_right != next_row[col]: + continue + else: + lost_point += 3 + + return lost_point + + +def _lost_point_level3(modules, modules_count): + # 1 : 1 : 3 : 1 : 1 ratio (dark:light:dark:light:dark) pattern in + # row/column, preceded or followed by light area 4 modules wide. From ISOIEC. + # pattern1: 10111010000 + # pattern2: 00001011101 + modules_range = range(modules_count) + modules_range_short = range(modules_count - 10) + lost_point = 0 + + for row in modules_range: + this_row = modules[row] + modules_range_short_iter = iter(modules_range_short) + col = 0 + for col in modules_range_short_iter: + if ( + not this_row[col + 1] + and this_row[col + 4] + and not this_row[col + 5] + and this_row[col + 6] + and not this_row[col + 9] + and ( + this_row[col + 0] + and this_row[col + 2] + and this_row[col + 3] + and not this_row[col + 7] + and not this_row[col + 8] + and not this_row[col + 10] + or not this_row[col + 0] + and not this_row[col + 2] + and not this_row[col + 3] + and this_row[col + 7] + and this_row[col + 8] + and this_row[col + 10] + ) + ): + lost_point += 40 + # horspool algorithm. + # if this_row[col + 10]: + # pattern1 shift 4, pattern2 shift 2. So min=2. + # else: + # pattern1 shift 1, pattern2 shift 1. So min=1. + if this_row[col + 10]: + next(modules_range_short_iter, None) + + for col in modules_range: + modules_range_short_iter = iter(modules_range_short) + row = 0 + for row in modules_range_short_iter: + if ( + not modules[row + 1][col] + and modules[row + 4][col] + and not modules[row + 5][col] + and modules[row + 6][col] + and not modules[row + 9][col] + and ( + modules[row + 0][col] + and modules[row + 2][col] + and modules[row + 3][col] + and not modules[row + 7][col] + and not modules[row + 8][col] + and not modules[row + 10][col] + or not modules[row + 0][col] + and not modules[row + 2][col] + and not modules[row + 3][col] + and modules[row + 7][col] + and modules[row + 8][col] + and modules[row + 10][col] + ) + ): + lost_point += 40 + if modules[row + 10][col]: + next(modules_range_short_iter, None) + + return lost_point + + +def _lost_point_level4(modules, modules_count): + dark_count = sum(map(sum, modules)) + percent = float(dark_count) / (modules_count**2) + # Every 5% departure from 50%, rating++ + rating = int(abs(percent * 100 - 50) / 5) + return rating * 10 + + +def optimal_data_chunks(data, minimum=4): + """ + An iterator returning QRData chunks optimized to the data content. + + :param minimum: The minimum number of bytes in a row to split as a chunk. + """ + data = to_bytestring(data) + num_pattern = rb"\d" + alpha_pattern = b"[" + re.escape(ALPHA_NUM) + b"]" + if len(data) <= minimum: + num_pattern = re.compile(b"^" + num_pattern + b"+$") + alpha_pattern = re.compile(b"^" + alpha_pattern + b"+$") + else: + re_repeat = b"{" + str(minimum).encode("ascii") + b",}" + num_pattern = re.compile(num_pattern + re_repeat) + alpha_pattern = re.compile(alpha_pattern + re_repeat) + num_bits = _optimal_split(data, num_pattern) + for is_num, chunk in num_bits: + if is_num: + yield QRData(chunk, mode=MODE_NUMBER, check_data=False) + else: + for is_alpha, sub_chunk in _optimal_split(chunk, alpha_pattern): + mode = MODE_ALPHA_NUM if is_alpha else MODE_8BIT_BYTE + yield QRData(sub_chunk, mode=mode, check_data=False) + + +def _optimal_split(data, pattern): + while data: + match = re.search(pattern, data) + if not match: + break + start, end = match.start(), match.end() + if start: + yield False, data[:start] + yield True, data[start:end] + data = data[end:] + if data: + yield False, data + + +def to_bytestring(data): + """ + Convert data to a (utf-8 encoded) byte-string if it isn't a byte-string + already. + """ + if not isinstance(data, bytes): + data = str(data).encode("utf-8") + return data + + +def optimal_mode(data): + """ + Calculate the optimal mode for this chunk of data. + """ + if data.isdigit(): + return MODE_NUMBER + if RE_ALPHA_NUM.match(data): + return MODE_ALPHA_NUM + return MODE_8BIT_BYTE + + +class QRData: + """ + Data held in a QR compatible format. + + Doesn't currently handle KANJI. + """ + + def __init__(self, data, mode=None, check_data=True): + """ + If ``mode`` isn't provided, the most compact QR data type possible is + chosen. + """ + if check_data: + data = to_bytestring(data) + + if mode is None: + self.mode = optimal_mode(data) + else: + self.mode = mode + if mode not in (MODE_NUMBER, MODE_ALPHA_NUM, MODE_8BIT_BYTE): + raise TypeError(f"Invalid mode ({mode})") # pragma: no cover + if check_data and mode < optimal_mode(data): # pragma: no cover + raise ValueError(f"Provided data can not be represented in mode {mode}") + + self.data = data + + def __len__(self): + return len(self.data) + + def write(self, buffer): + if self.mode == MODE_NUMBER: + for i in range(0, len(self.data), 3): + chars = self.data[i : i + 3] + bit_length = NUMBER_LENGTH[len(chars)] + buffer.put(int(chars), bit_length) + elif self.mode == MODE_ALPHA_NUM: + for i in range(0, len(self.data), 2): + chars = self.data[i : i + 2] + if len(chars) > 1: + buffer.put( + ALPHA_NUM.find(chars[0]) * 45 + ALPHA_NUM.find(chars[1]), 11 + ) + else: + buffer.put(ALPHA_NUM.find(chars), 6) + else: + # Iterating a bytestring in Python 3 returns an integer, + # no need to ord(). + data = self.data + for c in data: + buffer.put(c, 8) + + def __repr__(self): + return repr(self.data) + + +class BitBuffer: + def __init__(self): + self.buffer: list[int] = [] + self.length = 0 + + def __repr__(self): + return ".".join([str(n) for n in self.buffer]) + + def get(self, index): + buf_index = math.floor(index / 8) + return ((self.buffer[buf_index] >> (7 - index % 8)) & 1) == 1 + + def put(self, num, length): + for i in range(length): + self.put_bit(((num >> (length - i - 1)) & 1) == 1) + + def __len__(self): + return self.length + + def put_bit(self, bit): + buf_index = self.length // 8 + if len(self.buffer) <= buf_index: + self.buffer.append(0) + if bit: + self.buffer[buf_index] |= 0x80 >> (self.length % 8) + self.length += 1 + + +def create_bytes(buffer: BitBuffer, rs_blocks: list[RSBlock]): + offset = 0 + + maxDcCount = 0 + maxEcCount = 0 + + dcdata: list[list[int]] = [] + ecdata: list[list[int]] = [] + + for rs_block in rs_blocks: + dcCount = rs_block.data_count + ecCount = rs_block.total_count - dcCount + + maxDcCount = max(maxDcCount, dcCount) + maxEcCount = max(maxEcCount, ecCount) + + current_dc = [0xFF & buffer.buffer[i + offset] for i in range(dcCount)] + offset += dcCount + + # Get error correction polynomial. + if ecCount in LUT.rsPoly_LUT: + rsPoly = base.Polynomial(LUT.rsPoly_LUT[ecCount], 0) + else: + rsPoly = base.Polynomial([1], 0) + for i in range(ecCount): + rsPoly = rsPoly * base.Polynomial([1, base.gexp(i)], 0) + + rawPoly = base.Polynomial(current_dc, len(rsPoly) - 1) + + modPoly = rawPoly % rsPoly + current_ec = [] + mod_offset = len(modPoly) - ecCount + for i in range(ecCount): + modIndex = i + mod_offset + current_ec.append(modPoly[modIndex] if (modIndex >= 0) else 0) + + dcdata.append(current_dc) + ecdata.append(current_ec) + + data = [] + for i in range(maxDcCount): + for dc in dcdata: + if i < len(dc): + data.append(dc[i]) + for i in range(maxEcCount): + for ec in ecdata: + if i < len(ec): + data.append(ec[i]) + + return data + + +def create_data(version, error_correction, data_list): + buffer = BitBuffer() + for data in data_list: + buffer.put(data.mode, 4) + buffer.put(len(data), length_in_bits(data.mode, version)) + data.write(buffer) + + # Calculate the maximum number of bits for the given version. + rs_blocks = base.rs_blocks(version, error_correction) + bit_limit = sum(block.data_count * 8 for block in rs_blocks) + if len(buffer) > bit_limit: + raise exceptions.DataOverflowError( + "Code length overflow. Data size (%s) > size available (%s)" + % (len(buffer), bit_limit) + ) + + # Terminate the bits (add up to four 0s). + for _ in range(min(bit_limit - len(buffer), 4)): + buffer.put_bit(False) + + # Delimit the string into 8-bit words, padding with 0s if necessary. + delimit = len(buffer) % 8 + if delimit: + for _ in range(8 - delimit): + buffer.put_bit(False) + + # Add special alternating padding bitstrings until buffer is full. + bytes_to_fill = (bit_limit - len(buffer)) // 8 + for i in range(bytes_to_fill): + if i % 2 == 0: + buffer.put(PAD0, 8) + else: + buffer.put(PAD1, 8) + + return create_bytes(buffer, rs_blocks) diff --git a/talkingq-url/scripts/generate_bind_qr.py b/talkingq-url/scripts/generate_bind_qr.py new file mode 100644 index 0000000..d56f4d1 --- /dev/null +++ b/talkingq-url/scripts/generate_bind_qr.py @@ -0,0 +1,149 @@ +#!/usr/bin/env python3 +from __future__ import annotations + +import argparse +import json +import re +import struct +import sys +import zlib +from pathlib import Path +from typing import Iterable + + +SCRIPT_DIR = Path(__file__).resolve().parent +VENDOR_ROOT = SCRIPT_DIR / "_vendor" +if str(VENDOR_ROOT) not in sys.path: + sys.path.insert(0, str(VENDOR_ROOT)) + +from qrcode.constants import ERROR_CORRECT_M +from qrcode.main import QRCode + + +DEFAULT_OUTPUT_DIR = SCRIPT_DIR.parent / "output" / "qr" +SERIAL_PREFIX = "TalkingQ-" +SAFE_NAME_RE = re.compile(r"[^A-Za-z0-9._-]+") + + +def parse_args() -> argparse.Namespace: + parser = argparse.ArgumentParser( + description="Generate a binding QR PNG for the banban device bind flow." + ) + parser.add_argument("device_id", help="Device ID written into the QR payload.") + parser.add_argument( + "serial_number", + help=f"Device serial number. It must start with {SERIAL_PREFIX!r}.", + ) + parser.add_argument( + "-o", + "--output", + help="Output PNG path. Defaults to talkingq-url/output/qr/.png", + ) + parser.add_argument( + "--box-size", + type=int, + default=10, + help="Pixel size of one QR module. Default: 10", + ) + return parser.parse_args() + + +def validate_inputs(device_id: str, serial_number: str, box_size: int) -> tuple[str, str]: + normalized_device_id = device_id.strip() + normalized_serial_number = serial_number.strip() + + if not normalized_device_id: + raise ValueError("device_id cannot be empty") + if not normalized_serial_number: + raise ValueError("serial_number cannot be empty") + if not normalized_serial_number.startswith(SERIAL_PREFIX): + raise ValueError(f"serial_number must start with {SERIAL_PREFIX}") + if box_size <= 0: + raise ValueError("box_size must be greater than 0") + + return normalized_device_id, normalized_serial_number + + +def build_payload(device_id: str, serial_number: str) -> str: + return json.dumps( + {"device_id": device_id, "serial_number": serial_number}, + ensure_ascii=False, + separators=(",", ":"), + ) + + +def build_qr_matrix(payload: str) -> list[list[bool]]: + qr = QRCode(error_correction=ERROR_CORRECT_M, border=4, box_size=10) + qr.add_data(payload) + qr.make(fit=True) + return qr.get_matrix() + + +def write_png(path: Path, matrix: list[list[bool]], box_size: int) -> None: + width = len(matrix[0]) * box_size + height = len(matrix) * box_size + raw_rows = bytearray() + + for row in matrix: + expanded_row = bytearray() + for cell in row: + pixel = 0 if cell else 255 + expanded_row.extend([pixel] * box_size) + row_bytes = bytes(expanded_row) + for _ in range(box_size): + raw_rows.append(0) + raw_rows.extend(row_bytes) + + ihdr = struct.pack("!IIBBBBB", width, height, 8, 0, 0, 0, 0) + compressed = zlib.compress(bytes(raw_rows), level=9) + + with path.open("wb") as fp: + fp.write(b"\x89PNG\r\n\x1a\n") + write_png_chunk(fp, b"IHDR", ihdr) + write_png_chunk(fp, b"IDAT", compressed) + write_png_chunk(fp, b"IEND", b"") + + +def write_png_chunk(fp, chunk_type: bytes, data: bytes) -> None: + fp.write(struct.pack("!I", len(data))) + fp.write(chunk_type) + fp.write(data) + crc = zlib.crc32(chunk_type) + crc = zlib.crc32(data, crc) + fp.write(struct.pack("!I", crc & 0xFFFFFFFF)) + + +def resolve_output_path(device_id: str, output_arg: str | None) -> Path: + if output_arg: + return Path(output_arg).expanduser().resolve() + + safe_name = SAFE_NAME_RE.sub("_", device_id).strip("._") or "bind_qr" + return (DEFAULT_OUTPUT_DIR / f"{safe_name}.png").resolve() + + +def write_payload_copy(txt_path: Path, payload: str) -> None: + txt_path.write_text(payload, encoding="utf-8") + + +def main() -> int: + args = parse_args() + try: + device_id, serial_number = validate_inputs(args.device_id, args.serial_number, args.box_size) + except ValueError as exc: + print(f"Error: {exc}", file=sys.stderr) + return 1 + + payload = build_payload(device_id, serial_number) + output_path = resolve_output_path(device_id, args.output) + output_path.parent.mkdir(parents=True, exist_ok=True) + + matrix = build_qr_matrix(payload) + write_png(output_path, matrix, args.box_size) + write_payload_copy(output_path.with_suffix(".txt"), payload) + + print(output_path) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/talkingq-url/scripts/simulate_device_voice_exchange.py b/talkingq-url/scripts/simulate_device_voice_exchange.py new file mode 100644 index 0000000..8c297ce --- /dev/null +++ b/talkingq-url/scripts/simulate_device_voice_exchange.py @@ -0,0 +1,348 @@ +#!/usr/bin/env python3 +from __future__ import annotations + +import argparse +import asyncio +import json +import struct +import sys +import uuid +from dataclasses import dataclass, field +from pathlib import Path +from typing import Callable + +import pymysql +import websockets +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 + + +DEFAULT_WS_URL = "ws://127.0.0.1:8080/ws" + + +@dataclass +class DeviceContext: + device_id: str + serial_number: str + child_id: int + card_uuid: str + inbox: asyncio.Queue[str] = field(default_factory=asyncio.Queue) + websocket: websockets.WebSocketClientProtocol | None = None + receiver_task: asyncio.Task | None = None + + +def build_runtime_args() -> argparse.Namespace: + repo_root = Path(__file__).resolve().parents[2] + audio_dir = repo_root / "tmp_voice_sim" + + parser = argparse.ArgumentParser(description="Simulate real websocket voice exchange between two devices") + parser.add_argument("--ws-url", default=DEFAULT_WS_URL) + parser.add_argument("--device-a", default="TalkingQ_device001") + parser.add_argument("--device-b", default="TalkingQ_device002") + parser.add_argument("--a1-audio", default=str(audio_dir / "device001_to_device002_1.mp3")) + parser.add_argument("--b1-audio", default=str(audio_dir / "device002_to_device001_1.mp3")) + parser.add_argument("--a2-audio", default=str(audio_dir / "device001_to_device002_2.mp3")) + parser.add_argument("--b2-audio", default=str(audio_dir / "device002_to_device001_2.mp3")) + 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 load_device_contexts(device_ids: list[str]) -> dict[str, DeviceContext]: + placeholders = ", ".join(["%s"] * len(device_ids)) + sql = f""" + 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 IN ({placeholders}) + AND da.is_active = 1 + """ + + contexts: dict[str, DeviceContext] = {} + with connect_db() as connection: + with connection.cursor() as cursor: + cursor.execute(sql, device_ids) + rows = cursor.fetchall() + + for row in rows: + if row["child_id"] is None: + raise RuntimeError(f"device {row['device_id']} is not bound to any child") + if not row["card_uuid"]: + raise RuntimeError(f"device {row['device_id']} does not have an active card") + contexts[row["device_id"]] = DeviceContext( + device_id=str(row["device_id"]), + serial_number=str(row["serial_number"]), + child_id=int(row["child_id"]), + card_uuid=str(row["card_uuid"]), + ) + + missing = [device_id for device_id in device_ids if device_id not in contexts] + if missing: + raise RuntimeError(f"device context not found: {', '.join(missing)}") + return contexts + + +def fetch_conversation_snapshot(child_a_id: int, child_b_id: int) -> tuple[int | None, int, int]: + pair_key = f"{min(child_a_id, child_b_id)}:{max(child_a_id, child_b_id)}" + with connect_db() as connection: + with connection.cursor() as cursor: + cursor.execute( + """ + SELECT id, message_count + FROM im_conversations + WHERE conversation_type = 1 + AND pair_key = %s + LIMIT 1 + """, + (pair_key,), + ) + row = cursor.fetchone() + if not row: + return None, 0, 0 + + conversation_id = int(row["id"]) + message_count = int(row["message_count"]) + cursor.execute( + "SELECT COALESCE(MAX(id), 0) AS max_message_id FROM im_messages WHERE conversation_id = %s", + (conversation_id,), + ) + max_row = cursor.fetchone() or {"max_message_id": 0} + return conversation_id, message_count, int(max_row["max_message_id"] or 0) + + +def fetch_new_messages(conversation_id: int, min_message_id: int) -> list[dict]: + with connect_db() as connection: + with connection.cursor() as cursor: + cursor.execute( + """ + SELECT + id, + conversation_id, + seq, + sender_type, + sender_id, + receiver_type, + receiver_id, + content_type, + media_file_key, + client_msg_id, + created_at + FROM im_messages + WHERE conversation_id = %s + AND id > %s + ORDER BY id ASC + """, + (conversation_id, min_message_id), + ) + return cursor.fetchall() + + +async def receiver_loop(ctx: DeviceContext) -> None: + assert ctx.websocket is not None + async for message in ctx.websocket: + if isinstance(message, str): + print(f"[{ctx.device_id}] <- {message}", flush=True) + await ctx.inbox.put(message) + else: + print(f"[{ctx.device_id}] <- ", flush=True) + + +async def wait_for_text( + ctx: DeviceContext, + predicate: Callable[[str], bool], + *, + timeout: float, + description: str, +) -> str: + loop = asyncio.get_running_loop() + deadline = loop.time() + timeout + while True: + remaining = deadline - loop.time() + if remaining <= 0: + raise TimeoutError(f"{ctx.device_id} timed out waiting for {description}") + message = await asyncio.wait_for(ctx.inbox.get(), timeout=remaining) + if predicate(message): + return message + + +def create_packet(device_id: str, session_id: str, sequence_number: int, packet_type: int, audio_data: bytes) -> bytes: + if len(session_id) != 32: + raise ValueError("session_id must be exactly 32 ascii chars") + return ( + device_id.encode("ascii") + + b"\x00" + + session_id.encode("ascii") + + b"\x00" + + struct.pack(" None: + websocket = await websockets.connect(ws_url, ping_interval=None, max_size=None) + ctx.websocket = websocket + ctx.receiver_task = asyncio.create_task(receiver_loop(ctx)) + await websocket.send(json.dumps({"device_id": ctx.device_id, "serial_number": ctx.serial_number}, ensure_ascii=False)) + await wait_for_text( + ctx, + lambda text: json.loads(text).get("status") == "authenticated", + timeout=10, + description="authentication response", + ) + print(f"[{ctx.device_id}] authenticated", flush=True) + + +async def send_voice_message( + sender: DeviceContext, + target: DeviceContext, + audio_path: Path, + *, + label: str, +) -> None: + if sender.websocket is None: + raise RuntimeError(f"{sender.device_id} is not connected") + if not audio_path.exists(): + raise FileNotFoundError(f"audio file not found: {audio_path}") + + audio_bytes = audio_path.read_bytes() + session_id = uuid.uuid4().hex + + print(f"[{label}] register target card {target.card_uuid}", flush=True) + await sender.websocket.send(f"REGISTER_TARGET_DEVICE:{target.card_uuid}") + await wait_for_text( + sender, + lambda text: text.startswith("TARGET_DEVICE_REGISTERED_URL:"), + timeout=5, + description="target registration response", + ) + + await sender.websocket.send(create_packet(sender.device_id, session_id, 0, 1, b"")) + print(f"[{label}] start session {session_id}", flush=True) + + chunk_size = 2048 + sequence_number = 1 + for offset in range(0, len(audio_bytes), chunk_size): + chunk = audio_bytes[offset : offset + chunk_size] + await sender.websocket.send(create_packet(sender.device_id, session_id, sequence_number, 4, chunk)) + sequence_number += 1 + await asyncio.sleep(0.03) + + await sender.websocket.send(create_packet(sender.device_id, session_id, sequence_number, 2, b"")) + print(f"[{label}] finish session {session_id}, bytes={len(audio_bytes)}", flush=True) + + await wait_for_text( + sender, + lambda text: text.startswith("PROMPT_SOUND_URL:"), + timeout=20, + description="message stored response", + ) + await asyncio.sleep(0.5) + + +async def close_device(ctx: DeviceContext) -> None: + if ctx.websocket is not None: + await ctx.websocket.close() + if ctx.receiver_task is not None: + try: + await asyncio.wait_for(ctx.receiver_task, timeout=2) + except Exception: + ctx.receiver_task.cancel() + + +async def main() -> None: + args = build_runtime_args() + audio_paths = { + "a1": Path(args.a1_audio), + "b1": Path(args.b1_audio), + "a2": Path(args.a2_audio), + "b2": Path(args.b2_audio), + } + + contexts = load_device_contexts([args.device_a, args.device_b]) + device_a = contexts[args.device_a] + device_b = contexts[args.device_b] + + conversation_id, message_count_before, max_message_id_before = fetch_conversation_snapshot( + device_a.child_id, + device_b.child_id, + ) + print( + f"[snapshot-before] conversation_id={conversation_id} message_count={message_count_before} max_message_id={max_message_id_before}", + flush=True, + ) + + await connect_device(device_a, args.ws_url) + await connect_device(device_b, args.ws_url) + + try: + await send_voice_message(device_a, device_b, audio_paths["a1"], label="A->B #1") + await send_voice_message(device_b, device_a, audio_paths["b1"], label="B->A #1") + await send_voice_message(device_a, device_b, audio_paths["a2"], label="A->B #2") + await send_voice_message(device_b, device_a, audio_paths["b2"], label="B->A #2") + finally: + await close_device(device_a) + await close_device(device_b) + + await asyncio.sleep(2) + + conversation_id_after, message_count_after, max_message_id_after = fetch_conversation_snapshot( + device_a.child_id, + device_b.child_id, + ) + print( + f"[snapshot-after] conversation_id={conversation_id_after} message_count={message_count_after} max_message_id={max_message_id_after}", + flush=True, + ) + if conversation_id_after is None: + raise RuntimeError("child-peer conversation was not created") + + new_messages = fetch_new_messages(conversation_id_after, max_message_id_before) + print(f"[new-messages] count={len(new_messages)}", flush=True) + for row in new_messages: + print( + json.dumps( + { + "id": int(row["id"]), + "seq": int(row["seq"]), + "sender_id": row["sender_id"], + "receiver_id": row["receiver_id"], + "content_type": int(row["content_type"]), + "media_file_key": row["media_file_key"], + "client_msg_id": row["client_msg_id"], + "created_at": row["created_at"].isoformat(sep=" "), + }, + ensure_ascii=False, + ), + flush=True, + ) + + +if __name__ == "__main__": + asyncio.run(main()) diff --git a/talkingq-url/utils/ audio_format.py b/talkingq-url/utils/ audio_format.py new file mode 100644 index 0000000..8468df8 --- /dev/null +++ b/talkingq-url/utils/ audio_format.py @@ -0,0 +1,33 @@ +import io +import wave + + +DEFAULT_SAMPLE_RATE = 16000 +DEFAULT_CHANNELS = 1 +DEFAULT_SAMPLE_WIDTH = 2 + + +def detect_audio_format(audio_data: bytes) -> str: + if len(audio_data) >= 12 and audio_data[:4] == b"RIFF" and audio_data[8:12] == b"WAVE": + return "wav" + if audio_data.startswith(b"ID3"): + return "mp3" + if len(audio_data) >= 2 and audio_data[0] == 0xFF and (audio_data[1] & 0xE0) == 0xE0: + return "mp3" + return "pcm_s16le_16k_mono" + + +def wrap_pcm_as_wav( + audio_data: bytes, + *, + sample_rate: int = DEFAULT_SAMPLE_RATE, + channels: int = DEFAULT_CHANNELS, + sample_width: int = DEFAULT_SAMPLE_WIDTH, +) -> bytes: + wav_buffer = io.BytesIO() + with wave.open(wav_buffer, "wb") as wav_file: + wav_file.setnchannels(channels) + wav_file.setsampwidth(sample_width) + wav_file.setframerate(sample_rate) + wav_file.writeframes(audio_data) + return wav_buffer.getvalue()