├── setup.cfg ├── .gitignore ├── README.md ├── st4 ├── tests │ └── test_st4.py └── __init__.py ├── setup.py └── LICENSE.md /setup.cfg: -------------------------------------------------------------------------------- 1 | [aliases] 2 | test=pytest 3 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | **/__pycache__ 2 | *.pyc 3 | /dist/ 4 | /*.egg-info 5 | /.eggs -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # st4 2 | 3 | A Python module for driving the eMotimo Spectrum ST4 via serial USB -------------------------------------------------------------------------------- /st4/tests/test_st4.py: -------------------------------------------------------------------------------- 1 | import st4 2 | 3 | 4 | def test_st4(): 5 | s = st4.ST4('/dev/ttyUSB0') 6 | s.go_rapid(0, 0) 7 | assert s.readline() == 'Rapid to:,X0,Y0,Z0,W0' 8 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | import setuptools 2 | 3 | with open('README.md', 'r') as f: 4 | long_description = f.read() 5 | 6 | setuptools.setup( 7 | name='st4', 8 | version='0.1.0', 9 | author='Anders Langlands', 10 | author_email='anderslanglands@gmail.com', 11 | description='A Python module for controlling the eMotimo Spectrum ST4 vis serial USB', 12 | long_description=long_description, 13 | long_description_content_type='text/markdown', 14 | url='https://github.com/anderslanglands/st4', 15 | packages=['st4'], 16 | install_requires=['pyserial'], 17 | setup_requires=['pytest-runner'], 18 | tests_require=['pytest'], 19 | classifiers=[ 20 | 'Programming Language :: Python :: 3', 21 | 'License :: OSI Approved :: MIT License', 22 | 'Operating System :: Linux', 23 | ], 24 | python_requires='>=3.6', 25 | ) 26 | -------------------------------------------------------------------------------- /LICENSE.md: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2020 Anders Langlands 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. -------------------------------------------------------------------------------- /st4/__init__.py: -------------------------------------------------------------------------------- 1 | import serial 2 | 3 | # X and Y are in terms of which direction are we moving in rather than which 4 | # axis are we rotating around 5 | DEGREE_PER_MS_Y = 0.000115195 6 | DEGREE_PER_MS_X = 0.000305304 7 | MS_PER_DEGREE_Y = 8680.968858 8 | MS_PER_DEGREE_X = 3275.420875 9 | 10 | 11 | def degrees_to_ms_x(deg: float): 12 | """ 13 | Convert an angle in degrees to motor steps for the X axis (pan). 14 | Input can be -180 to 180 15 | """ 16 | # deg = max(min(deg, 180.0), -180.0) 17 | return int(round(deg * MS_PER_DEGREE_X)) 18 | 19 | 20 | def degrees_to_ms_y(deg: float): 21 | """ 22 | Convert an angle in degrees to motor steps for the Y axis (tilt). 23 | Input can be -180 to 180 24 | """ 25 | # deg = max(min(deg, 180.0), -180.0) 26 | return int(round(deg * MS_PER_DEGREE_Y)) 27 | 28 | 29 | class ST4: 30 | def __init__(self, port: str, timeout=1): 31 | self._port = port 32 | self._ser = serial.Serial(self._port, 57600, timeout=timeout) 33 | 34 | def _send(self, command: str): 35 | command += '\n' 36 | self._ser.write(command.encode('utf-8')) 37 | 38 | def firmware_version(self): 39 | """Returns firmware version of the connected ST4""" 40 | self._send('G700') 41 | return self.readline() 42 | 43 | def readline(self): 44 | """ 45 | Read a line (delimited by EOF) from the serial port. 46 | If no newline is encountered, keeps reading bytes until the timeout 47 | specified in the class constructor. 48 | """ 49 | return self._ser.readline().decode('utf-8') 50 | 51 | def go_rapid(self, 52 | x: float = None, 53 | y: float = None 54 | ): 55 | """ 56 | G0 57 | 58 | Goes to a particular position defined by absolute coordinates of all 59 | axes. 60 | Each motor moves independently to the position specified using 61 | the currently set max velocities and acceleration for each axis. 62 | 63 | - Virtual stops are not adhered to 64 | - If the value for an axis is None then no move command is given to 65 | that axis 66 | """ 67 | cmd = 'G0 ' 68 | if x is not None: 69 | cmd += f'X{degrees_to_ms_x(x)} ' 70 | 71 | if y is not None: 72 | cmd += f'Y{degrees_to_ms_y(y)} ' 73 | 74 | self._send(cmd) 75 | 76 | def go_coordinated(self, 77 | time: float, 78 | accel: float, 79 | x: float = None, 80 | y: float = None 81 | ): 82 | """ 83 | G1 84 | 85 | Goes to a particular position defined by absolute coordinates for 86 | each axis in the time given and with the given period of 87 | acceleration (both measured in seconds). 88 | 89 | - Virtual stops are not adhered to 90 | - If the value for an axis is None then no move command is given to 91 | that axis 92 | - If the move cannot be achieved in the time requested it will move 93 | at the fasted speed possible with the current VMAX and AMAX 94 | settings 95 | """ 96 | 97 | cmd = f'G1 T{time} A{accel}' 98 | 99 | if x is not None: 100 | cmd += f'X{degrees_to_ms_x(x)} ' 101 | 102 | if y is not None: 103 | cmd += f'Y{degrees_to_ms_y(y)} ' 104 | 105 | self._send(cmd) 106 | 107 | def jog(self, 108 | x: float = None, 109 | y: float = None 110 | ): 111 | """ 112 | G2 113 | 114 | Jogs the motor the specified number of steps 115 | 116 | - If you try to job over a stop, it will stop at `stop - expected`. 117 | If you are already over a stop, it will jog back to the limit 118 | """ 119 | 120 | cmd = 'G2 ' 121 | if x is not None: 122 | cmd += f'X{degrees_to_ms_x(x)} ' 123 | 124 | if y is not None: 125 | cmd += f'Y{degrees_to_ms_y(y)} ' 126 | 127 | self._send(cmd) 128 | 129 | def set_motor_position(self, axis: int, position: float): 130 | """ 131 | G200 132 | 133 | Set the current position of the specified motor to be a particular 134 | numerical value. This can be used for setting the zero position of a 135 | particular axis. 136 | """ 137 | if axis < 1 or axis > 4: 138 | raise ValueError( 139 | 'set_motor_position axis must be 1 for pan, 2 for tilt, 3 for M3 or 4 for M4') 140 | 141 | if axis == 2: 142 | ms = degrees_to_ms_y(position) 143 | else: 144 | ms = degrees_to_ms_x(position) 145 | 146 | cmd = f'G200 M{axis} P{ms}' 147 | self._send(cmd) 148 | 149 | def zero_all_motors(self): 150 | """ 151 | G201 152 | 153 | Set the current position of all motors to be the new zero position 154 | """ 155 | 156 | self._send('G201') 157 | --------------------------------------------------------------------------------