sr-robot3 2024.0.1__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/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.
@@ -156,6 +190,12 @@ class PowerBoard(Board):
156
190
  to connect to, defaults to None
157
191
  :return: A mapping of serial numbers to power boards.
158
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
+
159
199
  boards = {}
160
200
  serial_ports = comports()
161
201
  for port in serial_ports:
@@ -501,13 +541,13 @@ class Piezo:
501
541
  frequency, 8, 10_000, "Frequency must be between 8 and 10000Hz"))
502
542
  duration_ms = int(float_bounds_check(
503
543
  duration * 1000, 0, 2**31 - 1,
504
- 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}"))
505
545
 
506
546
  cmd = f'NOTE:{frequency_int}:{duration_ms}'
507
547
  self._serial.write(cmd)
508
548
 
509
549
  if blocking:
510
- sleep(duration)
550
+ _SLEEP_FN(duration)
511
551
 
512
552
  def __repr__(self) -> str:
513
553
  return f"<{self.__class__.__qualname__}: {self._serial}>"