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/kch.py CHANGED
@@ -2,17 +2,28 @@
2
2
  from __future__ import annotations
3
3
 
4
4
  import atexit
5
+ import logging
5
6
  import warnings
6
7
  from enum import IntEnum, unique
7
8
  from typing import Optional
8
9
 
10
+ from .exceptions import BoardDisconnectionError, IncorrectBoardError
11
+ from .serial_wrapper import SerialWrapper
12
+ from .utils import IN_SIMULATOR, Board, BoardIdentity, get_simulator_boards
13
+
9
14
  try:
10
15
  import RPi.GPIO as GPIO # isort: ignore
11
- HAS_HAT = True
16
+ HAS_HAT = True if not IN_SIMULATOR else False
12
17
  except ImportError:
13
18
  HAS_HAT = False
14
19
 
15
20
 
21
+ logger = logging.getLogger(__name__)
22
+
23
+ # Only used in the simulator
24
+ BAUDRATE = 115200
25
+
26
+
16
27
  @unique
17
28
  class RobotLEDs(IntEnum):
18
29
  """Mapping of LEDs to GPIO Pins."""
@@ -71,6 +82,8 @@ class KCH:
71
82
  __slots__ = ('_leds', '_pwm')
72
83
 
73
84
  def __init__(self) -> None:
85
+ self._leds: tuple[LED, ...]
86
+
74
87
  if HAS_HAT:
75
88
  GPIO.setmode(GPIO.BCM)
76
89
  with warnings.catch_warnings():
@@ -81,11 +94,7 @@ class KCH:
81
94
  # suppress this as we know the reason behind the warning
82
95
  GPIO.setup(RobotLEDs.all_leds(), GPIO.OUT, initial=GPIO.LOW)
83
96
  self._pwm: Optional[GPIO.PWM] = None
84
- self._leds = tuple(
85
- LED(rgb_led) for rgb_led in RobotLEDs.user_leds()
86
- )
87
97
 
88
- if HAS_HAT:
89
98
  # We are not running cleanup so the LED state persists after the code completes,
90
99
  # this will cause a warning with `GPIO.setup()` which we can suppress
91
100
  # atexit.register(GPIO.cleanup)
@@ -94,6 +103,25 @@ class KCH:
94
103
  # Mypy isn't aware of the version of atexit.register(func, *args)
95
104
  atexit.register(GPIO.cleanup, RobotLEDs.START) # type: ignore[call-arg]
96
105
 
106
+ self._leds = tuple(
107
+ PhysicalLED(rgb_led) for rgb_led in RobotLEDs.user_leds()
108
+ )
109
+ elif IN_SIMULATOR:
110
+ led_server = LedServer.initialise()
111
+ if led_server is not None:
112
+ self._leds = tuple(
113
+ SimulationLED(v, led_server)
114
+ for v in range(len(RobotLEDs.user_leds()))
115
+ )
116
+ else:
117
+ self._leds = tuple(
118
+ LED() for _ in RobotLEDs.user_leds()
119
+ )
120
+ else:
121
+ self._leds = tuple(
122
+ LED() for _ in RobotLEDs.user_leds()
123
+ )
124
+
97
125
  @property
98
126
  def _start(self) -> bool:
99
127
  """Get the state of the start LED."""
@@ -122,7 +150,60 @@ class KCH:
122
150
 
123
151
 
124
152
  class LED:
125
- """User programmable LED."""
153
+ """
154
+ User programmable LED.
155
+
156
+ This is a dummy class to handle the case where this is run on neither the
157
+ Raspberry Pi nor the simulator.
158
+ As such, this class does nothing.
159
+ """
160
+ __slots__ = ('_led',)
161
+
162
+ @property
163
+ def r(self) -> bool:
164
+ """Get the state of the Red LED segment."""
165
+ return False
166
+
167
+ @r.setter
168
+ def r(self, value: bool) -> None:
169
+ """Set the state of the Red LED segment."""
170
+
171
+ @property
172
+ def g(self) -> bool:
173
+ """Get the state of the Green LED segment."""
174
+ return False
175
+
176
+ @g.setter
177
+ def g(self, value: bool) -> None:
178
+ """Set the state of the Green LED segment."""
179
+
180
+ @property
181
+ def b(self) -> bool:
182
+ """Get the state of the Blue LED segment."""
183
+ return False
184
+
185
+ @b.setter
186
+ def b(self, value: bool) -> None:
187
+ """Set the state of the Blue LED segment."""
188
+
189
+ @property
190
+ def colour(self) -> tuple[bool, bool, bool]:
191
+ """Get the colour of the user LED."""
192
+ return False, False, False
193
+
194
+ @colour.setter
195
+ def colour(self, value: tuple[bool, bool, bool]) -> None:
196
+ """Set the colour of the user LED."""
197
+ if not isinstance(value, (tuple, list)) or len(value) != 3:
198
+ raise ValueError("The LED requires 3 values for it's colour")
199
+
200
+
201
+ class PhysicalLED(LED):
202
+ """
203
+ User programmable LED.
204
+
205
+ Used when running on the Raspberry Pi to control the actual LEDs.
206
+ """
126
207
  __slots__ = ('_led',)
127
208
 
128
209
  def __init__(self, led: tuple[int, int, int]):
@@ -131,47 +212,41 @@ class LED:
131
212
  @property
132
213
  def r(self) -> bool:
133
214
  """Get the state of the Red LED segment."""
134
- return GPIO.input(self._led[0]) if HAS_HAT else False
215
+ return GPIO.input(self._led[0])
135
216
 
136
217
  @r.setter
137
218
  def r(self, value: bool) -> None:
138
219
  """Set the state of the Red LED segment."""
139
- if HAS_HAT:
140
- GPIO.output(self._led[0], GPIO.HIGH if value else GPIO.LOW)
220
+ GPIO.output(self._led[0], GPIO.HIGH if value else GPIO.LOW)
141
221
 
142
222
  @property
143
223
  def g(self) -> bool:
144
224
  """Get the state of the Green LED segment."""
145
- return GPIO.input(self._led[1]) if HAS_HAT else False
225
+ return GPIO.input(self._led[1])
146
226
 
147
227
  @g.setter
148
228
  def g(self, value: bool) -> None:
149
229
  """Set the state of the Green LED segment."""
150
- if HAS_HAT:
151
- GPIO.output(self._led[1], GPIO.HIGH if value else GPIO.LOW)
230
+ GPIO.output(self._led[1], GPIO.HIGH if value else GPIO.LOW)
152
231
 
153
232
  @property
154
233
  def b(self) -> bool:
155
234
  """Get the state of the Blue LED segment."""
156
- return GPIO.input(self._led[2]) if HAS_HAT else False
235
+ return GPIO.input(self._led[2])
157
236
 
158
237
  @b.setter
159
238
  def b(self, value: bool) -> None:
160
239
  """Set the state of the Blue LED segment."""
161
- if HAS_HAT:
162
- GPIO.output(self._led[2], GPIO.HIGH if value else GPIO.LOW)
240
+ GPIO.output(self._led[2], GPIO.HIGH if value else GPIO.LOW)
163
241
 
164
242
  @property
165
243
  def colour(self) -> tuple[bool, bool, bool]:
166
244
  """Get the colour of the user LED."""
167
- if HAS_HAT:
168
- return (
169
- GPIO.input(self._led[0]),
170
- GPIO.input(self._led[1]),
171
- GPIO.input(self._led[2]),
172
- )
173
- else:
174
- return False, False, False
245
+ return (
246
+ GPIO.input(self._led[0]),
247
+ GPIO.input(self._led[1]),
248
+ GPIO.input(self._led[2]),
249
+ )
175
250
 
176
251
  @colour.setter
177
252
  def colour(self, value: tuple[bool, bool, bool]) -> None:
@@ -179,10 +254,161 @@ class LED:
179
254
  if not isinstance(value, (tuple, list)) or len(value) != 3:
180
255
  raise ValueError("The LED requires 3 values for it's colour")
181
256
 
182
- if HAS_HAT:
183
- GPIO.output(
184
- self._led,
185
- tuple(
186
- GPIO.HIGH if v else GPIO.LOW for v in value
187
- ),
188
- )
257
+ GPIO.output(
258
+ self._led,
259
+ tuple(
260
+ GPIO.HIGH if v else GPIO.LOW for v in value
261
+ ),
262
+ )
263
+
264
+
265
+ class LedServer(Board):
266
+ """
267
+ LED control over a socket.
268
+
269
+ Used when running in the simulator to control the simulated LEDs.
270
+ """
271
+
272
+ @staticmethod
273
+ def get_board_type() -> str:
274
+ """
275
+ Return the type of the board.
276
+
277
+ :return: The literal string 'KCHv1B'.
278
+ """
279
+ return 'KCHv1B'
280
+
281
+ def __init__(
282
+ self,
283
+ serial_port: str,
284
+ initial_identity: BoardIdentity | None = None,
285
+ ) -> None:
286
+ if initial_identity is None:
287
+ initial_identity = BoardIdentity()
288
+ self._serial = SerialWrapper(
289
+ serial_port,
290
+ BAUDRATE,
291
+ identity=initial_identity,
292
+ )
293
+
294
+ self._identity = self.identify()
295
+ if self._identity.board_type != self.get_board_type():
296
+ raise IncorrectBoardError(self._identity.board_type, self.get_board_type())
297
+ self._serial.set_identity(self._identity)
298
+
299
+ # Reset the board to a known state
300
+ self._serial.write('*RESET')
301
+
302
+ @classmethod
303
+ def initialise(cls) -> 'LedServer' | None:
304
+ """Initialise the LED server using simulator discovery."""
305
+ # The filter here is the name of the emulated board in the simulator
306
+ boards = get_simulator_boards('LedBoard')
307
+
308
+ if not boards:
309
+ return None
310
+
311
+ board_info = boards[0]
312
+
313
+ # Create board identity from the info given
314
+ initial_identity = BoardIdentity(
315
+ manufacturer='sbot_simulator',
316
+ board_type=board_info.type_str,
317
+ asset_tag=board_info.serial_number,
318
+ )
319
+
320
+ try:
321
+ board = cls(board_info.url, initial_identity)
322
+ except BoardDisconnectionError:
323
+ logger.warning(
324
+ f"Simulator specified LED board at port {board_info.url!r}, "
325
+ "could not be identified. Ignoring this device")
326
+ return None
327
+ except IncorrectBoardError as err:
328
+ logger.warning(
329
+ f"Board returned type {err.returned_type!r}, "
330
+ f"expected {err.expected_type!r}. Ignoring this device")
331
+ return None
332
+
333
+ return board
334
+
335
+ def identify(self) -> BoardIdentity:
336
+ """
337
+ Get the identity of the board.
338
+
339
+ :return: The identity of the board.
340
+ """
341
+ response = self._serial.query('*IDN?')
342
+ return BoardIdentity(*response.split(':'))
343
+
344
+ def set_leds(self, led_num: int, value: tuple[bool, bool, bool]) -> None:
345
+ """Set the colour of the LED."""
346
+ self._serial.write(f'LED:{led_num}:SET:{value[0]:d}:{value[1]:d}:{value[2]:d}')
347
+
348
+ def get_leds(self, led_num: int) -> tuple[bool, bool, bool]:
349
+ """Get the colour of the LED."""
350
+ response = self._serial.query(f'LED:{led_num}:GET?')
351
+ red, green, blue = response.split(':')
352
+ return bool(int(red)), bool(int(green)), bool(int(blue))
353
+
354
+
355
+ class SimulationLED(LED):
356
+ """
357
+ User programmable LED.
358
+
359
+ Used when running in the simulator to control the simulated LEDs.
360
+ """
361
+ __slots__ = ('_led_num', '_server')
362
+
363
+ def __init__(self, led_num: int, server: LedServer) -> None:
364
+ self._led_num = led_num
365
+ self._server = server
366
+
367
+ @property
368
+ def r(self) -> bool:
369
+ """Get the state of the Red LED segment."""
370
+ return self._server.get_leds(self._led_num)[0]
371
+
372
+ @r.setter
373
+ def r(self, value: bool) -> None:
374
+ """Set the state of the Red LED segment."""
375
+ # Fetch the current state of the LED so we can update only the red value
376
+ current = self._server.get_leds(self._led_num)
377
+ self._server.set_leds(self._led_num, (value, current[1], current[2]))
378
+
379
+ @property
380
+ def g(self) -> bool:
381
+ """Get the state of the Green LED segment."""
382
+ return self._server.get_leds(self._led_num)[1]
383
+
384
+ @g.setter
385
+ def g(self, value: bool) -> None:
386
+ """Set the state of the Green LED segment."""
387
+ # Fetch the current state of the LED so we can update only the green value
388
+ current = self._server.get_leds(self._led_num)
389
+ self._server.set_leds(self._led_num, (current[0], value, current[2]))
390
+
391
+ @property
392
+ def b(self) -> bool:
393
+ """Get the state of the Blue LED segment."""
394
+ return self._server.get_leds(self._led_num)[2]
395
+
396
+ @b.setter
397
+ def b(self, value: bool) -> None:
398
+ """Set the state of the Blue LED segment."""
399
+ # Fetch the current state of the LED so we can update only the blue value
400
+ current = self._server.get_leds(self._led_num)
401
+ self._server.set_leds(self._led_num, (current[0], current[1], value))
402
+
403
+ @property
404
+ def colour(self) -> tuple[bool, bool, bool]:
405
+ """Get the colour of the user LED."""
406
+ return self._server.get_leds(self._led_num)
407
+
408
+ @colour.setter
409
+ def colour(self, value: tuple[bool, bool, bool]) -> None:
410
+ """Set the colour of the user LED."""
411
+ if not isinstance(value, (tuple, list)) or len(value) != 3:
412
+ raise ValueError("The LED requires 3 values for it's colour")
413
+
414
+ self._server.set_leds(self._led_num, value)
sr/robot3/logging.py CHANGED
@@ -36,7 +36,7 @@ def setup_logging(debug_logging: bool, trace_logging: bool) -> None:
36
36
  """
37
37
  logformat = '%(name)s - %(levelname)s - %(message)s'
38
38
  formatter = logging.Formatter(fmt=logformat)
39
- handler = logging.StreamHandler()
39
+ handler = logging.StreamHandler(stream=sys.stdout)
40
40
  handler.setFormatter(formatter)
41
41
 
42
42
  root_logger = logging.getLogger()
sr/robot3/marker.py CHANGED
@@ -90,6 +90,13 @@ PixelCorners = Tuple[PixelCoordinates, PixelCoordinates, PixelCoordinates, Pixel
90
90
  class Marker(NamedTuple):
91
91
  """
92
92
  Wrapper of a marker detection with axis and rotation calculated.
93
+
94
+ :param id: The ID of the detected marker
95
+ :param size: The physical size of the marker in millimeters
96
+ :param pixel_corners: A tuple of the PixelCoordinates of the marker's corners in the frame
97
+ :param pixel_centre: The PixelCoordinates of the marker's centre in the frame
98
+ :param position: Position information of the marker relative to the camera
99
+ :param orientation: Orientation information of the marker
93
100
  """
94
101
 
95
102
  id: int
@@ -102,6 +109,7 @@ class Marker(NamedTuple):
102
109
 
103
110
  @classmethod
104
111
  def from_april_vision_marker(cls, marker: AprilMarker) -> 'Marker':
112
+ """Generate a marker object using the data from april_vision's Marker object."""
105
113
  if marker.rvec is None or marker.tvec is None:
106
114
  raise ValueError("Marker lacks pose information")
107
115
 
sr/robot3/motor_board.py CHANGED
@@ -13,8 +13,8 @@ from .exceptions import IncorrectBoardError
13
13
  from .logging import log_to_debug
14
14
  from .serial_wrapper import SerialWrapper
15
15
  from .utils import (
16
- Board, BoardIdentity, float_bounds_check,
17
- get_USB_identity, map_to_float, map_to_int,
16
+ IN_SIMULATOR, Board, BoardIdentity, float_bounds_check,
17
+ get_simulator_boards, get_USB_identity, map_to_float, map_to_int,
18
18
  )
19
19
 
20
20
  logger = logging.getLogger(__name__)
@@ -113,17 +113,46 @@ class MotorBoard(Board):
113
113
  f"expected {err.expected_type!r}. Ignoring this device")
114
114
  return None
115
115
  except Exception:
116
- if initial_identity is not None and initial_identity.board_type == 'manual':
117
- logger.warning(
118
- f"Manually specified motor board at port {serial_port!r}, "
119
- "could not be identified. Ignoring this device")
120
- else:
121
- logger.warning(
122
- f"Found motor board-like serial port at {serial_port!r}, "
123
- "but it could not be identified. Ignoring this device")
116
+ if initial_identity is not None:
117
+ if initial_identity.board_type == 'manual':
118
+ logger.warning(
119
+ f"Manually specified motor board at port {serial_port!r}, "
120
+ "could not be identified. Ignoring this device")
121
+ elif initial_identity.manufacturer == 'sbot_simulator':
122
+ logger.warning(
123
+ f"Simulator specified motor board at port {serial_port!r}, "
124
+ "could not be identified. Ignoring this device")
125
+ return None
126
+
127
+ logger.warning(
128
+ f"Found motor board-like serial port at {serial_port!r}, "
129
+ "but it could not be identified. Ignoring this device")
124
130
  return None
125
131
  return board
126
132
 
133
+ @classmethod
134
+ def _get_simulator_boards(cls) -> MappingProxyType[str, MotorBoard]:
135
+ """
136
+ Get the simulator boards.
137
+
138
+ :return: A mapping of board serial numbers to boards.
139
+ """
140
+ boards = {}
141
+ # The filter here is the name of the emulated board in the simulator
142
+ for board_info in get_simulator_boards('MotorBoard'):
143
+
144
+ # Create board identity from the info given
145
+ initial_identity = BoardIdentity(
146
+ manufacturer='sbot_simulator',
147
+ board_type=board_info.type_str,
148
+ asset_tag=board_info.serial_number,
149
+ )
150
+ if (board := cls._get_valid_board(board_info.url, initial_identity)) is None:
151
+ continue
152
+
153
+ boards[board._identity.asset_tag] = board
154
+ return MappingProxyType(boards)
155
+
127
156
  @classmethod
128
157
  def _get_supported_boards(
129
158
  cls, manual_boards: Optional[list[str]] = None,
@@ -137,6 +166,9 @@ class MotorBoard(Board):
137
166
  to connect to, defaults to None
138
167
  :return: A mapping of serial numbers to motor boards.
139
168
  """
169
+ if IN_SIMULATOR:
170
+ return cls._get_simulator_boards()
171
+
140
172
  boards = {}
141
173
  serial_ports = comports()
142
174
  for port in serial_ports:
sr/robot3/mqtt.py CHANGED
@@ -3,6 +3,7 @@ from __future__ import annotations
3
3
  import atexit
4
4
  import json
5
5
  import logging
6
+ import time
6
7
  from threading import Lock
7
8
  from typing import Any, Callable, Optional, TypedDict, Union
8
9
  from urllib.parse import urlparse
@@ -17,7 +18,7 @@ class MQTTClient:
17
18
  self,
18
19
  client_name: Optional[str] = None,
19
20
  topic_prefix: Optional[str] = None,
20
- mqtt_version: int = mqtt.MQTTv5,
21
+ mqtt_version: mqtt.MQTTProtocolVersion = mqtt.MQTTProtocolVersion.MQTTv5,
21
22
  use_tls: Union[bool, str] = False,
22
23
  username: str = '',
23
24
  password: str = '',
@@ -29,7 +30,11 @@ class MQTTClient:
29
30
  self.topic_prefix = topic_prefix
30
31
  self._client_name = client_name
31
32
 
32
- self._client = mqtt.Client(client_id=client_name, protocol=mqtt_version)
33
+ self._client = mqtt.Client(
34
+ callback_api_version=mqtt.CallbackAPIVersion.VERSION2,
35
+ client_id=client_name,
36
+ protocol=mqtt_version,
37
+ )
33
38
  self._client.on_connect = self._on_connect
34
39
 
35
40
  if use_tls:
@@ -143,19 +148,29 @@ class MQTTClient:
143
148
  """Wrap a payload up to be decodable as JSON."""
144
149
  if isinstance(payload, bytes):
145
150
  payload = payload.decode('utf-8')
151
+
152
+ payload_dict = {
153
+ "timestamp": time.time(),
154
+ "data": payload,
155
+ }
156
+
146
157
  self.publish(
147
158
  topic,
148
- json.dumps({"data": payload}),
159
+ json.dumps(payload_dict),
149
160
  retain=retain, abs_topic=abs_topic)
150
161
 
151
162
  def _on_connect(
152
- self, client: mqtt.Client, userdata: Any, flags: dict[str, int], rc: int,
153
- properties: Optional[mqtt.Properties] = None,
163
+ self,
164
+ client: mqtt.Client,
165
+ userdata: Any,
166
+ connect_flags: mqtt.ConnectFlags,
167
+ reason_code: mqtt.ReasonCode,
168
+ properties: mqtt.Properties | None = None,
154
169
  ) -> None:
155
170
  """Callback run each time the client connects to the broker."""
156
- if rc != mqtt.CONNACK_ACCEPTED:
171
+ if reason_code.is_failure:
157
172
  LOGGER.warning(
158
- f"Failed to connect to MQTT broker. Return code: {mqtt.error_string(rc)}"
173
+ f"Failed to connect to MQTT broker. Return code: {reason_code.getName()}" # type: ignore[no-untyped-call] # noqa: E501
159
174
  )
160
175
  return
161
176
 
@@ -182,7 +197,7 @@ def unpack_mqtt_url(url: str) -> MQTTVariables:
182
197
  The URL should be in the format:
183
198
  mqtt[s]://[<username>[:<password>]]@<host>[:<port>]/<topic_root>
184
199
  """
185
- url_parts = urlparse(url)
200
+ url_parts = urlparse(url, allow_fragments=False)
186
201
 
187
202
  if url_parts.scheme not in ('mqtt', 'mqtts'):
188
203
  raise ValueError(f"Invalid scheme: {url_parts.scheme}")
sr/robot3/power_board.py CHANGED
@@ -6,17 +6,21 @@ import logging
6
6
  from enum import IntEnum
7
7
  from time import sleep
8
8
  from types import MappingProxyType
9
- from typing import NamedTuple, Optional
9
+ from typing import Callable, NamedTuple, Optional
10
10
 
11
11
  from serial.tools.list_ports import comports
12
12
 
13
13
  from .exceptions import IncorrectBoardError
14
14
  from .logging import log_to_debug
15
15
  from .serial_wrapper import SerialWrapper
16
- from .utils import Board, BoardIdentity, float_bounds_check, get_USB_identity
16
+ from .utils import (
17
+ IN_SIMULATOR, Board, BoardIdentity, float_bounds_check,
18
+ get_simulator_boards, get_USB_identity,
19
+ )
17
20
 
18
21
  logger = logging.getLogger(__name__)
19
22
  BAUDRATE = 115200 # Since the power board is a USB device, this is ignored
23
+ _SLEEP_FN = sleep # For use in the simulator
20
24
 
21
25
 
22
26
  class PowerOutputPosition(IntEnum):
@@ -132,20 +136,50 @@ class PowerBoard(Board):
132
136
  f"expected {err.expected_type!r}. Ignoring this device")
133
137
  return None
134
138
  except Exception:
135
- if initial_identity is not None and initial_identity.board_type == 'manual':
136
- logger.warning(
137
- f"Manually specified power board at port {serial_port!r}, "
138
- "could not be identified. Ignoring this device")
139
- else:
140
- logger.warning(
141
- f"Found power board-like serial port at {serial_port!r}, "
142
- "but it could not be identified. Ignoring this device")
139
+ if initial_identity is not None:
140
+ if initial_identity.board_type == 'manual':
141
+ logger.warning(
142
+ f"Manually specified power board at port {serial_port!r}, "
143
+ "could not be identified. Ignoring this device")
144
+ elif initial_identity.manufacturer == 'sbot_simulator':
145
+ logger.warning(
146
+ f"Simulator specified power board at port {serial_port!r}, "
147
+ "could not be identified. Ignoring this device")
148
+
149
+ logger.warning(
150
+ f"Found power board-like serial port at {serial_port!r}, "
151
+ "but it could not be identified. Ignoring this device")
143
152
  return None
144
153
  return board
145
154
 
155
+ @classmethod
156
+ def _get_simulator_boards(cls) -> MappingProxyType[str, PowerBoard]:
157
+ """
158
+ Get the simulator boards.
159
+
160
+ :return: A mapping of board serial numbers to boards.
161
+ """
162
+ boards = {}
163
+ # The filter here is the name of the emulated board in the simulator
164
+ for board_info in get_simulator_boards('PowerBoard'):
165
+
166
+ # Create board identity from the info given
167
+ initial_identity = BoardIdentity(
168
+ manufacturer='sbot_simulator',
169
+ board_type=board_info.type_str,
170
+ asset_tag=board_info.serial_number,
171
+ )
172
+ if (board := cls._get_valid_board(board_info.url, initial_identity)) is None:
173
+ continue
174
+
175
+ boards[board._identity.asset_tag] = board
176
+ return MappingProxyType(boards)
177
+
146
178
  @classmethod
147
179
  def _get_supported_boards(
148
- cls, manual_boards: Optional[list[str]] = None,
180
+ cls,
181
+ manual_boards: Optional[list[str]] = None,
182
+ sleep_fn: Optional[Callable[[float], None]] = None,
149
183
  ) -> MappingProxyType[str, PowerBoard]:
150
184
  """
151
185
  Find all connected power boards.
@@ -154,7 +188,14 @@ class PowerBoard(Board):
154
188
 
155
189
  :param manual_boards: A list of manually specified serial ports to also attempt
156
190
  to connect to, defaults to None
191
+ :return: A mapping of serial numbers to power boards.
157
192
  """
193
+ if sleep_fn is not None:
194
+ global _SLEEP_FN
195
+ _SLEEP_FN = sleep_fn
196
+ if IN_SIMULATOR:
197
+ return cls._get_simulator_boards()
198
+
158
199
  boards = {}
159
200
  serial_ports = comports()
160
201
  for port in serial_ports:
@@ -500,13 +541,13 @@ class Piezo:
500
541
  frequency, 8, 10_000, "Frequency must be between 8 and 10000Hz"))
501
542
  duration_ms = int(float_bounds_check(
502
543
  duration * 1000, 0, 2**31 - 1,
503
- f"Duration is a float in seconds between 0 and {(2**31-1)/1000:,.0f}"))
544
+ f"Duration is a float in seconds between 0 and {(2**31 - 1) / 1000:,.0f}"))
504
545
 
505
546
  cmd = f'NOTE:{frequency_int}:{duration_ms}'
506
547
  self._serial.write(cmd)
507
548
 
508
549
  if blocking:
509
- sleep(duration)
550
+ _SLEEP_FN(duration)
510
551
 
511
552
  def __repr__(self) -> str:
512
553
  return f"<{self.__class__.__qualname__}: {self._serial}>"