sr-robot3 2024.0.1__py3-none-any.whl → 2025.0.1__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 +14 -2
- sr/robot3/arduino.py +87 -17
- sr/robot3/astoria.py +7 -2
- sr/robot3/camera.py +92 -24
- sr/robot3/kch.py +256 -30
- sr/robot3/motor_board.py +42 -10
- sr/robot3/mqtt.py +23 -8
- sr/robot3/power_board.py +53 -13
- sr/robot3/robot.py +97 -27
- sr/robot3/serial_wrapper.py +13 -5
- sr/robot3/servo_board.py +44 -11
- sr/robot3/simulator/__init__.py +0 -0
- sr/robot3/simulator/camera.py +75 -0
- sr/robot3/simulator/time_server.py +94 -0
- sr/robot3/timeout.py +29 -3
- sr/robot3/utils.py +40 -0
- {sr_robot3-2024.0.1.dist-info → sr_robot3-2025.0.1.dist-info}/METADATA +17 -15
- sr_robot3-2025.0.1.dist-info/RECORD +29 -0
- {sr_robot3-2024.0.1.dist-info → sr_robot3-2025.0.1.dist-info}/WHEEL +1 -1
- sr_robot3-2024.0.1.dist-info/RECORD +0 -26
- {sr_robot3-2024.0.1.dist-info → sr_robot3-2025.0.1.dist-info}/LICENSE +0 -0
- {sr_robot3-2024.0.1.dist-info → sr_robot3-2025.0.1.dist-info}/top_level.txt +0 -0
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 .
|
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
|
|
@@ -40,10 +43,12 @@ class Robot:
|
|
40
43
|
:param manual_boards: A dictionary of board types to a list of serial port paths
|
41
44
|
to allow for connecting to boards that are not automatically detected, defaults to None
|
42
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
|
43
47
|
"""
|
44
48
|
__slots__ = (
|
45
49
|
'_lock', '_metadata', '_power_board', '_motor_boards', '_servo_boards',
|
46
50
|
'_arduinos', '_cameras', '_mqtt', '_astoria', '_kch', '_raw_ports',
|
51
|
+
'_time_server', '_no_powerboard',
|
47
52
|
)
|
48
53
|
|
49
54
|
def __init__(
|
@@ -55,16 +60,30 @@ class Robot:
|
|
55
60
|
ignored_arduinos: Optional[list[str]] = None,
|
56
61
|
manual_boards: Optional[dict[str, list[str]]] = None,
|
57
62
|
raw_ports: Optional[list[tuple[str, int]]] = None,
|
63
|
+
no_powerboard: bool = False,
|
58
64
|
) -> None:
|
59
|
-
self._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()
|
60
74
|
self._metadata: Optional[Metadata] = None
|
75
|
+
self._no_powerboard = no_powerboard
|
61
76
|
|
62
77
|
setup_logging(debug, trace_logging)
|
63
78
|
ensure_atexit_on_term()
|
64
79
|
|
65
80
|
logger.info(f"sr.robot3 version {__version__}")
|
66
81
|
|
67
|
-
|
82
|
+
if IN_SIMULATOR:
|
83
|
+
self._mqtt = None
|
84
|
+
self._astoria = None
|
85
|
+
else:
|
86
|
+
self._mqtt, self._astoria = init_astoria_mqtt()
|
68
87
|
|
69
88
|
if manual_boards:
|
70
89
|
self._init_power_board(manual_boards.get(PowerBoard.get_board_type(), []))
|
@@ -88,7 +107,10 @@ class Robot:
|
|
88
107
|
defaults to None
|
89
108
|
:raises RuntimeError: If exactly one PowerBoard is not found
|
90
109
|
"""
|
91
|
-
|
110
|
+
if self._no_powerboard:
|
111
|
+
return
|
112
|
+
|
113
|
+
power_boards = PowerBoard._get_supported_boards(manual_boards, sleep_fn=self.sleep)
|
92
114
|
self._power_board = singular(power_boards)
|
93
115
|
|
94
116
|
# Enable all the outputs, so that we can find other boards.
|
@@ -125,7 +147,10 @@ class Robot:
|
|
125
147
|
self._servo_boards = ServoBoard._get_supported_boards(manual_servoboards)
|
126
148
|
self._arduinos = Arduino._get_supported_boards(manual_arduinos, ignored_arduinos)
|
127
149
|
if raw_ports:
|
128
|
-
|
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.")
|
129
154
|
|
130
155
|
def _init_camera(self) -> None:
|
131
156
|
"""
|
@@ -134,9 +159,15 @@ class Robot:
|
|
134
159
|
These cameras are used for AprilTag detection and provide location data of
|
135
160
|
markers in its field of view.
|
136
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
|
+
|
137
168
|
self._cameras = MappingProxyType(_setup_cameras(
|
138
169
|
game_specific.MARKER_SIZES,
|
139
|
-
|
170
|
+
publish_fn,
|
140
171
|
))
|
141
172
|
|
142
173
|
def _log_connected_boards(self) -> None:
|
@@ -145,8 +176,10 @@ class Robot:
|
|
145
176
|
|
146
177
|
Firmware versions are also logged at debug level.
|
147
178
|
"""
|
179
|
+
# we only have one power board so make it iterable
|
180
|
+
power_board = [] if self._no_powerboard else [self.power_board]
|
148
181
|
boards = itertools.chain(
|
149
|
-
|
182
|
+
power_board,
|
150
183
|
self.motor_boards.values(),
|
151
184
|
self.servo_boards.values(),
|
152
185
|
self.arduinos.values(),
|
@@ -178,6 +211,9 @@ class Robot:
|
|
178
211
|
|
179
212
|
:return: The power board object
|
180
213
|
"""
|
214
|
+
if self._no_powerboard:
|
215
|
+
raise RuntimeError("No power board was initialized")
|
216
|
+
|
181
217
|
return self._power_board
|
182
218
|
|
183
219
|
@property
|
@@ -279,27 +315,35 @@ class Robot:
|
|
279
315
|
"""
|
280
316
|
Sleep for a number of seconds.
|
281
317
|
|
282
|
-
This is a convenience method that
|
283
|
-
|
318
|
+
This is a convenience method that should be used instead of time.sleep()
|
319
|
+
to make your code compatible with the simulator.
|
284
320
|
|
285
321
|
:param secs: The number of seconds to sleep for
|
286
322
|
"""
|
287
|
-
|
323
|
+
if IN_SIMULATOR:
|
324
|
+
assert isinstance(self._lock, TimeServer)
|
325
|
+
self._lock.sleep(secs)
|
326
|
+
else:
|
327
|
+
time.sleep(secs)
|
288
328
|
|
289
329
|
@log_to_debug
|
290
330
|
def time(self) -> float:
|
291
331
|
"""
|
292
332
|
Get the number of seconds since the Unix Epoch.
|
293
333
|
|
294
|
-
This is a convenience method that
|
295
|
-
|
334
|
+
This is a convenience method that should be used instead of time.time()
|
335
|
+
to make your code compatible with the simulator.
|
296
336
|
|
297
337
|
NOTE: The robot's clock resets each time the robot is restarted, so this
|
298
338
|
will not be the correct actual time but can be used to measure elapsed time.
|
299
339
|
|
300
340
|
:returns: the number of seconds since the Unix Epoch.
|
301
341
|
"""
|
302
|
-
|
342
|
+
if IN_SIMULATOR:
|
343
|
+
assert isinstance(self._lock, TimeServer)
|
344
|
+
return self._lock.get_time()
|
345
|
+
else:
|
346
|
+
return time.time()
|
303
347
|
|
304
348
|
@property
|
305
349
|
@log_to_debug
|
@@ -350,7 +394,11 @@ class Robot:
|
|
350
394
|
|
351
395
|
:returns: path to the mountpoint of the USB code drive.
|
352
396
|
"""
|
353
|
-
|
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()
|
354
402
|
|
355
403
|
@property
|
356
404
|
def is_simulated(self) -> bool:
|
@@ -359,7 +407,7 @@ class Robot:
|
|
359
407
|
|
360
408
|
:returns: True if the robot is simulated. False otherwise.
|
361
409
|
"""
|
362
|
-
return
|
410
|
+
return IN_SIMULATOR
|
363
411
|
|
364
412
|
@log_to_debug
|
365
413
|
def wait_start(self) -> None:
|
@@ -371,25 +419,47 @@ class Robot:
|
|
371
419
|
Once the start button is pressed, the metadata will be loaded and the timeout
|
372
420
|
will start if in competition mode.
|
373
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
|
+
|
374
437
|
# ignore previous button presses
|
375
|
-
_ =
|
376
|
-
_ =
|
438
|
+
_ = start_button_pressed()
|
439
|
+
_ = remote_start_pressed()
|
377
440
|
logger.info('Waiting for start button.')
|
378
441
|
|
379
|
-
self.
|
380
|
-
|
442
|
+
if not self._no_powerboard:
|
443
|
+
self.power_board.piezo.buzz(Note.A6, 0.1)
|
444
|
+
self.power_board._run_led.flash()
|
381
445
|
self.kch._flash_start()
|
382
446
|
|
383
|
-
while not (
|
384
|
-
self.
|
385
|
-
):
|
386
|
-
time.sleep(0.1)
|
447
|
+
while not start_button_pressed() or remote_start_pressed():
|
448
|
+
self.sleep(0.1)
|
387
449
|
logger.info("Start signal received; continuing.")
|
388
|
-
self.
|
450
|
+
if not self._no_powerboard:
|
451
|
+
self.power_board._run_led.on()
|
389
452
|
self.kch._start = False
|
390
453
|
|
391
|
-
|
392
|
-
|
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()
|
393
462
|
|
394
|
-
|
463
|
+
# Simulator timeout is handled by the simulator supervisor
|
464
|
+
if self._metadata.game_timeout and not IN_SIMULATOR:
|
395
465
|
timeout.kill_after_delay(self._metadata.game_timeout)
|
sr/robot3/serial_wrapper.py
CHANGED
@@ -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(
|
@@ -80,7 +86,7 @@ class SerialWrapper:
|
|
80
86
|
self,
|
81
87
|
port: str,
|
82
88
|
baud: int,
|
83
|
-
timeout: float =
|
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
|
|
@@ -206,9 +213,10 @@ class SerialWrapper:
|
|
206
213
|
"""
|
207
214
|
try:
|
208
215
|
self.serial.open()
|
209
|
-
|
210
|
-
|
211
|
-
|
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)
|
212
220
|
self.serial.reset_input_buffer()
|
213
221
|
except serial.SerialException:
|
214
222
|
logger.error(
|
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(
|
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
|
113
|
-
|
114
|
-
|
115
|
-
|
116
|
-
|
117
|
-
|
118
|
-
|
119
|
-
|
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
|
@@ -0,0 +1,75 @@
|
|
1
|
+
import struct
|
2
|
+
|
3
|
+
import cv2
|
4
|
+
import numpy as np
|
5
|
+
from april_vision import FrameSource
|
6
|
+
from numpy.typing import NDArray
|
7
|
+
from serial import serial_for_url
|
8
|
+
|
9
|
+
from ..utils import BoardInfo
|
10
|
+
|
11
|
+
HEADER_SIZE = 5 # 1 byte for the type, 4 bytes for the length
|
12
|
+
IMAGE_TAG_ID = 0
|
13
|
+
|
14
|
+
|
15
|
+
class WebotsRemoteCameraSource(FrameSource):
|
16
|
+
# Webots cameras include an alpha channel, this informs april_vision of how to handle it
|
17
|
+
COLOURSPACE = cv2.COLOR_BGRA2GRAY
|
18
|
+
|
19
|
+
def __init__(self, camera_info: BoardInfo) -> None:
|
20
|
+
self.calibration = (0.0, 0.0, 0.0, 0.0)
|
21
|
+
# Use pyserial to give a nicer interface for connecting to the camera socket
|
22
|
+
self._serial = serial_for_url(camera_info.url, baudrate=115200, timeout=None)
|
23
|
+
|
24
|
+
# Check the camera is connected
|
25
|
+
response = self._make_request("*IDN?")
|
26
|
+
if not response.split(b":")[1].lower().startswith(b"cam"):
|
27
|
+
raise RuntimeError(f"Camera not connected to a camera, returned: {response!r}")
|
28
|
+
|
29
|
+
# Get the calibration data for this camera
|
30
|
+
response = self._make_request("CAM:CALIBRATION?")
|
31
|
+
|
32
|
+
# The calibration data is returned as a string of floats separated by colons
|
33
|
+
new_calibration = tuple(map(float, response.split(b":")))
|
34
|
+
assert len(new_calibration) == 4, f"Invalid calibration data: {new_calibration}"
|
35
|
+
self.calibration = new_calibration
|
36
|
+
|
37
|
+
# Get the image size for this camera
|
38
|
+
response = self._make_request("CAM:RESOLUTION?")
|
39
|
+
self.image_size = tuple(map(int, response.split(b":")))
|
40
|
+
assert len(self.image_size) == 2, f"Invalid image dimensions: {self.image_size}"
|
41
|
+
|
42
|
+
def read(self, fresh: bool = True) -> NDArray:
|
43
|
+
"""
|
44
|
+
The method for getting a new frame.
|
45
|
+
|
46
|
+
:param fresh: Whether to flush the device's buffer before capturing
|
47
|
+
the frame, unused.
|
48
|
+
"""
|
49
|
+
self._serial.write(b"CAM:FRAME!\n")
|
50
|
+
# The image is encoded as a TLV (Type, Length, Value) packet
|
51
|
+
# so we need to read the header to get the type and length of the image
|
52
|
+
header = self._serial.read(HEADER_SIZE)
|
53
|
+
assert len(header) == HEADER_SIZE, f"Invalid header length: {len(header)}"
|
54
|
+
img_tag, img_len = struct.unpack('>BI', header)
|
55
|
+
assert img_tag == IMAGE_TAG_ID, f"Invalid image tag: {img_tag}"
|
56
|
+
|
57
|
+
# Get the image data now we know the length
|
58
|
+
img_data = self._serial.read(img_len)
|
59
|
+
assert len(img_data) == img_len, f"Invalid image data length: {len(img_data)}"
|
60
|
+
|
61
|
+
rgb_frame_raw: NDArray[np.uint8] = np.frombuffer(img_data, np.uint8)
|
62
|
+
|
63
|
+
# Height is first, then width, then channels
|
64
|
+
return rgb_frame_raw.reshape((self.image_size[1], self.image_size[0], 4))
|
65
|
+
|
66
|
+
def close(self) -> None:
|
67
|
+
"""Close the underlying socket on exit."""
|
68
|
+
self._serial.close()
|
69
|
+
|
70
|
+
def _make_request(self, command: str) -> bytes:
|
71
|
+
self._serial.write(command.encode() + b"\n")
|
72
|
+
response = self._serial.readline()
|
73
|
+
if not response.endswith(b"\n") or response.startswith(b"NACK:"):
|
74
|
+
raise RuntimeError(f"Failed to communicate with camera, returned: {response!r}")
|
75
|
+
return response
|
@@ -0,0 +1,94 @@
|
|
1
|
+
from __future__ import annotations
|
2
|
+
|
3
|
+
import logging
|
4
|
+
from datetime import datetime
|
5
|
+
|
6
|
+
from ..exceptions import BoardDisconnectionError, IncorrectBoardError
|
7
|
+
from ..serial_wrapper import SerialWrapper
|
8
|
+
from ..utils import BoardIdentity, get_simulator_boards
|
9
|
+
|
10
|
+
logger = logging.getLogger(__name__)
|
11
|
+
|
12
|
+
BAUDRATE = 115200
|
13
|
+
|
14
|
+
|
15
|
+
class TimeServer:
|
16
|
+
@staticmethod
|
17
|
+
def get_board_type() -> str:
|
18
|
+
"""
|
19
|
+
Return the type of the board.
|
20
|
+
|
21
|
+
:return: The literal string 'TimeServer'.
|
22
|
+
"""
|
23
|
+
return 'TimeServer'
|
24
|
+
|
25
|
+
def __init__(
|
26
|
+
self,
|
27
|
+
serial_port: str,
|
28
|
+
initial_identity: BoardIdentity | None = None,
|
29
|
+
) -> None:
|
30
|
+
if initial_identity is None:
|
31
|
+
initial_identity = BoardIdentity()
|
32
|
+
self._serial = SerialWrapper(
|
33
|
+
serial_port,
|
34
|
+
BAUDRATE,
|
35
|
+
identity=initial_identity,
|
36
|
+
# Disable the timeout so sleep works properly
|
37
|
+
timeout=None,
|
38
|
+
)
|
39
|
+
|
40
|
+
self._identity = self.identify()
|
41
|
+
if self._identity.board_type != self.get_board_type():
|
42
|
+
raise IncorrectBoardError(self._identity.board_type, self.get_board_type())
|
43
|
+
self._serial.set_identity(self._identity)
|
44
|
+
|
45
|
+
@classmethod
|
46
|
+
def initialise(cls) -> 'TimeServer' | None:
|
47
|
+
# The filter here is the name of the emulated board in the simulator
|
48
|
+
boards = get_simulator_boards('TimeServer')
|
49
|
+
|
50
|
+
if not boards:
|
51
|
+
return None
|
52
|
+
|
53
|
+
board_info = boards[0]
|
54
|
+
|
55
|
+
# Create board identity from the info given
|
56
|
+
initial_identity = BoardIdentity(
|
57
|
+
manufacturer='sbot_simulator',
|
58
|
+
board_type=board_info.type_str,
|
59
|
+
asset_tag=board_info.serial_number,
|
60
|
+
)
|
61
|
+
|
62
|
+
try:
|
63
|
+
board = cls(board_info.url, initial_identity)
|
64
|
+
except BoardDisconnectionError:
|
65
|
+
logger.warning(
|
66
|
+
f"Simulator specified time server at port {board_info.url!r}, "
|
67
|
+
"could not be identified. Ignoring this device")
|
68
|
+
return None
|
69
|
+
except IncorrectBoardError as err:
|
70
|
+
logger.warning(
|
71
|
+
f"Board returned type {err.returned_type!r}, "
|
72
|
+
f"expected {err.expected_type!r}. Ignoring this device")
|
73
|
+
return None
|
74
|
+
|
75
|
+
return board
|
76
|
+
|
77
|
+
def identify(self) -> BoardIdentity:
|
78
|
+
"""
|
79
|
+
Get the identity of the board.
|
80
|
+
|
81
|
+
:return: The identity of the board.
|
82
|
+
"""
|
83
|
+
response = self._serial.query('*IDN?')
|
84
|
+
return BoardIdentity(*response.split(':'))
|
85
|
+
|
86
|
+
def get_time(self) -> float:
|
87
|
+
time_str = self._serial.query('TIME?')
|
88
|
+
return datetime.fromisoformat(time_str).timestamp()
|
89
|
+
|
90
|
+
def sleep(self, duration: float) -> None:
|
91
|
+
if duration < 0:
|
92
|
+
raise ValueError("sleep length must be non-negative")
|
93
|
+
|
94
|
+
self._serial.query(f'SLEEP:{int(duration * 1000)}')
|
sr/robot3/timeout.py
CHANGED
@@ -1,4 +1,5 @@
|
|
1
1
|
"""Functions for killing the robot after a certain amount of time."""
|
2
|
+
import atexit
|
2
3
|
import logging
|
3
4
|
import os
|
4
5
|
import signal
|
@@ -9,6 +10,9 @@ from typing import Optional
|
|
9
10
|
|
10
11
|
logger = logging.getLogger(__name__)
|
11
12
|
|
13
|
+
TIMEOUT_MESSAGE = "Timeout expired: Game Over!"
|
14
|
+
EXIT_ATTEMPTS = 0
|
15
|
+
|
12
16
|
|
13
17
|
def timeout_handler(signal_type: int, stack_frame: Optional[FrameType]) -> None:
|
14
18
|
"""
|
@@ -17,13 +21,34 @@ def timeout_handler(signal_type: int, stack_frame: Optional[FrameType]) -> None:
|
|
17
21
|
This function is called when the timeout expires and will stop the robot's main process.
|
18
22
|
In order for this to work, any threads that are created must be daemons.
|
19
23
|
|
24
|
+
If the process doesn't terminate clearly (perhaps because the exception was caught),
|
25
|
+
exit less cleanly.
|
26
|
+
|
20
27
|
NOTE: This function is not called on Windows.
|
21
28
|
|
22
29
|
:param signal_type: The signal that triggered this handler
|
23
30
|
:param stack_frame: The stack frame at the time of the signal
|
24
31
|
"""
|
25
|
-
|
26
|
-
|
32
|
+
global EXIT_ATTEMPTS
|
33
|
+
EXIT_ATTEMPTS += 1
|
34
|
+
|
35
|
+
if sys.platform == "win32":
|
36
|
+
raise AssertionError("This function should not be called on Windows")
|
37
|
+
|
38
|
+
if EXIT_ATTEMPTS == 1:
|
39
|
+
# Allow 2 seconds for the process to exit cleanly before killing it
|
40
|
+
signal.alarm(2)
|
41
|
+
logger.info(TIMEOUT_MESSAGE)
|
42
|
+
# Exit cleanly
|
43
|
+
exit(0)
|
44
|
+
else:
|
45
|
+
# The process didn't exit cleanly, so first call the cleanup handlers
|
46
|
+
# and use an unhanded alarm to force python to exit with a core dump
|
47
|
+
signal.signal(signal.SIGALRM, signal.SIG_DFL)
|
48
|
+
signal.alarm(2) # Allow 2 seconds for cleanup
|
49
|
+
|
50
|
+
atexit._run_exitfuncs()
|
51
|
+
exit(0)
|
27
52
|
|
28
53
|
|
29
54
|
def win_timeout_handler() -> None:
|
@@ -35,7 +60,7 @@ def win_timeout_handler() -> None:
|
|
35
60
|
|
36
61
|
NOTE: This function is only called on Windows.
|
37
62
|
"""
|
38
|
-
logger.info(
|
63
|
+
logger.info(TIMEOUT_MESSAGE)
|
39
64
|
os.kill(os.getpid(), signal.SIGTERM)
|
40
65
|
|
41
66
|
|
@@ -52,6 +77,7 @@ def kill_after_delay(timeout_seconds: int) -> None:
|
|
52
77
|
# Windows doesn't have SIGALRM,
|
53
78
|
# so we approximate its functionality using a delayed SIGTERM
|
54
79
|
timer = Timer(timeout_seconds, win_timeout_handler)
|
80
|
+
timer.daemon = True
|
55
81
|
timer.start()
|
56
82
|
else:
|
57
83
|
signal.signal(signal.SIGALRM, timeout_handler)
|