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/_version.py CHANGED
@@ -1,4 +1,16 @@
1
1
  # file generated by setuptools_scm
2
2
  # don't change, don't track in version control
3
- __version__ = version = '2024.0.0rc2'
4
- __version_tuple__ = version_tuple = (2024, 0, 0)
3
+ TYPE_CHECKING = False
4
+ if TYPE_CHECKING:
5
+ from typing import Tuple, Union
6
+ VERSION_TUPLE = Tuple[Union[int, str], ...]
7
+ else:
8
+ VERSION_TUPLE = object
9
+
10
+ version: str
11
+ __version__: str
12
+ __version_tuple__: VERSION_TUPLE
13
+ version_tuple: VERSION_TUPLE
14
+
15
+ __version__ = version = '2025.0.0'
16
+ __version_tuple__ = version_tuple = (2025, 0, 0)
sr/robot3/arduino.py CHANGED
@@ -11,10 +11,18 @@ from serial.tools.list_ports import comports
11
11
  from .exceptions import IncorrectBoardError
12
12
  from .logging import log_to_debug
13
13
  from .serial_wrapper import SerialWrapper
14
- from .utils import Board, BoardIdentity, get_USB_identity, map_to_float
14
+ from .utils import (
15
+ IN_SIMULATOR, Board, BoardIdentity,
16
+ get_simulator_boards, get_USB_identity, map_to_float,
17
+ )
15
18
 
16
19
  logger = logging.getLogger(__name__)
17
20
  BAUDRATE = 115200
21
+ if IN_SIMULATOR:
22
+ # Place each command on a new line in the simulator to simplify the implementation
23
+ ENDLINE = '\n'
24
+ else:
25
+ ENDLINE = ''
18
26
 
19
27
  SUPPORTED_VID_PIDS = {
20
28
  (0x2341, 0x0043), # Arduino Uno rev 3
@@ -58,7 +66,7 @@ class Arduino(Board):
58
66
  """
59
67
  The Arduino board interface.
60
68
 
61
- This is intended to be used with Arduino Uno boards running the sbot firmware.
69
+ This is intended to be used with Arduino Uno boards running the SR firmware.
62
70
 
63
71
  :param serial_port: The serial port to connect to.
64
72
  :param initial_identity: The identity of the board, as reported by the USB descriptor.
@@ -127,17 +135,46 @@ class Arduino(Board):
127
135
  f"expected {err.expected_type!r}. Ignoring this device")
128
136
  return None
129
137
  except Exception:
130
- if initial_identity is not None and initial_identity.board_type == 'manual':
131
- logger.warning(
132
- f"Manually specified Arduino at port {serial_port!r}, "
133
- "could not be identified. Ignoring this device")
134
- else:
135
- logger.warning(
136
- f"Found Arduino-like serial port at {serial_port!r}, "
137
- "but it could not be identified. Ignoring this device")
138
+ if initial_identity is not None:
139
+ if initial_identity.board_type == 'manual':
140
+ logger.warning(
141
+ f"Manually specified Arduino at port {serial_port!r} "
142
+ "could not be identified. Ignoring this device")
143
+ elif initial_identity.manufacturer == 'sbot_simulator':
144
+ logger.warning(
145
+ f"Simulator specified arduino at port {serial_port!r} "
146
+ "could not be identified. Ignoring this device")
147
+ return None
148
+
149
+ logger.warning(
150
+ f"Found Arduino-like serial port at {serial_port!r}, "
151
+ "but it could not be identified. Ignoring this device")
138
152
  return None
139
153
  return board
140
154
 
155
+ @classmethod
156
+ def _get_simulator_boards(cls) -> MappingProxyType[str, Arduino]:
157
+ """
158
+ Get the simulator boards.
159
+
160
+ :return: A mapping of board serial numbers to Arduinos
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('Arduino'):
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
+
141
178
  @classmethod
142
179
  def _get_supported_boards(
143
180
  cls,
@@ -149,8 +186,12 @@ class Arduino(Board):
149
186
 
150
187
  :param manual_boards: A list of manually specified board port strings,
151
188
  defaults to None
189
+ :param ignored_serials: A list of serial number to ignore during board discovery
152
190
  :return: A mapping of board serial numbers to Arduinos
153
191
  """
192
+ if IN_SIMULATOR:
193
+ return cls._get_simulator_boards()
194
+
154
195
  boards = {}
155
196
  if ignored_serials is None:
156
197
  ignored_serials = []
@@ -191,7 +232,7 @@ class Arduino(Board):
191
232
 
192
233
  :return: The identity of the board.
193
234
  """
194
- response = self._serial.query('v', endl='')
235
+ response = self._serial.query('v', endl=ENDLINE)
195
236
  response_fields = response.split(':')
196
237
 
197
238
  # The arduino firmware cannot access the serial number reported in the USB descriptor
@@ -223,13 +264,16 @@ class Arduino(Board):
223
264
  :param command: The command to send to the board.
224
265
  :return: The response from the board.
225
266
  """
226
- return self._serial.query(command, endl='')
267
+ if IN_SIMULATOR:
268
+ logger.warning("The command method is not fully supported in the simulator")
269
+ return self._serial.query(command, endl=ENDLINE)
227
270
 
228
271
  def map_pin_number(self, pin_number: int) -> str:
229
272
  """
230
273
  Map the pin number to the the serial format.
231
274
  Pin numbers are sent as printable ASCII characters, with 0 being 'a'.
232
275
 
276
+ :param pin_number: The pin number to encode.
233
277
  :return: The pin number in the serial format.
234
278
  :raises ValueError: If the pin number is invalid.
235
279
  """
@@ -239,6 +283,34 @@ class Arduino(Board):
239
283
  raise ValueError("Invalid pin provided") from None
240
284
  return chr(pin_number + ord('a'))
241
285
 
286
+ @log_to_debug
287
+ def ultrasound_measure(
288
+ self,
289
+ pulse_pin: int,
290
+ echo_pin: int,
291
+ ) -> int:
292
+ """
293
+ Measure the distance to an object using an ultrasound sensor.
294
+
295
+ The sensor can only measure distances up to 4m.
296
+
297
+ :param pulse_pin: The pin to send the ultrasound pulse from.
298
+ :param echo_pin: The pin to read the ultrasound echo from.
299
+ :raises ValueError: If either of the pins are invalid
300
+ :return: The distance measured by the ultrasound sensor in mm.
301
+ """
302
+ try: # bounds check
303
+ pulse_id = self.map_pin_number(pulse_pin)
304
+ except ValueError:
305
+ raise ValueError("Invalid pulse pin provided") from None
306
+ try:
307
+ echo_id = self.map_pin_number(echo_pin)
308
+ except ValueError:
309
+ raise ValueError("Invalid echo pin provided") from None
310
+
311
+ response = self._serial.query(f'u{pulse_id}{echo_id}', endl=ENDLINE)
312
+ return int(response)
313
+
242
314
  def __repr__(self) -> str:
243
315
  return f"<{self.__class__.__qualname__}: {self._serial}>"
244
316
 
@@ -250,6 +322,7 @@ class Pin:
250
322
  :param serial: The serial wrapper to use to communicate with the board.
251
323
  :param index: The index of the pin.
252
324
  :param supports_analog: Whether the pin supports analog reads.
325
+ :param disabled: Whether the pin can be controlled.
253
326
  """
254
327
  __slots__ = ('_serial', '_index', '_supports_analog', '_disabled', '_mode')
255
328
 
@@ -300,7 +373,7 @@ class Pin:
300
373
  mode_char = MODE_CHAR_MAP.get(value)
301
374
  if mode_char is None:
302
375
  raise IOError(f'Pin mode {value} is not supported')
303
- self._serial.write(self._build_command(mode_char), endl='')
376
+ self._serial.write(self._build_command(mode_char), endl=ENDLINE)
304
377
  self._mode = value
305
378
 
306
379
  @log_to_debug
@@ -315,7 +388,7 @@ class Pin:
315
388
  self._check_if_disabled()
316
389
  if self.mode not in DIGITAL_READ_MODES:
317
390
  raise IOError(f'Digital read is not supported in {self.mode}')
318
- response = self._serial.query(self._build_command('r'), endl='')
391
+ response = self._serial.query(self._build_command('r'), endl=ENDLINE)
319
392
  return response == 'h'
320
393
 
321
394
  @log_to_debug
@@ -331,9 +404,9 @@ class Pin:
331
404
  if self.mode not in DIGITAL_WRITE_MODES:
332
405
  raise IOError(f'Digital write is not supported in {self.mode}')
333
406
  if value:
334
- self._serial.write(self._build_command('h'), endl='')
407
+ self._serial.write(self._build_command('h'), endl=ENDLINE)
335
408
  else:
336
- self._serial.write(self._build_command('l'), endl='')
409
+ self._serial.write(self._build_command('l'), endl=ENDLINE)
337
410
 
338
411
  @log_to_debug
339
412
  def analog_read(self) -> float:
@@ -354,7 +427,7 @@ class Pin:
354
427
  raise IOError(f'Analog read is not supported in {self.mode}')
355
428
  if not self._supports_analog:
356
429
  raise IOError('Pin does not support analog read')
357
- response = self._serial.query(self._build_command('a'), endl='')
430
+ response = self._serial.query(self._build_command('a'), endl=ENDLINE)
358
431
  # map the response from the ADC range to the voltage range
359
432
  return map_to_float(int(response), ADC_MIN, ADC_MAX, 0.0, 5.0)
360
433
 
@@ -380,7 +453,7 @@ class Pin:
380
453
  """
381
454
  Generate the command to send to the board.
382
455
 
383
- :param command: The command character to send.
456
+ :param cmd_char: The command character to send.
384
457
  :return: The command string.
385
458
  """
386
459
  return f'{cmd_char}{self._map_pin_number()}'
@@ -393,13 +466,6 @@ class Pin:
393
466
  )
394
467
 
395
468
 
396
- # PIN:<n>:MODE:GET?
397
- # PIN:<n>:MODE:SET:<value>
398
- # PIN:<n>:DIGITAL:GET?
399
- # PIN:<n>:DIGITAL:SET:<1/0>
400
- # PIN:<n>:ANALOG:GET?
401
- # ULTRASOUND:<pulse>:<echo>:MEASURE?
402
-
403
469
  if __name__ == '__main__': # pragma: no cover
404
470
  arduinos = Arduino._get_supported_boards()
405
471
  for serial_num, board in arduinos.items():
sr/robot3/astoria.py CHANGED
@@ -9,8 +9,9 @@ from threading import Event, Lock
9
9
  from time import sleep
10
10
  from typing import Any, ClassVar, NewType, Optional, Tuple
11
11
 
12
+ import paho.mqtt.client as mqtt
12
13
  from paho.mqtt.client import Client as MQTT
13
- from paho.mqtt.client import MQTTMessage, MQTTv5, MQTTv311
14
+ from paho.mqtt.client import MQTTMessage
14
15
  from pydantic import BaseModel, ValidationError
15
16
 
16
17
  from .mqtt import MQTTClient
@@ -278,7 +279,11 @@ def init_mqtt(config: AstoriaConfig, client_name: str = 'sr-robot3') -> 'MQTTCli
278
279
  host=config.mqtt.host,
279
280
  port=config.mqtt.port,
280
281
  client_name=client_name,
281
- mqtt_version=MQTTv311 if config.mqtt.force_protocol_version_3_1 else MQTTv5,
282
+ mqtt_version=(
283
+ mqtt.MQTTProtocolVersion.MQTTv311
284
+ if config.mqtt.force_protocol_version_3_1 else
285
+ mqtt.MQTTProtocolVersion.MQTTv5
286
+ ),
282
287
  topic_prefix=config.mqtt.topic_prefix,
283
288
  )
284
289
  return client
sr/robot3/camera.py CHANGED
@@ -1,9 +1,11 @@
1
1
  """An implementation of a camera board using the april_vision library."""
2
+ from __future__ import annotations
3
+
2
4
  import logging
3
5
  from pathlib import Path
4
6
  from typing import Callable, Dict, Iterable, List, Optional, Union
5
7
 
6
- from april_vision import CalibratedCamera, Frame
8
+ from april_vision import CalibratedCamera, Frame, FrameSource
7
9
  from april_vision import Marker as AprilMarker
8
10
  from april_vision import (
9
11
  Processor, USBCamera, __version__, calibrations,
@@ -13,8 +15,11 @@ from april_vision.helpers import Base64Sender
13
15
  from numpy.typing import NDArray
14
16
 
15
17
  from .marker import Marker
16
- from .utils import Board, BoardIdentity
18
+ from .utils import (
19
+ IN_SIMULATOR, Board, BoardIdentity, BoardInfo, get_simulator_boards,
20
+ )
17
21
 
22
+ PathLike = Union[Path, str]
18
23
  LOGGER = logging.getLogger(__name__)
19
24
 
20
25
  robot_calibrations = calibrations.copy()
@@ -30,9 +35,11 @@ class AprilCamera(Board):
30
35
  in order to determine the spatial positon and orientation of the markers
31
36
  that it has detected.
32
37
 
33
- :param camera_id: The index of the camera to use.
34
- :param camera_data: The calibration data for the camera.
38
+ :param camera_source: The source of the camera frames.
39
+ :param calibration: The intrinsic calibration of the camera.
35
40
  :param serial_num: The serial number of the camera.
41
+ :param name: The name of the camera.
42
+ :param vidpid: The VID:PID of the camera.
36
43
  """
37
44
  __slots__ = ('_serial_num', '_cam')
38
45
 
@@ -56,28 +63,82 @@ class AprilCamera(Board):
56
63
 
57
64
  :return: A dict of cameras, keyed by their name and index.
58
65
  """
66
+ if IN_SIMULATOR:
67
+ return {
68
+ camera_info.serial_number: cls.from_webots_camera(camera_info)
69
+ for camera_info in get_simulator_boards('CameraBoard')
70
+ }
71
+
59
72
  return {
60
73
  (serial := f"{camera_data.name} - {camera_data.index}"):
61
- cls(camera_data.index, camera_data=camera_data, serial_num=serial)
74
+ cls.from_id(camera_data.index, camera_data=camera_data, serial_num=serial)
62
75
  for camera_data in find_cameras(robot_calibrations)
63
76
  }
64
77
 
65
- def __init__(self, camera_id: int, camera_data: CalibratedCamera, serial_num: str) -> None:
78
+ def __init__(
79
+ self, camera_source: FrameSource,
80
+ calibration: tuple[float, float, float, float] | None,
81
+ serial_num: str,
82
+ name: str,
83
+ vidpid: str = "",
84
+ ) -> None:
85
+ # The processor handles the detection and pose estimation
86
+ self._cam = Processor(
87
+ camera_source,
88
+ calibration=calibration,
89
+ name=name,
90
+ vidpid=vidpid,
91
+ mask_unknown_size_tags=True,
92
+ )
93
+ self._serial_num = serial_num
94
+
95
+ @classmethod
96
+ def from_webots_camera(cls, camera_info: BoardInfo) -> 'AprilCamera':
97
+ """
98
+ Create a camera from a webots camera.
99
+
100
+ :param camera_info: The information about the virtual camera,
101
+ including the url to connect to.
102
+ :return: The camera object.
103
+ """
104
+ from .simulator.camera import WebotsRemoteCameraSource
105
+
106
+ camera_source = WebotsRemoteCameraSource(camera_info)
107
+ return cls(
108
+ camera_source,
109
+ calibration=camera_source.calibration,
110
+ serial_num=camera_info.serial_number,
111
+ name=camera_info.serial_number,
112
+ )
113
+
114
+ @classmethod
115
+ def from_id(
116
+ cls,
117
+ camera_id: int,
118
+ camera_data: CalibratedCamera,
119
+ serial_num: str,
120
+ ) -> 'AprilCamera':
121
+ """
122
+ Create a camera from an ID.
123
+
124
+ :param camera_id: The ID of the camera to create.
125
+ :param camera_data: The calibration data for the camera.
126
+ :param serial_num: The serial number of the camera.
127
+ :return: The camera object.
128
+ """
66
129
  # The camera source handles the connection between the camera and the processor
67
130
  camera_source = USBCamera.from_calibration_file(
68
131
  camera_id,
69
132
  calibration_file=camera_data.calibration,
70
133
  vidpid=camera_data.vidpid,
71
134
  )
72
- # The processor handles the detection and pose estimation
73
- self._cam = Processor(
135
+ return cls(
74
136
  camera_source,
75
137
  calibration=camera_source.calibration,
138
+ serial_num=serial_num,
76
139
  name=camera_data.name,
77
140
  vidpid=camera_data.vidpid,
78
- mask_unknown_size_tags=True,
79
141
  )
80
- self._serial_num = serial_num
81
142
 
82
143
  def identify(self) -> BoardIdentity:
83
144
  """
@@ -103,34 +164,41 @@ class AprilCamera(Board):
103
164
  """
104
165
  self._cam.close()
105
166
 
106
- def see(self, *, frame: Optional[NDArray] = None) -> List[Marker]:
167
+ def see(
168
+ self,
169
+ *,
170
+ frame: Optional[NDArray] = None,
171
+ save: Optional[PathLike] = None,
172
+ ) -> List[Marker]:
107
173
  """
108
174
  Capture an image and identify fiducial markers.
109
175
 
110
176
  :param frame: An image to detect markers in, instead of capturing a new one,
177
+ :param save: If given, save the annotated frame to the path.
178
+ This is given a JPEG extension if none is provided.
111
179
  :returns: list of markers that the camera could see.
112
180
  """
181
+ if frame is None:
182
+ frame = self._cam.capture()
183
+
113
184
  markers = self._cam.see(frame=frame)
185
+
186
+ if save:
187
+ self._cam.save(save, frame=frame, detections=markers)
114
188
  return [Marker.from_april_vision_marker(marker) for marker in markers]
115
189
 
116
- def capture(self) -> NDArray:
190
+ def capture(self, *, save: Optional[PathLike] = None) -> NDArray:
117
191
  """
118
192
  Get the raw image data from the camera.
119
193
 
194
+ :param save: If given, save the annotated frame to the path.
195
+ This is given a JPEG extension if none is provided.
120
196
  :returns: Camera pixel data
121
197
  """
122
- return self._cam.capture()
123
-
124
- def save(self, path: Union[Path, str], *, frame: Optional[NDArray] = None) -> None:
125
- """
126
- Save an annotated image to a path.
127
-
128
- :param path: The path to save the image to,
129
- this is given a JPEG extension if none is provided.
130
- :param frame: An image to annotate and save, instead of capturing a new one,
131
- defaults to None
132
- """
133
- self._cam.save(path, frame=frame)
198
+ raw_frame = self._cam.capture()
199
+ if save:
200
+ self._cam.save(save, frame=raw_frame, annotated=False)
201
+ return raw_frame
134
202
 
135
203
  def _set_marker_sizes(
136
204
  self,