sr-robot3 2024.0.0rc2__py3-none-any.whl → 2025.0.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
sr/robot3/raw_serial.py CHANGED
@@ -2,13 +2,15 @@
2
2
  from __future__ import annotations
3
3
 
4
4
  import logging
5
+ from contextlib import contextmanager
5
6
  from time import sleep
6
7
  from types import MappingProxyType
7
- from typing import NamedTuple, Optional
8
+ from typing import Generator, NamedTuple, Optional
8
9
 
9
- from serial import Serial, serial_for_url
10
+ from serial import Serial, SerialException, serial_for_url
10
11
  from serial.tools.list_ports import comports
11
12
 
13
+ from .exceptions import BoardDisconnectionError
12
14
  from .logging import log_to_debug
13
15
  from .utils import Board, BoardIdentity, get_USB_identity
14
16
 
@@ -33,7 +35,7 @@ class RawSerial(Board):
33
35
  :param baudrate: The baudrate to use when connecting.
34
36
  :param identity: The identity of the board, as reported by the USB descriptor.
35
37
  """
36
- __slots__ = ('_serial', '_identity')
38
+ __slots__ = ('_serial', '_identity', 'delay_after_connect')
37
39
 
38
40
  @staticmethod
39
41
  def get_board_type() -> str:
@@ -63,9 +65,13 @@ class RawSerial(Board):
63
65
  serial_port,
64
66
  baudrate=baudrate,
65
67
  timeout=0.5, # 500ms timeout
68
+ do_not_open=True,
66
69
  )
67
70
 
68
- sleep(2) # Wait for boards to reset after connecting
71
+ # Certain boards will reset when the serial port is opened
72
+ # Wait for the board to be ready to receive data
73
+ self.delay_after_connect = 2
74
+ self._reconnect()
69
75
 
70
76
  @classmethod
71
77
  def _get_supported_boards(
@@ -75,7 +81,7 @@ class RawSerial(Board):
75
81
  """
76
82
  Discover connected serial devices filtered by serial number.
77
83
 
78
- :param serial_ports: A list of serial ports to check (serial_number, baudrate),
84
+ :param serial_devices: A list of serial ports to check (serial_number, baudrate),
79
85
  these are matched by serial number.
80
86
  :return: A mapping of board serial numbers to Arduinos
81
87
  """
@@ -123,9 +129,9 @@ class RawSerial(Board):
123
129
 
124
130
  @property
125
131
  @log_to_debug
126
- def raw(self) -> Serial:
132
+ def port(self) -> Serial:
127
133
  """
128
- The raw serial port.
134
+ The pySerial Serial object.
129
135
 
130
136
  :return: The raw serial port.
131
137
  """
@@ -138,7 +144,8 @@ class RawSerial(Board):
138
144
 
139
145
  :param data: The bytes to send.
140
146
  """
141
- self._serial.write(data)
147
+ with self._handle_serial_reconnect():
148
+ self._serial.write(data)
142
149
 
143
150
  @log_to_debug
144
151
  def read(self, size: int) -> bytes:
@@ -148,7 +155,8 @@ class RawSerial(Board):
148
155
  :param size: The number of bytes to read.
149
156
  :return: The line read from the port.
150
157
  """
151
- return self._serial.read(size)
158
+ with self._handle_serial_reconnect():
159
+ return self._serial.read(size)
152
160
 
153
161
  @log_to_debug
154
162
  def read_until(self, terminator: bytes = b'\n') -> bytes:
@@ -158,7 +166,60 @@ class RawSerial(Board):
158
166
  :param terminator: The terminator character to stop reading at.
159
167
  :return: The data read from the port.
160
168
  """
161
- return self._serial.read_until(terminator)
169
+ with self._handle_serial_reconnect():
170
+ return self._serial.read_until(terminator)
171
+
172
+ @contextmanager
173
+ def _handle_serial_reconnect(self) -> Generator[None, None, None]:
174
+ """
175
+ A context manager to handle reconnecting the serial ports on faults.
176
+ """
177
+ if not self._serial.is_open:
178
+ if not self._reconnect():
179
+ # If the serial port cannot be opened raise an error
180
+ raise BoardDisconnectionError(
181
+ f'Connection to port {self._identity.board_type}:'
182
+ f'{self._identity.asset_tag} could not be established',
183
+ )
184
+ try:
185
+ yield
186
+ except (SerialException, OSError):
187
+ # Serial connection failed, close the port and raise an error
188
+ self._serial.close()
189
+ logger.warning(
190
+ f'Port {self._identity.board_type}:{self._identity.asset_tag} disconnected',
191
+ )
192
+ raise BoardDisconnectionError(
193
+ f'Port {self._identity.board_type}:{self._identity.asset_tag} '
194
+ 'disconnected during transaction',
195
+ )
196
+
197
+ def _reconnect(self) -> bool:
198
+ """
199
+ Connect to the class's serial port.
200
+
201
+ This is called automatically when a message is sent to the board if the port is closed.
202
+
203
+ :raises serial.SerialException: If the serial port cannot be opened.
204
+ :return: True if the serial port was opened successfully, False otherwise.
205
+ """
206
+ try:
207
+ self._serial.open()
208
+ # Wait for the board to be ready to receive data
209
+ # Certain boards will reset when the serial port is opened
210
+ sleep(self.delay_after_connect)
211
+ self._serial.reset_input_buffer()
212
+ except SerialException:
213
+ logger.error(
214
+ 'Failed to connect to port '
215
+ f'{self._identity.board_type}:{self._identity.asset_tag}',
216
+ )
217
+ return False
218
+
219
+ logger.info(
220
+ f'Connected to port {self._identity.board_type}: {self._identity.asset_tag}',
221
+ )
222
+ return True
162
223
 
163
224
  def __repr__(self) -> str:
164
225
  return f"<{self.__class__.__qualname__}: {self._serial}>"
sr/robot3/robot.py CHANGED
@@ -3,8 +3,10 @@ from __future__ import annotations
3
3
 
4
4
  import itertools
5
5
  import logging
6
+ import os
6
7
  import time
7
8
  from pathlib import Path
9
+ from socket import socket
8
10
  from types import MappingProxyType
9
11
  from typing import Mapping, Optional
10
12
 
@@ -20,7 +22,8 @@ from .motor_board import MotorBoard
20
22
  from .power_board import Note, PowerBoard
21
23
  from .raw_serial import RawSerial
22
24
  from .servo_board import ServoBoard
23
- from .utils import ensure_atexit_on_term, obtain_lock, singular
25
+ from .simulator.time_server import TimeServer
26
+ from .utils import IN_SIMULATOR, ensure_atexit_on_term, obtain_lock, singular
24
27
 
25
28
  logger = logging.getLogger(__name__)
26
29
 
@@ -36,13 +39,16 @@ class Robot:
36
39
  :param wait_for_start: Wait in the constructor until the start button is pressed,
37
40
  defaults to True
38
41
  :param trace_logging: Enable trace level logging to the console, defaults to False
42
+ :param ignored_arduinos: A list of Arduino serial numbers to avoid connecting to
39
43
  :param manual_boards: A dictionary of board types to a list of serial port paths
40
44
  to allow for connecting to boards that are not automatically detected, defaults to None
41
45
  :param raw_ports: A list of serial number, baudrate tuples to try connecting to.
46
+ :param no_powerboard: If True, initialize the robot without a powerboard, defaults to False
42
47
  """
43
48
  __slots__ = (
44
49
  '_lock', '_metadata', '_power_board', '_motor_boards', '_servo_boards',
45
50
  '_arduinos', '_cameras', '_mqtt', '_astoria', '_kch', '_raw_ports',
51
+ '_time_server', '_no_powerboard',
46
52
  )
47
53
 
48
54
  def __init__(
@@ -54,16 +60,30 @@ class Robot:
54
60
  ignored_arduinos: Optional[list[str]] = None,
55
61
  manual_boards: Optional[dict[str, list[str]]] = None,
56
62
  raw_ports: Optional[list[tuple[str, int]]] = None,
63
+ no_powerboard: bool = False,
57
64
  ) -> None:
58
- self._lock = obtain_lock()
65
+ self._lock: TimeServer | socket | None
66
+ if IN_SIMULATOR:
67
+ self._lock = TimeServer.initialise()
68
+ if self._lock is None:
69
+ raise OSError(
70
+ 'Unable to obtain lock, Is another robot instance already running?'
71
+ )
72
+ else:
73
+ self._lock = obtain_lock()
59
74
  self._metadata: Optional[Metadata] = None
75
+ self._no_powerboard = no_powerboard
60
76
 
61
77
  setup_logging(debug, trace_logging)
62
78
  ensure_atexit_on_term()
63
79
 
64
80
  logger.info(f"sr.robot3 version {__version__}")
65
81
 
66
- self._mqtt, self._astoria = init_astoria_mqtt()
82
+ if IN_SIMULATOR:
83
+ self._mqtt = None
84
+ self._astoria = None
85
+ else:
86
+ self._mqtt, self._astoria = init_astoria_mqtt()
67
87
 
68
88
  if manual_boards:
69
89
  self._init_power_board(manual_boards.get(PowerBoard.get_board_type(), []))
@@ -87,7 +107,10 @@ class Robot:
87
107
  defaults to None
88
108
  :raises RuntimeError: If exactly one PowerBoard is not found
89
109
  """
90
- power_boards = PowerBoard._get_supported_boards(manual_boards)
110
+ if self._no_powerboard:
111
+ return
112
+
113
+ power_boards = PowerBoard._get_supported_boards(manual_boards, sleep_fn=self.sleep)
91
114
  self._power_board = singular(power_boards)
92
115
 
93
116
  # Enable all the outputs, so that we can find other boards.
@@ -108,6 +131,8 @@ class Robot:
108
131
 
109
132
  :param manual_boards: A dictionary of board types to a list of additional
110
133
  serial port paths that should be checked for boards of that type, defaults to None
134
+ :param ignored_arduinos: A list of Arduino serial numbers to avoid connecting to
135
+ :param raw_ports: A list of serial number, baudrate tuples to try connecting to
111
136
  """
112
137
  self._raw_ports: MappingProxyType[str, RawSerial] = MappingProxyType({})
113
138
  if manual_boards is None:
@@ -122,7 +147,10 @@ class Robot:
122
147
  self._servo_boards = ServoBoard._get_supported_boards(manual_servoboards)
123
148
  self._arduinos = Arduino._get_supported_boards(manual_arduinos, ignored_arduinos)
124
149
  if raw_ports:
125
- self._raw_ports = RawSerial._get_supported_boards(raw_ports)
150
+ if not IN_SIMULATOR:
151
+ self._raw_ports = RawSerial._get_supported_boards(raw_ports)
152
+ else:
153
+ logger.warning("Raw ports are not available in the simulator.")
126
154
 
127
155
  def _init_camera(self) -> None:
128
156
  """
@@ -131,9 +159,15 @@ class Robot:
131
159
  These cameras are used for AprilTag detection and provide location data of
132
160
  markers in its field of view.
133
161
  """
162
+ if IN_SIMULATOR:
163
+ publish_fn = None
164
+ else:
165
+ assert self._mqtt is not None
166
+ publish_fn = self._mqtt.wrapped_publish
167
+
134
168
  self._cameras = MappingProxyType(_setup_cameras(
135
169
  game_specific.MARKER_SIZES,
136
- self._mqtt.wrapped_publish,
170
+ publish_fn,
137
171
  ))
138
172
 
139
173
  def _log_connected_boards(self) -> None:
@@ -142,8 +176,10 @@ class Robot:
142
176
 
143
177
  Firmware versions are also logged at debug level.
144
178
  """
179
+ # we only have one power board so make it iterable
180
+ power_board = [] if self._no_powerboard else [self.power_board]
145
181
  boards = itertools.chain(
146
- [self.power_board], # we only have one power board so make it iterable
182
+ power_board,
147
183
  self.motor_boards.values(),
148
184
  self.servo_boards.values(),
149
185
  self.arduinos.values(),
@@ -175,6 +211,9 @@ class Robot:
175
211
 
176
212
  :return: The power board object
177
213
  """
214
+ if self._no_powerboard:
215
+ raise RuntimeError("No power board was initialized")
216
+
178
217
  return self._power_board
179
218
 
180
219
  @property
@@ -276,27 +315,35 @@ class Robot:
276
315
  """
277
316
  Sleep for a number of seconds.
278
317
 
279
- This is a convenience method that can be used instead of time.sleep().
280
- This exists for compatibility with the simulator API only.
318
+ This is a convenience method that should be used instead of time.sleep()
319
+ to make your code compatible with the simulator.
281
320
 
282
321
  :param secs: The number of seconds to sleep for
283
322
  """
284
- time.sleep(secs)
323
+ if IN_SIMULATOR:
324
+ assert isinstance(self._lock, TimeServer)
325
+ self._lock.sleep(secs)
326
+ else:
327
+ time.sleep(secs)
285
328
 
286
329
  @log_to_debug
287
330
  def time(self) -> float:
288
331
  """
289
332
  Get the number of seconds since the Unix Epoch.
290
333
 
291
- This is a convenience method that can be used instead of time.time().
292
- This exists for compatibility with the simulator API only.
334
+ This is a convenience method that should be used instead of time.time()
335
+ to make your code compatible with the simulator.
293
336
 
294
337
  NOTE: The robot's clock resets each time the robot is restarted, so this
295
338
  will not be the correct actual time but can be used to measure elapsed time.
296
339
 
297
340
  :returns: the number of seconds since the Unix Epoch.
298
341
  """
299
- return time.time()
342
+ if IN_SIMULATOR:
343
+ assert isinstance(self._lock, TimeServer)
344
+ return self._lock.get_time()
345
+ else:
346
+ return time.time()
300
347
 
301
348
  @property
302
349
  @log_to_debug
@@ -346,9 +393,12 @@ class Robot:
346
393
  The path of the USB code drive.
347
394
 
348
395
  :returns: path to the mountpoint of the USB code drive.
349
- :raises MetadataNotReadyError: If the start button has not been pressed yet
350
396
  """
351
- return self._astoria.fetch_mount_path()
397
+ if IN_SIMULATOR:
398
+ return Path(os.environ['SBOT_USBKEY_PATH'])
399
+ else:
400
+ assert self._astoria is not None
401
+ return self._astoria.fetch_mount_path()
352
402
 
353
403
  @property
354
404
  def is_simulated(self) -> bool:
@@ -357,7 +407,7 @@ class Robot:
357
407
 
358
408
  :returns: True if the robot is simulated. False otherwise.
359
409
  """
360
- return False
410
+ return IN_SIMULATOR
361
411
 
362
412
  @log_to_debug
363
413
  def wait_start(self) -> None:
@@ -369,25 +419,47 @@ class Robot:
369
419
  Once the start button is pressed, the metadata will be loaded and the timeout
370
420
  will start if in competition mode.
371
421
  """
422
+ def null_button_pressed() -> bool:
423
+ return False
424
+
425
+ if self._no_powerboard:
426
+ # null out the start button function
427
+ start_button_pressed = null_button_pressed
428
+ else:
429
+ start_button_pressed = self.power_board._start_button
430
+
431
+ if IN_SIMULATOR:
432
+ remote_start_pressed = null_button_pressed
433
+ else:
434
+ assert self._astoria is not None
435
+ remote_start_pressed = self._astoria.get_start_button_pressed
436
+
372
437
  # ignore previous button presses
373
- _ = self.power_board._start_button()
374
- _ = self._astoria.get_start_button_pressed()
438
+ _ = start_button_pressed()
439
+ _ = remote_start_pressed()
375
440
  logger.info('Waiting for start button.')
376
441
 
377
- self.power_board.piezo.buzz(Note.A6, 0.1)
378
- self.power_board._run_led.flash()
442
+ if not self._no_powerboard:
443
+ self.power_board.piezo.buzz(Note.A6, 0.1)
444
+ self.power_board._run_led.flash()
379
445
  self.kch._flash_start()
380
446
 
381
- while not (
382
- self.power_board._start_button() or self._astoria.get_start_button_pressed()
383
- ):
384
- time.sleep(0.1)
447
+ while not start_button_pressed() or remote_start_pressed():
448
+ self.sleep(0.1)
385
449
  logger.info("Start signal received; continuing.")
386
- self.power_board._run_led.on()
450
+ if not self._no_powerboard:
451
+ self.power_board._run_led.on()
387
452
  self.kch._start = False
388
453
 
389
- # Load the latest metadata that we have received over MQTT
390
- self._metadata = self._astoria.fetch_current_metadata()
454
+ if IN_SIMULATOR:
455
+ self._metadata = Metadata.parse_file(
456
+ Path(os.environ['SBOT_METADATA_PATH']) / 'astoria.json'
457
+ )
458
+ else:
459
+ assert self._astoria is not None
460
+ # Load the latest metadata that we have received over MQTT
461
+ self._metadata = self._astoria.fetch_current_metadata()
391
462
 
392
- if self._metadata.game_timeout is not None:
463
+ # Simulator timeout is handled by the simulator supervisor
464
+ if self._metadata.game_timeout and not IN_SIMULATOR:
393
465
  timeout.kill_after_delay(self._metadata.game_timeout)
@@ -17,7 +17,7 @@ import serial
17
17
 
18
18
  from .exceptions import BoardDisconnectionError
19
19
  from .logging import TRACE
20
- from .utils import BoardIdentity
20
+ from .utils import IN_SIMULATOR, BoardIdentity
21
21
 
22
22
  logger = logging.getLogger(__name__)
23
23
 
@@ -29,6 +29,12 @@ Param = ParamSpec("Param")
29
29
  RetType = TypeVar("RetType")
30
30
 
31
31
  E = TypeVar("E", bound=BaseException)
32
+ BASE_TIMEOUT: float | None
33
+
34
+ if IN_SIMULATOR:
35
+ BASE_TIMEOUT = None # Disable timeouts while in the simulator to allow for pausing
36
+ else:
37
+ BASE_TIMEOUT = 0.5
32
38
 
33
39
 
34
40
  def retry(
@@ -41,7 +47,7 @@ def retry(
41
47
  This outer function is used to pass arguments to the decorator.
42
48
 
43
49
  :param times: The number of times to retry the function.
44
- :param exceptions: The exception to catch and retry on.
50
+ :param exceptions: The exceptions to catch and retry on.
45
51
  :return: The templated decorator function.
46
52
  """
47
53
  def decorator(func: Callable[Param, RetType]) -> Callable[Param, RetType]:
@@ -80,7 +86,7 @@ class SerialWrapper:
80
86
  self,
81
87
  port: str,
82
88
  baud: int,
83
- timeout: float = 0.5,
89
+ timeout: float | None = BASE_TIMEOUT,
84
90
  identity: BoardIdentity = BoardIdentity(),
85
91
  delay_after_connect: float = 0,
86
92
  ):
@@ -96,6 +102,7 @@ class SerialWrapper:
96
102
  port,
97
103
  baudrate=baud,
98
104
  timeout=timeout,
105
+ write_timeout=timeout,
99
106
  do_not_open=True,
100
107
  )
101
108
 
@@ -124,19 +131,21 @@ class SerialWrapper:
124
131
  up to 3 times on serial errors.
125
132
 
126
133
  :param data: The data to write to the board.
134
+ :param endl: The endline character to add to outgoing data and remove from
135
+ received data
127
136
  :raises BoardDisconnectionError: If the serial connection fails during the transaction,
128
137
  including failing to respond to the command.
129
- :return: The response from the board with the trailing newline removed.
138
+ :return: The response from the board with the trailing endline character removed.
130
139
  """
131
140
  with self._lock:
132
141
  if not self.serial.is_open:
133
142
  if not self._connect():
134
143
  # If the serial port cannot be opened raise an error,
135
144
  # this will be caught by the retry decorator
136
- raise BoardDisconnectionError((
145
+ raise BoardDisconnectionError(
137
146
  f'Connection to board {self.identity.board_type}:'
138
147
  f'{self.identity.asset_tag} could not be established',
139
- ))
148
+ )
140
149
 
141
150
  try:
142
151
  if data is not None:
@@ -172,10 +181,10 @@ class SerialWrapper:
172
181
 
173
182
  if response_str.startswith('NACK'):
174
183
  _, error_msg = response_str.split(':', maxsplit=1)
175
- logger.error((
184
+ logger.error(
176
185
  f'Board {self.identity.board_type}:{self.identity.asset_tag} '
177
186
  f'returned NACK on write command: {error_msg}'
178
- ))
187
+ )
179
188
  raise RuntimeError(error_msg)
180
189
 
181
190
  return response_str
@@ -185,6 +194,8 @@ class SerialWrapper:
185
194
  Send a command to the board that does not require a response.
186
195
 
187
196
  :param data: The data to write to the board.
197
+ :param endl: The endline character to add to outgoing data and remove from
198
+ received data
188
199
  :raises RuntimeError: If the board returns a NACK response,
189
200
  the firmware's error message is raised.
190
201
  """
@@ -202,15 +213,16 @@ class SerialWrapper:
202
213
  """
203
214
  try:
204
215
  self.serial.open()
205
- # Wait for the board to be ready to receive data
206
- # Certain boards will reset when the serial port is opened
207
- time.sleep(self.delay_after_connect)
216
+ if not IN_SIMULATOR:
217
+ # Wait for the board to be ready to receive data
218
+ # Certain boards will reset when the serial port is opened
219
+ time.sleep(self.delay_after_connect)
208
220
  self.serial.reset_input_buffer()
209
221
  except serial.SerialException:
210
- logger.error((
222
+ logger.error(
211
223
  'Failed to connect to board '
212
224
  f'{self.identity.board_type}:{self.identity.asset_tag}'
213
- ))
225
+ )
214
226
  return False
215
227
 
216
228
  logger.info(
sr/robot3/servo_board.py CHANGED
@@ -12,14 +12,15 @@ from .exceptions import IncorrectBoardError
12
12
  from .logging import log_to_debug
13
13
  from .serial_wrapper import SerialWrapper
14
14
  from .utils import (
15
- Board, BoardIdentity, float_bounds_check,
16
- get_USB_identity, map_to_float, map_to_int,
15
+ IN_SIMULATOR, Board, BoardIdentity, float_bounds_check,
16
+ get_simulator_boards, get_USB_identity, map_to_float, map_to_int,
17
17
  )
18
18
 
19
19
  DUTY_MIN = 500
20
20
  DUTY_MAX = 4000
21
21
  START_DUTY_MIN = 1000
22
22
  START_DUTY_MAX = 2000
23
+ NUM_SERVOS = 12
23
24
 
24
25
  logger = logging.getLogger(__name__)
25
26
  BAUDRATE = 115200 # Since the servo board is a USB device, this is ignored
@@ -77,7 +78,7 @@ class ServoBoard(Board):
77
78
  self._serial = SerialWrapper(serial_port, BAUDRATE, identity=initial_identity)
78
79
 
79
80
  self._servos = tuple(
80
- Servo(self._serial, index) for index in range(12)
81
+ Servo(self._serial, index) for index in range(NUM_SERVOS)
81
82
  )
82
83
 
83
84
  self._identity = self.identify()
@@ -109,17 +110,46 @@ class ServoBoard(Board):
109
110
  f"expected {err.expected_type!r}. Ignoring this device")
110
111
  return None
111
112
  except Exception:
112
- if initial_identity is not None and initial_identity.board_type == 'manual':
113
- logger.warning(
114
- f"Manually specified servo board at port {serial_port!r}, "
115
- "could not be identified. Ignoring this device")
116
- else:
117
- logger.warning(
118
- f"Found servo board-like serial port at {serial_port!r}, "
119
- "but it could not be identified. Ignoring this device")
113
+ if initial_identity is not None:
114
+ if initial_identity.board_type == 'manual':
115
+ logger.warning(
116
+ f"Manually specified servo board at port {serial_port!r}, "
117
+ "could not be identified. Ignoring this device")
118
+ elif initial_identity.manufacturer == 'sbot_simulator':
119
+ logger.warning(
120
+ f"Simulator specified servo board at port {serial_port!r}, "
121
+ "could not be identified. Ignoring this device")
122
+ return None
123
+
124
+ logger.warning(
125
+ f"Found servo board-like serial port at {serial_port!r}, "
126
+ "but it could not be identified. Ignoring this device")
120
127
  return None
121
128
  return board
122
129
 
130
+ @classmethod
131
+ def _get_simulator_boards(cls) -> MappingProxyType[str, ServoBoard]:
132
+ """
133
+ Get the simulator boards.
134
+
135
+ :return: A mapping of board serial numbers to boards.
136
+ """
137
+ boards = {}
138
+ # The filter here is the name of the emulated board in the simulator
139
+ for board_info in get_simulator_boards('ServoBoard'):
140
+
141
+ # Create board identity from the info given
142
+ initial_identity = BoardIdentity(
143
+ manufacturer='sbot_simulator',
144
+ board_type=board_info.type_str,
145
+ asset_tag=board_info.serial_number,
146
+ )
147
+ if (board := cls._get_valid_board(board_info.url, initial_identity)) is None:
148
+ continue
149
+
150
+ boards[board._identity.asset_tag] = board
151
+ return MappingProxyType(boards)
152
+
123
153
  @classmethod
124
154
  def _get_supported_boards(
125
155
  cls, manual_boards: Optional[list[str]] = None,
@@ -133,6 +163,9 @@ class ServoBoard(Board):
133
163
  to connect to, defaults to None
134
164
  :return: A mapping of serial numbers to servo boards.
135
165
  """
166
+ if IN_SIMULATOR:
167
+ return cls._get_simulator_boards()
168
+
136
169
  boards = {}
137
170
  serial_ports = comports()
138
171
  for port in serial_ports:
File without changes