Coverage for tropicsquare / transports / tcp.py: 67%

122 statements  

« prev     ^ index     » next       coverage.py v7.13.5, created at 2026-03-27 21:24 +0000

1"""TCP Transport for libtropic Model/Simulator 

2 

3This module provides a TCP transport implementation that communicates 

4with the TROPIC01 model/simulator server using the libtropic tagged protocol. 

5 

6The transport enables pytropicsquare to work with the same model/simulator 

7infrastructure that libtropic uses, making it useful for: 

8 

9- Hardware-in-the-loop testing 

10- Chip simulation and development 

11- Cross-platform testing (CPython and MicroPython) 

12 

13Protocol Details: 

14 The transport implements the libtropic TCP protocol with tagged messages: 

15 

16 - Buffer format: 

17 

18 - tag (1) 

19 - length (2 LE) 

20 - payload (0-256) 

21 

22 - Request-response pattern with tag validation 

23 - Automatic retry logic for network operations 

24 

25Example:: 

26 

27 from tropicsquare import TropicSquare 

28 from tropicsquare.transports.tcp import TcpTransport 

29 

30 # Connect to model server 

31 transport = TcpTransport("127.0.0.1") 

32 ts = TropicSquare(transport) 

33 

34 # Use chip normally 

35 print(ts.chip_id) 

36 

37:note: Server must be running libtropic-compatible model/simulator from https://github.com/tropicsquare/ts-tvl/ 

38""" 

39 

40import socket 

41from tropicsquare.transports import L1Transport 

42from tropicsquare.exceptions import TropicSquareError, TropicSquareTimeoutError 

43 

44 

45class TcpTransport(L1Transport): 

46 """L1 transport for TCP connection to libtropic model/simulator. 

47 

48 Implements the libtropic tagged protocol for communicating with 

49 the TROPIC01 model/simulator server via TCP socket. 

50 

51 The transport maps L1 SPI operations to tagged TCP commands: 

52 

53 - ``_cs_low()`` → TAG_CSN_LOW (0x01) 

54 - ``_cs_high()`` → TAG_CSN_HIGH (0x02) 

55 - ``_transfer()`` → TAG_SPI_SEND (0x03) 

56 - ``_read()`` → TAG_SPI_SEND (0x03) with dummy bytes 

57 

58 :param host: Hostname or IP address of the model server 

59 :param port: Port number for the TCP connection (default: 28992) 

60 :param timeout: Socket timeout in seconds (default: 5.0) 

61 

62 :raises TropicSquareError: If connection fails 

63 

64 Example:: 

65 

66 transport = TcpTransport("127.0.0.1") 

67 ts = TropicSquare(transport) 

68 print(ts.chip_id) 

69 """ 

70 

71 # Protocol tags 

72 TAG_CSN_LOW = 0x01 

73 TAG_CSN_HIGH = 0x02 

74 TAG_SPI_SEND = 0x03 

75 TAG_WAIT = 0x06 

76 TAG_INVALID = 0xfd 

77 TAG_UNSUPPORTED = 0xfe 

78 

79 # Protocol limits 

80 MAX_PAYLOAD_LEN = 256 

81 MAX_BUFFER_LEN = 259 # 3 (tag + length) + 256 (payload) 

82 TX_ATTEMPTS = 3 

83 RX_ATTEMPTS = 3 

84 

85 @staticmethod 

86 def _is_timeout_exception(error: Exception) -> bool: 

87 if isinstance(error, OSError): 

88 if getattr(error, "errno", None) in (110, 60): 

89 return True 

90 return "timed out" in str(error).lower() 

91 

92 def __init__( 

93 self, 

94 host: str, 

95 port: int = 28992, 

96 timeout: float = 5.0, 

97 connect_timeout: float = 1.0, 

98 ): 

99 """Initialize TCP transport. 

100 

101 :param host: Hostname or IP address of the model server 

102 :param port: Port number for the TCP connection (default: 28992) 

103 :param timeout: Socket I/O timeout in seconds (default: 5.0) 

104 :param connect_timeout: Connect timeout per resolved address in seconds (default: 1.0) 

105 

106 :raises TropicSquareError: If connection fails 

107 """ 

108 self._sock = None 

109 try: 

110 addrinfos = socket.getaddrinfo(host, port, 0, socket.SOCK_STREAM, 0) 

111 errors = [] 

112 for family, socktype, proto, _, sockaddr in addrinfos: 

113 sock = None 

114 try: 

115 sock = socket.socket(family, socktype, proto) 

116 sock.settimeout(connect_timeout) 

117 sock.connect(sockaddr) 

118 sock.settimeout(timeout) 

119 self._sock = sock 

120 break 

121 except Exception as e: 

122 errors.append(f"{sockaddr}: {e}") 

123 if sock is not None: 

124 sock.close() 

125 if self._sock is None: 

126 if errors: 

127 summary = "; ".join(errors) 

128 raise OSError(summary) 

129 raise OSError("No resolved addresses") 

130 except Exception as e: 

131 if self._sock is not None: 

132 self._sock.close() 

133 raise TropicSquareError( 

134 f"Failed to connect to {host}:{port}: {e}" 

135 ) 

136 

137 

138 def _transfer(self, tx_data: bytes) -> bytes: 

139 """SPI bidirectional transfer. 

140 

141 Corresponds to SPI write_readinto operation. 

142 Sends tx_data and receives same number of bytes back. 

143 

144 :param tx_data: Data to transmit 

145 

146 :returns: Received data (same length as tx_data) 

147 

148 :raises TropicSquareError: If transfer length mismatch occurs 

149 """ 

150 rx_data = self._communicate(self.TAG_SPI_SEND, tx_data) 

151 

152 if len(rx_data) != len(tx_data): 

153 raise TropicSquareError( 

154 f"Transfer length mismatch: sent {len(tx_data)}, " 

155 f"received {len(rx_data)}" 

156 ) 

157 

158 return rx_data 

159 

160 

161 def _read(self, length: int) -> bytes: 

162 """SPI read operation. 

163 

164 Corresponds to SPI read operation. 

165 Sends dummy bytes (all zeros) and reads response. 

166 

167 :param length: Number of bytes to read 

168 

169 :returns: Read data 

170 """ 

171 # Send dummy bytes (all zeros) to clock out data 

172 return self._communicate(self.TAG_SPI_SEND, bytes(length)) 

173 

174 

175 def _cs_low(self) -> None: 

176 """Activate chip select (CS to logic 0). 

177 

178 Sends TAG_CSN_LOW command to the model server. 

179 """ 

180 self._communicate(self.TAG_CSN_LOW) 

181 

182 

183 def _cs_high(self) -> None: 

184 """Deactivate chip select (CS to logic 1). 

185 

186 Sends TAG_CSN_HIGH command to the model server. 

187 """ 

188 self._communicate(self.TAG_CSN_HIGH) 

189 

190 

191 def _send_all(self, data: bytes) -> None: 

192 """Send all data with retry logic. 

193 

194 :param data: Data to send 

195 

196 :raises TropicSquareError: If send fails 

197 :raises TropicSquareTimeoutError: If send times out after retries 

198 """ 

199 total_sent = 0 

200 attempts = 0 

201 

202 while total_sent < len(data) and attempts < self.TX_ATTEMPTS: 

203 try: 

204 sent = self._sock.send(data[total_sent:]) 

205 if sent == 0: 

206 raise TropicSquareError("Connection lost during send") 

207 total_sent += sent 

208 except Exception as e: 

209 if self._is_timeout_exception(e): 

210 attempts += 1 

211 if attempts >= self.TX_ATTEMPTS: 

212 raise TropicSquareTimeoutError( 

213 f"Send timeout after {attempts} attempts" 

214 ) 

215 continue 

216 raise TropicSquareError(f"Send failed: {e}") 

217 

218 if total_sent < len(data): 

219 raise TropicSquareError( 

220 f"Sent {total_sent}/{len(data)} bytes after {attempts} attempts" 

221 ) 

222 

223 

224 def _recv_exact(self, num_bytes: int) -> bytes: 

225 """Receive exactly num_bytes with retry logic. 

226 

227 :param num_bytes: Number of bytes to receive 

228 

229 :returns: Received data 

230 

231 :raises TropicSquareError: If receive fails or connection lost 

232 :raises TropicSquareTimeoutError: If receive times out after retries 

233 """ 

234 received = bytearray() 

235 attempts = 0 

236 

237 while len(received) < num_bytes and attempts < self.RX_ATTEMPTS: 

238 try: 

239 chunk = self._sock.recv(num_bytes - len(received)) 

240 if not chunk: 

241 raise TropicSquareError("Connection lost during receive") 

242 received.extend(chunk) 

243 except Exception as e: 

244 if self._is_timeout_exception(e): 

245 attempts += 1 

246 if attempts >= self.RX_ATTEMPTS: 

247 raise TropicSquareTimeoutError( 

248 f"Receive timeout after {attempts} attempts" 

249 ) 

250 continue 

251 raise TropicSquareError(f"Receive failed: {e}") 

252 

253 if len(received) < num_bytes: 

254 raise TropicSquareError( 

255 f"Received {len(received)}/{num_bytes} bytes" 

256 ) 

257 

258 return bytes(received) 

259 

260 

261 def _communicate(self, tag: int, tx_payload: bytes = None) -> bytes: 

262 """Send tagged request and receive response. 

263 

264 Implements the libtropic TCP protocol: 

265 

266 1. Build TX buffer: [tag (1)][length (2 LE)][payload (0-256)] 

267 2. Send all bytes with retry logic 

268 3. Receive response header (tag + length) 

269 4. Validate tag matches or is error tag 

270 5. Receive payload if length > 0 

271 6. Return payload data 

272 

273 :param tag: Protocol tag (TAG_CSN_LOW, TAG_CSN_HIGH, TAG_SPI_SEND, etc.) 

274 :param tx_payload: Optional payload data (max 256 bytes) 

275 

276 :returns: Response payload (or empty bytes if no payload) 

277 

278 :raises TropicSquareError: On protocol or network errors 

279 :raises TropicSquareTimeoutError: On timeout 

280 """ 

281 # Build TX buffer: [tag][length LE][payload] 

282 payload_len = len(tx_payload) if tx_payload else 0 

283 

284 if payload_len > self.MAX_PAYLOAD_LEN: 

285 raise TropicSquareError( 

286 f"Payload too large: {payload_len} > {self.MAX_PAYLOAD_LEN}" 

287 ) 

288 

289 tx_buffer = bytearray(self.MAX_BUFFER_LEN) 

290 tx_buffer[0] = tag 

291 tx_buffer[1] = payload_len & 0xFF # Little-endian low byte 

292 tx_buffer[2] = (payload_len >> 8) & 0xFF # Little-endian high byte 

293 

294 if tx_payload: 

295 tx_buffer[3:3 + payload_len] = tx_payload 

296 

297 tx_size = 3 + payload_len 

298 

299 # Send request 

300 self._send_all(bytes(tx_buffer[:tx_size])) 

301 

302 # Receive response header (tag + length) 

303 rx_header = self._recv_exact(3) 

304 rx_tag = rx_header[0] 

305 rx_len = rx_header[1] | (rx_header[2] << 8) # Little-endian 

306 

307 # Validate tag 

308 if rx_tag == self.TAG_INVALID: 

309 raise TropicSquareError( 

310 f"Server doesn't recognize tag {tag:#04x}" 

311 ) 

312 elif rx_tag == self.TAG_UNSUPPORTED: 

313 raise TropicSquareError( 

314 f"Server doesn't support tag {tag:#04x}" 

315 ) 

316 elif rx_tag != tag: 

317 raise TropicSquareError( 

318 f"Tag mismatch: sent {tag:#04x}, received {rx_tag:#04x}" 

319 ) 

320 

321 # Validate payload length 

322 if rx_len > self.MAX_PAYLOAD_LEN: 

323 raise TropicSquareError( 

324 f"Invalid payload length: {rx_len} > {self.MAX_PAYLOAD_LEN}" 

325 ) 

326 

327 # Receive payload if present 

328 if rx_len > 0: 

329 rx_payload = self._recv_exact(rx_len) 

330 else: 

331 rx_payload = b'' 

332 

333 return rx_payload