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
« prev ^ index » next coverage.py v7.13.5, created at 2026-03-27 21:24 +0000
1"""TCP Transport for libtropic Model/Simulator
3This module provides a TCP transport implementation that communicates
4with the TROPIC01 model/simulator server using the libtropic tagged protocol.
6The transport enables pytropicsquare to work with the same model/simulator
7infrastructure that libtropic uses, making it useful for:
9- Hardware-in-the-loop testing
10- Chip simulation and development
11- Cross-platform testing (CPython and MicroPython)
13Protocol Details:
14 The transport implements the libtropic TCP protocol with tagged messages:
16 - Buffer format:
18 - tag (1)
19 - length (2 LE)
20 - payload (0-256)
22 - Request-response pattern with tag validation
23 - Automatic retry logic for network operations
25Example::
27 from tropicsquare import TropicSquare
28 from tropicsquare.transports.tcp import TcpTransport
30 # Connect to model server
31 transport = TcpTransport("127.0.0.1")
32 ts = TropicSquare(transport)
34 # Use chip normally
35 print(ts.chip_id)
37:note: Server must be running libtropic-compatible model/simulator from https://github.com/tropicsquare/ts-tvl/
38"""
40import socket
41from tropicsquare.transports import L1Transport
42from tropicsquare.exceptions import TropicSquareError, TropicSquareTimeoutError
45class TcpTransport(L1Transport):
46 """L1 transport for TCP connection to libtropic model/simulator.
48 Implements the libtropic tagged protocol for communicating with
49 the TROPIC01 model/simulator server via TCP socket.
51 The transport maps L1 SPI operations to tagged TCP commands:
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
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)
62 :raises TropicSquareError: If connection fails
64 Example::
66 transport = TcpTransport("127.0.0.1")
67 ts = TropicSquare(transport)
68 print(ts.chip_id)
69 """
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
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
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()
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.
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)
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 )
138 def _transfer(self, tx_data: bytes) -> bytes:
139 """SPI bidirectional transfer.
141 Corresponds to SPI write_readinto operation.
142 Sends tx_data and receives same number of bytes back.
144 :param tx_data: Data to transmit
146 :returns: Received data (same length as tx_data)
148 :raises TropicSquareError: If transfer length mismatch occurs
149 """
150 rx_data = self._communicate(self.TAG_SPI_SEND, tx_data)
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 )
158 return rx_data
161 def _read(self, length: int) -> bytes:
162 """SPI read operation.
164 Corresponds to SPI read operation.
165 Sends dummy bytes (all zeros) and reads response.
167 :param length: Number of bytes to read
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))
175 def _cs_low(self) -> None:
176 """Activate chip select (CS to logic 0).
178 Sends TAG_CSN_LOW command to the model server.
179 """
180 self._communicate(self.TAG_CSN_LOW)
183 def _cs_high(self) -> None:
184 """Deactivate chip select (CS to logic 1).
186 Sends TAG_CSN_HIGH command to the model server.
187 """
188 self._communicate(self.TAG_CSN_HIGH)
191 def _send_all(self, data: bytes) -> None:
192 """Send all data with retry logic.
194 :param data: Data to send
196 :raises TropicSquareError: If send fails
197 :raises TropicSquareTimeoutError: If send times out after retries
198 """
199 total_sent = 0
200 attempts = 0
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}")
218 if total_sent < len(data):
219 raise TropicSquareError(
220 f"Sent {total_sent}/{len(data)} bytes after {attempts} attempts"
221 )
224 def _recv_exact(self, num_bytes: int) -> bytes:
225 """Receive exactly num_bytes with retry logic.
227 :param num_bytes: Number of bytes to receive
229 :returns: Received data
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
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}")
253 if len(received) < num_bytes:
254 raise TropicSquareError(
255 f"Received {len(received)}/{num_bytes} bytes"
256 )
258 return bytes(received)
261 def _communicate(self, tag: int, tx_payload: bytes = None) -> bytes:
262 """Send tagged request and receive response.
264 Implements the libtropic TCP protocol:
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
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)
276 :returns: Response payload (or empty bytes if no payload)
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
284 if payload_len > self.MAX_PAYLOAD_LEN:
285 raise TropicSquareError(
286 f"Payload too large: {payload_len} > {self.MAX_PAYLOAD_LEN}"
287 )
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
294 if tx_payload:
295 tx_buffer[3:3 + payload_len] = tx_payload
297 tx_size = 3 + payload_len
299 # Send request
300 self._send_all(bytes(tx_buffer[:tx_size]))
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
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 )
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 )
327 # Receive payload if present
328 if rx_len > 0:
329 rx_payload = self._recv_exact(rx_len)
330 else:
331 rx_payload = b''
333 return rx_payload