diff --git a/talkingq-url/config.py b/talkingq-url/config.py index 6778ff2..0e87fe9 100644 --- a/talkingq-url/config.py +++ b/talkingq-url/config.py @@ -71,6 +71,7 @@ class Settings(BaseSettings): assets_dir: str = Field(default="assets", validation_alias="ASSETS_DIR") session_timeout: int = Field(default=600, validation_alias="SESSION_TIMEOUT") + voice_target_timeout_seconds: float = Field(default=120, gt=0, le=600, validation_alias="VOICE_TARGET_TIMEOUT_SECONDS") conversation_history_timeout: int = Field(default=1800, validation_alias="CONVERSATION_HISTORY_TIMEOUT") max_conversation_history: int = Field(default=5, validation_alias="MAX_CONVERSATION_HISTORY") cleanup_interval: int = Field(default=300, validation_alias="CLEANUP_INTERVAL") diff --git a/talkingq-url/docs/voice-target-recovery.md b/talkingq-url/docs/voice-target-recovery.md new file mode 100644 index 0000000..8e30e1f --- /dev/null +++ b/talkingq-url/docs/voice-target-recovery.md @@ -0,0 +1,36 @@ +# 留言状态与后续 AI 对话恢复 + +留言目标只属于一次录音和一个 WebSocket 连接。目标设置后默认空闲有效期为 +120 秒,可用 `VOICE_TARGET_TIMEOUT_SECONDS` 调整;录音开始及有效留言音频会刷新 +空闲期限。每次访问均检查过期状态,后台会定期回收过期目标及缓存。 + +开始包统一返回一次 `START_ACK`。存在留言目标时,根据首个音频包选择流程: +类型 `0` 清除待处理留言目标并完成 AI 初始化,原始首包随后正常送入 ASR; +类型 `4` 写入本次留言的独立缓存。录音开始传输音频后不允许切换类型。 + +结束、取消、异常、空音频、超时和连接断开均会释放对应目标及音频缓存。 +结束时先消费本次目标,再处理上传和通知,避免旧处理完成时清除新设置的目标。 +旧连接断开只清理该连接的会话和任务,不影响替换它的新连接。 +音频缓存键按发送设备及本次目标独立生成,多个发送方对同一接收方留言时互不覆盖。 + +本次修复保留既有数据包类型、鉴权及设备协议。家长留言的上传、会话保存和通知流程 +沿用原实现。 + +## 本地验证(2026-10-28) + +- 全量后端回归 243/243,包括新增的 28 项留言状态与恢复测试。 +- 本机隔离 MySQL、原有 `/ws` 路由和真实 ASR/LLM/TTS:正常对话及静音 10/10, + 中断及恢复 4/4;包含两个角色在待处理、过期、取消家长留言后的 AI 对话。 +- 实际 WebSocket 家长留言取消 2 次,均收到取消确认及处理完成通知,随后 AI 对话成功。 +- 共 52 段音频通过 HTTP 200、Range 206、完整 MP3 解码及非零 PCM 检查。 +- 正常留言的上传、保存、通知和失败分支使用本地替身测试,未向真实家长发送通知。 +- 没有访问或部署生产服务,未验证实物设备的麦克风、扬声器及播放中打断。 + +覆盖边界:留言取消、空音频、处理异常、过期及未结束录音后的 AI 恢复已有回归; +AI 中断后再次 AI 对话已有真实服务验证。留言取消后再次留言验证了录音隔离, +AI 中断后留言验证了启动、收音及取消,尚未串联验证这两种情况下第二条留言完整发送。 +正常家长留言处理的成功、空音频和上传异常分支已用模拟外部接口验证。 + +状态测试:`.venv/bin/python -m pytest tests/test_voice_target_lifecycle.py -q` + +完整结果:[本地测试汇总](../output/voice-target-recovery-20261028/summary.json)。 diff --git a/talkingq-url/handlers/audio_session_handler.py b/talkingq-url/handlers/audio_session_handler.py index 4a65f45..067c78f 100644 --- a/talkingq-url/handlers/audio_session_handler.py +++ b/talkingq-url/handlers/audio_session_handler.py @@ -22,8 +22,10 @@ async def handle_websocket_data( if packet_type == 1: # 开始包 try: - await websocket.send_text("START_ACK") - session_logger.info(device_id, session_id, "已发送开始包确认") + if not session.start_ack_sent: + await websocket.send_text("START_ACK") + session.start_ack_sent = True + session_logger.info(device_id, session_id, "已发送开始包确认") except Exception as e: session_logger.error(device_id, session_id, f"发送开始包确认失败: {str(e)}") session.sample_rate = sample_rate diff --git a/talkingq-url/handlers/session_cleanup_handler.py b/talkingq-url/handlers/session_cleanup_handler.py index f73c751..505e65e 100644 --- a/talkingq-url/handlers/session_cleanup_handler.py +++ b/talkingq-url/handlers/session_cleanup_handler.py @@ -1,6 +1,8 @@ from services.interrupt_handler import interrupt_handler from services.audio_session import audio_session_manager from utils.logger import session_logger +from services.device_target_cache import device_target_cache +from services.task_manager import task_manager async def handle_old_session_cleanup(old_session, session_key): """ @@ -21,19 +23,22 @@ async def handle_old_session_cleanup(old_session, session_key): session_logger.error("unknown", "unknown", f"清理旧会话资源时出错: {e}") -async def cleanup_device_sessions(device_id): +async def cleanup_device_sessions(device_id, websocket=None): """ 清理设备所有会话 Args: device_id: 设备ID """ + await device_target_cache.remove_target(device_id, websocket=websocket, reason="disconnected") sessions_to_clean = [] for session_key, session in await audio_session_manager.get_all_sessions(): - if session_key[0] == device_id: + if session_key[0] == device_id and (websocket is None or session.websocket is websocket): sessions_to_clean.append((session_key, session)) for session_key, session in sessions_to_clean: + if websocket is not None and session.websocket is not websocket: + continue if hasattr(session, "set_interrupted"): session_logger.info( device_id, session_key[1], "WebSocket连接断开,中断会话" @@ -44,8 +49,11 @@ async def cleanup_device_sessions(device_id): await interrupt_handler.set_interrupt_state(session_key, True) if session.llm_service and hasattr(session.llm_service, "closed"): session.llm_service.closed = True + await task_manager.cancel_session_tasks(session_key) - for session_key, _ in sessions_to_clean: + for session_key, session in sessions_to_clean: + if websocket is not None and session.websocket is not websocket: + continue await audio_session_manager.remove_session(session_key) await interrupt_handler.remove_session(session_key) session_logger.info( diff --git a/talkingq-url/handlers/websocket_handler.py b/talkingq-url/handlers/websocket_handler.py index 8076a4a..f22b0dd 100644 --- a/talkingq-url/handlers/websocket_handler.py +++ b/talkingq-url/handlers/websocket_handler.py @@ -1,7 +1,6 @@ from fastapi import WebSocket, WebSocketDisconnect from utils.logger import session_logger from services.connection_manager import connection_manager -from services.task_manager import task_manager from services.offline_audio_cache import offline_audio_cache from handlers.websocket_auth_handler import authenticate_websocket from handlers.websocket_message_handler import handle_websocket_messages @@ -59,9 +58,7 @@ async def websocket_endpoint(websocket: WebSocket): finally: if device_id: await connection_manager.remove_connection(device_id, websocket) - await cleanup_device_sessions(device_id) - # 清理设备相关的所有异步任务 - await task_manager.cancel_device_tasks(device_id) + await cleanup_device_sessions(device_id, websocket) session_logger.info( device_id or "unknown", diff --git a/talkingq-url/handlers/websocket_message_handler.py b/talkingq-url/handlers/websocket_message_handler.py index b165b24..7ff4aca 100644 --- a/talkingq-url/handlers/websocket_message_handler.py +++ b/talkingq-url/handlers/websocket_message_handler.py @@ -44,6 +44,7 @@ async def handle_websocket_messages(websocket: WebSocket, device_id: str, serial websocket: WebSocket连接 device_id: 设备ID """ + await device_target_cache.bind_connection(device_id, websocket) current_active_session = None first_audio_received_time = None @@ -156,54 +157,111 @@ async def handle_text_message(websocket: WebSocket, device_id: str, text_data: s async def handle_binary_message(websocket: WebSocket, device_id: str, serial_number: str, binary_data, current_active_session, first_audio_received_time): """处理二进制音频消息""" + active_connection = await connection_manager.get_connection(device_id) + if active_connection is not None and active_connection is not websocket: + session_logger.warning(device_id, "connection", "忽略已被替换连接的录音包") + return current_active_session, first_audio_received_time session_key, session, packet_type, audio_data, sample_rate = await parse_packet(binary_data, websocket) - - if session: - session.websocket = websocket - session_id = session_key[1] - - if packet_type == 1: # 开始包 - voice_target = await device_target_cache.get_voice_target(device_id) - if voice_target: - await target_audio_cache.clear_audio_data(voice_target.audio_cache_key) - return None, None - current_active_session = await handle_start_packet( - device_id, session_id, session_key, session, current_active_session + if not session: + return current_active_session, first_audio_received_time + session_id = session_key[1] + if session.websocket is not None and session.websocket is not websocket: + session_logger.warning(device_id, session_id, "忽略其他连接的录音包") + return current_active_session, first_audio_received_time + session.websocket = websocket + session.last_activity_time = asyncio.get_running_loop().time() + + if packet_type == 1: + if session.start_received: + return current_active_session, first_audio_received_time + session.start_received = True + session.voice_target = await device_target_cache.claim_target(device_id, websocket, session_id) + if session.voice_target: + # Acknowledge START now; the first audio packet selects AI (0) or + # message (4). A pending message must not swallow an AI recording. + await websocket.send_text("START_ACK") + session.start_ack_sent = True + return session_key, None + current_active_session = await start_ai_recording( + websocket, device_id, session_id, session_key, session, current_active_session, sample_rate + ) + return current_active_session, None + + if not session.start_received or session.recording_finished: + session_logger.warning(device_id, session_id, "忽略未开始或已结束的录音包") + return current_active_session, first_audio_received_time + + if packet_type == 0: + if session.message_audio_received: + session_logger.warning(device_id, session_id, "忽略留言录音中混入的普通对话音频") + return current_active_session, first_audio_received_time + if not session.ai_started: + target = session.voice_target + if target: + await device_target_cache.remove_target(device_id, expected=target, reason="ai_audio") + session.voice_target = None + current_active_session = await start_ai_recording( + websocket, device_id, session_id, session_key, session, current_active_session, sample_rate ) - elif packet_type == 3: # 中断包 - voice_target = await device_target_cache.get_voice_target(device_id) - if voice_target: - return None, None - current_active_session = await handle_interrupt_packet( - device_id, session_id, session_key, session, current_active_session - ) - elif packet_type == 2: # 结束包 - voice_target = await device_target_cache.get_voice_target(device_id) - if voice_target: - session_logger.info(device_id, session_id, f"收到结束包,开始处理缓存音频数据") - if voice_target.kind == VOICE_TARGET_KIND_PARENT: - await process_parent_leave_message(device_id, voice_target.audio_cache_key) - else: - await process_cached_audio(device_id, voice_target.target_device_id, serial_number) - await device_target_cache.remove_target(device_id) - return None, None - if current_active_session == session_key: - current_active_session = None - elif packet_type == 4: # 发送给目标设备的音频包 - await handle_target_audio_packet(device_id, audio_data) - return None, None - elif packet_type == 0 and first_audio_received_time is None: + if first_audio_received_time is None: first_audio_received_time = time.perf_counter() session.start_time = first_audio_received_time - session_logger.info( - device_id, session_id, f"收到第一个音频数据包时间: {first_audio_received_time}" - ) - - await handle_websocket_data(websocket, session_key, session, packet_type, audio_data, sample_rate) - + session_logger.info(device_id, session_id, f"收到第一个音频数据包时间: {first_audio_received_time}") + elif packet_type == 4: + target = session.voice_target + if target and not session.ai_started: + if await handle_target_audio_packet(device_id, audio_data, target, websocket, session_id): + session.message_audio_received = True + else: + session_logger.warning(device_id, session_id, "忽略没有对应留言目标的留言音频包") + return current_active_session, first_audio_received_time + elif packet_type == 3: + if session.voice_target: + await device_target_cache.remove_target(device_id, expected=session.voice_target, reason="interrupted") + session.voice_target = None + session.recording_finished = True + await websocket.send_text("INTERRUPT_ACK") + await websocket.send_text("INTERRUPT_PROCESSED") + return None if current_active_session == session_key else current_active_session, None + current_active_session = await handle_interrupt_packet( + device_id, session_id, session_key, session, current_active_session + ) + elif packet_type == 2: + if session.voice_target: + target = await device_target_cache.take_target(device_id, session.voice_target, websocket, session_id) + session.voice_target = None + session.recording_finished = True + try: + await websocket.send_text("END_ACK") + if target: + session_logger.info(device_id, session_id, "收到结束包,开始处理缓存音频数据") + if target.kind == VOICE_TARGET_KIND_PARENT: + await process_parent_leave_message(device_id, target.audio_cache_key) + else: + await process_cached_audio(device_id, target.target_device_id, serial_number, + audio_cache_key=target.audio_cache_key) + finally: + if target: + await target_audio_cache.clear_audio_data(target.audio_cache_key) + return None if current_active_session == session_key else current_active_session, None + if current_active_session == session_key: + current_active_session = None + + await handle_websocket_data(websocket, session_key, session, packet_type, audio_data, sample_rate) + if packet_type in (2, 3): + session.recording_finished = True return current_active_session, first_audio_received_time +async def start_ai_recording(websocket, device_id, session_id, session_key, session, current_active_session, sample_rate): + current_active_session = await handle_start_packet( + device_id, session_id, session_key, session, current_active_session + ) + await handle_websocket_data(websocket, session_key, session, 1, b"", sample_rate) + session.ai_started = True + return current_active_session + + async def handle_start_packet(device_id, session_id, session_key, session, current_active_session): """处理会话开始包""" if current_active_session and current_active_session[1] != session_id: @@ -255,20 +313,17 @@ async def handle_interrupt_packet(device_id, session_id, session_key, session, c return current_active_session -async def handle_target_audio_packet(device_id: str, audio_data: bytes): +async def handle_target_audio_packet(device_id: str, audio_data: bytes, voice_target, websocket, session_id): """处理发送给目标设备的音频包""" try: - voice_target = await device_target_cache.get_voice_target(device_id) - - if not voice_target: - session_logger.warning(device_id, "target", "未设置目标设备,无法发送音频") - return - - await target_audio_cache.add_audio_data(voice_target.audio_cache_key, audio_data) + if not await device_target_cache.append_audio(device_id, voice_target, websocket, session_id, audio_data): + session_logger.warning(device_id, session_id, "忽略已过期或不属于本次录音的留言音频") + return if voice_target.kind == VOICE_TARGET_KIND_PARENT: session_logger.info(device_id, "target", "已缓存发给家长的留言音频数据") else: session_logger.info(device_id, "target", f"已缓存音频数据到目标设备 {voice_target.target_device_id}") + return True except Exception as e: session_logger.error(device_id, "target", f"处理目标音频包时出错: {e}", exc_info=True) @@ -337,10 +392,11 @@ async def process_parent_leave_message(device_id: str, audio_cache_key: str): await target_audio_cache.clear_audio_data(audio_cache_key) -async def process_cached_audio(device_id: str, target_device_id: str, serial_number: str): +async def process_cached_audio(device_id: str, target_device_id: str, serial_number: str, *, audio_cache_key=None): """处理缓存的音频数据并发送音频URL""" try: - cached_audio = await target_audio_cache.get_audio_data(target_device_id) + cache_key = audio_cache_key or target_device_id + cached_audio = await target_audio_cache.get_audio_data(cache_key) if not cached_audio: session_logger.info(device_id, "target", f"目标设备 {target_device_id} 没有缓存的音频数据") return @@ -398,4 +454,4 @@ async def process_cached_audio(device_id: str, target_device_id: str, serial_num session_logger.error(device_id, "target", f"处理缓存音频时出错: {e}", exc_info=True) finally: # 清除缓存 - await target_audio_cache.clear_audio_data(target_device_id) + await target_audio_cache.clear_audio_data(cache_key) diff --git a/talkingq-url/services/audio_session.py b/talkingq-url/services/audio_session.py index 2707e32..9b7c4a8 100644 --- a/talkingq-url/services/audio_session.py +++ b/talkingq-url/services/audio_session.py @@ -30,6 +30,12 @@ class AudioSession: self.session_id = None self.sample_rate = sample_rate self.audio_queue = None + self.voice_target = None + self.start_received = False + self.start_ack_sent = False + self.ai_started = False + self.recording_finished = False + self.message_audio_received = False async def set_interrupted(self, interrupted=True): diff --git a/talkingq-url/services/device_target_cache.py b/talkingq-url/services/device_target_cache.py index 5eac15d..fc2a2fb 100644 --- a/talkingq-url/services/device_target_cache.py +++ b/talkingq-url/services/device_target_cache.py @@ -1,59 +1,143 @@ import asyncio +import time from dataclasses import dataclass from typing import Dict, Optional +from uuid import uuid4 + +from config import settings +from services.connection_manager import connection_manager +from services.target_audio_cache import target_audio_cache +from utils.logger import session_logger VOICE_TARGET_KIND_DEVICE = "device" VOICE_TARGET_KIND_PARENT = "parent" -@dataclass(frozen=True) +@dataclass class VoiceTarget: kind: str target_device_id: Optional[str] = None uuid: Optional[str] = None audio_cache_key: Optional[str] = None + websocket: object = None + session_id: Optional[str] = None + expires_at: float = 0 class DeviceTargetCache: - def __init__(self): + def __init__(self, *, timeout_seconds=None, clock=None): self.targets: Dict[str, VoiceTarget] = {} self.lock = asyncio.Lock() + self.timeout_seconds = timeout_seconds if timeout_seconds is not None else settings.voice_target_timeout_seconds + self.clock = clock or time.monotonic + + async def _remove_locked(self, device_id, target, reason, *, clear_audio=True): + del self.targets[device_id] + if clear_audio: + await target_audio_cache.clear_audio_data(target.audio_cache_key) + session_logger.info(device_id, target.session_id or "target", + f"留言目标已清除: kind={target.kind}, reason={reason}") + + async def _get_locked(self, device_id): + target = self.targets.get(device_id) + if target and self.clock() >= target.expires_at: + await self._remove_locked(device_id, target, "timeout") + return None + return target async def get_target(self, device_id: str) -> Optional[str]: - async with self.lock: - target = self.targets.get(device_id) - if not target or target.kind != VOICE_TARGET_KIND_DEVICE: - return None - return target.target_device_id + target = await self.get_voice_target(device_id) + return target.target_device_id if target and target.kind == VOICE_TARGET_KIND_DEVICE else None async def get_voice_target(self, device_id: str) -> Optional[VoiceTarget]: async with self.lock: - return self.targets.get(device_id) + return await self._get_locked(device_id) + + async def _set_target(self, device_id, *, kind, target_device_id=None, uuid=None): + async with self.lock: + old = self.targets.get(device_id) + if old: + await self._remove_locked(device_id, old, "replaced") + self.targets[device_id] = VoiceTarget( + kind=kind, target_device_id=target_device_id, uuid=uuid, + audio_cache_key=f"voice:{device_id}:{uuid4().hex}", + websocket=await connection_manager.get_connection(device_id), + expires_at=self.clock() + self.timeout_seconds, + ) + session_logger.info(device_id, "target", f"留言目标已设置: kind={kind}, timeout={self.timeout_seconds}s") async def set_target(self, device_id: str, target_device_id: str): - async with self.lock: - self.targets[device_id] = VoiceTarget( - kind=VOICE_TARGET_KIND_DEVICE, - target_device_id=target_device_id, - audio_cache_key=target_device_id, - ) + await self._set_target(device_id, kind=VOICE_TARGET_KIND_DEVICE, target_device_id=target_device_id) async def set_parent_target(self, device_id: str, uuid: str): - async with self.lock: - self.targets[device_id] = VoiceTarget( - kind=VOICE_TARGET_KIND_PARENT, - uuid=uuid, - audio_cache_key=f"parent:{device_id}", - ) + await self._set_target(device_id, kind=VOICE_TARGET_KIND_PARENT, uuid=uuid) - async def remove_target(self, device_id: str): + async def claim_target(self, device_id, websocket, session_id): + """Bind one pending intent to one recording on one connection.""" async with self.lock: - if device_id in self.targets: - del self.targets[device_id] + active = await connection_manager.get_connection(device_id) + if active is not None and active is not websocket: + return None + target = await self._get_locked(device_id) + if target is None: + return None + if target.websocket is not None and target.websocket is not websocket: + await self._remove_locked(device_id, target, "connection_changed") + return None + if target.session_id is not None and target.session_id != session_id: + await self._remove_locked(device_id, target, "new_recording") + return None + target.websocket, target.session_id = websocket, session_id + target.expires_at = self.clock() + self.timeout_seconds + session_logger.info(device_id, session_id, f"留言目标已关联录音: kind={target.kind}") + return target + + async def bind_connection(self, device_id, websocket): + """Adopt commands received before connect; discard older socket intents.""" + async with self.lock: + if await connection_manager.get_connection(device_id) is not websocket: + return + target = await self._get_locked(device_id) + if target is None: + return + if target.websocket is not None and target.websocket is not websocket: + await self._remove_locked(device_id, target, "connection_changed") + else: + target.websocket = websocket + + async def append_audio(self, device_id, target, websocket, session_id, audio): + async with self.lock: + current = await self._get_locked(device_id) + if current is not target or target.websocket is not websocket or target.session_id != session_id: + return False + target.expires_at = self.clock() + self.timeout_seconds + await target_audio_cache.add_audio_data(target.audio_cache_key, audio) + return True + + async def take_target(self, device_id, target, websocket, session_id): + """Consume the exact recording before slow upload/notification work.""" + async with self.lock: + current = await self._get_locked(device_id) + if current is not target or target.websocket is not websocket or target.session_id != session_id: + return None + await self._remove_locked(device_id, target, "recording_ended", clear_audio=False) + return target + + async def remove_target(self, device_id: str, *, expected=None, websocket=None, reason="cancelled"): + async with self.lock: + target = self.targets.get(device_id) + if target is None or (expected is not None and target is not expected): + return False + if websocket is not None and target.websocket is not websocket: + return False + await self._remove_locked(device_id, target, reason) + return True async def get_all_targets(self) -> list: async with self.lock: + for device_id in list(self.targets): + await self._get_locked(device_id) return list(self.targets.items()) diff --git a/talkingq-url/services/schedule_session_cleanup.py b/talkingq-url/services/schedule_session_cleanup.py index b0609c9..6489bd8 100644 --- a/talkingq-url/services/schedule_session_cleanup.py +++ b/talkingq-url/services/schedule_session_cleanup.py @@ -3,6 +3,7 @@ from config import settings from services.audio_session import audio_session_manager from utils.logger import session_logger from services.conversation_history import conversation_history_manager +from services.device_target_cache import device_target_cache async def run_in_executor(func, *args): loop = asyncio.get_running_loop() @@ -12,6 +13,7 @@ async def background_cleanup_task(): try: while True: await asyncio.sleep(settings.cleanup_interval) + await device_target_cache.get_all_targets() current_time = asyncio.get_running_loop().time() audio_sessions_to_clean = [] @@ -54,4 +56,4 @@ async def background_cleanup_task(): except asyncio.CancelledError: session_logger.info("system", "cleanup", "后台清理任务已取消") except Exception as e: - session_logger.error("system", "cleanup", f"后台清理任务出错: {str(e)}") \ No newline at end of file + session_logger.error("system", "cleanup", f"后台清理任务出错: {str(e)}") diff --git a/talkingq-url/tests/test_voice_target_lifecycle.py b/talkingq-url/tests/test_voice_target_lifecycle.py new file mode 100644 index 0000000..d485f47 --- /dev/null +++ b/talkingq-url/tests/test_voice_target_lifecycle.py @@ -0,0 +1,345 @@ +"""Regression for stale message intents swallowing the next AI recording.""" +import asyncio +import struct +from types import SimpleNamespace +from unittest.mock import AsyncMock +from unittest.mock import Mock + +import pytest + +from handlers import audio_packet_parser as parser +from handlers import audio_session_handler as audio_handler +from handlers import session_cleanup_handler as cleanup +from handlers import websocket_message_handler as messages +from handlers import websocket_handler as endpoint +from services import device_target_cache as targets_module +from services.audio_session import AudioSessionManager +from services.connection_manager import ConnectionManager +from services.device_target_cache import DeviceTargetCache +from services.target_audio_cache import TargetAudioCache + +DEVICE = 'TalkingQ_local_voice' +PROCESS_PARENT_MESSAGE = messages.process_parent_leave_message + + +def packet(sid, kind, audio=b''): + return DEVICE.encode()+b'\0'+sid.encode()+b'\0'+struct.pack('