feathersdk 0.0.8__tar.gz

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.
Files changed (67) hide show
  1. feathersdk-0.0.8/PKG-INFO +31 -0
  2. feathersdk-0.0.8/README.md +3 -0
  3. feathersdk-0.0.8/pyproject.toml +9 -0
  4. feathersdk-0.0.8/setup.cfg +31 -0
  5. feathersdk-0.0.8/setup.py +22 -0
  6. feathersdk-0.0.8/src/feathersdk/__init__.py +7 -0
  7. feathersdk-0.0.8/src/feathersdk/comms/__init__.py +4 -0
  8. feathersdk-0.0.8/src/feathersdk/comms/comms_manager.py +399 -0
  9. feathersdk-0.0.8/src/feathersdk/comms/socketcan_tcp/__init__.py +384 -0
  10. feathersdk-0.0.8/src/feathersdk/comms/socketcan_tcp/recompilation.py +122 -0
  11. feathersdk-0.0.8/src/feathersdk/comms/socketcan_tcp/sct_error.h +93 -0
  12. feathersdk-0.0.8/src/feathersdk/comms/socketcan_tcp/sct_poll.h +267 -0
  13. feathersdk-0.0.8/src/feathersdk/comms/socketcan_tcp/sct_result.h +41 -0
  14. feathersdk-0.0.8/src/feathersdk/comms/socketcan_tcp/sct_sockets.h +148 -0
  15. feathersdk-0.0.8/src/feathersdk/comms/socketcan_tcp/sct_source_hash.h +2 -0
  16. feathersdk-0.0.8/src/feathersdk/comms/socketcan_tcp/sct_write.h +114 -0
  17. feathersdk-0.0.8/src/feathersdk/comms/socketcan_tcp/socketcan_tcp.c +47 -0
  18. feathersdk-0.0.8/src/feathersdk/comms/system.py +299 -0
  19. feathersdk-0.0.8/src/feathersdk/comms/wirehawk.py +215 -0
  20. feathersdk-0.0.8/src/feathersdk/robot/__init__.py +3 -0
  21. feathersdk-0.0.8/src/feathersdk/robot/battery_system.py +130 -0
  22. feathersdk-0.0.8/src/feathersdk/robot/instance_lock.py +239 -0
  23. feathersdk-0.0.8/src/feathersdk/robot/manipulation_platform.py +145 -0
  24. feathersdk-0.0.8/src/feathersdk/robot/motors/MMS760200-48-C2-1.eds +5172 -0
  25. feathersdk-0.0.8/src/feathersdk/robot/motors/MMS760400-48-C2-1.eds +5172 -0
  26. feathersdk-0.0.8/src/feathersdk/robot/motors/__init__.py +3 -0
  27. feathersdk-0.0.8/src/feathersdk/robot/motors/feetech_simple.py +175 -0
  28. feathersdk-0.0.8/src/feathersdk/robot/motors/motor.py +6 -0
  29. feathersdk-0.0.8/src/feathersdk/robot/motors/motors_manager.py +710 -0
  30. feathersdk-0.0.8/src/feathersdk/robot/motors/myactuator.py +84 -0
  31. feathersdk-0.0.8/src/feathersdk/robot/motors/robstride/__init__.py +7 -0
  32. feathersdk-0.0.8/src/feathersdk/robot/motors/robstride/admin.py +262 -0
  33. feathersdk-0.0.8/src/feathersdk/robot/motors/robstride/message_handler.py +111 -0
  34. feathersdk-0.0.8/src/feathersdk/robot/motors/robstride/params.py +442 -0
  35. feathersdk-0.0.8/src/feathersdk/robot/motors/robstride/robstride_motor.py +465 -0
  36. feathersdk-0.0.8/src/feathersdk/robot/motors/sim/__init__.py +0 -0
  37. feathersdk-0.0.8/src/feathersdk/robot/motors/sim/virtual_robstride.py +391 -0
  38. feathersdk-0.0.8/src/feathersdk/robot/navigation_platform.py +1003 -0
  39. feathersdk-0.0.8/src/feathersdk/robot/neck_platform.py +364 -0
  40. feathersdk-0.0.8/src/feathersdk/robot/pinocchio_model.py +201 -0
  41. feathersdk-0.0.8/src/feathersdk/robot/rh56f1_hand/rh56f1_wrapper.h +203 -0
  42. feathersdk-0.0.8/src/feathersdk/robot/robot.py +154 -0
  43. feathersdk-0.0.8/src/feathersdk/robot/robstride_neck_platform.py +390 -0
  44. feathersdk-0.0.8/src/feathersdk/robot/self_collision_checker.py +436 -0
  45. feathersdk-0.0.8/src/feathersdk/robot/steppable_system.py +57 -0
  46. feathersdk-0.0.8/src/feathersdk/robot/test_system.py +23 -0
  47. feathersdk-0.0.8/src/feathersdk/robot/torso_platform.py +519 -0
  48. feathersdk-0.0.8/src/feathersdk/robot/vision_platform.py +117 -0
  49. feathersdk-0.0.8/src/feathersdk/utils/__init__.py +1 -0
  50. feathersdk-0.0.8/src/feathersdk/utils/common.py +292 -0
  51. feathersdk-0.0.8/src/feathersdk/utils/constants.py +13 -0
  52. feathersdk-0.0.8/src/feathersdk/utils/feathertypes.py +4 -0
  53. feathersdk-0.0.8/src/feathersdk/utils/files.py +33 -0
  54. feathersdk-0.0.8/src/feathersdk/utils/format.py +63 -0
  55. feathersdk-0.0.8/src/feathersdk/utils/hydra_helper.py +21 -0
  56. feathersdk-0.0.8/src/feathersdk/utils/logger.py +179 -0
  57. feathersdk-0.0.8/src/feathersdk/utils/perf_logger.py +13 -0
  58. feathersdk-0.0.8/src/feathersdk/utils/telemetry.py +141 -0
  59. feathersdk-0.0.8/src/feathersdk/utils/timeout.py +65 -0
  60. feathersdk-0.0.8/src/feathersdk/utils/trajectory.py +8 -0
  61. feathersdk-0.0.8/src/feathersdk/utils/urdf_convex_hull.py +650 -0
  62. feathersdk-0.0.8/src/feathersdk.egg-info/PKG-INFO +31 -0
  63. feathersdk-0.0.8/src/feathersdk.egg-info/SOURCES.txt +66 -0
  64. feathersdk-0.0.8/src/feathersdk.egg-info/dependency_links.txt +1 -0
  65. feathersdk-0.0.8/src/feathersdk.egg-info/not-zip-safe +1 -0
  66. feathersdk-0.0.8/src/feathersdk.egg-info/requires.txt +7 -0
  67. feathersdk-0.0.8/src/feathersdk.egg-info/top_level.txt +1 -0
@@ -0,0 +1,31 @@
1
+ Metadata-Version: 2.4
2
+ Name: feathersdk
3
+ Version: 0.0.8
4
+ Summary: Feather Python SDK Library
5
+ Author: Feather Robotics Inc.
6
+ License: "MIT"
7
+ Platform: unix
8
+ Platform: linux
9
+ Platform: osx
10
+ Classifier: Programming Language :: Python :: 3
11
+ Classifier: Programming Language :: Python :: 3 :: Only
12
+ Classifier: Programming Language :: Python :: 3.9
13
+ Classifier: Programming Language :: Python :: 3.10
14
+ Classifier: Programming Language :: Python :: 3.11
15
+ Classifier: Programming Language :: Python :: 3.12
16
+ Requires-Python: >=3.9
17
+ Description-Content-Type: text/markdown
18
+ Requires-Dist: canopen
19
+ Requires-Dist: typing_extensions
20
+ Requires-Dist: filelock
21
+ Requires-Dist: numpy
22
+ Requires-Dist: psutil
23
+ Requires-Dist: smbus2
24
+ Requires-Dist: hydra-core
25
+ Dynamic: description
26
+ Dynamic: description-content-type
27
+ Dynamic: requires-dist
28
+
29
+ # Feather Python SDK Library
30
+
31
+ Feather Code!
@@ -0,0 +1,3 @@
1
+ # Feather Python SDK Library
2
+
3
+ Feather Code!
@@ -0,0 +1,9 @@
1
+ [build-system]
2
+ requires = ["setuptools>=77.0.3", "wheel"]
3
+ build-backend = "setuptools.build_meta"
4
+
5
+ [tool.pytest.ini_options]
6
+ #addopts = '--cov=feathersdk'
7
+ testpaths = [
8
+ "tests",
9
+ ]
@@ -0,0 +1,31 @@
1
+ [metadata]
2
+ name = feathersdk
3
+ version = 0.0.8
4
+ description = Feather Python SDK Library
5
+ readme = README.md
6
+ author = Feather Robotics Inc.
7
+ platforms = unix, linux, osx
8
+ classifiers =
9
+ Programming Language :: Python :: 3
10
+ Programming Language :: Python :: 3 :: Only
11
+ Programming Language :: Python :: 3.9
12
+ Programming Language :: Python :: 3.10
13
+ Programming Language :: Python :: 3.11
14
+ Programming Language :: Python :: 3.12
15
+ license = "MIT"
16
+ license-files = ["LICEN[CS]E*"]
17
+
18
+ [options]
19
+ packages =
20
+ feathersdk
21
+ install_requires =
22
+ numpy>=1.10
23
+ python_requires = >=3.9
24
+ package_dir =
25
+ =src
26
+ zip_safe = no
27
+
28
+ [egg_info]
29
+ tag_build =
30
+ tag_date = 0
31
+
@@ -0,0 +1,22 @@
1
+ from setuptools import setup, find_packages
2
+ import os
3
+
4
+ if __name__ == "__main__":
5
+ readme_path = os.path.join(os.path.dirname(__file__), 'README.md')
6
+ setup(
7
+ packages=find_packages(where='./src'),
8
+ package_data={
9
+ "feathersdk": ["**/*.c", "**/*.h", "**/*.eds"],
10
+ },
11
+ long_description=open(readme_path).read(),
12
+ long_description_content_type='text/markdown',
13
+ install_requires=[
14
+ 'canopen',
15
+ 'typing_extensions',
16
+ 'filelock',
17
+ 'numpy',
18
+ 'psutil',
19
+ 'smbus2',
20
+ 'hydra-core',
21
+ ],
22
+ )
@@ -0,0 +1,7 @@
1
+
2
+
3
+ # Suppress all logging from canopen. TODO: Filter the messages to keep the ones we want to worry about.
4
+ # Note: there are no CRITICAL logging messages in canopen 2.4.1, so this should disable all sdo.client/server messages.
5
+ import logging
6
+ logging.getLogger("canopen.sdo.client").setLevel(logging.CRITICAL)
7
+ logging.getLogger("canopen.sdo.server").setLevel(logging.CRITICAL)
@@ -0,0 +1,4 @@
1
+ from .comms_manager import CommsManager, CanOverloadError, MotorUIDType
2
+ from .socketcan_tcp import SocketResult, SocketType
3
+ from .system import enable_can_interface
4
+
@@ -0,0 +1,399 @@
1
+ import threading
2
+ import traceback
3
+ from feathersdk.utils.feathertypes import Optional, Callable, Any, Union, Self
4
+ from .system import is_can_interface, enable_can_interface, is_can_enabled
5
+ from .socketcan_tcp import _subscribe_multi, _cansend, _tcpsend_modbus, SocketResult, _stop_polling, \
6
+ _clear_all_sockets, _get_err_str
7
+ from feathersdk.utils.common import make_exception_pickleable, FeatherThreadLock, exponential_timeout, currtime, \
8
+ timediff, TimestampedMixin
9
+ import math
10
+ from collections import namedtuple
11
+ from feathersdk.utils.logger import warning, debug
12
+ from dataclasses import dataclass
13
+
14
+
15
+ MotorUIDType = str
16
+
17
+
18
+ class SocketCANLibError(Exception):
19
+ """Error raised when the socketcan library returns a non-zero error code."""
20
+ pass
21
+
22
+ class CanOverloadError(Exception):
23
+ """Error raised when the CAN bus is overloaded."""
24
+ pass
25
+
26
+ @make_exception_pickleable
27
+ class UnknownInterfaceError(Exception):
28
+ """Error raised when the interface is not an interface, or is not being tracked by CommsManager."""
29
+ def __init__(self, *, interface: str):
30
+ super().__init__(f"Interface \"{interface}\" is not an interface, or is not being tracked by CommsManager")
31
+
32
+ @make_exception_pickleable
33
+ class CommsManagerNotRunningError(Exception):
34
+ """Error raised when the comms manager is not running."""
35
+ def __init__(self):
36
+ super().__init__("CommsManager is not running")
37
+
38
+ @make_exception_pickleable
39
+ class CanNotEnabledError(Exception):
40
+ """Error raised when a CAN interface is not enabled."""
41
+ def __init__(self, *, interface: str):
42
+ super().__init__(f"CAN interface \"{interface}\" is not enabled")
43
+
44
+ @make_exception_pickleable
45
+ class MissingMotorUIDError(Exception):
46
+ """Error raised when a motor UID is required but not provided."""
47
+ def __init__(self):
48
+ super().__init__(f"`motor_uid` is required in single message mode")
49
+
50
+ @make_exception_pickleable
51
+ class PendingReceiveMessageError(Exception):
52
+ """Error raised when a message is to be sent to a motor when we are awaiting a response in single message mode."""
53
+ def __init__(self, *, motor_uid: MotorUIDType):
54
+ super().__init__(f"Message to motor \"{motor_uid}\" is pending, cannot send more until response is received")
55
+
56
+
57
+ # We calculate the maximum number of messages that can be sent over a CAN bus per second using math similar to:
58
+ # https://electronics.stackexchange.com/questions/121329/whats-the-maximum-can-bus-frame-message-rate-at-125-kbit-s
59
+ #
60
+ # Including all the extra overhead, CRC, bit stuffing, etc, we get a theoretical 144 bits per frame. At 1Mbs, this
61
+ # would be a theoretical max of 6944 frames per second. Since most all messages we send will have 1 reply from the
62
+ # device, we divide by 2 to get 3472 messages-sent per second. To give us ~10% leeway, we limit to 3000 messages-sent
63
+ # per second on average.
64
+ #
65
+ # Using our exponential decay formula of N_{x+1} = N_x * e^(-t * decay_rate) + decay_scale, and a decay rate of 0.01,
66
+ # experiments (see /playground/justin_exp_decay_math.py) show that at a rate of 3000 messages-sent per second, the
67
+ # decayed-count maxes out at ~300. So, we use this limit here.
68
+ CAN_BUS_DECAY_LIMIT: float = 300.0;
69
+ CAN_BUS_DECAY_RATE: float = 0.01;
70
+ CAN_BUS_DECAY_SCALE: float = 10.0; # Controls how quickly we reach our limit. Higher=more quicker.
71
+ # Value of 10.0 = ~30 messages before limit at full speed, ~50 at 100us/message
72
+
73
+
74
+ LoadAndTime = namedtuple("LoadAndTime", ["load", "last_message_time"])
75
+
76
+
77
+ @dataclass
78
+ class SendCanMessage(TimestampedMixin):
79
+ """Data class representing a CAN message that is about to be sent. Only used for WireHawk"""
80
+ iface: str
81
+ extended: bool
82
+ can_id: int
83
+ data: bytes
84
+ motor_uid: Optional[MotorUIDType] = None
85
+
86
+ def __hash__(self) -> int:
87
+ return hash((self.iface, self.extended, self.can_id, self.data, self.motor_uid))
88
+
89
+ @dataclass
90
+ class SendTCPMessage(TimestampedMixin):
91
+ """Data class representing a TCP message that is about to be sent. Only used for WireHawk"""
92
+ ip: str
93
+ tid: int
94
+ uid: int
95
+ fcode: int
96
+ reg_addr: int
97
+ reg_val: int
98
+
99
+ def __hash__(self) -> int:
100
+ return hash((self.ip, self.tid, self.uid, self.fcode, self.reg_addr, self.reg_val))
101
+
102
+
103
+ SendCallbackParamType = Union[SendCanMessage, SendTCPMessage]
104
+
105
+
106
+ class CommsManager:
107
+ """Handles sending and receiving messages over CAN and TCP.
108
+
109
+ Provides a single point of entry for sending and receiving messages over CAN and TCP. Any new instances of this
110
+ class will point to the same instance.
111
+
112
+ The manager also checks for message overload on the CAN bus and will raise an error if the message rate is too high.
113
+
114
+ You can
115
+ """
116
+
117
+ _instance: Optional[Self] = None
118
+ """The singleton instance of the CommsManager. Do not modify at runtime!!!"""
119
+
120
+ _lock: FeatherThreadLock = FeatherThreadLock("CommsManager")
121
+
122
+ def __new__(cls) -> Self:
123
+ with cls._lock:
124
+ if cls._instance is None:
125
+ cls._instance = super(CommsManager, cls).__new__(cls)
126
+ cls._instance.__reset()
127
+ return cls._instance
128
+
129
+ def __reset(self) -> None:
130
+ """Reset the comms manager to a clean state. Assumes you have the lock already."""
131
+ if hasattr(self, "thread") and self.is_running():
132
+ raise ValueError("Cannot reset CommsManager while it is running")
133
+
134
+ self.thread = None
135
+ self._thread_error: Optional[Exception] = None
136
+ self._thread_traceback: Optional[str] = None
137
+
138
+ self.is_dry = False
139
+ self._callbacks: list[Callable[[SocketResult], None]] = []
140
+ self.endpoints: list[str] = []
141
+ self._can_loads: dict[str, LoadAndTime] = {}
142
+ self.__overload_check = True
143
+
144
+ self._pending_msgs: dict[str, set[MotorUIDType]] = {}
145
+ self._single_msg_checkers: dict[str, Callable[[SocketResult], MotorUIDType]] = {}
146
+ self._single_msg_mode: bool = False
147
+
148
+ # Do not override any existing send callbacks since they're used by WireHawk
149
+ send_callbacks = [] if not hasattr(self, "_send_callbacks") else self._send_callbacks
150
+ self._send_callbacks: list[Callable[[SendCallbackParamType], None]] = send_callbacks
151
+
152
+ def _DANGEROUS_disable_overload_check(self) -> None:
153
+ """WARNING: Only use this for testing!"""
154
+ with self._lock:
155
+ self.__overload_check = False
156
+
157
+ def set_single_message_mode(self, single_message_mode: bool) -> None:
158
+ """Set the comms manager to single message mode.
159
+
160
+ In single message mode, the comms manager will only send one message at a time to any one motor.
161
+ """
162
+ with self._lock:
163
+ self._single_msg_mode = single_message_mode
164
+
165
+ def set_is_dry(self, is_dry: bool) -> None:
166
+ """Enable/disable 'dry run' mode.
167
+
168
+ In dry run mode, the comms manager will not send any messages, and instead just print info about them
169
+ """
170
+ with self._lock:
171
+ self.is_dry = is_dry
172
+
173
+ def start(self, endpoints: list[str], enable_cans: bool = True, allow_no_enable_can: bool = False) -> None:
174
+ """Start the comms manager
175
+
176
+ This will start a background thread that will poll all the endpoints and call the callback for each message.
177
+
178
+ Will call `enable_can_interface` for each endpoint that is a CAN interface with default bitrate of 1Mbs. Will
179
+ create vCAN interfaces for each endpoint that is a vCAN interface.
180
+
181
+ Args:
182
+ endpoints: list of endpoints to subscribe to, eg: ["can0", "vcan1", "192.168.11.210"], etc.
183
+ enable_cans: If True, will attempt to enable any CAN interface that is not already enabled.
184
+ allow_no_enable_can: If True, will not raise an error if any of the CAN interfaces are not enabled.
185
+ """
186
+ with self._lock:
187
+ if self.is_running():
188
+ raise ValueError("CommsManager is already running")
189
+
190
+ for ep in endpoints:
191
+ if is_can_interface(ep):
192
+ try:
193
+ if not is_can_enabled(ep, bitrate=1_000_000):
194
+ if not enable_cans:
195
+ raise CanNotEnabledError(interface=ep)
196
+ enable_can_interface(ep, bitrate=1_000_000)
197
+ except Exception as e:
198
+ if not allow_no_enable_can:
199
+ raise e
200
+ else:
201
+ warning(f"Could not enable CAN interface {ep}: {e}, for the robot to work properly, "
202
+ f"run `sudo ip link set {ep} up`")
203
+
204
+ _get_err_str() # Just to make sure library is compiled and ready
205
+
206
+ self.endpoints = endpoints
207
+ self._can_loads = {ep: LoadAndTime(0.0, 0.0) for ep in endpoints if is_can_interface(ep)}
208
+
209
+ self.thread = threading.Thread(target=self._thread_func, daemon=True)
210
+ self._thread_error = None
211
+ self._thread_traceback = None
212
+ self.thread.start()
213
+
214
+ def _thread_func(self) -> None:
215
+ """Thread function for the comms manager."""
216
+ try:
217
+ _subscribe_multi(self.endpoints, self.__main_callback)
218
+ except Exception as e:
219
+ self._thread_error = e
220
+ self._thread_traceback = traceback.format_exc()
221
+ warning(f"Error in comms manager thread:\n{self._thread_traceback}")
222
+
223
+ def restart_with_added_endpoints(self, endpoints: Union[str, list[str]], start_ok: bool = False) -> None:
224
+ """Add endpoints to the (running) comms manager and restart it
225
+
226
+ Note: if the endpoint is already in the comms manager, this will do nothing.
227
+
228
+ Args:
229
+ endpoints: The endpoint(s) to add. Can be string or multiple strings.
230
+ start_ok: By default, an error will be raised if the comms manager is not running. If True, will start the
231
+ comms manager if it is not running.
232
+ """
233
+ if isinstance(endpoints, str):
234
+ endpoints = [endpoints]
235
+
236
+ if not self.is_running():
237
+ if start_ok:
238
+ return self.start(endpoints)
239
+ raise CommsManagerNotRunningError()
240
+
241
+ with self._lock:
242
+ new_endpoints = list(set(endpoints).union(set(self.endpoints)))
243
+
244
+ if len(new_endpoints) > 0:
245
+ self.close()
246
+ self.start(new_endpoints)
247
+
248
+ def add_callback(self, callback: Callable[[SocketResult], None]) -> None:
249
+ """Add a callback to be called for each message."""
250
+ with self._lock:
251
+ self._callbacks.append(callback)
252
+
253
+ def _add_send_callback(self, callback: Callable[[SendCallbackParamType], None]) -> None:
254
+ """Add a callback to be called for each message that is about to be sent."""
255
+ with self._lock:
256
+ self._send_callbacks.append(callback)
257
+
258
+ def remove_callback(self, callback: Callable[[SocketResult], None]) -> None:
259
+ """Remove a callback from being called for each message."""
260
+ with self._lock:
261
+ self._callbacks.remove(callback)
262
+
263
+ def close(self, timeout: float = 0.1) -> None:
264
+ """Close the comms manager.
265
+
266
+ This will stop the background thread and close all the endpoints.
267
+
268
+ Args:
269
+ timeout: The timeout in seconds to wait for the comms manager to ensure closed.
270
+ """
271
+ with self._lock:
272
+ if not self.is_running():
273
+ raise CommsManagerNotRunningError()
274
+
275
+ _stop_polling()
276
+
277
+ def _clear_all_sockets_and_reset():
278
+ """Clear all sockets and reset the comms manager.
279
+
280
+ Used within exponential_timeout. Checks to make sure the thread is actually stopped, then clears and
281
+ resets the comms manager. We need to do it this way because we can get into deadlocks if we keep the lock
282
+ the entire time and a message comes in to the handler.
283
+ """
284
+ with self._lock:
285
+ if self.is_running():
286
+ return None
287
+
288
+ _clear_all_sockets()
289
+ self.__reset()
290
+
291
+ return True
292
+
293
+ # Make sure the thread is actually stopped
294
+ try:
295
+ exponential_timeout(timeout, _clear_all_sockets_and_reset)
296
+ except TimeoutError:
297
+ raise SystemError("CommsManager is still running after closing")
298
+
299
+ def register_single_message_checker(self, iface: str, func: Callable[[SocketResult], Optional[MotorUIDType]]) -> None:
300
+ """Register a function to check for expected responses in single message mode.
301
+
302
+ Args:
303
+ func: A function that takes a SocketResult and returns a string unique to the motor that sent the message,
304
+ or None if the message is not expected and should be ignored.
305
+ """
306
+ with self._lock:
307
+ self._single_msg_checkers[iface] = func
308
+
309
+ def __main_callback(self, result: SocketResult) -> None:
310
+ """Called by libsocketcantcp library to notify us of a new message."""
311
+ if result.is_error():
312
+ warning(f"Error in comms manager callback: {result.err_type} - {result.err_msg}")
313
+ return
314
+
315
+ # Check for expected responses in single message mode
316
+ with self._lock:
317
+ if self._single_msg_mode and result.socket_name in self._pending_msgs:
318
+ if (res := self._single_msg_checkers[result.socket_name](result)) is not None:
319
+ if not isinstance(res, MotorUIDType):
320
+ raise TypeError(f"Expected MotorUIDType, got {type(res)}")
321
+ self._pending_msgs[result.socket_name].discard(res)
322
+
323
+ for callback in self._callbacks:
324
+ callback(result)
325
+
326
+ def is_running(self) -> bool:
327
+ """Check if the background polling thread is currently running."""
328
+ return self.thread is not None and self.thread.is_alive()
329
+
330
+ def cansend(
331
+ self,
332
+ interface: str,
333
+ extended: bool,
334
+ can_id: int,
335
+ data: bytes,
336
+ motor_uid: Optional[MotorUIDType] = None
337
+ ) -> None:
338
+ """Send a CAN message.
339
+
340
+ Args:
341
+ interface: The interface to send the message on.
342
+ extended: Whether the message is an extended (29-bit) CAN message, or a standard (11-bit) CAN message.
343
+ can_id: The CAN ID of the message. Must be in the range [0-0x1FFFFFFF] for extended messages, or
344
+ [0-0x7FF] for standard messages.
345
+ data: The data to send in the message. Must be 8 bytes long.
346
+ motor_uid: The unique identifier of the motor that sent the message. Only needed in single message mode.
347
+ """
348
+ for callback in self._send_callbacks:
349
+ callback(SendCanMessage(iface=interface, extended=extended, can_id=can_id, data=data, motor_uid=motor_uid))
350
+
351
+ if self.is_dry:
352
+ debug("Dry cansend: ", interface, can_id, data)
353
+ return
354
+
355
+ with self._lock:
356
+ # Make sure we are not in single message mode and have a pending message for this interface
357
+ if self._single_msg_mode:
358
+ if interface not in self._pending_msgs:
359
+ self._pending_msgs[interface] = set()
360
+ if motor_uid is None:
361
+ raise MissingMotorUIDError()
362
+ if not isinstance(motor_uid, MotorUIDType):
363
+ raise TypeError(f"Expected MotorUIDType, got {type(motor_uid)}")
364
+ if motor_uid in self._pending_msgs[interface]:
365
+ raise PendingReceiveMessageError(motor_uid=motor_uid)
366
+
367
+ # We are good to send this message, mark it as pending
368
+ self._pending_msgs[interface].add(motor_uid)
369
+
370
+ # Make sure we are not overloading the CAN bus
371
+ if interface not in self._can_loads:
372
+ raise UnknownInterfaceError(interface=interface)
373
+
374
+ if self.__overload_check:
375
+ dt_ms = timediff(self._can_loads[interface].last_message_time) * 1000.0 * CAN_BUS_DECAY_SCALE
376
+ new_load = self._can_loads[interface].load * math.exp(-dt_ms * CAN_BUS_DECAY_RATE) + CAN_BUS_DECAY_SCALE
377
+ if new_load > CAN_BUS_DECAY_LIMIT:
378
+ raise CanOverloadError(f"CAN bus {interface} is overloaded")
379
+
380
+ self._can_loads[interface] = LoadAndTime(load=new_load, last_message_time=currtime())
381
+
382
+ self._run_and_check(_cansend, interface, extended, can_id, data)
383
+
384
+ def tcpsend_modbus(self, ip: str, tid: int, uid: int, fcode: int, reg_addr: int, reg_val: int) -> None:
385
+ """Send a Modbus TCP message."""
386
+ for callback in self._send_callbacks:
387
+ callback(SendTCPMessage(ip=ip, tid=tid, uid=uid, fcode=fcode, reg_addr=reg_addr, reg_val=reg_val))
388
+
389
+ if self.is_dry:
390
+ debug("dry tcpsend", ip, tid, uid, fcode, reg_addr, reg_val)
391
+ return
392
+
393
+ self._run_and_check(_tcpsend_modbus, ip, tid, uid, fcode, reg_addr, reg_val)
394
+
395
+ def _run_and_check(self, func: Callable[[Any], None], *args: Any) -> None:
396
+ """Run a library function and check the result. If the result is non-zero, print a warning."""
397
+ if not self.is_running():
398
+ raise CommsManagerNotRunningError()
399
+ func(*args)