SpecSMC 1.0.0__py3-none-any.whl
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.
- SpecSMC/SpecSMC.py +760 -0
- SpecSMC/__init__.py +2 -0
- SpecSMC/cli.py +262 -0
- SpecSMC/config/default_config.json +32 -0
- SpecSMC/gui.py +1949 -0
- SpecSMC/version.py +1 -0
- specsmc-1.0.0.dist-info/METADATA +231 -0
- specsmc-1.0.0.dist-info/RECORD +12 -0
- specsmc-1.0.0.dist-info/WHEEL +5 -0
- specsmc-1.0.0.dist-info/entry_points.txt +3 -0
- specsmc-1.0.0.dist-info/licenses/LICENSE +685 -0
- specsmc-1.0.0.dist-info/top_level.txt +1 -0
SpecSMC/SpecSMC.py
ADDED
|
@@ -0,0 +1,760 @@
|
|
|
1
|
+
'''
|
|
2
|
+
This is a package to control a stepper motor using G code. The stepper motor controller has been implemented with customized Marlin firmware.
|
|
3
|
+
|
|
4
|
+
Company: Bruker BioSpin
|
|
5
|
+
|
|
6
|
+
Author: Yen-Chun Huang
|
|
7
|
+
|
|
8
|
+
Date: 06/24/2026
|
|
9
|
+
'''
|
|
10
|
+
import serial
|
|
11
|
+
import serial.tools.list_ports
|
|
12
|
+
import time
|
|
13
|
+
import re
|
|
14
|
+
from typing import Dict, List, Optional, Union
|
|
15
|
+
|
|
16
|
+
MAX_ATTEMPTS = 50
|
|
17
|
+
DEFAULT_AXIS_ALIASES = {'X': 'Goniometer', 'Z': 'IRIS'}
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def _to_float(value, name):
|
|
21
|
+
"""
|
|
22
|
+
Convert a numeric input value to ``float``.
|
|
23
|
+
|
|
24
|
+
Args:
|
|
25
|
+
value: Value supplied by the caller.
|
|
26
|
+
name: Argument name used in the error message.
|
|
27
|
+
|
|
28
|
+
Returns:
|
|
29
|
+
Converted floating-point value.
|
|
30
|
+
|
|
31
|
+
Raises:
|
|
32
|
+
ValueError: If the value cannot be converted to ``float``.
|
|
33
|
+
"""
|
|
34
|
+
try:
|
|
35
|
+
return float(value)
|
|
36
|
+
except (TypeError, ValueError) as error:
|
|
37
|
+
raise ValueError(f'{name} must be a number') from error
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def _parse_homing_sensitivity_response(response, axes):
|
|
41
|
+
"""
|
|
42
|
+
Parse an ``M914`` homing sensitivity response.
|
|
43
|
+
|
|
44
|
+
Args:
|
|
45
|
+
response: Controller response text, for example
|
|
46
|
+
``"Y homing sensitivity: 8\\r\\nZ homing sensitivity: 25"``.
|
|
47
|
+
axes: Axis names expected by this controller.
|
|
48
|
+
|
|
49
|
+
Returns:
|
|
50
|
+
A dictionary containing all expected axes. Axes missing from the
|
|
51
|
+
response are assigned ``0``.
|
|
52
|
+
"""
|
|
53
|
+
sensitivities = {axis: 0 for axis in axes}
|
|
54
|
+
for line in response.splitlines():
|
|
55
|
+
match = re.search(r'^\s*([A-Za-z])\s+homing sensitivity:\s*([-+]?\d+(?:\.\d+)?)', line)
|
|
56
|
+
if match:
|
|
57
|
+
axis = match.group(1).upper()
|
|
58
|
+
if axis in sensitivities:
|
|
59
|
+
sensitivities[axis] = float(match.group(2))
|
|
60
|
+
return sensitivities
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
class SMC:
|
|
64
|
+
"""
|
|
65
|
+
Serial interface for a Stepper Motor Controller running Marlin G-code.
|
|
66
|
+
|
|
67
|
+
The class wraps common controller operations such as movement, homing,
|
|
68
|
+
status queries, and EEPROM settings. It opens a serial connection during
|
|
69
|
+
initialization and keeps cached copies of axis position, feedrate,
|
|
70
|
+
resolution, current, and movement mode.
|
|
71
|
+
"""
|
|
72
|
+
|
|
73
|
+
def __init__(
|
|
74
|
+
self,
|
|
75
|
+
port: str = None,
|
|
76
|
+
baud_rate=250000,
|
|
77
|
+
write_timeout=0,
|
|
78
|
+
timeout=1,
|
|
79
|
+
axis: Optional[List[str]] = None,
|
|
80
|
+
axis_types: Optional[List[str]] = None,
|
|
81
|
+
axis_aliases: Optional[Dict[str, str]] = None,
|
|
82
|
+
verbose=0,
|
|
83
|
+
):
|
|
84
|
+
"""
|
|
85
|
+
Connect to the controller and initialize cached axis settings.
|
|
86
|
+
|
|
87
|
+
Args:
|
|
88
|
+
port: Serial port name such as ``"COM3"``. If omitted, SpecSMC
|
|
89
|
+
attempts to auto-detect the controller.
|
|
90
|
+
baud_rate: Serial baud rate used for the connection.
|
|
91
|
+
write_timeout: Serial write timeout in seconds.
|
|
92
|
+
timeout: Serial read timeout in seconds.
|
|
93
|
+
axis: Axis names managed by the controller.
|
|
94
|
+
axis_types: Axis type for each axis, where ``"r"`` is rotational
|
|
95
|
+
and ``"l"`` is linear.
|
|
96
|
+
axis_aliases: Optional display names keyed by controller axis.
|
|
97
|
+
Aliases are accepted as input, but G-code still uses the
|
|
98
|
+
underlying controller axis letter.
|
|
99
|
+
verbose: Console output level. Values greater than or equal to 1
|
|
100
|
+
print connection and validation messages.
|
|
101
|
+
|
|
102
|
+
Raises:
|
|
103
|
+
ConnectionError: If no controller can be found or opened.
|
|
104
|
+
"""
|
|
105
|
+
self.ser = None
|
|
106
|
+
if axis is None:
|
|
107
|
+
axis = ['X', 'Y', 'Z', 'E']
|
|
108
|
+
if axis_types is None:
|
|
109
|
+
axis_types = ['r', 'l', 'l', 'l']
|
|
110
|
+
if len(axis) != len(axis_types):
|
|
111
|
+
raise ValueError('axis and axis_types must have the same length')
|
|
112
|
+
|
|
113
|
+
self.__autoConnectSMCSerialPort(port, baud_rate, write_timeout, timeout)
|
|
114
|
+
self.axis = axis
|
|
115
|
+
self.types = axis_types # r: rotational, l: linear
|
|
116
|
+
self.axis_aliases = {axis_name: DEFAULT_AXIS_ALIASES.get(axis_name, axis_name) for axis_name in self.axis}
|
|
117
|
+
if axis_aliases:
|
|
118
|
+
for axis_name, alias in axis_aliases.items():
|
|
119
|
+
self.set_axis_alias(axis_name, alias)
|
|
120
|
+
self._update_axis_alias_lookup()
|
|
121
|
+
self.positions = self.position()
|
|
122
|
+
self.feedrates = self.feedrate() # unit per second
|
|
123
|
+
self.homing_sensitivities = self.homing_sensitivity()
|
|
124
|
+
self.resolutions = self.steps_per_unit() # step per unit
|
|
125
|
+
self.currents = self.current() # mA
|
|
126
|
+
self.relative_mode = False # movement
|
|
127
|
+
self.verbose = verbose
|
|
128
|
+
if self.verbose >= 1:
|
|
129
|
+
print('Stepper motor controller is connected')
|
|
130
|
+
|
|
131
|
+
def _update_axis_alias_lookup(self):
|
|
132
|
+
"""
|
|
133
|
+
Rebuild the lookup table used to accept axis letters and aliases.
|
|
134
|
+
"""
|
|
135
|
+
self._axis_alias_lookup = {axis.upper(): axis for axis in self.axis}
|
|
136
|
+
for axis, alias in self.axis_aliases.items():
|
|
137
|
+
self._axis_alias_lookup[str(alias).upper()] = axis
|
|
138
|
+
|
|
139
|
+
def _resolve_axis(self, axis):
|
|
140
|
+
"""
|
|
141
|
+
Convert an axis alias or letter into the controller axis letter.
|
|
142
|
+
"""
|
|
143
|
+
if axis is None:
|
|
144
|
+
return None
|
|
145
|
+
return self._axis_alias_lookup.get(str(axis).upper(), axis)
|
|
146
|
+
|
|
147
|
+
def axis_label(self, axis):
|
|
148
|
+
"""
|
|
149
|
+
Return the display label for an axis.
|
|
150
|
+
|
|
151
|
+
Args:
|
|
152
|
+
axis: Controller axis letter or alias.
|
|
153
|
+
|
|
154
|
+
Returns:
|
|
155
|
+
Display alias for the axis, or the original value if unknown.
|
|
156
|
+
"""
|
|
157
|
+
resolved_axis = self._resolve_axis(axis)
|
|
158
|
+
return self.axis_aliases.get(resolved_axis, axis)
|
|
159
|
+
|
|
160
|
+
def set_axis_alias(self, axis, alias):
|
|
161
|
+
"""
|
|
162
|
+
Set a display-only alias for a controller axis.
|
|
163
|
+
|
|
164
|
+
Args:
|
|
165
|
+
axis: Controller axis letter, such as ``"Z"``.
|
|
166
|
+
alias: Display name to use for the axis.
|
|
167
|
+
|
|
168
|
+
Returns:
|
|
169
|
+
``True`` when the alias is updated, or ``False`` for an invalid
|
|
170
|
+
controller axis.
|
|
171
|
+
"""
|
|
172
|
+
axis = str(axis).upper()
|
|
173
|
+
if axis not in self.axis:
|
|
174
|
+
return False
|
|
175
|
+
self.axis_aliases[axis] = str(alias)
|
|
176
|
+
self._update_axis_alias_lookup()
|
|
177
|
+
return True
|
|
178
|
+
|
|
179
|
+
def help(self):
|
|
180
|
+
"""
|
|
181
|
+
Print a compact command reference for the interactive control panel.
|
|
182
|
+
|
|
183
|
+
The output uses the same command syntax accepted by the ``SpecSMC``
|
|
184
|
+
terminal command, for example ``move IRIS 0.8`` or ``relative true``.
|
|
185
|
+
"""
|
|
186
|
+
commands = [
|
|
187
|
+
('status', 'Show movement mode, position, feedrate, steps/unit, and current.'),
|
|
188
|
+
('info', 'Alias for status.'),
|
|
189
|
+
('set_axis_alias AXIS NAME', 'Set a display-only alias for an axis.'),
|
|
190
|
+
('move AXIS POSITION', 'Move a linear axis.'),
|
|
191
|
+
('theta AXIS DEGREE', 'Rotate an axis.'),
|
|
192
|
+
('feedrate [AXIS FEEDRATE]', 'Read or set feedrate.'),
|
|
193
|
+
('homing_sensitivity [AXIS VALUE]', 'Read or set homing sensitivity.'),
|
|
194
|
+
('current [AXIS CURRENT_MA]', 'Read or set motor current.'),
|
|
195
|
+
('steps_per_unit [AXIS STEPS]', 'Read or set steps per unit.'),
|
|
196
|
+
('position [AXIS POSITION]', 'Read or set position.'),
|
|
197
|
+
('home [AXIS]', 'Home all axes or one axis.'),
|
|
198
|
+
('set_home [AXIS]', 'Set current position as home.'),
|
|
199
|
+
('relative [true|false]', 'Read or set relative movement mode.'),
|
|
200
|
+
('send_command COMMAND [RECV]', 'Send raw G-code.'),
|
|
201
|
+
('save', 'Save settings to EEPROM.'),
|
|
202
|
+
('restore', 'Load settings from EEPROM.'),
|
|
203
|
+
('reset', 'Reset settings in memory.'),
|
|
204
|
+
('exit', 'Leave the control panel.'),
|
|
205
|
+
]
|
|
206
|
+
examples = [
|
|
207
|
+
'move IRIS 0.8',
|
|
208
|
+
'theta Goniometer 15',
|
|
209
|
+
'feedrate IRIS 5',
|
|
210
|
+
'set_axis_alias Z Probe',
|
|
211
|
+
'send_command M114 true',
|
|
212
|
+
]
|
|
213
|
+
|
|
214
|
+
print('SpecSMC control panel')
|
|
215
|
+
print('')
|
|
216
|
+
print('Usage:')
|
|
217
|
+
print(' command [arguments]')
|
|
218
|
+
print(' Axis aliases are display names only; controller commands still use X/Y/Z/E.')
|
|
219
|
+
print('')
|
|
220
|
+
print('Commands:')
|
|
221
|
+
for command, description in commands:
|
|
222
|
+
print(f' {command:<31} {description}')
|
|
223
|
+
print('')
|
|
224
|
+
print('Examples:')
|
|
225
|
+
for example in examples:
|
|
226
|
+
print(f' {example}')
|
|
227
|
+
|
|
228
|
+
def __repr__(self):
|
|
229
|
+
"""
|
|
230
|
+
Return a human-readable summary of the current cached controller state.
|
|
231
|
+
|
|
232
|
+
Returns:
|
|
233
|
+
A multi-line string containing movement mode, position, feedrate,
|
|
234
|
+
steps/unit, and current for each configured axis.
|
|
235
|
+
"""
|
|
236
|
+
s = ''
|
|
237
|
+
s += 'Movement Mode: Relative\n' if self.relative_mode else 'Movement Mode: Absolute\n'
|
|
238
|
+
# print('Movement Mode: Relative') if self.relative_mode else print('Movement Mode: Absolute')
|
|
239
|
+
for axis in self.axis:
|
|
240
|
+
label = self.axis_label(axis)
|
|
241
|
+
s += '%s current position: %s\n' %(label, self.positions[axis])
|
|
242
|
+
s += '%s feedrate: %s\n' %(label, self.feedrates[axis])
|
|
243
|
+
s += '%s homing sensitivity: %s\n' %(label, self.homing_sensitivities[axis])
|
|
244
|
+
s += '%s steps/unit: %s\n' %(label, self.resolutions[axis])
|
|
245
|
+
s += '%s current: %s mA\n' %(label, self.currents[axis])
|
|
246
|
+
|
|
247
|
+
return s
|
|
248
|
+
|
|
249
|
+
def status(self):
|
|
250
|
+
"""
|
|
251
|
+
Return the current cached controller status.
|
|
252
|
+
|
|
253
|
+
Returns:
|
|
254
|
+
The same multi-line string produced by ``repr(smc)``.
|
|
255
|
+
"""
|
|
256
|
+
return str(self)
|
|
257
|
+
|
|
258
|
+
def info(self):
|
|
259
|
+
"""
|
|
260
|
+
Return the current cached controller status.
|
|
261
|
+
|
|
262
|
+
This is an alias for :meth:`status`.
|
|
263
|
+
|
|
264
|
+
Returns:
|
|
265
|
+
The same multi-line string produced by ``status()``.
|
|
266
|
+
"""
|
|
267
|
+
return self.status()
|
|
268
|
+
|
|
269
|
+
def move(self, axis: str, position: Union[float, int, str]):
|
|
270
|
+
'''
|
|
271
|
+
Move a linear axis to a target position.
|
|
272
|
+
|
|
273
|
+
Args:
|
|
274
|
+
axis: Axis name to move.
|
|
275
|
+
position: Target position. In absolute mode this is the controller
|
|
276
|
+
coordinate to move to; in relative mode this is the distance to
|
|
277
|
+
move from the current position.
|
|
278
|
+
|
|
279
|
+
Returns:
|
|
280
|
+
``True`` when the command is accepted, or ``False`` when the axis
|
|
281
|
+
is invalid or not configured as linear.
|
|
282
|
+
|
|
283
|
+
'''
|
|
284
|
+
try:
|
|
285
|
+
position = _to_float(position, 'position')
|
|
286
|
+
except ValueError as error:
|
|
287
|
+
if self.verbose >= 1:
|
|
288
|
+
print(error)
|
|
289
|
+
return False
|
|
290
|
+
|
|
291
|
+
axis = self._resolve_axis(axis)
|
|
292
|
+
self.relative(self.relative_mode)
|
|
293
|
+
|
|
294
|
+
if axis not in self.axis:
|
|
295
|
+
if self.verbose >= 1:
|
|
296
|
+
print('Please provide correct axis.')
|
|
297
|
+
return False
|
|
298
|
+
|
|
299
|
+
if self.types[self.axis.index(axis)] != 'l':
|
|
300
|
+
if self.verbose >= 1:
|
|
301
|
+
print('Axis type does not match.')
|
|
302
|
+
return False
|
|
303
|
+
|
|
304
|
+
self.send_command('G0 %s%s'%(axis, position))
|
|
305
|
+
feedrate = self.feedrates[axis]
|
|
306
|
+
difference = abs(position) if self.relative_mode else abs(self.positions[axis] - position)
|
|
307
|
+
time.sleep(difference/feedrate + 0.2)
|
|
308
|
+
self.positions = self.position()
|
|
309
|
+
return True
|
|
310
|
+
|
|
311
|
+
def theta(self, axis: str, theta: Union[float, int, str]):
|
|
312
|
+
'''
|
|
313
|
+
Rotate a rotational axis to a target angle.
|
|
314
|
+
|
|
315
|
+
Args:
|
|
316
|
+
axis: Axis name to rotate.
|
|
317
|
+
theta: Target angle in degrees. In absolute mode, values must be
|
|
318
|
+
between -360 and 360. In relative mode, this is the angle to
|
|
319
|
+
move from the current position.
|
|
320
|
+
|
|
321
|
+
Returns:
|
|
322
|
+
``True`` when the command is accepted, or ``False`` when the axis
|
|
323
|
+
is invalid, not rotational, or outside the allowed range.
|
|
324
|
+
|
|
325
|
+
'''
|
|
326
|
+
try:
|
|
327
|
+
theta = _to_float(theta, 'theta')
|
|
328
|
+
except ValueError as error:
|
|
329
|
+
if self.verbose >= 1:
|
|
330
|
+
print(error)
|
|
331
|
+
return False
|
|
332
|
+
|
|
333
|
+
axis = self._resolve_axis(axis)
|
|
334
|
+
self.relative(self.relative_mode)
|
|
335
|
+
|
|
336
|
+
if axis not in self.axis:
|
|
337
|
+
if self.verbose >= 1:
|
|
338
|
+
print('Please provide correct axis.')
|
|
339
|
+
return False
|
|
340
|
+
|
|
341
|
+
if self.types[self.axis.index(axis)] != 'r':
|
|
342
|
+
if self.verbose >= 1:
|
|
343
|
+
print('Axis type does not match.')
|
|
344
|
+
return False
|
|
345
|
+
|
|
346
|
+
if not self.relative_mode and (theta < -360 or theta > 360):
|
|
347
|
+
if self.verbose >= 1:
|
|
348
|
+
print('position is out of range')
|
|
349
|
+
return False
|
|
350
|
+
|
|
351
|
+
self.send_command('G0 %s%s'%(axis, theta))
|
|
352
|
+
feedrate = self.feedrates[axis]
|
|
353
|
+
difference = abs(theta) if self.relative_mode else abs(self.positions[axis] - theta)
|
|
354
|
+
time.sleep(difference/feedrate + 0.2)
|
|
355
|
+
if self.relative_mode:
|
|
356
|
+
self.positions[axis] += theta
|
|
357
|
+
self.position(axis, self.positions[axis])
|
|
358
|
+
self.positions = self.position()
|
|
359
|
+
|
|
360
|
+
return True
|
|
361
|
+
|
|
362
|
+
def feedrate(self, axis: Optional[str] = None, feedrate: Optional[Union[float, int]] = None):
|
|
363
|
+
'''
|
|
364
|
+
Read all feedrates or set the feedrate for one axis.
|
|
365
|
+
|
|
366
|
+
Args:
|
|
367
|
+
axis: Axis name to update. Required when ``feedrate`` is provided.
|
|
368
|
+
feedrate: Maximum feedrate to set in controller units per second.
|
|
369
|
+
If omitted, all feedrates are queried from the controller.
|
|
370
|
+
|
|
371
|
+
Returns:
|
|
372
|
+
A dictionary of feedrates when reading values, ``False`` for an
|
|
373
|
+
invalid axis or invalid X-axis feedrate, otherwise ``None`` after a
|
|
374
|
+
successful write.
|
|
375
|
+
|
|
376
|
+
'''
|
|
377
|
+
if feedrate is not None:
|
|
378
|
+
axis = self._resolve_axis(axis)
|
|
379
|
+
if not axis or axis not in self.axis:
|
|
380
|
+
if self.verbose >= 1:
|
|
381
|
+
print('Please provide correct axis.')
|
|
382
|
+
return False
|
|
383
|
+
|
|
384
|
+
if axis == 'X' and (feedrate >= 15 or feedrate <= 0):
|
|
385
|
+
if self.verbose >= 1:
|
|
386
|
+
print('X axis feedrate cannot exceed 15 or lower than 0')
|
|
387
|
+
return False
|
|
388
|
+
self.send_command('M203 %s%s'%(axis, feedrate))
|
|
389
|
+
self.feedrates[axis] = feedrate
|
|
390
|
+
|
|
391
|
+
else:
|
|
392
|
+
feedrates = self.send_command('M203', True).strip().split(' ')[1:] # remove M203 as the first return element
|
|
393
|
+
feedrate_detail = {axis: float(feedrate.replace(axis, '')) for axis, feedrate in zip(self.axis, feedrates)}
|
|
394
|
+
return feedrate_detail
|
|
395
|
+
|
|
396
|
+
def homing_sensitivity(self, axis: Optional[str] = None, sensitivity: Optional[Union[float, int]] = None):
|
|
397
|
+
'''
|
|
398
|
+
Read all homing sensitivities or set the value for one axis.
|
|
399
|
+
|
|
400
|
+
Args:
|
|
401
|
+
axis: Axis name to update. Required when ``sensitivity`` is provided.
|
|
402
|
+
sensitivity: Homing sensitivity value to set. If omitted, all
|
|
403
|
+
homing sensitivities are queried from the controller.
|
|
404
|
+
|
|
405
|
+
Returns:
|
|
406
|
+
A dictionary of homing sensitivities when reading values, ``False``
|
|
407
|
+
for an invalid axis, otherwise ``None`` after a successful write.
|
|
408
|
+
|
|
409
|
+
'''
|
|
410
|
+
if sensitivity is not None:
|
|
411
|
+
axis = self._resolve_axis(axis)
|
|
412
|
+
if not axis or axis not in self.axis:
|
|
413
|
+
if self.verbose >= 1:
|
|
414
|
+
print('Please provide correct axis.')
|
|
415
|
+
return False
|
|
416
|
+
self.send_command('M914 %s%s'%(axis, sensitivity))
|
|
417
|
+
self.homing_sensitivities[axis] = sensitivity
|
|
418
|
+
|
|
419
|
+
else:
|
|
420
|
+
return _parse_homing_sensitivity_response(self.send_command('M914', True), self.axis)
|
|
421
|
+
|
|
422
|
+
|
|
423
|
+
def position(self, axis: Optional[str] = None, position: Optional[Union[float, int]] = None):
|
|
424
|
+
'''
|
|
425
|
+
Read all axis positions or set the current position of one axis.
|
|
426
|
+
|
|
427
|
+
Args:
|
|
428
|
+
axis: Axis name to update. Required when ``position`` is provided.
|
|
429
|
+
position: Coordinate to assign to the current axis position. If
|
|
430
|
+
omitted, positions are queried from the controller.
|
|
431
|
+
|
|
432
|
+
Returns:
|
|
433
|
+
A dictionary of current positions when reading values, ``False``
|
|
434
|
+
for an invalid axis, otherwise ``None`` after a successful write.
|
|
435
|
+
'''
|
|
436
|
+
if position is not None:
|
|
437
|
+
axis = self._resolve_axis(axis)
|
|
438
|
+
if not axis or axis not in self.axis:
|
|
439
|
+
if self.verbose >= 1:
|
|
440
|
+
print('Please provide correct axis.')
|
|
441
|
+
return False
|
|
442
|
+
self.send_command('G92 %s%s'%(axis, position))
|
|
443
|
+
time.sleep(0.2)
|
|
444
|
+
self.positions[axis] = position
|
|
445
|
+
|
|
446
|
+
else:
|
|
447
|
+
positions = self.send_command('M114', True).split(' ')
|
|
448
|
+
position_detail = {}
|
|
449
|
+
for position in positions:
|
|
450
|
+
axis = position.split(':')[0]
|
|
451
|
+
if axis in self.axis:
|
|
452
|
+
val = position.split(':')[1]
|
|
453
|
+
position_detail[axis] = float(val)
|
|
454
|
+
if axis == 'Count':
|
|
455
|
+
break
|
|
456
|
+
time.sleep(0.2)
|
|
457
|
+
return position_detail
|
|
458
|
+
|
|
459
|
+
def home(self, axis: Optional[str] = None):
|
|
460
|
+
'''
|
|
461
|
+
Home one or all linear axes with the controller ``G28`` command.
|
|
462
|
+
|
|
463
|
+
Args:
|
|
464
|
+
axis: Axis name to home. If omitted, all configured axes are homed.
|
|
465
|
+
|
|
466
|
+
Returns:
|
|
467
|
+
``True`` when the homing command is sent, or ``False`` if an axis
|
|
468
|
+
is invalid or rotational.
|
|
469
|
+
|
|
470
|
+
'''
|
|
471
|
+
if axis is None:
|
|
472
|
+
linear_axes = [axis for axis, axis_type in zip(self.axis, self.types) if axis_type == 'l']
|
|
473
|
+
if not linear_axes:
|
|
474
|
+
if self.verbose >= 1:
|
|
475
|
+
print('No linear axis is available to home.')
|
|
476
|
+
return False
|
|
477
|
+
self.send_command('G28 %s'%(' '.join(linear_axes)))
|
|
478
|
+
time.sleep(10)
|
|
479
|
+
self.positions = self.position()
|
|
480
|
+
return True
|
|
481
|
+
|
|
482
|
+
axis = self._resolve_axis(axis)
|
|
483
|
+
if axis not in self.axis:
|
|
484
|
+
if self.verbose >= 1:
|
|
485
|
+
print('Please provide correct axis.')
|
|
486
|
+
return False
|
|
487
|
+
|
|
488
|
+
if self.types[self.axis.index(axis)] != 'l':
|
|
489
|
+
if self.verbose >= 1:
|
|
490
|
+
print('Only linear axes can be homed.')
|
|
491
|
+
return False
|
|
492
|
+
|
|
493
|
+
self.send_command('G28 %s'%(axis))
|
|
494
|
+
time.sleep(10)
|
|
495
|
+
self.positions = self.position()
|
|
496
|
+
return True
|
|
497
|
+
|
|
498
|
+
def set_home(self, axis: Optional[str] = None):
|
|
499
|
+
'''
|
|
500
|
+
Set the current position as home for one or all axes.
|
|
501
|
+
|
|
502
|
+
Args:
|
|
503
|
+
axis: Axis name to update. If omitted, all configured axes are set
|
|
504
|
+
to position zero.
|
|
505
|
+
|
|
506
|
+
Returns:
|
|
507
|
+
``True`` after the position reset command is sent, or ``False`` if
|
|
508
|
+
an invalid axis is supplied.
|
|
509
|
+
|
|
510
|
+
'''
|
|
511
|
+
if not axis:
|
|
512
|
+
for axis in self.axis:
|
|
513
|
+
if self.position(axis, 0) is False:
|
|
514
|
+
return False
|
|
515
|
+
return True
|
|
516
|
+
else:
|
|
517
|
+
axis = self._resolve_axis(axis)
|
|
518
|
+
return self.position(axis, 0) is not False
|
|
519
|
+
|
|
520
|
+
def current(self, axis: Optional[str] = None, current: Optional[Union[float, int]] = None):
|
|
521
|
+
'''
|
|
522
|
+
Read all motor currents or set the current for one axis.
|
|
523
|
+
|
|
524
|
+
Args:
|
|
525
|
+
axis: Axis name to update. Required when ``current`` is provided.
|
|
526
|
+
current: Motor current in milliamps. If omitted, all motor currents
|
|
527
|
+
are queried from the controller.
|
|
528
|
+
|
|
529
|
+
Returns:
|
|
530
|
+
A dictionary of motor currents after reading or writing values, or
|
|
531
|
+
``False`` for an invalid axis.
|
|
532
|
+
|
|
533
|
+
'''
|
|
534
|
+
if current is not None:
|
|
535
|
+
axis = self._resolve_axis(axis)
|
|
536
|
+
if not axis or axis not in self.axis:
|
|
537
|
+
if self.verbose >= 1:
|
|
538
|
+
print('Please provide correct axis.')
|
|
539
|
+
return False
|
|
540
|
+
|
|
541
|
+
self.send_command('M906 %s%s'%(axis, current))
|
|
542
|
+
self.currents[axis] = current
|
|
543
|
+
|
|
544
|
+
currents = self.send_command('M906', True).replace(' driver current: ', '').split('\n')
|
|
545
|
+
currents = {axis: float(current.replace(axis, '')) for axis, current in zip(self.axis, currents)}
|
|
546
|
+
return currents
|
|
547
|
+
|
|
548
|
+
def steps_per_unit(self, axis: Optional[str] = None, step: Optional[Union[float, int]] = None):
|
|
549
|
+
'''
|
|
550
|
+
Read all resolutions or set steps per unit for one axis.
|
|
551
|
+
|
|
552
|
+
This setting controls how many stepper motor steps are used for each
|
|
553
|
+
unit of motion. The value should account for the controller firmware's
|
|
554
|
+
microstep setting.
|
|
555
|
+
|
|
556
|
+
Args:
|
|
557
|
+
axis: Axis name to update. Required when ``step`` is provided.
|
|
558
|
+
step: Steps per unit to write. If omitted, all resolutions are
|
|
559
|
+
queried from the controller.
|
|
560
|
+
|
|
561
|
+
Returns:
|
|
562
|
+
A dictionary of steps/unit values when reading values, ``False``
|
|
563
|
+
for an invalid axis, otherwise ``None`` after a successful write.
|
|
564
|
+
|
|
565
|
+
'''
|
|
566
|
+
if step is not None:
|
|
567
|
+
axis = self._resolve_axis(axis)
|
|
568
|
+
if not axis or axis not in self.axis:
|
|
569
|
+
if self.verbose >= 1:
|
|
570
|
+
print('Please provide correct axis.')
|
|
571
|
+
return False
|
|
572
|
+
|
|
573
|
+
self.send_command('M92 %s%s'%(axis, step))
|
|
574
|
+
self.resolutions[axis] = step
|
|
575
|
+
|
|
576
|
+
else:
|
|
577
|
+
steps = self.send_command('M92', True).strip().split(' ')[1:] # remove M203 as the first return element
|
|
578
|
+
resolutions = {axis: float(step.replace(axis, '')) for axis, step in zip(self.axis, steps)}
|
|
579
|
+
return resolutions
|
|
580
|
+
|
|
581
|
+
def save(self):
|
|
582
|
+
'''
|
|
583
|
+
Save current configurable settings to controller EEPROM.
|
|
584
|
+
|
|
585
|
+
Returns:
|
|
586
|
+
``True`` after the save command is sent.
|
|
587
|
+
'''
|
|
588
|
+
self.send_command('M500')
|
|
589
|
+
return True
|
|
590
|
+
|
|
591
|
+
def restore(self):
|
|
592
|
+
'''
|
|
593
|
+
Reload saved configurable settings from controller EEPROM.
|
|
594
|
+
|
|
595
|
+
Returns:
|
|
596
|
+
``True`` after the restore command is sent.
|
|
597
|
+
'''
|
|
598
|
+
self.send_command('M501')
|
|
599
|
+
return True
|
|
600
|
+
|
|
601
|
+
def reset(self):
|
|
602
|
+
'''
|
|
603
|
+
Reset configurable settings in memory to firmware defaults.
|
|
604
|
+
|
|
605
|
+
This does not write to EEPROM. Use :meth:`save` after resetting if the
|
|
606
|
+
defaults should persist after power cycling.
|
|
607
|
+
|
|
608
|
+
Returns:
|
|
609
|
+
``True`` after the reset command is sent.
|
|
610
|
+
'''
|
|
611
|
+
self.send_command('M502')
|
|
612
|
+
return True
|
|
613
|
+
|
|
614
|
+
def relative(self, enable: Optional[bool] = None):
|
|
615
|
+
'''
|
|
616
|
+
Read or set the controller movement mode.
|
|
617
|
+
|
|
618
|
+
Args:
|
|
619
|
+
enable: ``True`` enables relative movement, ``False`` enables
|
|
620
|
+
absolute movement, and ``None`` returns the cached mode without
|
|
621
|
+
sending a command.
|
|
622
|
+
|
|
623
|
+
Returns:
|
|
624
|
+
``True`` when relative mode is active, or ``False`` when absolute
|
|
625
|
+
mode is active.
|
|
626
|
+
|
|
627
|
+
'''
|
|
628
|
+
if enable is None:
|
|
629
|
+
return self.relative_mode
|
|
630
|
+
|
|
631
|
+
if enable:
|
|
632
|
+
self.send_command("G91")
|
|
633
|
+
self.relative_mode = True
|
|
634
|
+
return self.relative_mode
|
|
635
|
+
else:
|
|
636
|
+
self.send_command("G90")
|
|
637
|
+
self.relative_mode = False
|
|
638
|
+
return self.relative_mode
|
|
639
|
+
|
|
640
|
+
def send_command(self, command: str, recv: bool = False):
|
|
641
|
+
"""
|
|
642
|
+
Send a raw command string to the controller.
|
|
643
|
+
|
|
644
|
+
Args:
|
|
645
|
+
command: G-code or controller command to send.
|
|
646
|
+
recv: If ``True``, read and return controller response lines until
|
|
647
|
+
an ``ok`` response is received or the retry limit is reached.
|
|
648
|
+
|
|
649
|
+
Returns:
|
|
650
|
+
The controller response string when ``recv`` is ``True``. Returns
|
|
651
|
+
``None`` when no response is requested.
|
|
652
|
+
|
|
653
|
+
Raises:
|
|
654
|
+
RuntimeError: If the retry limit is reached while waiting for a
|
|
655
|
+
controller response.
|
|
656
|
+
|
|
657
|
+
"""
|
|
658
|
+
self.ser.reset_input_buffer() # reset and flush buffer
|
|
659
|
+
send_string = "%s\n" % command
|
|
660
|
+
send_bytes = send_string.encode("utf-8")
|
|
661
|
+
self.ser.write(send_bytes)
|
|
662
|
+
time.sleep(0.1)
|
|
663
|
+
if recv == True:
|
|
664
|
+
attempts = 0
|
|
665
|
+
from_mps_string = ''
|
|
666
|
+
while attempts < MAX_ATTEMPTS:
|
|
667
|
+
from_mps_bytes = self.ser.readline()
|
|
668
|
+
try:
|
|
669
|
+
if attempts == 0:
|
|
670
|
+
from_mps_string += from_mps_bytes.decode("utf-8").rstrip()
|
|
671
|
+
else:
|
|
672
|
+
if 'ok' in from_mps_bytes.decode("utf-8").rstrip():
|
|
673
|
+
return from_mps_string
|
|
674
|
+
from_mps_string += '\n' + from_mps_bytes.decode("utf-8").rstrip()
|
|
675
|
+
except:
|
|
676
|
+
print('Warning: the decode is not working appropriately.')
|
|
677
|
+
attempts += 1
|
|
678
|
+
raise RuntimeError('Maximum attempts reached')
|
|
679
|
+
|
|
680
|
+
def specman_connect(self, initval: float):
|
|
681
|
+
"""
|
|
682
|
+
Return a simple acknowledgement for SpecMan integration checks.
|
|
683
|
+
|
|
684
|
+
Args:
|
|
685
|
+
initval: Initial value supplied by the external SpecMan caller.
|
|
686
|
+
|
|
687
|
+
Returns:
|
|
688
|
+
A tuple containing an acknowledgement message and ``True``.
|
|
689
|
+
"""
|
|
690
|
+
return 'Acknowledged specman connection',True
|
|
691
|
+
|
|
692
|
+
def __autoConnectSMCSerialPort(self, port, baud_rate, write_timeout, timeout):
|
|
693
|
+
"""
|
|
694
|
+
Open the serial connection to the controller.
|
|
695
|
+
|
|
696
|
+
Args:
|
|
697
|
+
port: Serial port name or port object. If ``None``, connected ports
|
|
698
|
+
are scanned for the expected controller USB identifiers.
|
|
699
|
+
baud_rate: Serial baud rate.
|
|
700
|
+
write_timeout: Serial write timeout in seconds.
|
|
701
|
+
timeout: Serial read timeout in seconds.
|
|
702
|
+
|
|
703
|
+
Returns:
|
|
704
|
+
``True`` when the serial port opens successfully.
|
|
705
|
+
|
|
706
|
+
Raises:
|
|
707
|
+
ConnectionError: If no controller port is found or the serial port
|
|
708
|
+
cannot be opened.
|
|
709
|
+
"""
|
|
710
|
+
|
|
711
|
+
if port is None: # auto detection
|
|
712
|
+
ports = list(serial.tools.list_ports.comports())
|
|
713
|
+
for p in ports:
|
|
714
|
+
if p.vid == 1155 or p.pid == 22336:
|
|
715
|
+
port = p.device
|
|
716
|
+
|
|
717
|
+
if port is None:
|
|
718
|
+
raise ConnectionError('Please connect stepper motor controller or specify the port')
|
|
719
|
+
|
|
720
|
+
try:
|
|
721
|
+
self.ser = serial.Serial(port, baud_rate, write_timeout = write_timeout, timeout = timeout)
|
|
722
|
+
self.ser.reset_input_buffer()
|
|
723
|
+
return True
|
|
724
|
+
except Exception as error:
|
|
725
|
+
port_name = getattr(port, 'device', port)
|
|
726
|
+
raise ConnectionError('No stepper motor controller is found on port %s' %port_name) from error
|
|
727
|
+
|
|
728
|
+
|
|
729
|
+
|
|
730
|
+
def __del__(self):
|
|
731
|
+
"""
|
|
732
|
+
Close the serial port when the object is garbage-collected.
|
|
733
|
+
"""
|
|
734
|
+
if getattr(self, 'ser', None) and self.ser.is_open:
|
|
735
|
+
self.ser.close()
|
|
736
|
+
|
|
737
|
+
if __name__ == "__main__":
|
|
738
|
+
smc = SMC()
|
|
739
|
+
print('===========================')
|
|
740
|
+
smc.help()
|
|
741
|
+
print('===========================')
|
|
742
|
+
print('Current information:')
|
|
743
|
+
smc.info()
|
|
744
|
+
print('===========================')
|
|
745
|
+
print('move X to 30 degree')
|
|
746
|
+
smc.theta('X', 30)
|
|
747
|
+
print('get current position')
|
|
748
|
+
print(smc.position())
|
|
749
|
+
print('move X to -60 degree')
|
|
750
|
+
smc.theta('X', -60)
|
|
751
|
+
print('get current position')
|
|
752
|
+
print(smc.position())
|
|
753
|
+
print('home X axis')
|
|
754
|
+
smc.home('X')
|
|
755
|
+
print('get current position')
|
|
756
|
+
print(smc.position())
|
|
757
|
+
print('Done')
|
|
758
|
+
|
|
759
|
+
|
|
760
|
+
|