motor-python 0.0.3__tar.gz → 0.0.4__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.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: motor_python
3
- Version: 0.0.3
3
+ Version: 0.0.4
4
4
  Summary: Motor module for exosuit
5
5
  Project-URL: homepage, https://github.com/TUM-Aries-Lab/motor-module
6
6
  Author-email: Tsmorz <tony.smoragiewicz@tum.de>, Hannes <hannes.nguyen@tum.de>
@@ -8,6 +8,7 @@ License-File: LICENSE
8
8
  Requires-Python: <3.14,>=3.11
9
9
  Requires-Dist: loguru>=0.7.3
10
10
  Requires-Dist: numpy>=2.2.3
11
+ Requires-Dist: pyserial>=3.5
11
12
  Description-Content-Type: text/markdown
12
13
 
13
14
  # Motor Control Software for Soft Exoskeleton
@@ -58,20 +59,37 @@ if __name__ == "__main__":
58
59
  uv run python -m motor_python
59
60
  ```
60
61
 
62
+ ## Testing
63
+
64
+ ### Unit Tests (No Hardware Required)
65
+ Run unit tests without hardware (using mocks):
66
+ ```bash
67
+ make test # Run unit tests only (no hardware required)
68
+ make test-hardware # Run hardware integration tests (requires motor connected)
69
+ make test-all # Run all tests including hardware tests (full coverage)
70
+ ```
71
+
72
+ **Note:** Hardware tests are skipped automatically if motor hardware is not available.
73
+
61
74
  ## Structure
62
75
  <!-- TREE-START -->
63
76
  ```
64
77
  ├── src
65
78
  │ └── motor_python
79
+ │ ├── config
66
80
  │ ├── __init__.py
67
81
  │ ├── __main__.py
68
82
  │ ├── cube_mars_motor.py
69
- │ ├── defintions.py
83
+ │ ├── definitions.py
84
+ │ ├── examples.py
85
+ │ ├── motor_status_parser.py
70
86
  │ └── utils.py
71
87
  ├── tests
72
88
  │ ├── __init__.py
73
89
  │ ├── conftest.py
74
- │ ├── main_test.py
90
+ │ ├── cube_mars_motor_test.py
91
+ │ ├── hardware_test.py
92
+ │ ├── motor_status_parser_test.py
75
93
  │ └── utils_test.py
76
94
  ├── .dockerignore
77
95
  ├── .gitignore
@@ -46,20 +46,37 @@ if __name__ == "__main__":
46
46
  uv run python -m motor_python
47
47
  ```
48
48
 
49
+ ## Testing
50
+
51
+ ### Unit Tests (No Hardware Required)
52
+ Run unit tests without hardware (using mocks):
53
+ ```bash
54
+ make test # Run unit tests only (no hardware required)
55
+ make test-hardware # Run hardware integration tests (requires motor connected)
56
+ make test-all # Run all tests including hardware tests (full coverage)
57
+ ```
58
+
59
+ **Note:** Hardware tests are skipped automatically if motor hardware is not available.
60
+
49
61
  ## Structure
50
62
  <!-- TREE-START -->
51
63
  ```
52
64
  ├── src
53
65
  │ └── motor_python
66
+ │ ├── config
54
67
  │ ├── __init__.py
55
68
  │ ├── __main__.py
56
69
  │ ├── cube_mars_motor.py
57
- │ ├── defintions.py
70
+ │ ├── definitions.py
71
+ │ ├── examples.py
72
+ │ ├── motor_status_parser.py
58
73
  │ └── utils.py
59
74
  ├── tests
60
75
  │ ├── __init__.py
61
76
  │ ├── conftest.py
62
- │ ├── main_test.py
77
+ │ ├── cube_mars_motor_test.py
78
+ │ ├── hardware_test.py
79
+ │ ├── motor_status_parser_test.py
63
80
  │ └── utils_test.py
64
81
  ├── .dockerignore
65
82
  ├── .gitignore
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "motor_python"
3
- version = "0.0.3"
3
+ version = "0.0.4"
4
4
  description = "Motor module for exosuit"
5
5
  readme = "README.md"
6
6
  authors = [{ name = "Tsmorz", email = "tony.smoragiewicz@tum.de"},{ name = "Hannes", email = "hannes.nguyen@tum.de" }]
@@ -8,8 +8,9 @@ requires-python = ">=3.11,<3.14"
8
8
 
9
9
  # --- Core dependencies: NO hardware here ---
10
10
  dependencies = [
11
- "numpy>=2.2.3",
12
- "loguru>=0.7.3",
11
+ "numpy>=2.2.3",
12
+ "loguru>=0.7.3",
13
+ "pyserial>=3.5",
13
14
  ]
14
15
 
15
16
  [dependency-groups]
@@ -22,7 +23,10 @@ dev = [
22
23
  "coveralls>=4.0.1",
23
24
  "pyright>=1.1.407",
24
25
  ]
25
-
26
+ hw = [
27
+ ]
28
+ no_hw = [
29
+ ]
26
30
  [project.urls]
27
31
  homepage = "https://github.com/TUM-Aries-Lab/motor-module"
28
32
 
@@ -48,6 +52,13 @@ quote-style = "double"
48
52
  indent-style = "space"
49
53
  line-ending = "lf"
50
54
 
55
+ [tool.pytest.ini_options]
56
+ markers = [
57
+ "hardware: tests that require actual motor hardware (deselect with '-m \"not hardware\"')",
58
+ ]
59
+ testpaths = ["tests"]
60
+ pythonpath = ["src"]
61
+
51
62
  [build-system]
52
63
  requires = ["hatchling"]
53
64
  build-backend = "hatchling.build"
@@ -1,3 +1,3 @@
1
1
  """Sample doc string."""
2
2
 
3
- __version__ = "0.0.3"
3
+ __version__ = "0.0.4"
@@ -0,0 +1,87 @@
1
+ """Motor control main entry point using CubeMarsAK606v3 class."""
2
+
3
+ import argparse
4
+ import time
5
+
6
+ from loguru import logger
7
+
8
+ from motor_python.cube_mars_motor import CubeMarsAK606v3
9
+ from motor_python.definitions import DEFAULT_LOG_LEVEL, LogLevel
10
+ from motor_python.examples import run_motor_loop
11
+ from motor_python.utils import setup_logger
12
+
13
+
14
+ def main(
15
+ log_level: str = DEFAULT_LOG_LEVEL, stderr_level: str = DEFAULT_LOG_LEVEL
16
+ ) -> None:
17
+ """Run the main motor control loop.
18
+
19
+ :param log_level: The log level to use.
20
+ :param stderr_level: The std err level to use.
21
+ :return: None
22
+ """
23
+ setup_logger(log_level=log_level, stderr_level=stderr_level)
24
+ logger.info("Starting motor control loop...")
25
+
26
+ # Use the CubeMarsAK606v3 class with context manager
27
+ try:
28
+ motor = CubeMarsAK606v3()
29
+ except Exception as e:
30
+ logger.error(f"Failed to initialize motor controller: {e}")
31
+ return
32
+
33
+ with motor:
34
+ if not motor.connected or not motor.check_communication():
35
+ logger.warning(
36
+ "Motor hardware not connected - cannot run motor test"
37
+ if not motor.connected
38
+ else "Motor is connected but not responding - hardware may be powered off or cables disconnected"
39
+ )
40
+ return
41
+
42
+ logger.info("Testing motor feedback response...")
43
+
44
+ # Query motor status at startup
45
+ logger.info("Initial motor status query:")
46
+ motor.get_status()
47
+ time.sleep(0.5)
48
+
49
+ # Run the motor control loop with all modes
50
+ try:
51
+ run_motor_loop(motor)
52
+ except KeyboardInterrupt:
53
+ logger.info("Interrupted by user")
54
+
55
+ # Stop motor safely
56
+ logger.info("Stopping motor...")
57
+ motor.stop()
58
+
59
+ # Final status
60
+ logger.info("Final motor status:")
61
+ motor.get_status()
62
+ time.sleep(0.5)
63
+
64
+ logger.info("Motor control loop complete!")
65
+
66
+
67
+ if __name__ == "__main__": # pragma: no cover
68
+ parser = argparse.ArgumentParser("Run the pipeline.")
69
+ parser.add_argument(
70
+ "--log-level",
71
+ default=DEFAULT_LOG_LEVEL,
72
+ choices=list(LogLevel()),
73
+ help="Set the log level.",
74
+ required=False,
75
+ type=str,
76
+ )
77
+ parser.add_argument(
78
+ "--stderr-level",
79
+ default=DEFAULT_LOG_LEVEL,
80
+ choices=list(LogLevel()),
81
+ help="Set the std err level.",
82
+ required=False,
83
+ type=str,
84
+ )
85
+ args = parser.parse_args()
86
+
87
+ main(log_level=args.log_level)
@@ -0,0 +1,468 @@
1
+ """AK60-6 Motor Control Class - CubeMars UART Protocol."""
2
+
3
+ import struct
4
+ import time
5
+ from enum import IntEnum
6
+ from pathlib import Path
7
+
8
+ import numpy as np
9
+ import serial
10
+ from loguru import logger
11
+
12
+ from motor_python.definitions import (
13
+ CRC16_TAB,
14
+ CRC_CONSTANTS,
15
+ FRAME_BYTES,
16
+ MOTOR_DEFAULTS,
17
+ MOTOR_LIMITS,
18
+ SCALE_FACTORS,
19
+ )
20
+ from motor_python.motor_status_parser import MotorStatusParser
21
+
22
+
23
+ class MotorCommand(IntEnum):
24
+ """CubeMars UART command codes."""
25
+
26
+ CMD_GET_PARAMS = 0x13 # Get specific parameters based on bit field
27
+ CMD_GET_STATUS = 0x45 # Get all motor parameters
28
+ CMD_SET_DUTY = 0x46
29
+ CMD_SET_CURRENT = 0x47
30
+ CMD_SET_SPEED = 0x49
31
+ CMD_SET_POSITION = 0x4A
32
+ CMD_GET_POSITION = 0x4C # Get current position (updates every 10ms)
33
+
34
+
35
+ class CubeMarsAK606v3:
36
+ """AK60-6 Motor Controller for CubeMars V3 UART Protocol."""
37
+
38
+ def __init__(
39
+ self,
40
+ port: Path | str = MOTOR_DEFAULTS.port,
41
+ baudrate: int = MOTOR_DEFAULTS.baudrate,
42
+ ) -> None:
43
+ """Initialize motor connection.
44
+
45
+ :param port: Serial port path (default: MOTOR_DEFAULTS.port).
46
+ :param baudrate: Communication baudrate (default: MOTOR_DEFAULTS.baudrate).
47
+ :return: None
48
+ """
49
+ self.port = str(port) # Convert Path to str for serial library
50
+ self.baudrate = baudrate
51
+ self.serial: serial.Serial | None = None
52
+ self.status_parser = MotorStatusParser()
53
+ self.connected = False
54
+ self.communicating = False
55
+ self._consecutive_no_response = 0
56
+ self._max_no_response = MOTOR_DEFAULTS.max_no_response_attempts
57
+ self._connect()
58
+
59
+ def _connect(self) -> None:
60
+ """Establish serial connection to motor.
61
+
62
+ :return: None
63
+ """
64
+ try:
65
+ self.serial = serial.Serial(
66
+ port=self.port,
67
+ baudrate=self.baudrate,
68
+ timeout=1,
69
+ rtscts=False,
70
+ dsrdtr=False,
71
+ )
72
+ time.sleep(0.1) # Allow connection to stabilize
73
+ self.connected = True
74
+ logger.info(f"Connected to motor on {self.port} at {self.baudrate} baud")
75
+ except serial.SerialException as e:
76
+ logger.warning(f"Failed to connect to motor on {self.port}: {e}")
77
+ self.connected = False
78
+ except Exception as e:
79
+ logger.warning(f"Unexpected error connecting to motor: {e}")
80
+ self.connected = False
81
+
82
+ def _crc16(self, data: bytes) -> int:
83
+ """Calculate CRC16-CCITT checksum.
84
+
85
+ :param data: Bytes to calculate checksum over.
86
+ :return: 16-bit CRC checksum.
87
+ """
88
+ checksum = CRC_CONSTANTS.initial_value
89
+ for byte in data:
90
+ # Extract high byte of checksum and XOR with current byte
91
+ high_byte = (checksum >> CRC_CONSTANTS.shift_bits) & CRC_CONSTANTS.byte_mask
92
+ table_index = high_byte ^ byte
93
+ # Lookup table value and combine with shifted checksum
94
+ table_value = CRC16_TAB[table_index]
95
+ shifted_checksum = (
96
+ checksum << CRC_CONSTANTS.shift_bits
97
+ ) & CRC_CONSTANTS.word_mask
98
+ checksum = table_value ^ shifted_checksum
99
+ return checksum
100
+
101
+ def _build_frame(self, cmd: int, payload: bytes) -> bytes:
102
+ """Build CubeMars UART frame with proper structure.
103
+
104
+ Frame structure: AA | DataLength | CMD | Payload | CRC_H | CRC_L | BB
105
+
106
+ :param cmd: Command byte (0x46, 0x47, 0x49, 0x4A).
107
+ :param payload: Command payload data.
108
+ :return: Complete frame ready to send.
109
+ """
110
+ data_frame = bytes([cmd]) + payload
111
+ crc = self._crc16(data_frame)
112
+ frame = bytes(
113
+ [
114
+ FRAME_BYTES.start,
115
+ len(data_frame),
116
+ *data_frame,
117
+ crc >> 8,
118
+ crc & 0xFF,
119
+ FRAME_BYTES.end,
120
+ ]
121
+ )
122
+ return frame
123
+
124
+ def _get_command_from_frame(self, frame: bytes) -> int:
125
+ """Extract command byte from frame.
126
+
127
+ :param frame: Frame bytes.
128
+ :return: Command byte, or 0 if frame is too short.
129
+ """
130
+ # Frame structure: AA | DataLength | CMD | Payload | CRC_H | CRC_L | BB
131
+ # Command byte is at index 2
132
+ return frame[2] if len(frame) > 2 else 0
133
+
134
+ def _send_frame(self, frame: bytes) -> bytes:
135
+ """Send frame to motor over UART and read response.
136
+
137
+ :param frame: Complete frame to send.
138
+ :return: Response bytes from motor (if any).
139
+ """
140
+ if not self.connected or self.serial is None or not self.serial.is_open:
141
+ logger.debug("Motor not connected - skipping message send")
142
+ return b""
143
+
144
+ self.serial.write(frame)
145
+ logger.debug(f"TX: {' '.join(f'{b:02X}' for b in frame)}")
146
+
147
+ # Wait for response - some commands need more time
148
+ time.sleep(0.1)
149
+
150
+ # Read any available response
151
+ response = b""
152
+ bytes_waiting = self.serial.in_waiting
153
+ logger.debug(f"Bytes waiting in buffer: {bytes_waiting}")
154
+
155
+ if bytes_waiting > 0:
156
+ response = self.serial.read(bytes_waiting)
157
+ if response:
158
+ logger.debug(f"RX: {' '.join(f'{b:02X}' for b in response)}")
159
+ self._parse_motor_response(response)
160
+ # Reset failure counter on successful communication
161
+ self._consecutive_no_response = 0
162
+ self.communicating = True
163
+ else:
164
+ logger.debug("No response from motor")
165
+ # Track consecutive failures for status queries
166
+ cmd_byte = self._get_command_from_frame(frame)
167
+ if cmd_byte in (MotorCommand.CMD_GET_STATUS, MotorCommand.CMD_GET_POSITION):
168
+ self._consecutive_no_response += 1
169
+ if (
170
+ self._consecutive_no_response >= self._max_no_response
171
+ and self.communicating
172
+ ):
173
+ logger.warning(
174
+ f"Motor not responding after {self._consecutive_no_response} attempts - "
175
+ "hardware may be disconnected or powered off"
176
+ )
177
+ self.communicating = False
178
+
179
+ return response
180
+
181
+ def _parse_full_status(self, payload: bytes) -> None:
182
+ """Parse full status response (command 0x45).
183
+
184
+ :param payload: Response payload bytes.
185
+ :return: None
186
+ """
187
+ status = self.status_parser.parse_full_status(payload)
188
+ if status:
189
+ self.status_parser.log_motor_status(status)
190
+
191
+ def _parse_motor_response(self, response: bytes) -> None:
192
+ """Parse and display motor response data.
193
+
194
+ :param response: Raw response bytes from motor.
195
+ :return: None
196
+ """
197
+ if len(response) < 10: # Minimum valid response length
198
+ return
199
+
200
+ # Check for valid frame structure (AA ... BB)
201
+ if response[0] != FRAME_BYTES.start or response[-1] != FRAME_BYTES.end:
202
+ logger.warning("Invalid response frame structure")
203
+ return
204
+
205
+ # Extract basic frame info
206
+ data_length = response[1]
207
+ cmd = response[2] if len(response) > 2 else 0
208
+
209
+ logger.info("=" * 50)
210
+ logger.info("MOTOR RESPONSE:")
211
+ logger.info(f" Command: 0x{cmd:02X}")
212
+ logger.info(f" Data Length: {data_length}")
213
+
214
+ # Parse payload according to command type
215
+ if len(response) >= 6:
216
+ # Payload starts at byte 3, ends before CRC (last 3 bytes)
217
+ payload = response[3:-3]
218
+
219
+ try:
220
+ if cmd == 0x45: # Full status response
221
+ self._parse_full_status(payload)
222
+ elif cmd in {0x4C, 0x57}: # Position response (0x57 is echo of 0x4C)
223
+ if len(payload) >= 4:
224
+ position = struct.unpack(">f", payload[0:4])[0]
225
+ logger.info(f" Position: {position:.2f}°")
226
+
227
+ except Exception as e:
228
+ logger.warning(f"Error parsing motor response: {e}")
229
+ logger.info(f" Raw payload: {payload.hex().upper()}")
230
+
231
+ logger.info("=" * 50)
232
+
233
+ def set_position(self, position_degrees: float) -> None:
234
+ """Set motor position in degrees.
235
+
236
+ Limited to ±360° for exosuit joint safety.
237
+
238
+ :param position_degrees: Target position in degrees (±360° = ±1 rotation)
239
+ :return: None
240
+ """
241
+ if (
242
+ position_degrees > MOTOR_LIMITS.max_position_degrees
243
+ or position_degrees < MOTOR_LIMITS.min_position_degrees
244
+ ):
245
+ logger.warning(
246
+ f"Position {position_degrees:.2f}° exceeds limits "
247
+ f"[{MOTOR_LIMITS.min_position_degrees:.2f}°, {MOTOR_LIMITS.max_position_degrees:.2f}°]. "
248
+ f"Clamping to safe range."
249
+ )
250
+ position_degrees = np.clip(
251
+ position_degrees,
252
+ MOTOR_LIMITS.min_position_degrees,
253
+ MOTOR_LIMITS.max_position_degrees,
254
+ )
255
+ value = int(position_degrees * SCALE_FACTORS.position)
256
+ payload = struct.pack(">i", value)
257
+ frame = self._build_frame(MotorCommand.CMD_SET_POSITION, payload)
258
+ self._send_frame(frame)
259
+
260
+ def set_velocity(self, velocity_erpm: int) -> None:
261
+ """Set motor velocity in electrical RPM.
262
+
263
+ :param velocity_erpm: Target velocity in ERPM
264
+ :return: None
265
+ """
266
+ velocity_erpm_int = int(velocity_erpm)
267
+ if (
268
+ velocity_erpm_int > MOTOR_LIMITS.max_velocity_electrical_rpm
269
+ or velocity_erpm_int < MOTOR_LIMITS.min_velocity_electrical_rpm
270
+ ):
271
+ logger.warning(
272
+ f"Velocity {velocity_erpm_int} ERPM exceeds limits "
273
+ f"[{MOTOR_LIMITS.min_velocity_electrical_rpm}, {MOTOR_LIMITS.max_velocity_electrical_rpm}]. "
274
+ f"Clamping to safe range."
275
+ )
276
+ velocity_erpm = np.clip(
277
+ velocity_erpm_int,
278
+ MOTOR_LIMITS.min_velocity_electrical_rpm,
279
+ MOTOR_LIMITS.max_velocity_electrical_rpm,
280
+ )
281
+ payload = struct.pack(">i", velocity_erpm)
282
+ frame = self._build_frame(MotorCommand.CMD_SET_SPEED, payload)
283
+ self._send_frame(frame)
284
+
285
+ def _estimate_movement_time(
286
+ self, target_degrees: float, motor_speed_erpm: int
287
+ ) -> float:
288
+ """Estimate time needed to reach target position at given speed.
289
+
290
+ Converts ERPM to degrees/second and calculates travel time.
291
+ This is a rough estimate - actual time may vary.
292
+
293
+ :param target_degrees: Target position in degrees
294
+ :param motor_speed_erpm: Motor speed in electrical RPM (absolute value used for calculation)
295
+ :return: Estimated time in seconds
296
+ """
297
+ if motor_speed_erpm == 0:
298
+ return 0.0
299
+
300
+ # Convert ERPM to degrees per second
301
+ # ERPM = electrical revolutions per minute
302
+ # degrees/sec = (ERPM / 60) * 360
303
+ degrees_per_second = abs(motor_speed_erpm) / 60.0 * 360.0
304
+
305
+ # Calculate time needed
306
+ estimated_time = abs(target_degrees) / degrees_per_second
307
+ return estimated_time
308
+
309
+ def move_to_position_with_speed(
310
+ self,
311
+ target_degrees: float,
312
+ motor_speed_erpm: int,
313
+ step_delay: float = MOTOR_DEFAULTS.step_delay,
314
+ ) -> None:
315
+ """Reach the target position through speed-controlled increments.
316
+
317
+ :param target_degrees: Target position in degrees.
318
+ :param motor_speed_erpm: Motor speed in electrical RPM (absolute value, direction determined by target).
319
+ :param step_delay: Delay between steps in seconds.
320
+ :return: None
321
+ """
322
+ # Get current position (assume we're tracking it)
323
+ # For now, we'll send velocity command to move, then switch to position
324
+
325
+ # Use velocity control to move at specified speed
326
+ direction = 1 if target_degrees > 0 else -1
327
+ self.set_velocity(motor_speed_erpm * direction)
328
+
329
+ # Calculate approximate time needed
330
+ estimated_time = self._estimate_movement_time(target_degrees, motor_speed_erpm)
331
+ time.sleep(min(estimated_time, 5.0)) # Cap at 5 seconds
332
+
333
+ # Switch to position hold
334
+ self.set_position(target_degrees)
335
+ logger.info(f"Reached position: {target_degrees}° at {motor_speed_erpm} ERPM")
336
+
337
+ def set_duty_cycle(self, duty_cycle_percent: float) -> None:
338
+ """Set motor PWM duty cycle percentage.
339
+
340
+ :param duty_cycle_percent: Duty cycle value (-1.0 to 1.0, where 1.0 = 100%)
341
+ :return: None
342
+ """
343
+ # Limit to 95% to prevent saturation
344
+ if (
345
+ duty_cycle_percent > MOTOR_LIMITS.max_duty_cycle
346
+ or duty_cycle_percent < MOTOR_LIMITS.min_duty_cycle
347
+ ):
348
+ logger.warning(
349
+ f"Duty cycle {duty_cycle_percent:.2f} exceeds limits "
350
+ f"[{MOTOR_LIMITS.min_duty_cycle:.2f}, {MOTOR_LIMITS.max_duty_cycle:.2f}]. "
351
+ f"Clamping to safe range."
352
+ )
353
+ duty_cycle_percent = np.clip(
354
+ duty_cycle_percent,
355
+ MOTOR_LIMITS.min_duty_cycle,
356
+ MOTOR_LIMITS.max_duty_cycle,
357
+ )
358
+ value = int(duty_cycle_percent * 100000.0)
359
+ payload = struct.pack(">i", value)
360
+ frame = self._build_frame(MotorCommand.CMD_SET_DUTY, payload)
361
+ self._send_frame(frame)
362
+
363
+ def set_current(self, current_amps: float) -> None:
364
+ """Set motor current in amperes.
365
+
366
+ :param current_amps: Current in Amps
367
+ :return: None
368
+ """
369
+ if (
370
+ current_amps > MOTOR_LIMITS.max_current_amps
371
+ or current_amps < MOTOR_LIMITS.min_current_amps
372
+ ):
373
+ logger.warning(
374
+ f"Current {current_amps:.2f}A exceeds limits "
375
+ f"[{MOTOR_LIMITS.min_current_amps:.2f}A, {MOTOR_LIMITS.max_current_amps:.2f}A]. "
376
+ f"Clamping to safe range."
377
+ )
378
+ current_amps = np.clip(
379
+ current_amps,
380
+ MOTOR_LIMITS.min_current_amps,
381
+ MOTOR_LIMITS.max_current_amps,
382
+ )
383
+ value = int(current_amps * 1000.0)
384
+ payload = struct.pack(">i", value)
385
+ frame = self._build_frame(MotorCommand.CMD_SET_CURRENT, payload)
386
+ self._send_frame(frame)
387
+
388
+ def get_status(self) -> bytes:
389
+ """Get all motor parameters.
390
+
391
+ :return: Raw status bytes from motor.
392
+ """
393
+ # Command 0x45 requires no payload - it returns everything
394
+ frame = self._build_frame(MotorCommand.CMD_GET_STATUS, b"")
395
+ status = self._send_frame(frame)
396
+ return status
397
+
398
+ def get_position(self) -> bytes:
399
+ """Get current motor position via command 0x4C.
400
+
401
+ Command 0x4C returns current position every 10ms.
402
+ Lightweight query for position feedback only.
403
+
404
+ :return: Raw response bytes from motor containing position
405
+ """
406
+ # Command 0x4C with no payload
407
+ frame = self._build_frame(MotorCommand.CMD_GET_POSITION, b"")
408
+ response = self._send_frame(frame)
409
+ return response
410
+
411
+ def check_communication(self) -> bool:
412
+ """Verify motor is responding to commands.
413
+
414
+ :return: True if motor responds, False otherwise
415
+ """
416
+ if not self.connected:
417
+ return False
418
+
419
+ # Try to get status MOTOR_DEFAULTS.max_communication_attempts times
420
+ for _attempt in range(MOTOR_DEFAULTS.max_communication_attempts):
421
+ status = self.get_status()
422
+ if status and len(status) > 0:
423
+ self.communicating = True
424
+ self._consecutive_no_response = 0
425
+ logger.info("Motor communication verified")
426
+ return True
427
+ time.sleep(MOTOR_DEFAULTS.communication_retry_delay)
428
+
429
+ logger.warning("Motor not responding to status queries")
430
+ self.communicating = False
431
+ return False
432
+
433
+ def stop(self) -> None:
434
+ """Stop the motor by setting all control values to zero.
435
+
436
+ :return: None
437
+ """
438
+ self.set_duty_cycle(0.0)
439
+ self.set_current(0.0)
440
+ self.set_velocity(0)
441
+ logger.info("Motor stopped")
442
+
443
+ def close(self) -> None:
444
+ """Close serial connection to motor.
445
+
446
+ :return: None
447
+ """
448
+ if self.serial and self.serial.is_open:
449
+ self.stop()
450
+ self.serial.close()
451
+ logger.info("Motor connection closed")
452
+
453
+ def __enter__(self) -> "CubeMarsAK606v3":
454
+ """Context manager entry.
455
+
456
+ :return: Self instance for use in with statement.
457
+ """
458
+ return self
459
+
460
+ def __exit__(self, exc_type, exc_val, exc_tb) -> None:
461
+ """Context manager exit.
462
+
463
+ :param exc_type: Exception type if an exception occurred.
464
+ :param exc_val: Exception value if an exception occurred.
465
+ :param exc_tb: Exception traceback if an exception occurred.
466
+ :return: None
467
+ """
468
+ self.close()