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 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
+