moteus 0.3.89__tar.gz → 0.3.91__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 (37) hide show
  1. {moteus-0.3.89 → moteus-0.3.91}/PKG-INFO +1 -1
  2. {moteus-0.3.89 → moteus-0.3.91}/moteus/command.py +8 -2
  3. moteus-0.3.91/moteus/device_info.py +61 -0
  4. {moteus-0.3.89 → moteus-0.3.91}/moteus/export.py +14 -7
  5. moteus-0.3.91/moteus/fdcanusb.py +30 -0
  6. moteus-0.3.91/moteus/fdcanusb_device.py +355 -0
  7. {moteus-0.3.89 → moteus-0.3.91}/moteus/moteus.py +113 -546
  8. {moteus-0.3.89 → moteus-0.3.91}/moteus/moteus_tool.py +162 -112
  9. moteus-0.3.91/moteus/protocol.py +429 -0
  10. moteus-0.3.89/moteus/transport.py → moteus-0.3.91/moteus/pythoncan.py +7 -14
  11. moteus-0.3.91/moteus/pythoncan_device.py +244 -0
  12. moteus-0.3.91/moteus/transport.py +713 -0
  13. moteus-0.3.91/moteus/transport_device.py +194 -0
  14. moteus-0.3.91/moteus/transport_factory.py +188 -0
  15. moteus-0.3.91/moteus/transport_wrapper.py +51 -0
  16. {moteus-0.3.89 → moteus-0.3.91}/moteus/version.py +1 -1
  17. {moteus-0.3.89 → moteus-0.3.91}/moteus.egg-info/PKG-INFO +1 -1
  18. {moteus-0.3.89 → moteus-0.3.91}/moteus.egg-info/SOURCES.txt +7 -1
  19. {moteus-0.3.89 → moteus-0.3.91}/setup.py +1 -1
  20. moteus-0.3.89/moteus/fdcanusb.py +0 -240
  21. moteus-0.3.89/moteus/pythoncan.py +0 -152
  22. moteus-0.3.89/moteus/router.py +0 -60
  23. {moteus-0.3.89 → moteus-0.3.91}/README.md +0 -0
  24. {moteus-0.3.89 → moteus-0.3.91}/moteus/__init__.py +0 -0
  25. {moteus-0.3.89 → moteus-0.3.91}/moteus/aioserial.py +0 -0
  26. {moteus-0.3.89 → moteus-0.3.91}/moteus/aiostream.py +0 -0
  27. {moteus-0.3.89 → moteus-0.3.91}/moteus/calibrate_encoder.py +0 -0
  28. {moteus-0.3.89 → moteus-0.3.91}/moteus/multiplex.py +0 -0
  29. {moteus-0.3.89 → moteus-0.3.91}/moteus/posix_aioserial.py +0 -0
  30. {moteus-0.3.89 → moteus-0.3.91}/moteus/reader.py +0 -0
  31. {moteus-0.3.89 → moteus-0.3.91}/moteus/regression.py +0 -0
  32. {moteus-0.3.89 → moteus-0.3.91}/moteus/win32_aioserial.py +0 -0
  33. {moteus-0.3.89 → moteus-0.3.91}/moteus.egg-info/dependency_links.txt +0 -0
  34. {moteus-0.3.89 → moteus-0.3.91}/moteus.egg-info/entry_points.txt +0 -0
  35. {moteus-0.3.89 → moteus-0.3.91}/moteus.egg-info/requires.txt +0 -0
  36. {moteus-0.3.89 → moteus-0.3.91}/moteus.egg-info/top_level.txt +0 -0
  37. {moteus-0.3.89 → moteus-0.3.91}/setup.cfg +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.1
2
2
  Name: moteus
3
- Version: 0.3.89
3
+ Version: 0.3.91
4
4
  Summary: moteus brushless controller library and tools
5
5
  Home-page: https://github.com/mjbots/moteus
6
6
  Author: mjbots Robotic Systems
@@ -13,8 +13,11 @@
13
13
  # limitations under the License.
14
14
 
15
15
 
16
+ from .device_info import DeviceAddress
17
+
18
+
16
19
  class Command():
17
- destination = 1
20
+ destination = DeviceAddress(can_id=1)
18
21
  source = 0
19
22
  reply_required = False
20
23
  data = b''
@@ -26,7 +29,10 @@ class Command():
26
29
  # non-moteus devices).
27
30
  raw = False
28
31
  arbitration_id = 0 # this is the name python-can gives
29
- bus = None # Only valid for pi3hat
32
+
33
+ # The channel can be specified to direct this to a particular
34
+ # transport device.
35
+ channel = None
30
36
 
31
37
  def parse(self, message):
32
38
  # By default, we just return the message as is.
@@ -0,0 +1,61 @@
1
+ # Copyright 2025 mjbots Robotic Systems, LLC. info@mjbots.com
2
+ #
3
+ # Licensed under the Apache License, Version 2.0 (the "License");
4
+ # you may not use this file except in compliance with the License.
5
+ # You may obtain a copy of the License at
6
+ #
7
+ # http://www.apache.org/licenses/LICENSE-2.0
8
+ #
9
+ # Unless required by applicable law or agreed to in writing, software
10
+ # distributed under the License is distributed on an "AS IS" BASIS,
11
+ # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12
+ # See the License for the specific language governing permissions and
13
+ # limitations under the License.
14
+
15
+ from dataclasses import dataclass
16
+ import typing
17
+ import uuid
18
+
19
+ from .transport_device import TransportDevice
20
+
21
+ @dataclass(frozen=True)
22
+ class DeviceAddress:
23
+ """The minimal set of information necessary to communicate with a
24
+ device in a system. It may be just a CAN ID if that is unique, or
25
+ it may be a UUID prefix. It may also include a transport device,
26
+ although that is not required."""
27
+ can_id: typing.Optional[int] = None
28
+ uuid: typing.Optional[bytes] = None
29
+ transport_device: typing.Optional[TransportDevice] = None
30
+
31
+ def __repr__(self):
32
+ if self.can_id:
33
+ return f'DeviceAddress(can_id={self.can_id}, td={self.transport_device})'
34
+ uuid_bytes = self.uuid.hex() if self.uuid else None
35
+ return f'DeviceAddress(uuid={uuid_bytes}, td={self.transport_device})'
36
+
37
+
38
+ @dataclass
39
+ class DeviceInfo:
40
+ """This describes a device that was discovered on the CAN bus. It
41
+ includes the full available addressing information, as well as the
42
+ minimal DeviceAddress structure necessary to address it in the
43
+ current system."""
44
+
45
+ can_id: int = 1
46
+ uuid: typing.Optional[bytes] = None
47
+ transport_device: typing.Optional[TransportDevice] = None
48
+ address: typing.Optional[DeviceAddress] = None
49
+
50
+ def __repr__(self):
51
+ uuid_bytes = uuid.UUID(bytes=self.uuid) if self.uuid else None
52
+ return f'DeviceInfo(can_id={self.can_id}, uuid={uuid_bytes}, td={self.transport_device})'
53
+
54
+ def _cmp_key(self):
55
+ return (self.can_id, self.uuid or b'')
56
+
57
+ def __lt__(self, other):
58
+ if not isinstance(other, DeviceInfo):
59
+ return NotImplemented
60
+
61
+ return self._cmp_key() < other._cmp_key()
@@ -18,8 +18,10 @@ controller."""
18
18
  ALL = [
19
19
  'aiostream',
20
20
  'make_transport_args', 'get_singleton_transport',
21
- 'Fdcanusb', 'Router', 'Controller', 'Register', 'Transport',
22
- 'PythonCan',
21
+ 'DeviceAddress', 'DeviceInfo',
22
+ 'Frame', 'FrameFilter', 'TransportDevice',
23
+ 'Fdcanusb', 'FdcanusbDevice', 'Controller', 'Register', 'Transport', 'TransportWrapper',
24
+ 'PythonCan', 'PythonCanDevice',
23
25
  'Mode', 'QueryResolution', 'PositionResolution', 'Command', 'CommandError',
24
26
  'Stream',
25
27
  'TRANSPORT_FACTORIES',
@@ -28,19 +30,24 @@ ALL = [
28
30
  'RegisterParser', 'QueryParser',
29
31
  ]
30
32
  from moteus.command import Command
33
+ from moteus.device_info import DeviceAddress, DeviceInfo
31
34
  from moteus.fdcanusb import Fdcanusb
32
- from moteus.router import Router
35
+ from moteus.fdcanusb_device import FdcanusbDevice
33
36
  from moteus.transport import Transport
37
+ from moteus.transport_wrapper import TransportWrapper
34
38
  from moteus.pythoncan import PythonCan
39
+ from moteus.pythoncan_device import PythonCanDevice
40
+ from moteus.multiplex import (INT8, INT16, INT32, F32, IGNORE,
41
+ RegisterParser, QueryParser)
42
+ from moteus.transport_device import Frame, FrameFilter, TransportDevice
43
+ import moteus.reader as reader
44
+ import moteus.aiostream as aiostream
45
+
35
46
  from moteus.moteus import (
36
47
  CommandError,
37
48
  Controller, Register, Mode, QueryResolution, PositionResolution, Stream,
38
49
  make_transport_args, get_singleton_transport,
39
50
  TRANSPORT_FACTORIES)
40
- from moteus.multiplex import (INT8, INT16, INT32, F32, IGNORE,
41
- RegisterParser, QueryParser)
42
- import moteus.reader as reader
43
- import moteus.aiostream as aiostream
44
51
 
45
52
  try:
46
53
  from moteus.version import VERSION
@@ -0,0 +1,30 @@
1
+ # Copyright 2025 mjbots Robotic Systems, LLC. info@mjbots.com
2
+ #
3
+ # Licensed under the Apache License, Version 2.0 (the "License");
4
+ # you may not use this file except in compliance with the License.
5
+ # You may obtain a copy of the License at
6
+ #
7
+ # http://www.apache.org/licenses/LICENSE-2.0
8
+ #
9
+ # Unless required by applicable law or agreed to in writing, software
10
+ # distributed under the License is distributed on an "AS IS" BASIS,
11
+ # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12
+ # See the License for the specific language governing permissions and
13
+ # limitations under the License.
14
+
15
+ import typing
16
+
17
+ from .fdcanusb_device import FdcanusbDevice
18
+ from .transport_wrapper import TransportWrapper
19
+
20
+ class Fdcanusb(TransportWrapper):
21
+ def __init__(self, path=None, *args, **kwargs):
22
+ if path is None:
23
+ path = Fdcanusb.detect_fdcanusb()
24
+
25
+ device = FdcanusbDevice(path, *args, **kwargs)
26
+ super().__init__(device)
27
+
28
+ @staticmethod
29
+ def detect_fdcanusb():
30
+ return FdcanusbDevice.detect_fdcanusb()
@@ -0,0 +1,355 @@
1
+ # Copyright 2025 mjbots Robotic Systems, LLC. info@mjbots.com
2
+ #
3
+ # Licensed under the Apache License, Version 2.0 (the "License");
4
+ # you may not use this file except in compliance with the License.
5
+ # You may obtain a copy of the License at
6
+ #
7
+ # http://www.apache.org/licenses/LICENSE-2.0
8
+ #
9
+ # Unless required by applicable law or agreed to in writing, software
10
+ # distributed under the License is distributed on an "AS IS" BASIS,
11
+ # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12
+ # See the License for the specific language governing permissions and
13
+ # limitations under the License.
14
+
15
+ import asyncio
16
+ import glob
17
+ import os
18
+ import serial
19
+ import serial.tools
20
+ import serial.tools.list_ports
21
+ import sys
22
+ import time
23
+ import typing
24
+
25
+ from . import aioserial
26
+ from .transport_device import Frame, FrameFilter, TransportDevice
27
+
28
+
29
+ def _hexify(data):
30
+ return ''.join(['{:02X}'.format(x) for x in data])
31
+
32
+
33
+ def _dehexify(data):
34
+ result = b''
35
+ for i in range(0, len(data), 2):
36
+ result += bytes([int(data[i:i + 2], 16)])
37
+ return result
38
+
39
+
40
+ def _find_serial_number(path):
41
+ """Attempt to find the USB serial number for a given device path.
42
+
43
+ This function handles both direct device paths and symlinks to the
44
+ actual device, like custom udev rules that create /dev/fdcanusb -> /dev/serial/by-id/foo.
45
+ """
46
+ if not path:
47
+ return None
48
+
49
+ try:
50
+ # Resolve symlinks to get the actual device path
51
+ real_path = os.path.realpath(path)
52
+
53
+ # Get all ports including symlinks
54
+ ports = serial.tools.list_ports.comports(include_links=True)
55
+
56
+ for port in ports:
57
+ # Check if either the device path or the resolved path matches
58
+ if port.device == path or port.device == real_path:
59
+ # Return serial number if available
60
+ if hasattr(port, 'serial_number') and port.serial_number:
61
+ return port.serial_number
62
+
63
+ except Exception:
64
+ # If anything goes wrong, just return None
65
+ pass
66
+
67
+ return None
68
+
69
+
70
+ class FdcanusbDevice(TransportDevice):
71
+ """Connects to a single mjbots fdcanusb."""
72
+
73
+ def __init__(self, path=None, debug_log=None, disable_brs=False, **kwargs):
74
+ """Constructor.
75
+
76
+ Arguments:
77
+ path: serial port where fdcanusb is located
78
+ """
79
+ super(FdcanusbDevice, self).__init__(**kwargs)
80
+
81
+ self._disable_brs = disable_brs
82
+
83
+ # A fdcanusb ignores the requested baudrate, so we'll just
84
+ # pick something nice and random.
85
+ self._serial = aioserial.AioSerial(port=path, baudrate=9600)
86
+
87
+ # Attempt to discover the USB serial number associated with
88
+ # this device for pretty-printing.
89
+ self._serial_number = _find_serial_number(path)
90
+
91
+ self._stream_data = b''
92
+
93
+ self._ok_waiters = []
94
+
95
+ self._reader_task = None
96
+ self._running = False
97
+
98
+ self._debug_log = debug_log
99
+
100
+ # Start the reader if we can.
101
+ self._start_reader()
102
+
103
+ def __repr__(self):
104
+ if self._serial_number:
105
+ return f"Fdcanusb(sn='{self._serial_number}')"
106
+ else:
107
+ return 'Fdcanusb()'
108
+
109
+ def close(self):
110
+ if self._reader_task and not self._reader_task.done():
111
+ self._reader_task.cancel()
112
+
113
+ if hasattr(self._serial, 'close'):
114
+ self._serial.close()
115
+
116
+ self._running = False
117
+
118
+ def empty_bus_tx_safe(self):
119
+ return True
120
+
121
+ async def __aenter__(self):
122
+ return self
123
+
124
+ async def __aexit__(self, exc_type, exc_val, exc_tb):
125
+ self.close()
126
+ return False
127
+
128
+ def _start_reader(self):
129
+ self._running = True
130
+ try:
131
+ self._reader_task = asyncio.create_task(self._reader_loop())
132
+ except RuntimeError:
133
+ self._reader_task = None
134
+
135
+ async def _ensure_reader_started(self):
136
+ if self._reader_task is None and self._running:
137
+ self._reader_task = asyncio.create_task(self._reader_loop())
138
+
139
+ async def _reader_loop(self):
140
+ try:
141
+ while self._running:
142
+ try:
143
+ line = await self._readline(self._serial)
144
+ if not line:
145
+ continue
146
+
147
+ if self._debug_log:
148
+ self._write_log(b'< ' + line.rstrip())
149
+
150
+ if line.startswith(b'rcv'):
151
+ frame = self._parse_frame(line)
152
+ await self._handle_received_frame(frame)
153
+ elif line.startswith(b'OK'):
154
+ await self._handle_ok_response(line)
155
+ else:
156
+ await self._handle_other_response(line)
157
+ except asyncio.CancelledError:
158
+ break
159
+ except Exception as e:
160
+ raise
161
+ # Log and continue running.
162
+ if self._debug_log:
163
+ self._write_log(f'ERROR: {str(e)}'.encode('latin1'))
164
+
165
+ # Sleep briefly to prevent a tight error loop.
166
+ await asyncio.sleep(0.01)
167
+ finally:
168
+ self._running = False
169
+
170
+ async def _handle_ok_response(self, line):
171
+ # Just notify the first non-done OK waiter. An OK received
172
+ # with no waiters is assumed to be stale.
173
+ #
174
+ # Callers are *required* to enqueue their self._ok_waiters
175
+ # *before* sending anything that could result in an OK being
176
+ # emitted.
177
+ for waiter in self._ok_waiters:
178
+ if not waiter.done():
179
+ waiter.set_result(None)
180
+ return
181
+
182
+ async def _handle_other_response(self, line):
183
+ raise RuntimeError(f'{self} received error {line}')
184
+
185
+ async def _readline(self, stream):
186
+ while True:
187
+ offset = min((self._stream_data.find(c) for c in b"\r\n"
188
+ if c in self._stream_data), default=None)
189
+ if offset is not None:
190
+ to_return, self._stream_data = (
191
+ self._stream_data[0:offset+1],
192
+ self._stream_data[offset+1:])
193
+ if offset > 0:
194
+ return to_return
195
+ else:
196
+ continue
197
+ else:
198
+ data = await stream.read(8192, block=False)
199
+ if not data:
200
+ # If for some reason we got a completely empty
201
+ # response, which shouldn't happen, ensure we
202
+ # yield control to somebody else so that hopefully
203
+ # we eventually make forward progress.
204
+ await asyncio.sleep(0)
205
+ else:
206
+ self._stream_data += data
207
+
208
+ def _parse_frame(self, line):
209
+ fields = line.split(b" ")
210
+ frame = Frame()
211
+ frame.data = _dehexify(fields[2])
212
+ frame.dlc = len(frame.data)
213
+ frame.arbitration_id = int(fields[1], 16)
214
+ frame.channel = self
215
+
216
+ for flag in fields[3:]:
217
+ if flag == b'E':
218
+ frame.is_extended_id = True
219
+ if flag == b'B':
220
+ frame.bitrate_switch = True
221
+ if flag == b'F':
222
+ frame.is_fd = True
223
+
224
+ return frame
225
+
226
+ async def _write_send_frame(self, frame):
227
+ actual_brs = frame.bitrate_switch and not self._disable_brs
228
+
229
+ hexdata = _hexify(frame.data)
230
+ on_wire_size = self._round_up_dlc(len(frame.data))
231
+ hexdata += self._padding_hex * (on_wire_size - len(frame.data))
232
+
233
+ flags = ''
234
+ if actual_brs:
235
+ flags += 'B'
236
+ else:
237
+ flags += 'b'
238
+ if frame.is_fd:
239
+ flags += 'F'
240
+
241
+ cmd = "can send {:04x} {}{}{}\n".format(
242
+ frame.arbitration_id,
243
+ hexdata,
244
+ ' ' if len(flags) > 0 else '',
245
+ flags).encode('latin1')
246
+
247
+ self._serial.write(cmd)
248
+ if self._debug_log:
249
+ self._write_log(b'> ' + cmd.rstrip())
250
+
251
+ async def send_frame(self, frame: Frame):
252
+ await self._ensure_reader_started()
253
+
254
+ try:
255
+ ok_waiter = asyncio.Future()
256
+ self._ok_waiters.append(ok_waiter)
257
+
258
+ await self._write_send_frame(frame)
259
+ await self._serial.drain()
260
+
261
+ await ok_waiter
262
+ finally:
263
+ self._ok_waiters = [w for w in self._ok_waiters
264
+ if w != ok_waiter]
265
+
266
+ async def receive_frame(self):
267
+ await self._ensure_reader_started()
268
+
269
+ return await super(FdcanusbDevice, self).receive_frame()
270
+
271
+ async def transaction(
272
+ self,
273
+ requests: typing.List[TransportDevice.Request],
274
+ **kwargs):
275
+
276
+ # We do not support child devices.
277
+ assert not any([request.child_device is not None
278
+ for request in requests])
279
+
280
+ def make_subscription(request):
281
+ future = asyncio.Future()
282
+
283
+ async def handler(frame, request=request, future=future):
284
+ if future.done():
285
+ # Stick it in our receive queue so that it isn't
286
+ # lost.
287
+ self._receive_queue.append(frame)
288
+
289
+ return
290
+
291
+ request.responses.append(frame)
292
+ # While we may receive more than one frame for a given
293
+ # request, we only wait for one.
294
+ future.set_result(None)
295
+
296
+ return self._subscribe(request.frame_filter, handler), future
297
+
298
+ subscriptions = [
299
+ make_subscription(request)
300
+ for request in requests
301
+ if request.frame_filter is not None
302
+ ]
303
+
304
+ try:
305
+ # Now we will send all our requests.
306
+ ok_waiters = set([asyncio.Future()
307
+ for _ in range(len(requests))])
308
+ try:
309
+ self._ok_waiters.extend(list(ok_waiters))
310
+
311
+ # TODO: Provide control over the amount of pipelining
312
+ # like the C++ class.
313
+ for request in requests:
314
+ await self._write_send_frame(request.frame)
315
+
316
+ await self._serial.drain()
317
+
318
+ await asyncio.gather(*ok_waiters)
319
+ finally:
320
+ self._ok_waiters = [w for w in self._ok_waiters
321
+ if w not in ok_waiters]
322
+
323
+ # If there any responses to wait for, do so.
324
+ if subscriptions:
325
+ await asyncio.gather(*[x[1] for x in subscriptions])
326
+ finally:
327
+ # Clean up all our subscriptions.
328
+ for x in subscriptions:
329
+ x[0].cancel()
330
+
331
+ def _write_log(self, output: bytes):
332
+ assert self._debug_log is not None
333
+ self._debug_log.write(f'{time.time():.6f}/{self._serial_number} '.encode('latin1') + output + b'\n')
334
+
335
+ @staticmethod
336
+ def detect_fdcanusbs():
337
+ if sys.platform == 'win32':
338
+ return FdcanusbDevice.pyserial_detect_fdcanusbs()
339
+
340
+ maybe_list = glob.glob('/dev/serial/by-id/*fdcanusb*')
341
+ if len(maybe_list):
342
+ return sorted(maybe_list)
343
+
344
+ return FdcanusbDevice.pyserial_detect_fdcanusbs()
345
+
346
+ @staticmethod
347
+ def detect_fdcanusb():
348
+ return FdcanusbDevice.detect_fdcanusbs()[0]
349
+
350
+ @staticmethod
351
+ def pyserial_detect_fdcanusbs():
352
+ ports = serial.tools.list_ports.comports()
353
+
354
+ return [x.device for x in ports
355
+ if x.vid == 0x0483 and x.pid == 0x5740]