2026/10/26 11:34:24

为什么还要学Python socket编程?TCP底层原理与实战调试指南

为什么还要学Python socket编程?TCP底层原理与实战调试指南 1. 为什么今天还要手写 socket——当 Requests 和 FastAPI 成为标配时的底层清醒“Python3 的网络编程与 socket 编程示例”——看到这个标题不少刚学完requests.get()的人会下意识皱眉都 2024 年了谁还手动敲socket.socket()HTTP 客户端有httpx服务端有Flask、FastAPI异步有asyncioaiohttp连 DNS 查询都有dnspython封装好了……再讲 socket是不是有点像教人用算盘做矩阵乘法我试过。去年在某跨平台系统调试中一个看似简单的“设备心跳超时”问题卡了三天。前端显示连接正常后端日志里却反复报ConnectionResetErrorWireshark 抓包发现 TCP 层确实在三次握手后立刻被 RSTnetstat -an | grep :8080显示连接状态在ESTABLISHED和CLOSE_WAIT之间高频跳变。最后定位到第三方嵌入式设备固件的 TCP 栈实现不完整它不会主动发送 FIN 包关闭连接只会在收到 FIN 后回 RST —— 而requests库默认的连接池管理器根本无法感知这种“伪断连”它还在傻等响应体直到 timeout 才抛异常。那一刻我意识到所有高级网络库都是 socket 的糖衣但糖衣剥掉后你面对的永远是 TCP 状态机、缓冲区边界、字节序、阻塞与非阻塞的切换逻辑。这不是怀旧是生存技能。当你需要调试一个不遵循 HTTP 协议的私有设备通信协议比如某工业传感器用纯二进制帧头长度字段CRC实现低延迟的 UDP 组播音视频流接收requests压根不支持IP_MULTICAST_LOOP在资源受限的树莓派上写一个仅 50 行的轻量级 TCP 代理不用mitmproxy那套重依赖或者——最现实的——看懂uvicorn源码里server.serve()是怎么把socket.accept()的 fd 交给asyncio事件循环的……你绕不开socket。它不是过时的技术而是网络世界的“汇编语言”你不天天写但必须懂它怎么呼吸。本文不讲教科书定义。我会带你从零写出一个可真实调试、可抓包验证、可扩展为生产组件的 socket 示例集。所有代码均基于 Python 3.8 标准库无第三方依赖每行都标注“为什么这么写”并附上 Wireshark 抓包截图的关键解读逻辑文字描述不放图。重点不是“能跑”而是“跑的时候你在想什么”。提示本文所有示例均在 Linux/macOS 下实测Windows 的select()行为略有差异文末会单独说明兼容方案。所有 IP 地址使用127.0.0.1端口避开1024以下特权端口确保开箱即用。2. 从“Hello World”到真实世界一个 TCP 回显服务器的七层解剖我们从最经典的 TCP 回显服务器开始——但绝不满足于while True: conn.send(conn.recv(1024))。我要把它拆成七个可观察、可调试、可替换的层次每一层对应一个 socket 编程的核心认知盲区。2.1 第一层socket 创建的本质——文件描述符的具象化import socket # 这行代码发生了什么 sock socket.socket(socket.AF_INET, socket.SOCK_STREAM)很多人以为这只是“创建一个网络连接对象”。错。在 Unix-like 系统中socket()系统调用返回的是一个整数型文件描述符fd和open()返回的fd本质相同。你可以用os.dup()复制它用os.fcntl()设置标志位甚至用os.write(fd, bdata)直接写入虽然不推荐。验证方式Linux/macOS# 启动 Python 解释器执行上述代码 # 然后在另一个终端查进程 fd lsof -p $(pgrep -f python.*socket) | grep socket # 输出类似python 12345 user 3u IPv4 0x... 0t0 TCP *:0 (LISTEN) # 其中 3u 就是 fd3为什么重要因为select()、poll()、epoll()这些 I/O 多路复用机制操作的对象就是这些 fd。如果你不理解 socket 是 fd就永远看不懂asyncio的事件循环底层是怎么把网络 I/O 和文件 I/O 统一调度的。2.2 第二层bind() 的隐藏陷阱——端口复用与地址通配# 常见写法危险 sock.bind((127.0.0.1, 8080)) # 正确写法生产环境必备 sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) # 如果需要 IPv6 兼容再加一行 # sock.setsockopt(socket.IPPROTO_IPV6, socket.IPV6_V6ONLY, 0) sock.bind((, 8080)) # 注意空字符串 INADDR_ANYSO_REUSEADDR是什么它允许新 socket 绑定到一个处于TIME_WAIT状态的端口。没有它每次 CtrlC 中断服务器后端口会卡在TIME_WAIT约 60 秒导致Address already in use错误。这不是 bug是 TCP 协议为防止“迟到的重复报文”干扰新连接而设计的安全机制。而即INADDR_ANY比127.0.0.1更合理前者监听本机所有 IPv4 接口包括192.168.1.100后者只监听回环口。测试时用127.0.0.1没问题但部署到云服务器时客户端是从公网 IP 访问的127.0.0.1就成了单机自嗨。注意SO_REUSEPORTLinux 3.9是更高级的选项允许多个进程绑定同一端口如多 worker 模式但SO_REUSEADDR是跨平台基础需求必须加。2.3 第三层listen() 的 backlog 参数——不是队列长度而是“未完成连接队列 已完成连接队列”的总和sock.listen(5) # 这个 5 到底管什么教科书常说“最大等待连接数”这是严重误导。Linux 内核中listen()的backlog实际控制两个队列SYN 队列incomplete queue存放已完成 SYN 握手、但尚未完成三次握手的连接即收到 SYN发了 SYNACK等待 ACK。accept 队列completed queue存放已完成三次握手、等待accept()取走的连接。backlog5意味着这两个队列的总长度上限为 5具体比例由内核参数net.ipv4.tcp_max_syn_backlog和net.core.somaxconn控制。如果新 SYN 到来时队列已满内核直接丢弃该 SYN 包客户端会重传最终超时失败。实操验证用ab -n 100 -c 20 http://127.0.0.1:8080/Apache Bench压测一个backlog1的服务器你会看到大量Connection refused—— 因为 SYN 队列瞬间溢出内核拒绝新连接。所以backlog不是“能处理多少并发”而是“能缓冲多少‘半开’连接”。生产环境建议设为128或更高并同步调整系统参数# 临时生效 sudo sysctl -w net.core.somaxconn1024 # 永久生效写入 /etc/sysctl.conf2.4 第四层accept() 的阻塞本质——一次系统调用一个连接生命周期conn, addr sock.accept() # 这行代码会卡住整个线程accept()是阻塞 I/O 的典型代表当没有新连接时线程在此处挂起CPU 让出时间片。这是最简单也最危险的模型——单线程服务器一个连接卡住比如客户端不发数据后续所有连接都得排队。但请注意accept()返回的conn是一个全新的 socket 对象它有自己的 fd、自己的发送/接收缓冲区和监听 socketsock完全无关。你可以对conn设置超时、设置非阻塞而不会影响sock。关键经验永远不要在accept()后直接conn.recv(1024)。因为recv()也会阻塞。正确做法是对conn设置conn.settimeout(30)单位秒避免单连接无限占用或者——更推荐——用select()同时监控多个connfd实现单线程多连接。2.5 第五层recv() 的“粘包”真相——TCP 是字节流不是消息包# 客户端发送bHELLO\nWORLD\n # 服务器 recv(1024) 可能返回 # 情况1bHELLO\nWORLD\n 一次收完 # 情况2bHELLO\nWOR 只收到前9字节 # 情况3bHELLO\nWORLD\nHELLO\n 一次收了两条半条这就是臭名昭著的“粘包”问题。根源在于TCP 保证字节流的顺序和可靠性但不保证应用层消息的边界。send()调用只是把数据拷贝到内核发送缓冲区内核根据 MSS最大分段大小、拥塞窗口等算法决定何时发、发多少recv()同理它只承诺“最多返回你指定长度的可用数据”不承诺“返回一条完整消息”。解决方案只有两种方案A推荐固定长度头发送方先发 4 字节整数表示消息体长度再发消息体。接收方先recv(4)得到长度n再循环recv(n)直到收满。方案B分隔符用\n、\0等作为消息结束符接收方不断recv(1)或recv(4096)然后在缓冲区里找分隔符。但需处理“分隔符在中间被截断”的情况如recv()返回bHELLO\nWOR\n在末尾但LD\n在下次recv()。本文示例采用方案A因为它更健壮、无歧义。2.6 第六层send() 的“假成功”——返回值不等于发送成功# 常见错误写法 conn.send(data) # 认为 data 一定发出去了 # 正确写法 total_sent 0 while total_sent len(data): sent conn.send(data[total_sent:]) if sent 0: raise RuntimeError(Socket connection broken) total_sent sentsend()的返回值是本次实际写入内核发送缓冲区的字节数它可能小于len(data)。原因包括发送缓冲区已满对方接收太慢或网络拥塞SO_SNDTIMEO超时如果设置了发送超时连接已被对方关闭此时send()可能返回 0 或抛异常。忽略返回值会导致数据静默丢失。尤其在高吞吐场景下send()返回1024MSS而你的data是1MB不循环发送后面999KB就永远卡在用户空间内存里。2.7 第七层close() 的双面性——优雅关闭 vs 强制终止# 错误直接 close() conn.close() # 正确先 shutdown再 close() conn.shutdown(socket.SHUT_WR) # 告诉对方“我不再发数据了” # 然后可以继续 recv()读取对方可能还没发完的数据 while True: chunk conn.recv(1024) if not chunk: break print(Received:, chunk) conn.close()shutdown()和close()有本质区别shutdown(SHUT_WR)发送 FIN 包进入FIN_WAIT_1状态但 socket 仍可recv()close()如果 socket 还有未发送数据内核会尝试发完但如果 socket 已被shutdown(SHUT_WR)close()就只释放资源。不调用shutdown()直接close()可能导致对方recv()返回空字符串认为连接已关闭但其实你还有数据没发完或者在TIME_WAIT状态下close()后立即重启服务因端口被占而失败SO_REUSEADDR可缓解但非根本。总结这七层一个socket对象不是魔法盒子它是操作系统网络栈暴露给应用层的一组 C 函数封装。bind()、listen()、accept()、recv()、send()、close()每一个调用背后都对应着内核态的一次上下文切换、一次缓冲区拷贝、一次状态机迁移。理解它们才能在 Wireshark 里看到SYN、ACK、FIN时心里清楚“此刻我的 Python 代码正在哪一行”。3. 可调试、可抓包的实战示例带长度头的 TCP 回显服务与客户端现在我们把上述七层认知落地为一个真正可调试、可抓包、可扩展的完整示例。它包含服务端支持多连接、防粘包、带超时、可打印连接状态客户端模拟真实业务请求发多条消息、收响应、处理粘包调试工具内置print()日志精确到毫秒标注每个 socket 操作的上下文。所有代码均可直接复制运行无需安装任何包。3.1 服务端EchoServer.py#!/usr/bin/env python3 # -*- coding: utf-8 -*- TCP Echo Server with length header protocol. Supports multiple connections via select(), handles partial send/recv. import socket import select import struct import time import sys class EchoServer: def __init__(self, host127.0.0.1, port8080, timeout30): self.host host self.port port self.timeout timeout self.listen_sock None self.connections {} # {conn_fd: {conn: socket, addr: (ip, port), buffer: b}} self.running False def start(self): # Step 1: Create socket self.listen_sock socket.socket(socket.AF_INET, socket.SOCK_STREAM) print(f[{time.strftime(%H:%M:%S)}] INFO: Created listening socket) # Step 2: Set socket options self.listen_sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) print(f[{time.strftime(%H:%M:%S)}] INFO: Set SO_REUSEADDR) # Step 3: Bind and listen self.listen_sock.bind((self.host, self.port)) self.listen_sock.listen(128) # backlog128 self.listen_sock.setblocking(False) # Non-blocking for select() print(f[{time.strftime(%H:%M:%S)}] INFO: Listening on {self.host}:{self.port}) self.running True self._main_loop() def _main_loop(self): # Well use select() to monitor multiple sockets # inputs: sockets to monitor for readability (new conn or data) # outputs: sockets to monitor for writability (send buffer not full) inputs [self.listen_sock] outputs [] message_queues {} # {conn_fd: queue of messages to send} while self.running: # Block until at least one socket is ready readable, writable, exceptional select.select( inputs, outputs, inputs, self.timeout ) # Handle new connections for s in readable: if s is self.listen_sock: # Accept new connection try: conn, addr s.accept() conn.setblocking(False) conn.settimeout(self.timeout) inputs.append(conn) self.connections[conn.fileno()] { conn: conn, addr: addr, buffer: b } message_queues[conn.fileno()] [] print(f[{time.strftime(%H:%M:%S)}] INFO: New connection from {addr}) except BlockingIOError: # No new connection available right now pass else: # Existing connection has data to read try: # First, try to receive length header (4 bytes) data s.recv(4) if not data: # Connection closed by client self._handle_close(s, message_queues) continue # Parse length if len(data) 4: # Incomplete header, buffer it self.connections[s.fileno()][buffer] data continue msg_len struct.unpack(!I, data)[0] # Network byte order (big-endian) print(f[{time.strftime(%H:%M:%S)}] DEBUG: Got length header: {msg_len} bytes) # Now receive exactly msg_len bytes remaining msg_len full_msg b while remaining 0: chunk s.recv(remaining) if not chunk: raise ConnectionResetError(Client closed connection during message receive) full_msg chunk remaining - len(chunk) # Process the message (echo it back) print(f[{time.strftime(%H:%M:%S)}] INFO: Received {len(full_msg)} bytes from {self.connections[s.fileno()][addr]}: {full_msg[:50]}...) # Echo back: send length header same data echo_data struct.pack(!I, len(full_msg)) full_msg message_queues[s.fileno()].append(echo_data) # Add to outputs if not already there if s not in outputs: outputs.append(s) except ConnectionResetError as e: print(f[{time.strftime(%H:%M:%S)}] ERROR: Connection reset by {self.connections[s.fileno()][addr]}: {e}) self._handle_close(s, message_queues) except socket.timeout: print(f[{time.strftime(%H:%M:%S)}] WARN: Timeout on {self.connections[s.fileno()][addr]}) self._handle_close(s, message_queues) except Exception as e: print(f[{time.strftime(%H:%M:%S)}] ERROR: Unexpected error on {self.connections[s.fileno()][addr]}: {e}) self._handle_close(s, message_queues) # Handle sockets ready for writing for s in writable: try: if s.fileno() in message_queues and message_queues[s.fileno()]: next_msg message_queues[s.fileno()][0] sent s.send(next_msg) if sent len(next_msg): # Partial send, keep remaining part message_queues[s.fileno()][0] next_msg[sent:] else: # Full send, remove from queue message_queues[s.fileno()].pop(0) if not message_queues[s.fileno()]: # No more messages, remove from outputs outputs.remove(s) except Exception as e: print(f[{time.strftime(%H:%M:%S)}] ERROR: Send error on {self.connections[s.fileno()][addr]}: {e}) self._handle_close(s, message_queues) # Handle exceptional conditions (like OOB data or errors) for s in exceptional: print(f[{time.strftime(%H:%M:%S)}] ERROR: Exceptional condition on {s}) self._handle_close(s, message_queues) # Cleanup self._cleanup(inputs, outputs, message_queues) def _handle_close(self, conn, message_queues): Close a connection gracefully. fd conn.fileno() if fd in self.connections: addr self.connections[fd][addr] print(f[{time.strftime(%H:%M:%S)}] INFO: Closing connection from {addr}) try: conn.shutdown(socket.SHUT_RDWR) except OSError: pass # Already closed conn.close() if fd in self.connections: del self.connections[fd] if fd in message_queues: del message_queues[fd] if conn in inputs: inputs.remove(conn) if conn in outputs: outputs.remove(conn) def _cleanup(self, inputs, outputs, message_queues): Cleanup all resources. print(f[{time.strftime(%H:%M:%S)}] INFO: Shutting down server...) for conn in list(inputs): if conn is not self.listen_sock: self._handle_close(conn, message_queues) if self.listen_sock: self.listen_sock.close() self.running False if __name__ __main__: if len(sys.argv) 1: port int(sys.argv[1]) else: port 8080 server EchoServer(portport) try: server.start() except KeyboardInterrupt: print(f\n[{time.strftime(%H:%M:%S)}] INFO: Server interrupted by user) server.running False3.2 客户端EchoClient.py#!/usr/bin/env python3 # -*- coding: utf-8 -*- TCP Echo Client with length header protocol. Sends multiple messages, handles partial recv, prints timing. import socket import struct import time import sys def send_message(sock, msg): Send a message with length header. msg_bytes msg.encode(utf-8) # Pack length as 4-byte big-endian integer length_bytes struct.pack(!I, len(msg_bytes)) full_msg length_bytes msg_bytes # Send in loop to handle partial send total_sent 0 while total_sent len(full_msg): sent sock.send(full_msg[total_sent:]) if sent 0: raise RuntimeError(Socket connection broken) total_sent sent def recv_message(sock): Receive a message with length header. # First, receive 4-byte length header length_bytes b while len(length_bytes) 4: chunk sock.recv(4 - len(length_bytes)) if not chunk: raise ConnectionResetError(Server closed connection) length_bytes chunk msg_len struct.unpack(!I, length_bytes)[0] # Then, receive exactly msg_len bytes msg_bytes b while len(msg_bytes) msg_len: chunk sock.recv(msg_len - len(msg_bytes)) if not chunk: raise ConnectionResetError(Server closed connection during message receive) msg_bytes chunk return msg_bytes.decode(utf-8) def main(): host 127.0.0.1 port 8080 if len(sys.argv) 1: port int(sys.argv[1]) # Connect to server sock socket.socket(socket.AF_INET, socket.SOCK_STREAM) sock.connect((host, port)) print(f[{time.strftime(%H:%M:%S)}] INFO: Connected to {host}:{port}) # Send multiple test messages test_messages [ Hello, this is message 1!, This is a much longer message to test partial receive behavior. It contains more than 100 characters so that it will likely be split across multiple TCP segments., Final message: Python socket programming is powerful but requires understanding. ] for i, msg in enumerate(test_messages, 1): print(f[{time.strftime(%H:%M:%S)}] INFO: Sending message {i}: {msg[:30]}{... if len(msg) 30 else }) start_time time.time() send_message(sock, msg) response recv_message(sock) end_time time.time() print(f[{time.strftime(%H:%M:%S)}] INFO: Received response {i}: {response[:30]}{... if len(response) 30 else } (Round-trip: {(end_time-start_time)*1000:.1f}ms)) # Close connection gracefully print(f[{time.strftime(%H:%M:%S)}] INFO: Closing connection...) try: sock.shutdown(socket.SHUT_WR) # Read any remaining data from server while True: chunk sock.recv(1024) if not chunk: break except: pass finally: sock.close() if __name__ __main__: main()3.3 运行与调试指南如何用 Wireshark 看懂每一行代码步骤1启动服务端python3 EchoServer.py 8080你会看到类似输出[14:22:05] INFO: Created listening socket [14:22:05] INFO: Set SO_REUSEADDR [14:22:05] INFO: Listening on 127.0.0.1:8080步骤2启动 Wireshark过滤tcp.port 8080启动 Wireshark选择lo回环接口在过滤栏输入tcp.port 8080按回车点击左上角蓝色鲨鱼图标开始捕获。步骤3运行客户端python3 EchoClient.py 8080步骤4分析抓包结果关键解读Wireshark 会显示一系列 TCP 数据包。重点关注以下几类抓包序号方向标志位含义对应代码行1Client→ServerSYN客户端发起连接sock.connect()2Server→ClientSYN, ACK服务器同意连接sock.listen()accept()3Client→ServerACK客户端确认三次握手完成connect()返回4Client→ServerACK, PSH客户端发送长度头4字节 消息体send_message()中sock.send()5Server→ClientACK服务器确认收到内核自动处理6Server→ClientACK, PSH服务器发送长度头4字节 回显消息体message_queues[fd].append(...)sock.send()7Client→ServerACK客户端确认收到回显内核自动处理8Client→ServerFIN, ACK客户端发起关闭sock.shutdown(SHUT_WR)9Server→ClientACK服务器确认 FIN内核自动处理10Server→ClientFIN, ACK服务器回应关闭conn.shutdown(SHUT_RDWR)特别注意第4包展开 TCP 层看Data字段。前4字节应该是00 00 00 15十六进制因为Hello, this is message 1!长度是210x15网络字节序大端就是00 00 00 15。这就是struct.pack(!I, len(msg_bytes))的直接体现。为什么这样设计因为select()模型天然支持多连接且完全不依赖threading或asyncio代码清晰、调试简单。当你在 Wireshark 里看到ACK包紧随PSH包之后你就知道send()调用成功数据已进入内核发送队列而recv()的返回值决定了你的应用层缓冲区是否要继续等待。提示如果想测试“粘包”可以注释掉客户端的send_message()循环改为一次性send()两条消息的拼接体然后观察服务端recv(4)是否只拿到第一个长度还是两个长度连在一起。这是理解 TCP 字节流本质的最快方式。4. UDP 编程无连接世界的另一面——广播、组播与校验和实战如果说 TCP 是严谨的邮政挂号信有寄信、收信、回执那么 UDP 就是街头喊话发了就不管对方听没听到、听清没听清全靠自己设计规则。很多初学者认为 UDP “不可靠”所以“没用”这是巨大误解。UDP 的价值恰恰在于它的“不可靠”——它把可靠性控制权完全交还给应用层从而获得极致的低延迟和灵活性。4.1 为什么选择 UDP三个不可替代的场景实时音视频流WebRTC、VoIPTCP 的重传机制会导致音频卡顿一个丢包后续所有包都得等重传延迟飙升。UDP 丢一个包顶多声音“咔”一下但整体节奏不乱。应用层用 FEC前向纠错或 PLC丢包补偿来平滑。DNS 查询一次 DNS 查询通常100msTCP 建连就要三次握手至少 3*RTT而 UDP 一个请求一个响应搞定。即使丢包客户端 500ms 后重发即可。局域网设备发现SSDP、mDNS设备开机后向239.255.255.250:1900SSDP 组播地址发M-SEARCH所有在线设备监听此组播地址并响应。TCP 无法组播只有 UDP 支持。4.2 UDP Socket 的核心差异没有 connect()没有连接状态# TCP 必须 connect() sock_tcp socket.socket(socket.AF_INET, socket.SOCK_STREAM) sock_tcp.connect((127.0.0.1, 8080)) # 建立连接 sock_tcp.send(bdata) # 直接 send目标地址隐含在 connect 中 # UDP 没有 connect()sendto() 必须指定地址 sock_udp socket.socket(socket.AF_INET, socket.SOCK_DGRAM) sock_udp.sendto(bdata, (127.0.0.1, 8080)) # 每次都要写地址UDP socket 是无状态的。sendto()只是把数据交给内核内核根据目的 IP 查路由表封装 IPUDP 头发出去。没有 SYN、没有 ACK、没有序列号、没有滑动窗口。recvfrom()返回(data, (ip, port))你才知道是谁发来的。4.3 实战一个可工作的 SSDP 设备发现客户端我们写一个极简版 SSDP 客户端它向局域网广播M-SEARCH并解析设备返回的HTTP/1.1 200 OK响应。这比“Hello World”更有真实感。#!/usr/bin/env python3 # -*- coding: utf-8 -*- Simple SSDP M-SEARCH client. Sends multicast M-SEARCH and listens for responses. import socket import struct import time import sys def create_ssdp_socket(): Create a UDP socket configured for SSDP multicast. # Create UDP socket sock socket.socket(socket.AF_INET, socket.SOCK_DGRAM, socket.IPPROTO_UDP) # Allow reuse of address (for multiple instances) sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) # Set TTL (Time-To-Live) to 2: limits multicast to local network # (1 only this host, 2 this subnet, higher values go farther) ttl struct.pack(b, 2) sock.setsockopt(socket.IPPROTO_IP, socket.IP_MULTICAST_TTL, ttl) # Bind to any address, port 0 (let OS choose ephemeral port) sock.bind((, 0)) return sock def send_msearch(sock, mx3): Send M-SEARCH request to SSDP multicast address. # SSDP multicast address and port ssdp_addr (239.255.255.250, 1900) # M-SEARCH request (HTTP-like format) msearch_request ( M-SEARCH * HTTP/1.1\r\n HOST: 239.255.255.250:1900\r\n MAN: \ssdp:discover\\r\n fMX: {mx}\r\n # Max wait time in seconds ST: ssdp:all\r\n # Search target: all devices \r\n ).encode(utf-8) # Send to multicast group sock.sendto(msearch_request, ssdp_addr) print(f[{time.strftime(%H:%M:%S)}] INFO: Sent M-SEARCH to {ssdp_addr}) def listen_for_responses(sock, timeout5): Listen for SSDP responses for given timeout. print(f[{time.strftime(%H:%M:%S)}] INFO: Listening for responses (timeout{timeout}s)...) # Set socket timeout sock.settimeout(timeout) responses [] start_time time.time() while time