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.
- {motor_python-0.0.3 → motor_python-0.0.4}/PKG-INFO +21 -3
- {motor_python-0.0.3 → motor_python-0.0.4}/README.md +19 -2
- {motor_python-0.0.3 → motor_python-0.0.4}/pyproject.toml +15 -4
- {motor_python-0.0.3 → motor_python-0.0.4}/src/motor_python/__init__.py +1 -1
- motor_python-0.0.4/src/motor_python/__main__.py +87 -0
- motor_python-0.0.4/src/motor_python/cube_mars_motor.py +468 -0
- motor_python-0.0.4/src/motor_python/definitions.py +478 -0
- motor_python-0.0.4/src/motor_python/examples.py +103 -0
- motor_python-0.0.4/src/motor_python/motor_status_parser.py +361 -0
- motor_python-0.0.3/src/motor_python/__main__.py +0 -44
- motor_python-0.0.3/src/motor_python/cube_mars_motor.py +0 -8
- motor_python-0.0.3/src/motor_python/definitions.py +0 -43
- {motor_python-0.0.3 → motor_python-0.0.4}/.gitignore +0 -0
- {motor_python-0.0.3 → motor_python-0.0.4}/LICENSE +0 -0
- {motor_python-0.0.3 → motor_python-0.0.4}/src/motor_python/utils.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: motor_python
|
|
3
|
-
Version: 0.0.
|
|
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
|
-
│ ├──
|
|
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
|
-
│ ├──
|
|
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
|
-
│ ├──
|
|
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
|
-
│ ├──
|
|
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
|
+
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
|
-
|
|
12
|
-
|
|
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"
|
|
@@ -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()
|