moons-motor 0.1.5__tar.gz → 1.0.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: moons_motor
3
- Version: 0.1.5
3
+ Version: 1.0.0
4
4
  Summary: This is a python library for controlling the Moons' motor through the serial port.
5
5
  Author-email: miroc <mike8503111@gmail.com>
6
6
  Project-URL: Repository, https://github.com/miroc99/moons_motor.git
@@ -16,6 +16,7 @@ Requires-Dist: pyserial
16
16
  Requires-Dist: rich
17
17
  Requires-Dist: python-socketio
18
18
  Requires-Dist: requests
19
+ Requires-Dist: pyserial_asyncio
19
20
  Dynamic: license-file
20
21
 
21
22
  # Moons Motor
@@ -1,520 +1,550 @@
1
- import serial
2
- import serial.rs485
3
- from serial.tools import list_ports
4
- import re
5
- import threading
6
- from rich import print
7
- from rich.console import Console
8
- from rich.panel import Panel
9
- import queue
10
- from moons_motor.subject import Subject
11
- import time
12
- import threading
13
-
14
- from dataclasses import dataclass
15
-
16
- import logging
17
-
18
-
19
- class ColoredFormatter(logging.Formatter):
20
- # Define ANSI escape codes for colors and reset
21
- grey = "\x1b[38;21m"
22
- yellow = "\x1b[33;21m"
23
- red = "\x1b[31;21m"
24
- bold_red = "\x1b[31;1m"
25
- bold_yellow = "\x1b[33;1m"
26
- bold_green = "\x1b[32;1m"
27
- bold_blue = "\x1b[34;1m"
28
- bold_cyan = "\x1b[36;1m"
29
- bold_magenta = "\x1b[35;1m"
30
- reset = "\x1b[0m"
31
-
32
- # Define the base format string
33
- format_time = "%(asctime)s"
34
- format_header = " [%(levelname)s]"
35
- format_base = " %(message)s"
36
-
37
- # Map log levels to colored format strings
38
- FORMATS = {
39
- logging.DEBUG: format_time
40
- + bold_cyan
41
- + format_header
42
- + reset
43
- + format_base
44
- + reset,
45
- logging.INFO: format_time
46
- + bold_green
47
- + format_header
48
- + reset
49
- + format_base
50
- + reset,
51
- logging.WARNING: format_time
52
- + bold_yellow
53
- + format_header
54
- + reset
55
- + format_base
56
- + reset,
57
- logging.ERROR: format_time
58
- + bold_red
59
- + format_header
60
- + reset
61
- + format_base
62
- + reset,
63
- logging.CRITICAL: format_time
64
- + bold_magenta
65
- + format_header
66
- + reset
67
- + format_base
68
- + reset,
69
- }
70
-
71
- def format(self, record):
72
- log_fmt = self.FORMATS.get(record.levelno)
73
- formatter = logging.Formatter(log_fmt)
74
- return formatter.format(record)
75
-
76
-
77
- class StepperModules:
78
- STM17S_3RN = "STM17S-3RN"
79
-
80
-
81
- @dataclass(frozen=True)
82
- class StepperCommand:
83
- JOG: str = "CJ" # Start jogging
84
- JOG_SPEED: str = "JS" # Jogging speed (Need to set before start jogging)
85
- JOG_ACCELERATION: str = (
86
- "JA" # Jogging acceleration (Need to set before start jogging)
87
- )
88
- CHANGE_JOG_SPEED: str = "CS" # Change jogging speed while jogging
89
- STOP_JOG: str = "SJ" # Stop jogging with deceleration
90
- STOP: str = "ST" # Stop immediately (No deceleration)
91
- STOP_DECEL: str = "STD" # Stop with deceleration
92
- STOP_KILL: str = (
93
- "SK" # Stop with deceleration(Control by AM) and kill all unexecuted commands
94
- )
95
- STOP_KILL_DECEL: str = (
96
- "SKD" # Stop and kill all unexecuted commands with deceleration(Control by DE)
97
- )
98
- ENABLE: str = "ME" # Enable motor
99
- DISABLE: str = "MD" # Disable motor
100
- MOVE_ABSOLUTE: str = "FP" # Move to absolute position
101
- MOVE_FIXED_DISTANCE: str = "FL" # Move to fixed distance
102
- POSITION: str = "IP" # Motor absolute position(Calculated trajectory position)
103
- TEMPERATURE: str = "IT" # Motor temperature
104
- VOLTAGE: str = "IU" # Motor voltage
105
-
106
- ENCODER_POSITION: str = "EP" # Encoder position
107
- SET_POSITION: str = "SP" # Set encoder position
108
-
109
- HOME: str = "SH" # Home position
110
- VELOCITY: str = "VE" # Set velocity
111
-
112
- ALARM_RESET: str = "AR" # Reset alarm
113
-
114
- SET_RETURN_FORMAT_DECIMAL: str = "IFD" # Set return format to decimal
115
- SET_RETURN_FORMAT_HEXADECIMAL: str = "IFH" # Set return format to hexadecimal
116
-
117
- SET_TRANSMIT_DELAY: str = "TD" # Set transmit delay
118
- REQUEST_STATUS: str = "RS" # Request status
119
-
120
-
121
- class MoonsStepper(Subject):
122
- motorAdress = [
123
- "0",
124
- "1",
125
- "2",
126
- "3",
127
- "4",
128
- "5",
129
- "6",
130
- "7",
131
- "8",
132
- "9",
133
- "!",
134
- '"',
135
- "#",
136
- "$",
137
- "%",
138
- "&",
139
- "'",
140
- "(",
141
- ")",
142
- "*",
143
- "+",
144
- ",",
145
- "-",
146
- ".",
147
- "/",
148
- ":",
149
- ";",
150
- "<",
151
- "=",
152
- ">",
153
- "?",
154
- "@",
155
- ]
156
- # Configure logging
157
- logger = logging.getLogger(__name__)
158
- logger.setLevel(logging.INFO)
159
-
160
- ch = logging.StreamHandler()
161
- ch.setFormatter(ColoredFormatter())
162
- logger.addHandler(ch)
163
-
164
- def __init__(
165
- self,
166
- model: StepperModules,
167
- VID,
168
- PID,
169
- SERIAL_NUM,
170
- only_simlate=False,
171
- universe=0,
172
- ):
173
- super().__init__()
174
- self.universe = universe
175
- self.model = model # Motor model
176
- self.only_simulate = only_simlate
177
- self.device = "" # COM port description
178
- self.VID = VID
179
- self.PID = PID
180
- self.SERIAL_NUM = SERIAL_NUM # ID for determent the deivice had same VID and PID, can be config using chips manufacturer tool
181
- self.ser = None
182
- self.Opened = False
183
- self.recvQueue = queue.Queue()
184
- self.sendQueue = queue.Queue()
185
- self.pending_callbacks = queue.Queue()
186
- self.update_thread = None
187
- self.is_updating = False
188
- self.readBuffer = ""
189
-
190
- self.console = Console()
191
-
192
- self.is_log_message = True
193
-
194
- self.microstep = {
195
- 0: 200,
196
- 1: 400,
197
- 3: 2000,
198
- 4: 5000,
199
- 5: 10000,
200
- 6: 12800,
201
- 7: 18000,
202
- 8: 20000,
203
- 9: 21600,
204
- 10: 25000,
205
- 11: 25400,
206
- 12: 25600,
207
- 13: 36000,
208
- 14: 50000,
209
- 15: 50800,
210
- }
211
-
212
- # region connection & main functions
213
- @staticmethod
214
- def list_all_ports():
215
- ports = list(list_ports.comports())
216
- simple_ports = []
217
- port_info = ""
218
- for p in ports:
219
- port_info += f"■ {p.device} {p.description} [blue]{p.usb_info()}[/blue]"
220
- if p != ports[-1]:
221
- port_info += "\n"
222
- simple_ports.append(p.description)
223
- print(Panel(port_info, title="All COMPorts"))
224
- return simple_ports
225
-
226
- @staticmethod
227
- def process_response(response):
228
- equal_sign_index = response.index("=")
229
- address = response[0]
230
- command = response[1:equal_sign_index]
231
- value = response[equal_sign_index + 1 :]
232
-
233
- if command == "IT" or command == "IU":
234
- # Handle temperature response
235
- value = int(value) / 10.0
236
- return {
237
- "address": address,
238
- "command": command,
239
- "value": value,
240
- }
241
-
242
- def __start_update_thread(self):
243
- self.update_thread = threading.Thread(target=self.update, daemon=True)
244
- self.is_updating = True
245
- self.update_thread.start()
246
-
247
- def connect(self, COM=None, baudrate=9600, callback=None):
248
- # Simulate mode
249
- if self.only_simulate:
250
- self.Opened = True
251
- self.device = f"Simulate-{self.universe}"
252
- MoonsStepper.logger.info(f"{self.device} connected")
253
- if callback:
254
- callback(self.device, self.Opened)
255
- return
256
-
257
- def attempt_connect(COM, baudrate):
258
- try:
259
- # self.ser = serial.Serial(
260
- # port=COM,
261
- # baudrate=baudrate,
262
- # bytesize=serial.EIGHTBITS,
263
- # parity=serial.PARITY_NONE,
264
- # stopbits=serial.STOPBITS_ONE,
265
- # timeout=0.5,
266
- # )
267
- self.ser = serial.rs485.RS485(port=COM, baudrate=baudrate)
268
- self.ser.rs485_mode = serial.rs485.RS485Settings(
269
- rts_level_for_tx=True,
270
- rts_level_for_rx=False,
271
- loopback=False,
272
- delay_before_tx=0.02,
273
- delay_before_rx=0.02,
274
- )
275
- if self.ser is None:
276
- self.Opened = False
277
- if self.ser.is_open:
278
- self.Opened = True
279
- MoonsStepper.logger.info(f"Device connected: {self.device}")
280
-
281
- except Exception as e:
282
- MoonsStepper.logger.error(f"Device error: {e} ")
283
- self.Opened = False
284
-
285
- ports = list(list_ports.comports())
286
- if COM is not None and not self.only_simulate:
287
- attempt_connect(COM, baudrate)
288
- if callback:
289
- callback(self.device, self.Opened)
290
- else:
291
- for p in ports:
292
- m = re.match(
293
- r"USB\s*VID:PID=(\w+):(\w+)\s*SER=([A-Za-z0-9]*)", p.usb_info()
294
- )
295
- MoonsStepper.logger.info(p.usb_info())
296
- if (
297
- m
298
- and m.group(1) == self.VID
299
- and m.group(2) == self.PID
300
- # and m.group(3) == self.SERIAL_NUM
301
- ):
302
- if m.group(3) == self.SERIAL_NUM or self.SERIAL_NUM == "":
303
- MoonsStepper.logger.info(
304
- f"Device founded: {p.description} | VID: {m.group(1)} | PID: {m.group(2)} | SER: {m.group(3)}"
305
- )
306
-
307
- self.device = p.description
308
-
309
- attempt_connect(p.device, baudrate)
310
-
311
- break
312
-
313
- if self.only_simulate:
314
- self.device = "Simulate"
315
- self.Opened = True
316
- time.sleep(0.5)
317
- self.__start_update_thread()
318
- if callback:
319
- callback(self.device, self.Opened)
320
-
321
- if not self.Opened:
322
- MoonsStepper.logger.error(f"Device not found")
323
- if callback:
324
- callback(self.device, self.Opened)
325
-
326
- def disconnect(self):
327
- self.send_command(command=StepperCommand.STOP_KILL)
328
- time.sleep(0.5)
329
- self.sendQueue.queue.clear()
330
- self.recvQueue.queue.clear()
331
- self.is_updating = False
332
- self.update_thread = None
333
- if self.only_simulate:
334
- self.listen = False
335
- self.is_sending = False
336
- self.Opened = False
337
- MoonsStepper.logger.info(f"Simulate-{self.universe} disconnected")
338
- return
339
- if self.ser is not None and self.ser.is_open:
340
- self.listen = False
341
- self.is_sending = False
342
- self.Opened = False
343
- self.ser.flush()
344
- self.ser.close()
345
- MoonsStepper.logger.info(f"Device disconnected: {self.device}")
346
-
347
- def send(self, command, eol=b"\r"):
348
- if (self.ser != None and self.ser.is_open) or self.only_simulate:
349
- self.temp_cmd = command + "\r"
350
-
351
- if self.ser is not None or not self.only_simulate:
352
- self.ser.write(self.temp_cmd.encode("ascii"))
353
- if self.is_log_message:
354
- MoonsStepper.logger.debug(f"Send to {self.device}: {self.temp_cmd}")
355
- super().notify_observers(f"{self.universe}-{self.temp_cmd}")
356
- else:
357
- MoonsStepper.logger.debug(
358
- f"Target device is not opened. Command: {command}"
359
- )
360
-
361
- def send_command(self, address="", command="", value=None):
362
- if command == "":
363
- MoonsStepper.logger.warning("Command can't be empty")
364
- return
365
- if value is not None:
366
- command = self.addressed_cmd(address, command + str(value))
367
- else:
368
- command = self.addressed_cmd(address, command)
369
-
370
- self.sendQueue.put_nowait(command)
371
-
372
- def update(self):
373
-
374
- while self.is_updating:
375
- if self.ser is not None:
376
- if self.ser.in_waiting > 0:
377
- response = self.ser.read(self.ser.in_waiting)
378
- response = response.decode("ascii", errors="ignore").strip()
379
- response = response.split("\r")
380
-
381
- for r in response:
382
- if r != "":
383
- self.readBuffer += r
384
- self.handle_recv(r)
385
-
386
- if self.sendQueue.empty() != True:
387
- # time.sleep(
388
- # 0.02
389
- # ) # Time for RS485 converter to switch between Transmit and Receive mode
390
- while not self.sendQueue.empty():
391
- # time.sleep(
392
- # 0.05
393
- # ) # Time for RS485 converter to switch between Transmit and Receive mode
394
- cmd = self.sendQueue.get_nowait()
395
- self.send(cmd)
396
- self.sendQueue.task_done()
397
-
398
- def handle_recv(self, response):
399
- if "*" in response:
400
- MoonsStepper.logger.info(f"(o)buffered_ack")
401
- elif "%" in response:
402
- MoonsStepper.logger.info(f"(v)success_ack")
403
- elif "?" in response:
404
- MoonsStepper.logger.info(f"(x)fail_ack")
405
- else:
406
- MoonsStepper.logger.info(f"Received from {self.device}: ", response)
407
- self.recvQueue.put_nowait(response)
408
-
409
- if "=" in response:
410
- callback = self.pending_callbacks.get_nowait()
411
- if callback:
412
- callback(response)
413
- # for command, callback in list(self.pending_callbacks.items()):
414
- # if command in response:
415
- # if callback:
416
- # callback(response)
417
- # del self.pending_callbacks[command]
418
- # break
419
-
420
- # endregion
421
-
422
- # region motor motion functions
423
-
424
- # def setup_motor(self, motor_address="", kill=False):
425
- # if kill:
426
- # self.stop_and_kill(motor_address)
427
- # self.set_transmit_delay(motor_address, 25)
428
- # self.set_return_format_dexcimal(motor_address)
429
-
430
- def home(self, motor_address="", speed=0.3, onComplete=None):
431
- homing_complete = threading.Event() # Shared event to signal completion
432
-
433
- def check_status(response):
434
- result = MoonsStepper.process_response(response)
435
- MoonsStepper.logger.info(f"Status check result: {result}")
436
- if "H" not in result["value"]:
437
- MoonsStepper.logger.info("Motor is homed.")
438
- if onComplete: # Call the onComplete callback if provided
439
- onComplete(result)
440
- homing_complete.set() # Signal that homing is complete
441
- else:
442
- MoonsStepper.logger.info("Motor is not homed yet.")
443
-
444
- def check_homing_complete():
445
- while not homing_complete.is_set(): # Loop until homing is complete
446
- self.get_status(
447
- motor_address=motor_address,
448
- command=StepperCommand.REQUEST_STATUS,
449
- callback=check_status,
450
- )
451
- time.sleep(0.3)
452
-
453
- home_thread = threading.Thread(
454
- target=check_homing_complete,
455
- daemon=True,
456
- )
457
- self.send_command(
458
- address=motor_address, command=StepperCommand.VELOCITY, value=speed
459
- )
460
- self.send_command(
461
- address=motor_address, command=StepperCommand.HOME, value="3F"
462
- )
463
- self.send_command(
464
- address=motor_address, command=StepperCommand.ENCODER_POSITION, value=0
465
- )
466
- self.send_command(
467
- address=motor_address, command=StepperCommand.SET_POSITION, value=0
468
- )
469
- home_thread.start()
470
-
471
- # endregion
472
- def get_status(self, motor_address, command: StepperCommand, callback=None):
473
- command = self.addressed_cmd(motor_address, command)
474
- if callback:
475
- self.pending_callbacks.put_nowait(callback)
476
- self.sendQueue.put_nowait(command)
477
-
478
- def decode_status(status_code):
479
- """
480
- Decode the status code from the motor.
481
- """
482
- status = {
483
- "A": "An Alarm code is present (use AL command to see code, AR command to clear code)",
484
- "D": "Disabled (the drive is disabled)",
485
- "E": "Drive Fault (drive must be reset by AR command to clear this fault)",
486
- "F": "Motor moving",
487
- "H": "Homing (SH in progress)",
488
- "J": "Jogging (CJ in progress)",
489
- "M": "Motion in progress (Feed & Jog Commands)",
490
- "P": "In position",
491
- "R": "Ready (Drive is enabled and ready)",
492
- "S": "Stopping a motion (ST or SK command executing)",
493
- "T": "Wait Time (WT command executing)",
494
- "W": "Wait Input (WI command executing)",
495
- }
496
- status_string = ""
497
- for char in status_code:
498
- if char in status:
499
- status_string += status[char]
500
- status_string += "\n"
501
- else:
502
- status_string += f"Unknown status code: {char}"
503
- return status_string
504
-
505
- # endregion
506
-
507
- # region utility functions
508
-
509
- def addressed_cmd(self, motor_address, command):
510
- return f"{motor_address}{command}"
511
-
512
-
513
- # endregion
514
-
515
- # SERIAL => 上次已知父系(尾巴+A) 或是事件分頁
516
- # reg USB\s*VID:PID=(\w+):(\w+)\s*SER=([A-Za-z0-9]+)
517
-
518
-
519
- # serial_num 裝置例項路徑
520
- # TD(Tramsmit Delay) = 15
1
+ import serial
2
+ import serial.rs485
3
+ from serial.tools import list_ports
4
+ import re
5
+ import asyncio
6
+ import serial_asyncio
7
+ from rich import print
8
+ from rich.console import Console
9
+ from rich.panel import Panel
10
+ from moons_motor.subject import Subject
11
+ import time
12
+
13
+ from dataclasses import dataclass
14
+
15
+ import logging
16
+
17
+
18
+ class ColoredFormatter(logging.Formatter):
19
+ # Define ANSI escape codes for colors and reset
20
+ grey = "\x1b[38;21m"
21
+ yellow = "\x1b[33;21m"
22
+ red = "\x1b[31;21m"
23
+ bold_red = "\x1b[31;1m"
24
+ bold_yellow = "\x1b[33;1m"
25
+ bold_green = "\x1b[32;1m"
26
+ bold_blue = "\x1b[34;1m"
27
+ bold_cyan = "\x1b[36;1m"
28
+ bold_magenta = "\x1b[35;1m"
29
+ reset = "\x1b[0m"
30
+
31
+ # Define the base format string
32
+ format_time = "%(asctime)s"
33
+ format_header = " [%(levelname)s]"
34
+ format_base = " %(message)s"
35
+
36
+ # Map log levels to colored format strings
37
+ FORMATS = {
38
+ logging.DEBUG: format_time
39
+ + bold_cyan
40
+ + format_header
41
+ + reset
42
+ + format_base
43
+ + reset,
44
+ logging.INFO: format_time
45
+ + bold_green
46
+ + format_header
47
+ + reset
48
+ + format_base
49
+ + reset,
50
+ logging.WARNING: format_time
51
+ + bold_yellow
52
+ + format_header
53
+ + reset
54
+ + format_base
55
+ + reset,
56
+ logging.ERROR: format_time
57
+ + bold_red
58
+ + format_header
59
+ + reset
60
+ + format_base
61
+ + reset,
62
+ logging.CRITICAL: format_time
63
+ + bold_magenta
64
+ + format_header
65
+ + reset
66
+ + format_base
67
+ + reset,
68
+ }
69
+
70
+ def format(self, record):
71
+ log_fmt = self.FORMATS.get(record.levelno)
72
+ formatter = logging.Formatter(log_fmt)
73
+ return formatter.format(record)
74
+
75
+
76
+ class StepperModules:
77
+ STM17S_3RN = "STM17S-3RN"
78
+
79
+
80
+ @dataclass(frozen=True)
81
+ class StepperCommand:
82
+ JOG: str = "CJ" # Start jogging
83
+ JOG_SPEED: str = "JS" # Jogging speed (Need to set before start jogging)
84
+ JOG_ACCELERATION: str = (
85
+ "JA" # Jogging acceleration (Need to set before start jogging)
86
+ )
87
+ CHANGE_JOG_SPEED: str = "CS" # Change jogging speed while jogging
88
+ STOP_JOG: str = "SJ" # Stop jogging with deceleration
89
+ STOP: str = "ST" # Stop immediately (No deceleration)
90
+ STOP_DECEL: str = "STD" # Stop with deceleration
91
+ STOP_KILL: str = (
92
+ "SK" # Stop with deceleration(Control by AM) and kill all unexecuted commands
93
+ )
94
+ STOP_KILL_DECEL: str = (
95
+ "SKD" # Stop and kill all unexecuted commands with deceleration(Control by DE)
96
+ )
97
+ ENABLE: str = "ME" # Enable motor
98
+ DISABLE: str = "MD" # Disable motor
99
+ MOVE_ABSOLUTE: str = "FP" # Move to absolute position
100
+ MOVE_FIXED_DISTANCE: str = "FL" # Move to fixed distance
101
+ POSITION: str = "IP" # Motor absolute position(Calculated trajectory position)
102
+ TEMPERATURE: str = "IT" # Motor temperature
103
+ VOLTAGE: str = "IU" # Motor voltage
104
+
105
+ ENCODER_POSITION: str = "EP" # Encoder position
106
+ SET_POSITION: str = "SP" # Set encoder position
107
+
108
+ HOME: str = "SH" # Home position
109
+ VELOCITY: str = "VE" # Set velocity
110
+
111
+ ALARM_RESET: str = "AR" # Reset alarm
112
+
113
+ SET_RETURN_FORMAT_DECIMAL: str = "IFD" # Set return format to decimal
114
+ SET_RETURN_FORMAT_HEXADECIMAL: str = "IFH" # Set return format to hexadecimal
115
+
116
+ SET_TRANSMIT_DELAY: str = "TD" # Set transmit delay
117
+ REQUEST_STATUS: str = "RS" # Request status
118
+
119
+
120
+ class MoonsStepper(Subject):
121
+ motorAdress = [
122
+ "0",
123
+ "1",
124
+ "2",
125
+ "3",
126
+ "4",
127
+ "5",
128
+ "6",
129
+ "7",
130
+ "8",
131
+ "9",
132
+ "!",
133
+ '"',
134
+ "#",
135
+ "$",
136
+ "%",
137
+ "&",
138
+ "'",
139
+ "(",
140
+ ")",
141
+ "*",
142
+ "+",
143
+ ",",
144
+ "-",
145
+ ".",
146
+ "/",
147
+ ":",
148
+ ";",
149
+ "<",
150
+ "=",
151
+ ">",
152
+ "?",
153
+ "@",
154
+ ]
155
+ # Configure logging
156
+ logger = logging.getLogger(__name__)
157
+ logger.setLevel(logging.INFO)
158
+
159
+ ch = logging.StreamHandler()
160
+ ch.setFormatter(ColoredFormatter())
161
+ logger.addHandler(ch)
162
+
163
+ def __init__(
164
+ self,
165
+ model: StepperModules,
166
+ VID,
167
+ PID,
168
+ SERIAL_NUM,
169
+ only_simlate=False,
170
+ universe=0,
171
+ ):
172
+ super().__init__()
173
+ self.universe = universe
174
+ self.model = model # Motor model
175
+ self.only_simulate = only_simlate
176
+ self.device = "" # COM port description
177
+ self.VID = VID
178
+ self.PID = PID
179
+ self.SERIAL_NUM = SERIAL_NUM # ID for determent the deivice had same VID and PID, can be config using chips manufacturer tool
180
+
181
+ self.is_connected = False
182
+ self.ser_reader = None
183
+ self.ser_writer = None
184
+ self.send_queue = asyncio.Queue()
185
+ self.recv_queue = asyncio.Queue()
186
+ self.pending_futures = {}
187
+ self._io_tasks = []
188
+ self.readBuffer = ""
189
+
190
+ self.console = Console()
191
+
192
+ self.is_log_message = True
193
+
194
+ self.microstep = {
195
+ 0: 200,
196
+ 1: 400,
197
+ 3: 2000,
198
+ 4: 5000,
199
+ 5: 10000,
200
+ 6: 12800,
201
+ 7: 18000,
202
+ 8: 20000,
203
+ 9: 21600,
204
+ 10: 25000,
205
+ 11: 25400,
206
+ 12: 25600,
207
+ 13: 36000,
208
+ 14: 50000,
209
+ 15: 50800,
210
+ }
211
+
212
+ # region connection & main functions
213
+ @staticmethod
214
+ def list_all_ports():
215
+ ports = list(list_ports.comports())
216
+ simple_ports = []
217
+ port_info = ""
218
+ for p in ports:
219
+ port_info += f"■ {p.device} {p.description} [blue]{p.usb_info()}[/blue]"
220
+ if p != ports[-1]:
221
+ port_info += "\n"
222
+ simple_ports.append(p.description)
223
+ print(Panel(port_info, title="All COMPorts"))
224
+ return simple_ports
225
+
226
+ @staticmethod
227
+ def process_response(response):
228
+ equal_sign_index = response.index("=")
229
+ address = response[0]
230
+ command = response[1:equal_sign_index]
231
+ value = response[equal_sign_index + 1 :]
232
+
233
+ if command == "IT" or command == "IU":
234
+ # Handle temperature response
235
+ value = int(value) / 10.0
236
+ return {
237
+ "address": address,
238
+ "command": command,
239
+ "value": value,
240
+ }
241
+
242
+ async def connect(self, COM=None, baudrate=9600, callback=None):
243
+ # Simulate mode
244
+ if self.only_simulate:
245
+ self.is_connected = True
246
+ self.device = f"Simulate-{self.universe}"
247
+ MoonsStepper.logger.info(f"{self.device} connected")
248
+ if callback:
249
+ callback(self.device, self.is_connected)
250
+ return
251
+
252
+ # Find port
253
+ ports = list(list_ports.comports())
254
+ found_port = None
255
+ if COM is not None:
256
+ for p in ports:
257
+ if p.device == COM:
258
+ found_port = p
259
+ self.device = p.description
260
+ break
261
+ if not found_port:
262
+ MoonsStepper.logger.error(f"Specified COM port {COM} not found.")
263
+ if callback:
264
+ callback(self.device, False)
265
+ return
266
+ else:
267
+ # Auto-detect port by VID/PID
268
+ for p in ports:
269
+ m = re.match(
270
+ r"USB\s*VID:PID=(\w+):(\w+)\s*SER=([A-Za-z0-9]*)", p.usb_info()
271
+ )
272
+ if (
273
+ m
274
+ and m.group(1) == self.VID
275
+ and m.group(2) == self.PID
276
+ and (m.group(3) == self.SERIAL_NUM or self.SERIAL_NUM == "")
277
+ ):
278
+ MoonsStepper.logger.info(
279
+ f"Device found: {p.description} | VID: {m.group(1)} | PID: {m.group(2)} | SER: {m.group(3)}"
280
+ )
281
+ self.device = p.description
282
+ found_port = p
283
+ break
284
+
285
+ if not found_port:
286
+ MoonsStepper.logger.error(
287
+ f"Device with VID={self.VID}, PID={self.PID} not found."
288
+ )
289
+ if callback:
290
+ callback(self.device, self.is_connected)
291
+ return
292
+
293
+ # Attempt to connect
294
+ try:
295
+ # --- THIS IS THE CRITICAL RS485 PART ---
296
+ rs485_settings = serial.rs485.RS485Settings(
297
+ rts_level_for_tx=True,
298
+ rts_level_for_rx=False,
299
+ loopback=False,
300
+ delay_before_tx=0.02,
301
+ delay_before_rx=0.02,
302
+ )
303
+ self.ser_reader, self.ser_writer = (
304
+ await serial_asyncio.open_serial_connection(
305
+ url=found_port.device,
306
+ baudrate=baudrate,
307
+ # rs485_mode=rs485_settings, # Pass the settings here
308
+ )
309
+ )
310
+ # ----------------------------------------
311
+
312
+ self.is_connected = True
313
+ MoonsStepper.logger.info(f"Device connected: {self.device}")
314
+
315
+ # Start I/O handlers
316
+ read_task = asyncio.create_task(self._read_handler())
317
+ write_task = asyncio.create_task(self._write_handler())
318
+ self._io_tasks = [read_task, write_task]
319
+
320
+ except Exception as e:
321
+ MoonsStepper.logger.error(f"Device connection error: {e}")
322
+ self.is_connected = False
323
+
324
+ await asyncio.sleep(0.1)
325
+
326
+ if callback:
327
+ callback(self.device, self.is_connected)
328
+
329
+ async def _read_handler(self):
330
+ MoonsStepper.logger.info("Read handler started.")
331
+ while self.is_connected:
332
+ try:
333
+ response_bytes = await self.ser_reader.readuntil(b"\r")
334
+ response = response_bytes.decode("ascii", errors="ignore").strip()
335
+ if response:
336
+ self.handle_recv(response)
337
+ except asyncio.CancelledError:
338
+ MoonsStepper.logger.info("Read handler cancelled.")
339
+ break
340
+ except Exception as e:
341
+ MoonsStepper.logger.error(f"Read error: {e}")
342
+ self.is_connected = False
343
+ break
344
+
345
+ async def _write_handler(self):
346
+ MoonsStepper.logger.info("Write handler started.")
347
+ while self.is_connected:
348
+ try:
349
+ command = await self.send_queue.get()
350
+
351
+ full_command = (command + "\r").encode("ascii")
352
+ self.ser_writer.write(full_command)
353
+ await self.ser_writer.drain()
354
+
355
+ if self.is_log_message:
356
+ MoonsStepper.logger.debug(f"Sent to {self.device}: {command}")
357
+
358
+ self.send_queue.task_done()
359
+ await asyncio.sleep(0.05)
360
+ except asyncio.CancelledError:
361
+ MoonsStepper.logger.info("Write handler cancelled.")
362
+ break
363
+ except Exception as e:
364
+ MoonsStepper.logger.error(f"Write error: {e}")
365
+ self.is_connected = False
366
+ break
367
+
368
+ async def disconnect(self):
369
+ if not self.is_connected and not self.only_simulate:
370
+ return
371
+
372
+ # For simulation
373
+ if self.only_simulate:
374
+ self.is_connected = False
375
+ MoonsStepper.logger.info(f"Simulate-{self.universe} disconnected")
376
+ return
377
+
378
+ # Stop I/O tasks
379
+ self.is_connected = False
380
+ for task in self._io_tasks:
381
+ task.cancel()
382
+
383
+ # Wait for tasks to finish cancellation
384
+ await asyncio.gather(*self._io_tasks, return_exceptions=True)
385
+
386
+ # Clear queues
387
+ while not self.send_queue.empty():
388
+ self.send_queue.get_nowait()
389
+ while not self.recv_queue.empty():
390
+ self.recv_queue.get_nowait()
391
+
392
+ # Close serial connection
393
+ if self.ser_writer:
394
+ self.ser_writer.close()
395
+ await self.ser_writer.wait_closed()
396
+
397
+ MoonsStepper.logger.info(f"Device disconnected: {self.device}")
398
+
399
+ async def send_command(self, address="", command="", value=None):
400
+ if not self.is_connected and not self.only_simulate:
401
+ MoonsStepper.logger.warning("Not connected. Cannot send command.")
402
+ return
403
+
404
+ if command == "":
405
+ MoonsStepper.logger.warning("Command can't be empty")
406
+ return
407
+
408
+ if value is not None:
409
+ command_str = self.addressed_cmd(address, command + str(value))
410
+ else:
411
+ command_str = self.addressed_cmd(address, command)
412
+
413
+ await self.send_queue.put(command_str)
414
+ await super().notify_observers(f"{self.universe}-{command_str}")
415
+
416
+ def handle_recv(self, response):
417
+ # First, process the response to extract key information
418
+ try:
419
+ processed = MoonsStepper.process_response(response)
420
+ address = processed.get("address")
421
+ command = processed.get("command")
422
+ # Create a unique key for the request-response pair
423
+ future_key = (address, command)
424
+
425
+ # Check if a future is waiting for this specific response
426
+ if future_key in self.pending_futures:
427
+ future = self.pending_futures.pop(future_key)
428
+ future.set_result(processed) # Set the future's result
429
+ MoonsStepper.logger.debug(f"Future for {future_key} resolved.")
430
+ return # Stop further processing
431
+
432
+ except Exception as e:
433
+ # This can happen for simple ACK/NACK responses that don't have an '='
434
+ MoonsStepper.logger.debug(f"Received non-standard response: {response}. Error: {e}")
435
+
436
+ # Handle general ACKs or unexpected messages
437
+ if "*" in response:
438
+ MoonsStepper.logger.info(f"(o)buffered_ack")
439
+ elif "%" in response:
440
+ MoonsStepper.logger.info(f"(v)success_ack")
441
+ elif "?" in response:
442
+ MoonsStepper.logger.info(f"(x)fail_ack")
443
+ else:
444
+ MoonsStepper.logger.info(f"Received unhandled message from {self.device}: {response}")
445
+ # Optionally, put unhandled messages into the general queue
446
+ self.recv_queue.put_nowait(response)
447
+
448
+ # endregion
449
+
450
+ # region motor motion functions
451
+
452
+ # def setup_motor(self, motor_address="", kill=False):
453
+ # if kill:
454
+ # self.stop_and_kill(motor_address)
455
+ # self.set_transmit_delay(motor_address, 25)
456
+ # self.set_return_format_dexcimal(motor_address)
457
+
458
+ async def home(self, motor_address="", speed=0.3):
459
+ # Send initial homing commands
460
+ await self.send_command(
461
+ address=motor_address, command=StepperCommand.VELOCITY, value=speed
462
+ )
463
+ await self.send_command(
464
+ address=motor_address, command=StepperCommand.HOME, value="3F"
465
+ )
466
+ await self.send_command(
467
+ address=motor_address, command=StepperCommand.ENCODER_POSITION, value=0
468
+ )
469
+ await self.send_command(
470
+ address=motor_address, command=StepperCommand.SET_POSITION, value=0
471
+ )
472
+
473
+ MoonsStepper.logger.info(f"Homing command sent to address {motor_address}. Polling for completion...")
474
+
475
+ # Loop until the motor is no longer in the homing state
476
+ while self.is_connected:
477
+ result = await self.get_status(
478
+ motor_address=motor_address,
479
+ command=StepperCommand.REQUEST_STATUS,
480
+ )
481
+
482
+ if result and "H" not in result.get("value", ""):
483
+ MoonsStepper.logger.info(f"Motor at address {motor_address} is homed.")
484
+ return # Homing is complete
485
+
486
+ MoonsStepper.logger.info(f"Motor at address {motor_address} is not homed yet. Waiting...")
487
+ # Wait a bit before polling again
488
+ await asyncio.sleep(0.5)
489
+
490
+ # endregion
491
+ async def get_status(self, motor_address, command: StepperCommand):
492
+ future = asyncio.get_running_loop().create_future()
493
+ future_key = (motor_address, command)
494
+ self.pending_futures[future_key] = future
495
+
496
+ await self.send_command(motor_address, command)
497
+
498
+ try:
499
+ # Wait for the future to be resolved by handle_recv
500
+ result = await asyncio.wait_for(future, timeout=2.0) # 2-second timeout
501
+ return result
502
+ except asyncio.TimeoutError:
503
+ MoonsStepper.logger.error(f"Timeout waiting for response for {future_key}")
504
+ # Clean up the pending future on timeout
505
+ self.pending_futures.pop(future_key, None)
506
+ return None
507
+
508
+ def decode_status(status_code):
509
+ """
510
+ Decode the status code from the motor.
511
+ """
512
+ status = {
513
+ "A": "An Alarm code is present (use AL command to see code, AR command to clear code)",
514
+ "D": "Disabled (the drive is disabled)",
515
+ "E": "Drive Fault (drive must be reset by AR command to clear this fault)",
516
+ "F": "Motor moving",
517
+ "H": "Homing (SH in progress)",
518
+ "J": "Jogging (CJ in progress)",
519
+ "M": "Motion in progress (Feed & Jog Commands)",
520
+ "P": "In position",
521
+ "R": "Ready (Drive is enabled and ready)",
522
+ "S": "Stopping a motion (ST or SK command executing)",
523
+ "T": "Wait Time (WT command executing)",
524
+ "W": "Wait Input (WI command executing)",
525
+ }
526
+ status_string = ""
527
+ for char in status_code:
528
+ if char in status:
529
+ status_string += status[char]
530
+ status_string += "\n"
531
+ else:
532
+ status_string += f"Unknown status code: {char}"
533
+ return status_string
534
+
535
+ # endregion
536
+
537
+ # region utility functions
538
+
539
+ def addressed_cmd(self, motor_address, command):
540
+ return f"{motor_address}{command}"
541
+
542
+
543
+ # endregion
544
+
545
+ # SERIAL => 上次已知父系(尾巴+A) 或是事件分頁
546
+ # reg USB\s*VID:PID=(\w+):(\w+)\s*SER=([A-Za-z0-9]+)
547
+
548
+
549
+ # serial_num 裝置例項路徑
550
+ # TD(Tramsmit Delay) = 15
@@ -1,7 +1,8 @@
1
- from abc import ABC, abstractmethod
2
-
3
-
4
- @abstractmethod
5
- class Observer(ABC):
6
- def update(self, event):
7
- pass
1
+ from abc import ABC, abstractmethod
2
+
3
+
4
+ @abstractmethod
5
+ class Observer(ABC):
6
+ @abstractmethod
7
+ async def update(self, event):
8
+ pass
@@ -1,15 +1,17 @@
1
- class Subject:
2
- def __init__(self):
3
- self._observers = []
4
-
5
- def register(self, observer):
6
- if observer not in self._observers:
7
- self._observers.append(observer)
8
-
9
- def unregister(self, observer):
10
- if observer in self._observers:
11
- self._observers.remove(observer)
12
-
13
- def notify_observers(self, event):
14
- for observer in self._observers:
15
- observer.update(event)
1
+ import asyncio
2
+
3
+ class Subject:
4
+ def __init__(self):
5
+ self._observers = []
6
+
7
+ def register(self, observer):
8
+ if observer not in self._observers:
9
+ self._observers.append(observer)
10
+
11
+ def unregister(self, observer):
12
+ if observer in self._observers:
13
+ self._observers.remove(observer)
14
+
15
+ async def notify_observers(self, event):
16
+ tasks = [observer.update(event) for observer in self._observers]
17
+ await asyncio.gather(*tasks)
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: moons_motor
3
- Version: 0.1.5
3
+ Version: 1.0.0
4
4
  Summary: This is a python library for controlling the Moons' motor through the serial port.
5
5
  Author-email: miroc <mike8503111@gmail.com>
6
6
  Project-URL: Repository, https://github.com/miroc99/moons_motor.git
@@ -16,6 +16,7 @@ Requires-Dist: pyserial
16
16
  Requires-Dist: rich
17
17
  Requires-Dist: python-socketio
18
18
  Requires-Dist: requests
19
+ Requires-Dist: pyserial_asyncio
19
20
  Dynamic: license-file
20
21
 
21
22
  # Moons Motor
@@ -2,3 +2,4 @@ pyserial
2
2
  rich
3
3
  python-socketio
4
4
  requests
5
+ pyserial_asyncio
@@ -4,12 +4,18 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "moons_motor"
7
- version = "0.1.5"
7
+ version = "1.0.0"
8
8
  authors = [{ name = "miroc", email = "mike8503111@gmail.com" }]
9
9
  description = "This is a python library for controlling the Moons' motor through the serial port."
10
10
  readme = "README.md"
11
11
  requires-python = ">=3.12"
12
- dependencies = ["pyserial", "rich", "python-socketio", "requests"]
12
+ dependencies = [
13
+ "pyserial",
14
+ "rich",
15
+ "python-socketio",
16
+ "requests",
17
+ "pyserial_asyncio",
18
+ ]
13
19
  classifiers = [
14
20
  # How mature is this project? Common values are
15
21
  # 3 - Alpha
File without changes
File without changes
File without changes
File without changes