puda-drivers 0.0.3__py3-none-any.whl → 0.0.5__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.
@@ -2,13 +2,12 @@
2
2
  Generic Serial Controller for communicating with devices over serial ports.
3
3
  """
4
4
 
5
- import serial
6
- import sys
7
5
  import time
8
- import serial.tools.list_ports
9
6
  import logging
10
7
  from typing import Optional, List, Tuple
11
- from abc import ABC, abstractmethod
8
+ from abc import ABC
9
+ import serial
10
+ import serial.tools.list_ports
12
11
 
13
12
  logger = logging.getLogger(__name__)
14
13
 
@@ -46,7 +45,7 @@ def list_serial_ports(filter_desc: Optional[str] = None) -> List[Tuple[str, str,
46
45
 
47
46
  class SerialController(ABC):
48
47
  DEFAULT_BAUDRATE = 9600
49
- DEFAULT_TIMEOUT = 20 # seconds
48
+ DEFAULT_TIMEOUT = 30 # seconds
50
49
  POLL_INTERVAL = 0.1 # seconds
51
50
 
52
51
  def __init__(self, port_name, baudrate=DEFAULT_BAUDRATE, timeout=DEFAULT_TIMEOUT):
@@ -58,23 +57,6 @@ class SerialController(ABC):
58
57
 
59
58
  self.connect()
60
59
 
61
- @staticmethod
62
- def list_ports(filter_desc: Optional[str] = None) -> List[Tuple[str, str, str]]:
63
- """
64
- Lists available serial ports on the system.
65
-
66
- .. deprecated:: 0.0.1
67
- Use the module-level function :func:`list_serial_ports` instead.
68
- This method is kept for backward compatibility.
69
-
70
- Args:
71
- filter_desc: Optional string to filter ports by description (case-insensitive).
72
-
73
- Returns:
74
- List of tuples, where each tuple contains (port_name, description, hwid).
75
- """
76
- return list_serial_ports(filter_desc)
77
-
78
60
  def connect(self) -> None:
79
61
  """
80
62
  Establishes the serial connection to the port.
@@ -89,7 +71,9 @@ class SerialController(ABC):
89
71
 
90
72
  try:
91
73
  self._logger.info(
92
- f"Attempting connection to {self.port_name} at {self.baudrate} baud."
74
+ "Attempting connection to %s at %s baud.",
75
+ self.port_name,
76
+ self.baudrate,
93
77
  )
94
78
  self._serial = serial.Serial(
95
79
  port=self.port_name,
@@ -97,10 +81,10 @@ class SerialController(ABC):
97
81
  timeout=self.timeout,
98
82
  )
99
83
  self._serial.flush()
100
- self._logger.info(f"Successfully connected to {self.port_name}.")
84
+ self._logger.info("Successfully connected to %s.", self.port_name)
101
85
  except serial.SerialException as e:
102
86
  self._serial = None
103
- self._logger.error(f"Error connecting to port {self.port_name}: {e}")
87
+ self._logger.error("Error connecting to port %s: %s", self.port_name, e)
104
88
  raise serial.SerialException(
105
89
  f"Error connecting to port {self.port_name}: {e}"
106
90
  )
@@ -113,7 +97,7 @@ class SerialController(ABC):
113
97
  port_name = self._serial.port
114
98
  self._serial.close()
115
99
  self._serial = None
116
- self._logger.info(f"Serial connection to {port_name} closed.")
100
+ self._logger.info("Serial connection to %s closed.", port_name)
117
101
  else:
118
102
  self._logger.warning(
119
103
  "Serial port already disconnected or was never connected."
@@ -131,29 +115,31 @@ class SerialController(ABC):
131
115
  """
132
116
  if not self.is_connected or not self._serial:
133
117
  self._logger.error(
134
- f"Attempt to send command '{command}' failed: Device not connected."
118
+ "Attempt to send command '%s' failed: Device not connected.",
119
+ command,
135
120
  )
136
121
  # Retain raising an error for being disconnected, as that's a connection state issue
137
122
  raise serial.SerialException("Device not connected. Call connect() first.")
138
123
 
139
- command_bytes = bytes(command, "utf-8")
140
- self._logger.info(f"-> Sending: {repr(command)}")
124
+ self._logger.info("-> Sending: %r", command)
141
125
 
142
126
  # Send the command
143
127
  try:
144
128
  self._serial.reset_input_buffer() # clear input buffer
145
129
  self._serial.reset_output_buffer() # clear output buffer
146
- self._serial.write(command_bytes)
147
130
  self._serial.flush()
131
+ self._serial.write(bytes(command, "utf-8"))
148
132
 
149
133
  except serial.SerialTimeoutException as e:
150
134
  # Log the timeout error and return None as requested (no re-raise)
151
- self._logger.error(f"Timeout on command '{command}'. Error: {e}")
135
+ self._logger.error("Timeout on command '%s'. Error: %s", command, e)
152
136
  return None
153
137
 
154
138
  except serial.SerialException as e:
155
139
  self._logger.error(
156
- f"Serial error writing or reading command '{command}'. Error: {e}"
140
+ "Serial error writing or reading command '%s'. Error: %s",
141
+ command,
142
+ e,
157
143
  )
158
144
  return None
159
145
 
@@ -173,22 +159,51 @@ class SerialController(ABC):
173
159
  # Read all available bytes
174
160
  response += self._serial.read(self._serial.in_waiting)
175
161
 
176
- # 5. Check if the 'ok' response is in the accumulated data
177
- if b"ok" in response:
178
- self._logger.debug(f"<- Received response: {response!r}")
179
- return response.decode("utf-8", errors="ignore").strip()
180
- if b"err" in response:
181
- self._logger.error(f"<- Received error: {response!r}")
182
- return response.decode("utf-8", errors="ignore").strip()
162
+ # Check for expected response markers for early return
163
+ if b"ok" in response or b"err" in response:
164
+ break
183
165
  else:
184
166
  time.sleep(0.1)
185
167
 
168
+ # Timeout reached - check what we got
186
169
  if not response:
187
- self._logger.warning("<- Received no data (empty response).")
188
- self._logger.error(f"No response within {self.timeout} seconds.")
189
- sys.exit(124) # Exit code for timeout
190
-
191
- self._logger.info(
192
- f"<- Received: {response.decode('utf-8', errors='ignore').strip()!r}"
193
- )
194
- return response.decode("utf-8", errors="ignore").strip()
170
+ self._logger.error("No response within %s seconds.", self.timeout)
171
+ raise serial.SerialTimeoutException(
172
+ f"No response received within {self.timeout} seconds."
173
+ )
174
+
175
+ # Decode once and check the decoded string
176
+ decoded_response = response.decode("utf-8", errors="ignore").strip()
177
+
178
+ if "ok" in decoded_response.lower():
179
+ self._logger.debug("<- Received response: %r", decoded_response)
180
+ elif "err" in decoded_response.lower():
181
+ self._logger.error("<- Received error: %r", decoded_response)
182
+ else:
183
+ self._logger.warning(
184
+ "<- Received unexpected response (no 'ok' or 'err'): %r", decoded_response
185
+ )
186
+
187
+ return decoded_response
188
+
189
+ def execute(self, command: str) -> str:
190
+ """
191
+ Send a command and read the response atomically.
192
+
193
+ This method combines sending a command and reading its response into a
194
+ single atomic operation, ensuring the response corresponds to the command
195
+ that was just sent. This is the preferred method for commands that
196
+ require a response.
197
+
198
+ Args:
199
+ command: Command string to send (should include protocol terminator if needed)
200
+
201
+ Returns:
202
+ Response string from the device
203
+
204
+ Raises:
205
+ serial.SerialException: If device is not connected or communication fails
206
+ serial.SerialTimeoutException: If no response is received within timeout
207
+ """
208
+ self._send_command(command)
209
+ return self._read_response()
@@ -2,24 +2,50 @@
2
2
  G-code controller for motion systems.
3
3
 
4
4
  This module provides a Python interface for controlling G-code compatible motion
5
- systems (e.g., QuBot) via serial communication. Supports absolute and relative
6
- positioning, homing, and position synchronization.
5
+ systems (e.g., QuBot) via serial communication. All movements are executed in
6
+ absolute coordinates, with relative moves converted to absolute internally.
7
+ Supports homing and position synchronization.
7
8
  """
8
9
 
9
10
  import re
10
11
  import logging
11
- from typing import Optional, Dict, Tuple
12
+ from dataclasses import dataclass
13
+ from typing import Optional, Dict, Tuple, Union
12
14
 
13
15
  from puda_drivers.core.serialcontroller import SerialController
14
16
 
15
17
 
18
+ @dataclass
19
+ class AxisLimits:
20
+ """Holds min/max limits for an axis."""
21
+
22
+ min: float
23
+ max: float
24
+
25
+ def validate(self, value: float) -> None:
26
+ """
27
+ Validate that a value is within the axis limits.
28
+
29
+ Args:
30
+ value: Value to validate
31
+
32
+ Raises:
33
+ ValueError: If value is outside the limits
34
+ """
35
+ if not (self.min <= value <= self.max):
36
+ raise ValueError(
37
+ f"Value {value} outside axis limits [{self.min}, {self.max}]"
38
+ )
39
+
40
+
16
41
  class GCodeController(SerialController):
17
42
  """
18
43
  Controller for G-code compatible motion systems.
19
44
 
20
45
  This class provides methods for controlling multi-axis motion systems that
21
- understand G-code commands, including homing, absolute/relative movement,
22
- and position synchronization.
46
+ understand G-code commands. All movements are executed in absolute coordinates,
47
+ with relative moves converted to absolute internally. Supports homing and
48
+ position synchronization.
23
49
 
24
50
  Attributes:
25
51
  DEFAULT_FEEDRATE: Default feed rate in mm/min (3000)
@@ -29,7 +55,9 @@ class GCodeController(SerialController):
29
55
 
30
56
  DEFAULT_FEEDRATE = 3000 # mm/min
31
57
  MAX_FEEDRATE = 3000 # mm/min
58
+ MAX_Z_FEED_RATE = 1000 # mm/min
32
59
  TOLERANCE = 0.01 # tolerance for position sync in mm
60
+ SAFE_MOVE_HEIGHT = -5 # safe height for Z and A axes in mm
33
61
 
34
62
  PROTOCOL_TERMINATOR = "\r"
35
63
  VALID_AXES = "XYZA"
@@ -40,6 +68,7 @@ class GCodeController(SerialController):
40
68
  baudrate: int = SerialController.DEFAULT_BAUDRATE,
41
69
  timeout: int = SerialController.DEFAULT_TIMEOUT,
42
70
  feed: int = DEFAULT_FEEDRATE,
71
+ z_feed: int = MAX_Z_FEED_RATE,
43
72
  ):
44
73
  """
45
74
  Initialize the G-code controller.
@@ -61,14 +90,22 @@ class GCodeController(SerialController):
61
90
  )
62
91
 
63
92
  # Tracks internal position state
64
- self.current_position: Dict[str, float] = {
93
+ self._current_position: Dict[str, float] = {
65
94
  "X": 0.0,
66
95
  "Y": 0.0,
67
96
  "Z": 0.0,
68
97
  "A": 0.0,
69
98
  }
70
99
  self._feed: int = feed
71
- self._is_absolute_mode: bool = True # absolute mode by default
100
+ self._z_feed: int = z_feed
101
+
102
+ # Initialize axis limits with default values
103
+ self._axis_limits: Dict[str, AxisLimits] = {
104
+ "X": AxisLimits(0, 0),
105
+ "Y": AxisLimits(0, 0),
106
+ "Z": AxisLimits(0, 0),
107
+ "A": AxisLimits(0, 0),
108
+ }
72
109
 
73
110
  @property
74
111
  def feed(self) -> int:
@@ -118,6 +155,15 @@ class GCodeController(SerialController):
118
155
  """
119
156
  return f"{command}{self.PROTOCOL_TERMINATOR}"
120
157
 
158
+ def wait_for_move(self) -> None:
159
+ """
160
+ Wait for the current move to complete (M400 command).
161
+
162
+ This sends the M400 command which waits for all moves in the queue to complete
163
+ before continuing. This ensures that position updates are accurate.
164
+ """
165
+ self.execute(self._build_command("M400"))
166
+
121
167
  def _validate_axis(self, axis: str) -> str:
122
168
  """
123
169
  Validate and normalize an axis name.
@@ -143,6 +189,102 @@ class GCodeController(SerialController):
143
189
  )
144
190
  return axis_upper
145
191
 
192
+ def _validate_move_positions(
193
+ self,
194
+ x: Optional[float] = None,
195
+ y: Optional[float] = None,
196
+ z: Optional[float] = None,
197
+ a: Optional[float] = None,
198
+ ) -> None:
199
+ """
200
+ Validate that move positions are within axis limits.
201
+
202
+ Only validates axes that are being moved (not None). Raises ValueError
203
+ if any position is outside the configured limits.
204
+
205
+ Args:
206
+ x: Target X position (optional)
207
+ y: Target Y position (optional)
208
+ z: Target Z position (optional)
209
+ a: Target A position (optional)
210
+
211
+ Raises:
212
+ ValueError: If any position is outside the axis limits
213
+ """
214
+ if x is not None:
215
+ if "X" in self._axis_limits:
216
+ try:
217
+ self._axis_limits["X"].validate(x)
218
+ except ValueError as e:
219
+ self._logger.error("Move validation failed for X axis: %s", e)
220
+ raise
221
+ if y is not None:
222
+ if "Y" in self._axis_limits:
223
+ try:
224
+ self._axis_limits["Y"].validate(y)
225
+ except ValueError as e:
226
+ self._logger.error("Move validation failed for Y axis: %s", e)
227
+ raise
228
+ if z is not None:
229
+ if "Z" in self._axis_limits:
230
+ try:
231
+ self._axis_limits["Z"].validate(z)
232
+ except ValueError as e:
233
+ self._logger.error("Move validation failed for Z axis: %s", e)
234
+ raise
235
+ if a is not None:
236
+ if "A" in self._axis_limits:
237
+ try:
238
+ self._axis_limits["A"].validate(a)
239
+ except ValueError as e:
240
+ self._logger.error("Move validation failed for A axis: %s", e)
241
+ raise
242
+
243
+ def set_axis_limits(
244
+ self, axis: str, min_val: float, max_val: float
245
+ ) -> None:
246
+ """
247
+ Set the min/max limits for an axis.
248
+
249
+ Args:
250
+ axis: Axis name ('X', 'Y', 'Z', 'A')
251
+ min_val: Minimum allowed value
252
+ max_val: Maximum allowed value
253
+
254
+ Raises:
255
+ ValueError: If axis is unknown or min >= max
256
+ """
257
+ axis = self._validate_axis(axis)
258
+
259
+ if min_val >= max_val:
260
+ raise ValueError("min must be < max")
261
+
262
+ self._axis_limits[axis] = AxisLimits(min_val, max_val)
263
+ self._logger.info(
264
+ "Set limits for axis %s: [%s, %s]", axis, min_val, max_val
265
+ )
266
+
267
+ def get_axis_limits(
268
+ self, axis: Optional[str] = None
269
+ ) -> Union[AxisLimits, Dict[str, AxisLimits]]:
270
+ """
271
+ Get the current limits for an axis or all axes.
272
+
273
+ Args:
274
+ axis: Optional axis name ('X', 'Y', 'Z', 'A'). If None, returns all limits.
275
+
276
+ Returns:
277
+ If axis is specified: AxisLimits object with min and max values.
278
+ If axis is None: Dictionary of all axis limits.
279
+
280
+ Raises:
281
+ ValueError: If axis is unknown (only when axis is provided)
282
+ """
283
+ if axis is None:
284
+ return self._axis_limits.copy()
285
+ axis = self._validate_axis(axis)
286
+ return self._axis_limits[axis]
287
+
146
288
  def home(self, axis: Optional[str] = None) -> None:
147
289
  """
148
290
  Home one or all axes (G28 command).
@@ -163,19 +305,19 @@ class GCodeController(SerialController):
163
305
  home_target = "All"
164
306
 
165
307
  self._logger.info("[%s] homing axis/axes: %s **", cmd, home_target)
166
- self._send_command(self._build_command(cmd))
308
+ self.execute(self._build_command(cmd))
167
309
  self._logger.info("Homing of %s completed.", home_target)
168
310
 
169
311
  # Update internal position (optimistic zeroing)
170
312
  if axis:
171
- self.current_position[axis] = 0.0
313
+ self._current_position[axis] = 0.0
172
314
  else:
173
- for key in self.current_position:
174
- self.current_position[key] = 0.0
315
+ for key in self._current_position:
316
+ self._current_position[key] = 0.0
175
317
 
176
318
  self._logger.debug(
177
319
  "Internal position updated (optimistically zeroed) to %s",
178
- self.current_position,
320
+ self._current_position,
179
321
  )
180
322
 
181
323
  def move_absolute(
@@ -185,7 +327,7 @@ class GCodeController(SerialController):
185
327
  z: Optional[float] = None,
186
328
  a: Optional[float] = None,
187
329
  feed: Optional[int] = None,
188
- ) -> None:
330
+ ) -> Dict[str, float]:
189
331
  """
190
332
  Move to an absolute position (G90 + G1 command).
191
333
 
@@ -195,24 +337,33 @@ class GCodeController(SerialController):
195
337
  z: Target Z position (optional)
196
338
  a: Target A position (optional)
197
339
  feed: Feed rate for this move (optional, uses current feed if not specified)
340
+
341
+ Raises:
342
+ ValueError: If any position is outside the axis limits
198
343
  """
344
+ # Validate positions before executing move
345
+ self._validate_move_positions(x=x, y=y, z=z, a=a)
346
+
347
+ # Fill in missing axes with current positions
348
+ target_x = x if x is not None else self._current_position["X"]
349
+ target_y = y if y is not None else self._current_position["Y"]
350
+ target_z = z if z is not None else self._current_position["Z"]
351
+ target_a = a if a is not None else self._current_position["A"]
352
+
199
353
  feed_rate = feed if feed is not None else self._feed
200
354
  self._logger.info(
201
355
  "Preparing absolute move to X:%s, Y:%s, Z:%s, A:%s at F:%s",
202
- x,
203
- y,
204
- z,
205
- a,
356
+ target_x,
357
+ target_y,
358
+ target_z,
359
+ target_a,
206
360
  feed_rate,
207
361
  )
208
362
 
209
- # Ensure absolute mode is active
210
- if not self._is_absolute_mode:
211
- self._logger.debug("Switching to absolute positioning mode (G90).")
212
- self._send_command(self._build_command("G90"))
213
- self._is_absolute_mode = True
214
-
215
- self._execute_move(x=x, y=y, z=z, a=a, feed=feed)
363
+ return self._execute_move(
364
+ position={"X": target_x, "Y": target_y, "Z": target_z, "A": target_a},
365
+ feed=feed_rate
366
+ )
216
367
 
217
368
  def move_relative(
218
369
  self,
@@ -221,9 +372,9 @@ class GCodeController(SerialController):
221
372
  z: Optional[float] = None,
222
373
  a: Optional[float] = None,
223
374
  feed: Optional[int] = None,
224
- ) -> None:
375
+ ) -> Dict[str, float]:
225
376
  """
226
- Move relative to the current position (G91 + G1 command).
377
+ Move relative to the current position (converted to absolute move internally).
227
378
 
228
379
  Args:
229
380
  x: Relative X movement (optional)
@@ -231,6 +382,9 @@ class GCodeController(SerialController):
231
382
  z: Relative Z movement (optional)
232
383
  a: Relative A movement (optional)
233
384
  feed: Feed rate for this move (optional, uses current feed if not specified)
385
+
386
+ Raises:
387
+ ValueError: If any resulting absolute position is outside the axis limits
234
388
  """
235
389
  feed_rate = feed if feed is not None else self._feed
236
390
  self._logger.info(
@@ -242,153 +396,111 @@ class GCodeController(SerialController):
242
396
  feed_rate,
243
397
  )
244
398
 
245
- # Ensure relative mode is active
246
- if self._is_absolute_mode:
247
- self._logger.debug("Switching to relative positioning mode (G91).")
248
- self._send_command(self._build_command("G91"))
249
- self._is_absolute_mode = False
399
+ # Convert relative movements to absolute positions, filling in missing axes with current position
400
+ abs_x = (self._current_position["X"] + x) if x is not None else self._current_position["X"]
401
+ abs_y = (self._current_position["Y"] + y) if y is not None else self._current_position["Y"]
402
+ abs_z = (self._current_position["Z"] + z) if z is not None else self._current_position["Z"]
403
+ abs_a = (self._current_position["A"] + a) if a is not None else self._current_position["A"]
250
404
 
251
- self._execute_move(x=x, y=y, z=z, a=a, feed=feed)
405
+ # Validate absolute positions before executing move
406
+ self._validate_move_positions(x=abs_x, y=abs_y, z=abs_z, a=abs_a)
407
+
408
+ return self._execute_move(
409
+ position={"X": abs_x, "Y": abs_y, "Z": abs_z, "A": abs_a},
410
+ feed=feed_rate
411
+ )
252
412
 
253
413
  def _execute_move(
254
414
  self,
255
- x: Optional[float] = None,
256
- y: Optional[float] = None,
257
- z: Optional[float] = None,
258
- a: Optional[float] = None,
259
- feed: Optional[int] = None,
260
- ) -> None:
415
+ position: Dict[str, float],
416
+ feed: int,
417
+ ) -> Dict[str, float]:
261
418
  """
262
419
  Internal helper for executing G1 move commands with safe movement pattern.
420
+ All coordinates are treated as absolute positions.
263
421
 
264
422
  Safe move pattern:
265
423
  1. If X or Y movement is needed, first move Z to 0 (safe height)
266
- 2. Then move X, Y (and optionally A) to target
267
- 3. Finally move Z to target position (if specified)
424
+ 2. Then move X, Y to target
425
+ 3. Finally move Z and A back to original position (or target if specified)
268
426
 
269
427
  Args:
270
- x: X axis value (optional)
271
- y: Y axis value (optional)
272
- z: Z axis value (optional)
273
- a: A axis value (optional)
274
- feed: Feed rate (optional)
275
- """
276
- # Calculate target positions
277
- target_pos = self.current_position.copy()
278
- has_x = x is not None
279
- has_y = y is not None
280
- has_z = z is not None
281
- has_a = a is not None
282
-
283
- if not (has_x or has_y or has_z or has_a):
428
+ position: Dictionary with absolute positions for X, Y, Z, A axes
429
+ feed: Feed rate for the move
430
+ """
431
+ # Check if any movement is needed
432
+ needs_x_move = abs(position["X"] - self._current_position["X"]) > self.TOLERANCE
433
+ needs_y_move = abs(position["Y"] - self._current_position["Y"]) > self.TOLERANCE
434
+ needs_z_move = abs(position["Z"] - self._current_position["Z"]) > self.TOLERANCE
435
+ needs_a_move = abs(position["A"] - self._current_position["A"]) > self.TOLERANCE
436
+
437
+ if not (needs_x_move or needs_y_move or needs_z_move or needs_a_move):
284
438
  self._logger.warning(
285
439
  "Move command issued without any axis movement. Skipping transmission."
286
440
  )
287
441
  return
442
+
443
+ if needs_z_move and needs_a_move:
444
+ self._logger.warning(
445
+ "Move command issued with both Z and A movement. This is not supported. Skipping transmission."
446
+ )
447
+ raise ValueError("Move command issued with both Z and A movement. This is not supported.")
288
448
 
289
- # Calculate target positions
290
- if self._is_absolute_mode:
291
- if has_x:
292
- target_pos["X"] = x
293
- if has_y:
294
- target_pos["Y"] = y
295
- if has_z:
296
- target_pos["Z"] = z
297
- if has_a:
298
- target_pos["A"] = a
299
- else:
300
- if has_x:
301
- target_pos["X"] = self.current_position["X"] + x
302
- if has_y:
303
- target_pos["Y"] = self.current_position["Y"] + y
304
- if has_z:
305
- target_pos["Z"] = self.current_position["Z"] + z
306
- if has_a:
307
- target_pos["A"] = self.current_position["A"] + a
308
-
309
- feed_rate = feed if feed is not None else self._feed
310
- if feed_rate > self.MAX_FEEDRATE:
311
- feed_rate = self.MAX_FEEDRATE
312
-
313
- # Safe move pattern: Z to 0, then XY, then Z to target
314
- needs_xy_move = has_x or has_y
315
- current_z = self.current_position["Z"]
316
- target_z = target_pos["Z"] if has_z else current_z
449
+ # Step 0: Ensure absolute mode is active
450
+ self.execute(self._build_command("G90"))
451
+ needs_xy_move = needs_x_move or needs_y_move
317
452
 
318
- # Step 1: Move Z to safe height (0) if XY movement is needed and Z is not already at 0
319
- if needs_xy_move and abs(current_z) > 0.001: # Small tolerance for floating point
453
+ # Step 1: Move Z and A to SAFE_MOVE_HEIGHT if XY movement is needed
454
+ if needs_xy_move:
320
455
  self._logger.info(
321
- "Safe move: Raising Z to safe height (0) before XY movement"
456
+ "Safe move: Raising Z and A to safe height (%s) before XY movement", self.SAFE_MOVE_HEIGHT
322
457
  )
323
- if self._is_absolute_mode:
324
- move_cmd = "G1 Z0"
325
- else:
326
- # In relative mode, move by negative current_z to reach 0
327
- move_cmd = f"G1 Z{-current_z}"
328
- move_cmd += f" F{feed_rate}"
329
- self._send_command(self._build_command(move_cmd))
330
- self.current_position["Z"] = 0.0
331
- self._logger.debug("Z moved to safe height (0)")
332
-
333
- # Step 2: Move X, Y (and optionally A) to target
334
- if needs_xy_move or has_a:
458
+ move_cmd = f"G1 Z-5 A-5 F{self._z_feed}"
459
+ self.execute(self._build_command(move_cmd))
460
+ self.wait_for_move()
461
+ self._current_position["Z"] = self.SAFE_MOVE_HEIGHT
462
+ self._current_position["A"] = self.SAFE_MOVE_HEIGHT
463
+ self._logger.debug("Z and A moved to safe height (%s)", self.SAFE_MOVE_HEIGHT)
464
+
465
+ # Step 2: Move X, Y to target
466
+ if needs_xy_move:
335
467
  move_cmd = "G1"
336
- if has_x:
337
- # Use target position in absolute mode, relative value in relative mode
338
- if self._is_absolute_mode:
339
- move_cmd += f" X{target_pos['X']}"
340
- else:
341
- move_cmd += f" X{x}"
342
- if has_y:
343
- if self._is_absolute_mode:
344
- move_cmd += f" Y{target_pos['Y']}"
345
- else:
346
- move_cmd += f" Y{y}"
347
- if has_a:
348
- if self._is_absolute_mode:
349
- move_cmd += f" A{target_pos['A']}"
350
- else:
351
- move_cmd += f" A{a}"
352
- move_cmd += f" F{feed_rate}"
468
+ if needs_x_move:
469
+ move_cmd += f" X{position['X']}"
470
+ if needs_y_move:
471
+ move_cmd += f" Y{position['Y']}"
472
+ move_cmd += f" F{feed}"
353
473
 
354
474
  self._logger.info("Executing XY move command: %s", move_cmd)
355
- self._send_command(self._build_command(move_cmd))
475
+ self.execute(self._build_command(move_cmd))
476
+ self.wait_for_move()
356
477
 
357
478
  # Update position for moved axes
358
- if has_x:
359
- self.current_position["X"] = target_pos["X"]
360
- if has_y:
361
- self.current_position["Y"] = target_pos["Y"]
362
- if has_a:
363
- self.current_position["A"] = target_pos["A"]
364
-
365
- # Step 3: Move Z to target position (if Z movement was requested)
366
- if has_z:
367
- z_needs_move = abs(target_z - (0.0 if needs_xy_move else current_z)) > 0.001
368
- if z_needs_move:
369
- if self._is_absolute_mode:
370
- move_cmd = f"G1 Z{target_z} F{feed_rate}"
371
- else:
372
- # In relative mode, use the original z value
373
- move_cmd = f"G1 Z{z} F{feed_rate}"
374
-
375
- if needs_xy_move:
376
- self._logger.info(
377
- "Safe move: Lowering Z to target position: %s", target_z
378
- )
379
- else:
380
- self._logger.info("Executing Z move command: %s", move_cmd)
381
-
382
- self._send_command(self._build_command(move_cmd))
383
- self.current_position["Z"] = target_z
479
+ if needs_x_move:
480
+ self._current_position["X"] = position['X']
481
+ if needs_y_move:
482
+ self._current_position["Y"] = position['Y']
483
+
484
+ # Step 3: Move Z and A back to original position (or target if specified)
485
+ if needs_z_move:
486
+ move_cmd = f"G1 Z{position['Z']} F{self._z_feed}"
487
+ self.execute(self._build_command(move_cmd))
488
+ self._current_position["Z"] = position['Z']
489
+ elif needs_a_move:
490
+ move_cmd = f"G1 A{position['A']} F{self._z_feed}"
491
+ self.execute(self._build_command(move_cmd))
492
+ self._current_position["A"] = position['A']
493
+ self.wait_for_move()
384
494
 
385
495
  self._logger.info(
386
- "Move complete. Final position: %s", self.current_position
496
+ "Move complete. Final position: %s", self._current_position
387
497
  )
388
- self._logger.debug("New internal position: %s", self.current_position)
498
+ self._logger.debug("New internal position: %s", self._current_position)
389
499
 
390
- # Post-move position synchronization check
500
+ # Step 4: Post-move position synchronization check
391
501
  self.sync_position()
502
+
503
+ return self._current_position
392
504
 
393
505
  def query_position(self) -> Dict[str, float]:
394
506
  """
@@ -401,8 +513,7 @@ class GCodeController(SerialController):
401
513
  Returns an empty dictionary if the query fails or no positions are found.
402
514
  """
403
515
  self._logger.info("Querying current machine position (M114).")
404
- self._send_command(self._build_command("M114"))
405
- res: str = self._read_response()
516
+ res: str = self.execute(self._build_command("M114"))
406
517
 
407
518
  # Extract position values using regex
408
519
  pattern = re.compile(r"([XYZA]):(-?\d+\.\d+)")
@@ -444,8 +555,8 @@ class GCodeController(SerialController):
444
555
  queried_position = self.query_position()
445
556
 
446
557
  if not queried_position:
447
- self._logger.warning("Query position failed. Cannot synchronize.")
448
- return False, self.current_position
558
+ self._logger.error("Query position failed. Cannot synchronize.")
559
+ raise ValueError("Query position failed. Cannot synchronize.")
449
560
 
450
561
  # Compare internal vs. queried position
451
562
  axis_keys = ["X", "Y", "Z", "A"]
@@ -453,36 +564,31 @@ class GCodeController(SerialController):
453
564
 
454
565
  for axis in axis_keys:
455
566
  if (
456
- axis in self.current_position
567
+ axis in self._current_position
457
568
  and axis in queried_position
458
- and abs(self.current_position[axis] - queried_position[axis])
569
+ and abs(self._current_position[axis] - queried_position[axis])
459
570
  > self.TOLERANCE
460
571
  ):
461
572
  self._logger.warning(
462
573
  "Position mismatch found on %s axis: Internal=%.3f, Queried=%.3f",
463
574
  axis,
464
- self.current_position[axis],
575
+ self._current_position[axis],
465
576
  queried_position[axis],
466
577
  )
467
578
  adjustment_needed = True
468
579
  elif axis in queried_position:
469
580
  # Update internal position with queried position if it differs slightly
470
- self.current_position[axis] = queried_position[axis]
581
+ self._current_position[axis] = queried_position[axis]
471
582
 
472
583
  # Perform re-synchronization move if needed
473
584
  if adjustment_needed:
474
585
  self._logger.info(
475
- "** DISCREPANCY DETECTED. Moving robot back to internal position: %s **",
476
- self.current_position,
586
+ "** DISCREPANCY DETECTED. Moving robot to internal position: %s **",
587
+ self._current_position,
477
588
  )
478
589
 
479
590
  try:
480
- target_x = self.current_position.get("X")
481
- target_y = self.current_position.get("Y")
482
- target_z = self.current_position.get("Z")
483
- target_a = self.current_position.get("A")
484
-
485
- self.move_absolute(x=target_x, y=target_y, z=target_z, a=target_a)
591
+ self.move_absolute(x=self._current_position["X"], y=self._current_position["Y"], z=self._current_position["Z"], a=self._current_position["A"])
486
592
  self._logger.info("Synchronization move successfully completed.")
487
593
 
488
594
  # Recursive call to verify position after move
@@ -491,8 +597,6 @@ class GCodeController(SerialController):
491
597
  self._logger.error("Synchronization move failed: %s", e)
492
598
  adjustment_needed = False
493
599
 
494
- final_position = self.current_position.copy()
495
-
496
600
  if adjustment_needed:
497
601
  self._logger.info(
498
602
  "Position check complete. Internal position is synchronized with machine."
@@ -500,7 +604,7 @@ class GCodeController(SerialController):
500
604
  else:
501
605
  self._logger.info("No adjustment was made.")
502
606
 
503
- return adjustment_needed, final_position
607
+ return adjustment_needed, self._current_position.copy()
504
608
 
505
609
  def get_info(self) -> str:
506
610
  """
@@ -510,9 +614,7 @@ class GCodeController(SerialController):
510
614
  Machine information string from the device
511
615
  """
512
616
  self._logger.info("Querying machine information (M115).")
513
- self._send_command(self._build_command("M115"))
514
- res: str = self._read_response()
515
- return res
617
+ return self.execute(self._build_command("M115"))
516
618
 
517
619
  def get_internal_position(self) -> Dict[str, float]:
518
620
  """
@@ -521,5 +623,5 @@ class GCodeController(SerialController):
521
623
  Returns:
522
624
  Dictionary containing the current internal position for all axes
523
625
  """
524
- self._logger.debug("Returning internal position: %s", self.current_position)
525
- return self.current_position.copy()
626
+ self._logger.debug("Returning internal position: %s", self._current_position)
627
+ return self._current_position.copy()
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: puda-drivers
3
- Version: 0.0.3
3
+ Version: 0.0.5
4
4
  Summary: Hardware drivers for the PUDA platform.
5
5
  Project-URL: Homepage, https://github.com/zhao-bears/puda-drivers
6
6
  Project-URL: Issues, https://github.com/zhao-bears/puda-drivers/issues
@@ -56,13 +56,19 @@ from puda_drivers.move import GCodeController
56
56
  gantry = GCodeController(port_name="/dev/ttyACM0", feed=3000)
57
57
  gantry.connect()
58
58
 
59
+ # Configure axis limits for safety (recommended)
60
+ gantry.set_axis_limits("X", 0, 200)
61
+ gantry.set_axis_limits("Y", -200, 0)
62
+ gantry.set_axis_limits("Z", -100, 0)
63
+ gantry.set_axis_limits("A", -180, 180)
64
+
59
65
  # Home the gantry
60
66
  gantry.home()
61
67
 
62
- # Move to absolute position
68
+ # Move to absolute position (validated against limits)
63
69
  gantry.move_absolute(x=50.0, y=-100.0, z=-10.0)
64
70
 
65
- # Move relative to current position
71
+ # Move relative to current position (validated after conversion to absolute)
66
72
  gantry.move_relative(x=20.0, y=-10.0)
67
73
 
68
74
  # Query current position
@@ -73,6 +79,8 @@ print(f"Current position: {position}")
73
79
  gantry.disconnect()
74
80
  ```
75
81
 
82
+ **Axis Limits and Validation**: The `move_absolute()` and `move_relative()` methods automatically validate that target positions are within configured axis limits. If a position is outside the limits, a `ValueError` is raised before any movement is executed. Use `set_axis_limits()` to configure limits for each axis.
83
+
76
84
  ### Liquid Handling (Sartorius)
77
85
 
78
86
  ```python
@@ -134,6 +142,7 @@ pipette.disconnect()
134
142
  - Supports X, Y, Z, and A axes
135
143
  - Configurable feed rates
136
144
  - Position synchronization and homing
145
+ - Automatic axis limit validation for safe operation
137
146
 
138
147
  ### Liquid Handling
139
148
 
@@ -142,6 +151,38 @@ pipette.disconnect()
142
151
  - Tip attachment and ejection
143
152
  - Configurable speeds and volumes
144
153
 
154
+ ## Error Handling
155
+
156
+ ### Axis Limit Validation
157
+
158
+ Both `move_absolute()` and `move_relative()` validate positions against configured axis limits before executing any movement. If a position is outside the limits, a `ValueError` is raised:
159
+
160
+ ```python
161
+ from puda_drivers.move import GCodeController
162
+
163
+ gantry = GCodeController(port_name="/dev/ttyACM0")
164
+ gantry.connect()
165
+
166
+ # Set axis limits
167
+ gantry.set_axis_limits("X", 0, 200)
168
+ gantry.set_axis_limits("Y", -200, 0)
169
+
170
+ try:
171
+ # This will raise ValueError: Value 250 outside axis limits [0, 200]
172
+ gantry.move_absolute(x=250.0, y=-50.0)
173
+ except ValueError as e:
174
+ print(f"Move rejected: {e}")
175
+
176
+ # Relative moves are also validated after conversion to absolute positions
177
+ try:
178
+ # If current X is 150, moving 100 more would exceed the limit
179
+ gantry.move_relative(x=100.0)
180
+ except ValueError as e:
181
+ print(f"Move rejected: {e}")
182
+ ```
183
+
184
+ Validation errors are automatically logged at the ERROR level before the exception is raised.
185
+
145
186
  ## Finding Serial Ports
146
187
 
147
188
  To discover available serial ports on your system:
@@ -158,8 +199,6 @@ for port, desc, hwid in ports:
158
199
  sartorius_ports = list_serial_ports(filter_desc="Sartorius")
159
200
  ```
160
201
 
161
- **Note:** The `list_ports()` method is also available as a static method on `SerialController` for backward compatibility, but the module-level `list_serial_ports()` function is the recommended approach.
162
-
163
202
  ## Requirements
164
203
 
165
204
  - Python >= 3.14
@@ -1,9 +1,9 @@
1
1
  puda_drivers/__init__.py,sha256=rcF5xCkMgyLlJLN3gWwJnUoW0ShPyISeyENvaqwg4Ik,503
2
2
  puda_drivers/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
3
3
  puda_drivers/core/__init__.py,sha256=JM6eWTelwcmjTGM3gprQlJWzPGEpIdRrDmbCHtGoKyM,119
4
- puda_drivers/core/serialcontroller.py,sha256=-wdQqq30yb6dSsNwA2FQec5HUh0U3j71OOMt6pbQIpc,7230
4
+ puda_drivers/core/serialcontroller.py,sha256=I7TsLNl45HPrO29LkhMRIQQ8fWdjdJUvIwQQ5swbMHM,7660
5
5
  puda_drivers/move/__init__.py,sha256=i7G5VKD5FgnmC21TLxoASVtC88IrPUTLDJrTnp99u-0,35
6
- puda_drivers/move/gcode.py,sha256=1KkpgwXBiNrcV4Ilc-6n-ADQwOVwVgK_9x08LKhbE5g,18106
6
+ puda_drivers/move/gcode.py,sha256=egZw3D5m9d1R8P32L1wd3lDwiWcFMDGPHsFMFIYXkRA,22069
7
7
  puda_drivers/move/grbl/__init__.py,sha256=vBeeti8DVN2dACi1rLmHN_UGIOdo0s-HZX6mIepLV5I,98
8
8
  puda_drivers/move/grbl/api.py,sha256=loj8_Vap7S9qaD0ReHhgxr9Vkl6Wp7DGzyLkZyZ6v_k,16995
9
9
  puda_drivers/move/grbl/constants.py,sha256=4736CRDzLGWVqGscLajMlrIQMyubsHfthXi4RF1CHNg,9585
@@ -11,7 +11,7 @@ puda_drivers/transfer/liquid/sartorius/__init__.py,sha256=QGpKz5YUwa8xCdSMXeZ0iR
11
11
  puda_drivers/transfer/liquid/sartorius/api.py,sha256=jxwIJmY2k1K2ts6NC2ZgFTe4MOiH8TGnJeqYOqNa3rE,28250
12
12
  puda_drivers/transfer/liquid/sartorius/constants.py,sha256=mcsjLrVBH-RSodH-pszstwcEL9wwbV0vOgHbGNxZz9w,2770
13
13
  puda_drivers/transfer/liquid/sartorius/sartorius.py,sha256=iW3v-YHjj4ZAfGv0x0J-XV-Y0fAAhS6xmSg2ozQm4UI,13803
14
- puda_drivers-0.0.3.dist-info/METADATA,sha256=and-Tnldz1Er_yVN76nJdmDDCsqoiEEJmyIovzaXNMI,5153
15
- puda_drivers-0.0.3.dist-info/WHEEL,sha256=WLgqFyCfm_KASv4WHyYy0P3pM_m7J5L9k2skdKLirC8,87
16
- puda_drivers-0.0.3.dist-info/licenses/LICENSE,sha256=7EI8xVBu6h_7_JlVw-yPhhOZlpY9hP8wal7kHtqKT_E,1074
17
- puda_drivers-0.0.3.dist-info/RECORD,,
14
+ puda_drivers-0.0.5.dist-info/METADATA,sha256=5VB_QiC_hcuV4-FEoXi-qEp637jAjQLiv5fipHX9QPg,6559
15
+ puda_drivers-0.0.5.dist-info/WHEEL,sha256=WLgqFyCfm_KASv4WHyYy0P3pM_m7J5L9k2skdKLirC8,87
16
+ puda_drivers-0.0.5.dist-info/licenses/LICENSE,sha256=7EI8xVBu6h_7_JlVw-yPhhOZlpY9hP8wal7kHtqKT_E,1074
17
+ puda_drivers-0.0.5.dist-info/RECORD,,