puda-drivers 0.0.3__tar.gz → 0.0.4__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.
- {puda_drivers-0.0.3 → puda_drivers-0.0.4}/PKG-INFO +1 -3
- {puda_drivers-0.0.3 → puda_drivers-0.0.4}/README.md +0 -2
- {puda_drivers-0.0.3 → puda_drivers-0.0.4}/pyproject.toml +1 -1
- {puda_drivers-0.0.3 → puda_drivers-0.0.4}/src/puda_drivers/core/serialcontroller.py +59 -44
- {puda_drivers-0.0.3 → puda_drivers-0.0.4}/src/puda_drivers/move/gcode.py +182 -75
- {puda_drivers-0.0.3 → puda_drivers-0.0.4}/tests/qubot.py +26 -5
- {puda_drivers-0.0.3 → puda_drivers-0.0.4}/tests/together.py +21 -7
- {puda_drivers-0.0.3 → puda_drivers-0.0.4}/uv.lock +1 -1
- {puda_drivers-0.0.3 → puda_drivers-0.0.4}/.gitignore +0 -0
- {puda_drivers-0.0.3 → puda_drivers-0.0.4}/LICENSE +0 -0
- {puda_drivers-0.0.3 → puda_drivers-0.0.4}/src/puda_drivers/__init__.py +0 -0
- {puda_drivers-0.0.3 → puda_drivers-0.0.4}/src/puda_drivers/core/__init__.py +0 -0
- {puda_drivers-0.0.3 → puda_drivers-0.0.4}/src/puda_drivers/move/__init__.py +0 -0
- {puda_drivers-0.0.3 → puda_drivers-0.0.4}/src/puda_drivers/move/grbl/__init__.py +0 -0
- {puda_drivers-0.0.3 → puda_drivers-0.0.4}/src/puda_drivers/move/grbl/api.py +0 -0
- {puda_drivers-0.0.3 → puda_drivers-0.0.4}/src/puda_drivers/move/grbl/constants.py +0 -0
- {puda_drivers-0.0.3 → puda_drivers-0.0.4}/src/puda_drivers/py.typed +0 -0
- {puda_drivers-0.0.3 → puda_drivers-0.0.4}/src/puda_drivers/transfer/liquid/sartorius/__init__.py +0 -0
- {puda_drivers-0.0.3 → puda_drivers-0.0.4}/src/puda_drivers/transfer/liquid/sartorius/api.py +0 -0
- {puda_drivers-0.0.3 → puda_drivers-0.0.4}/src/puda_drivers/transfer/liquid/sartorius/constants.py +0 -0
- {puda_drivers-0.0.3 → puda_drivers-0.0.4}/src/puda_drivers/transfer/liquid/sartorius/sartorius.py +0 -0
- {puda_drivers-0.0.3 → puda_drivers-0.0.4}/tests/example.py +0 -0
- {puda_drivers-0.0.3 → puda_drivers-0.0.4}/tests/sartorius.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: puda-drivers
|
|
3
|
-
Version: 0.0.
|
|
3
|
+
Version: 0.0.4
|
|
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
|
|
@@ -158,8 +158,6 @@ for port, desc, hwid in ports:
|
|
|
158
158
|
sartorius_ports = list_serial_ports(filter_desc="Sartorius")
|
|
159
159
|
```
|
|
160
160
|
|
|
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
161
|
## Requirements
|
|
164
162
|
|
|
165
163
|
- Python >= 3.14
|
|
@@ -138,8 +138,6 @@ for port, desc, hwid in ports:
|
|
|
138
138
|
sartorius_ports = list_serial_ports(filter_desc="Sartorius")
|
|
139
139
|
```
|
|
140
140
|
|
|
141
|
-
**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.
|
|
142
|
-
|
|
143
141
|
## Requirements
|
|
144
142
|
|
|
145
143
|
- Python >= 3.14
|
|
@@ -3,12 +3,11 @@ Generic Serial Controller for communicating with devices over serial ports.
|
|
|
3
3
|
"""
|
|
4
4
|
|
|
5
5
|
import serial
|
|
6
|
-
import sys
|
|
7
6
|
import time
|
|
8
7
|
import serial.tools.list_ports
|
|
9
8
|
import logging
|
|
10
9
|
from typing import Optional, List, Tuple
|
|
11
|
-
from abc import ABC
|
|
10
|
+
from abc import ABC
|
|
12
11
|
|
|
13
12
|
logger = logging.getLogger(__name__)
|
|
14
13
|
|
|
@@ -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
|
-
|
|
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(
|
|
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(
|
|
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(
|
|
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
|
-
|
|
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
|
-
|
|
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(
|
|
130
|
+
self._serial.write(bytes(command, "utf-8"))
|
|
147
131
|
self._serial.flush()
|
|
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(
|
|
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
|
-
|
|
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
|
-
#
|
|
177
|
-
if b"ok" in response:
|
|
178
|
-
|
|
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.
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
)
|
|
194
|
-
|
|
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.
|
|
6
|
-
|
|
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
|
|
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
|
|
22
|
-
|
|
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)
|
|
@@ -68,7 +94,14 @@ class GCodeController(SerialController):
|
|
|
68
94
|
"A": 0.0,
|
|
69
95
|
}
|
|
70
96
|
self._feed: int = feed
|
|
71
|
-
|
|
97
|
+
|
|
98
|
+
# Initialize axis limits with default values
|
|
99
|
+
self._axis_limits: Dict[str, AxisLimits] = {
|
|
100
|
+
"X": AxisLimits(0, 0),
|
|
101
|
+
"Y": AxisLimits(0, 0),
|
|
102
|
+
"Z": AxisLimits(0, 0),
|
|
103
|
+
"A": AxisLimits(0, 0),
|
|
104
|
+
}
|
|
72
105
|
|
|
73
106
|
@property
|
|
74
107
|
def feed(self) -> int:
|
|
@@ -143,6 +176,102 @@ class GCodeController(SerialController):
|
|
|
143
176
|
)
|
|
144
177
|
return axis_upper
|
|
145
178
|
|
|
179
|
+
def _validate_move_positions(
|
|
180
|
+
self,
|
|
181
|
+
x: Optional[float] = None,
|
|
182
|
+
y: Optional[float] = None,
|
|
183
|
+
z: Optional[float] = None,
|
|
184
|
+
a: Optional[float] = None,
|
|
185
|
+
) -> None:
|
|
186
|
+
"""
|
|
187
|
+
Validate that move positions are within axis limits.
|
|
188
|
+
|
|
189
|
+
Only validates axes that are being moved (not None). Raises ValueError
|
|
190
|
+
if any position is outside the configured limits.
|
|
191
|
+
|
|
192
|
+
Args:
|
|
193
|
+
x: Target X position (optional)
|
|
194
|
+
y: Target Y position (optional)
|
|
195
|
+
z: Target Z position (optional)
|
|
196
|
+
a: Target A position (optional)
|
|
197
|
+
|
|
198
|
+
Raises:
|
|
199
|
+
ValueError: If any position is outside the axis limits
|
|
200
|
+
"""
|
|
201
|
+
if x is not None:
|
|
202
|
+
if "X" in self._axis_limits:
|
|
203
|
+
try:
|
|
204
|
+
self._axis_limits["X"].validate(x)
|
|
205
|
+
except ValueError as e:
|
|
206
|
+
self._logger.error("Move validation failed for X axis: %s", e)
|
|
207
|
+
raise
|
|
208
|
+
if y is not None:
|
|
209
|
+
if "Y" in self._axis_limits:
|
|
210
|
+
try:
|
|
211
|
+
self._axis_limits["Y"].validate(y)
|
|
212
|
+
except ValueError as e:
|
|
213
|
+
self._logger.error("Move validation failed for Y axis: %s", e)
|
|
214
|
+
raise
|
|
215
|
+
if z is not None:
|
|
216
|
+
if "Z" in self._axis_limits:
|
|
217
|
+
try:
|
|
218
|
+
self._axis_limits["Z"].validate(z)
|
|
219
|
+
except ValueError as e:
|
|
220
|
+
self._logger.error("Move validation failed for Z axis: %s", e)
|
|
221
|
+
raise
|
|
222
|
+
if a is not None:
|
|
223
|
+
if "A" in self._axis_limits:
|
|
224
|
+
try:
|
|
225
|
+
self._axis_limits["A"].validate(a)
|
|
226
|
+
except ValueError as e:
|
|
227
|
+
self._logger.error("Move validation failed for A axis: %s", e)
|
|
228
|
+
raise
|
|
229
|
+
|
|
230
|
+
def set_axis_limits(
|
|
231
|
+
self, axis: str, min_val: float, max_val: float
|
|
232
|
+
) -> None:
|
|
233
|
+
"""
|
|
234
|
+
Set the min/max limits for an axis.
|
|
235
|
+
|
|
236
|
+
Args:
|
|
237
|
+
axis: Axis name ('X', 'Y', 'Z', 'A')
|
|
238
|
+
min_val: Minimum allowed value
|
|
239
|
+
max_val: Maximum allowed value
|
|
240
|
+
|
|
241
|
+
Raises:
|
|
242
|
+
ValueError: If axis is unknown or min >= max
|
|
243
|
+
"""
|
|
244
|
+
axis = self._validate_axis(axis)
|
|
245
|
+
|
|
246
|
+
if min_val >= max_val:
|
|
247
|
+
raise ValueError("min must be < max")
|
|
248
|
+
|
|
249
|
+
self._axis_limits[axis] = AxisLimits(min_val, max_val)
|
|
250
|
+
self._logger.info(
|
|
251
|
+
"Set limits for axis %s: [%s, %s]", axis, min_val, max_val
|
|
252
|
+
)
|
|
253
|
+
|
|
254
|
+
def get_axis_limits(
|
|
255
|
+
self, axis: Optional[str] = None
|
|
256
|
+
) -> Union[AxisLimits, Dict[str, AxisLimits]]:
|
|
257
|
+
"""
|
|
258
|
+
Get the current limits for an axis or all axes.
|
|
259
|
+
|
|
260
|
+
Args:
|
|
261
|
+
axis: Optional axis name ('X', 'Y', 'Z', 'A'). If None, returns all limits.
|
|
262
|
+
|
|
263
|
+
Returns:
|
|
264
|
+
If axis is specified: AxisLimits object with min and max values.
|
|
265
|
+
If axis is None: Dictionary of all axis limits.
|
|
266
|
+
|
|
267
|
+
Raises:
|
|
268
|
+
ValueError: If axis is unknown (only when axis is provided)
|
|
269
|
+
"""
|
|
270
|
+
if axis is None:
|
|
271
|
+
return self._axis_limits.copy()
|
|
272
|
+
axis = self._validate_axis(axis)
|
|
273
|
+
return self._axis_limits[axis]
|
|
274
|
+
|
|
146
275
|
def home(self, axis: Optional[str] = None) -> None:
|
|
147
276
|
"""
|
|
148
277
|
Home one or all axes (G28 command).
|
|
@@ -195,7 +324,13 @@ class GCodeController(SerialController):
|
|
|
195
324
|
z: Target Z position (optional)
|
|
196
325
|
a: Target A position (optional)
|
|
197
326
|
feed: Feed rate for this move (optional, uses current feed if not specified)
|
|
327
|
+
|
|
328
|
+
Raises:
|
|
329
|
+
ValueError: If any position is outside the axis limits
|
|
198
330
|
"""
|
|
331
|
+
# Validate positions before executing move
|
|
332
|
+
self._validate_move_positions(x=x, y=y, z=z, a=a)
|
|
333
|
+
|
|
199
334
|
feed_rate = feed if feed is not None else self._feed
|
|
200
335
|
self._logger.info(
|
|
201
336
|
"Preparing absolute move to X:%s, Y:%s, Z:%s, A:%s at F:%s",
|
|
@@ -206,12 +341,6 @@ class GCodeController(SerialController):
|
|
|
206
341
|
feed_rate,
|
|
207
342
|
)
|
|
208
343
|
|
|
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
344
|
self._execute_move(x=x, y=y, z=z, a=a, feed=feed)
|
|
216
345
|
|
|
217
346
|
def move_relative(
|
|
@@ -223,7 +352,7 @@ class GCodeController(SerialController):
|
|
|
223
352
|
feed: Optional[int] = None,
|
|
224
353
|
) -> None:
|
|
225
354
|
"""
|
|
226
|
-
Move relative to the current position (
|
|
355
|
+
Move relative to the current position (converted to absolute move internally).
|
|
227
356
|
|
|
228
357
|
Args:
|
|
229
358
|
x: Relative X movement (optional)
|
|
@@ -231,6 +360,9 @@ class GCodeController(SerialController):
|
|
|
231
360
|
z: Relative Z movement (optional)
|
|
232
361
|
a: Relative A movement (optional)
|
|
233
362
|
feed: Feed rate for this move (optional, uses current feed if not specified)
|
|
363
|
+
|
|
364
|
+
Raises:
|
|
365
|
+
ValueError: If any resulting absolute position is outside the axis limits
|
|
234
366
|
"""
|
|
235
367
|
feed_rate = feed if feed is not None else self._feed
|
|
236
368
|
self._logger.info(
|
|
@@ -242,13 +374,16 @@ class GCodeController(SerialController):
|
|
|
242
374
|
feed_rate,
|
|
243
375
|
)
|
|
244
376
|
|
|
245
|
-
#
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
377
|
+
# Convert relative movements to absolute positions
|
|
378
|
+
abs_x = (self.current_position["X"] + x) if x is not None else None
|
|
379
|
+
abs_y = (self.current_position["Y"] + y) if y is not None else None
|
|
380
|
+
abs_z = (self.current_position["Z"] + z) if z is not None else None
|
|
381
|
+
abs_a = (self.current_position["A"] + a) if a is not None else None
|
|
250
382
|
|
|
251
|
-
|
|
383
|
+
# Validate absolute positions before executing move
|
|
384
|
+
self._validate_move_positions(x=abs_x, y=abs_y, z=abs_z, a=abs_a)
|
|
385
|
+
|
|
386
|
+
self._execute_move(x=abs_x, y=abs_y, z=abs_z, a=abs_a, feed=feed)
|
|
252
387
|
|
|
253
388
|
def _execute_move(
|
|
254
389
|
self,
|
|
@@ -260,6 +395,7 @@ class GCodeController(SerialController):
|
|
|
260
395
|
) -> None:
|
|
261
396
|
"""
|
|
262
397
|
Internal helper for executing G1 move commands with safe movement pattern.
|
|
398
|
+
All coordinates are treated as absolute positions.
|
|
263
399
|
|
|
264
400
|
Safe move pattern:
|
|
265
401
|
1. If X or Y movement is needed, first move Z to 0 (safe height)
|
|
@@ -267,13 +403,13 @@ class GCodeController(SerialController):
|
|
|
267
403
|
3. Finally move Z to target position (if specified)
|
|
268
404
|
|
|
269
405
|
Args:
|
|
270
|
-
x: X
|
|
271
|
-
y: Y
|
|
272
|
-
z: Z
|
|
273
|
-
a: A
|
|
406
|
+
x: Absolute X position (optional)
|
|
407
|
+
y: Absolute Y position (optional)
|
|
408
|
+
z: Absolute Z position (optional)
|
|
409
|
+
a: Absolute A position (optional)
|
|
274
410
|
feed: Feed rate (optional)
|
|
275
411
|
"""
|
|
276
|
-
# Calculate target positions
|
|
412
|
+
# Calculate target positions (all absolute)
|
|
277
413
|
target_pos = self.current_position.copy()
|
|
278
414
|
has_x = x is not None
|
|
279
415
|
has_y = y is not None
|
|
@@ -286,30 +422,23 @@ class GCodeController(SerialController):
|
|
|
286
422
|
)
|
|
287
423
|
return
|
|
288
424
|
|
|
289
|
-
#
|
|
290
|
-
if
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
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
|
|
425
|
+
# Set target positions (all absolute)
|
|
426
|
+
if has_x:
|
|
427
|
+
target_pos["X"] = x
|
|
428
|
+
if has_y:
|
|
429
|
+
target_pos["Y"] = y
|
|
430
|
+
if has_z:
|
|
431
|
+
target_pos["Z"] = z
|
|
432
|
+
if has_a:
|
|
433
|
+
target_pos["A"] = a
|
|
308
434
|
|
|
309
435
|
feed_rate = feed if feed is not None else self._feed
|
|
310
436
|
if feed_rate > self.MAX_FEEDRATE:
|
|
311
437
|
feed_rate = self.MAX_FEEDRATE
|
|
312
438
|
|
|
439
|
+
# Ensure absolute mode is active
|
|
440
|
+
self._send_command(self._build_command("G90"))
|
|
441
|
+
|
|
313
442
|
# Safe move pattern: Z to 0, then XY, then Z to target
|
|
314
443
|
needs_xy_move = has_x or has_y
|
|
315
444
|
current_z = self.current_position["Z"]
|
|
@@ -317,15 +446,12 @@ class GCodeController(SerialController):
|
|
|
317
446
|
|
|
318
447
|
# Step 1: Move Z to safe height (0) if XY movement is needed and Z is not already at 0
|
|
319
448
|
if needs_xy_move and abs(current_z) > 0.001: # Small tolerance for floating point
|
|
449
|
+
# Validate safe height (Z=0) is within limits
|
|
450
|
+
self._validate_move_positions(z=0.0)
|
|
320
451
|
self._logger.info(
|
|
321
452
|
"Safe move: Raising Z to safe height (0) before XY movement"
|
|
322
453
|
)
|
|
323
|
-
|
|
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}"
|
|
454
|
+
move_cmd = f"G1 Z0 F{feed_rate}"
|
|
329
455
|
self._send_command(self._build_command(move_cmd))
|
|
330
456
|
self.current_position["Z"] = 0.0
|
|
331
457
|
self._logger.debug("Z moved to safe height (0)")
|
|
@@ -334,21 +460,11 @@ class GCodeController(SerialController):
|
|
|
334
460
|
if needs_xy_move or has_a:
|
|
335
461
|
move_cmd = "G1"
|
|
336
462
|
if has_x:
|
|
337
|
-
|
|
338
|
-
if self._is_absolute_mode:
|
|
339
|
-
move_cmd += f" X{target_pos['X']}"
|
|
340
|
-
else:
|
|
341
|
-
move_cmd += f" X{x}"
|
|
463
|
+
move_cmd += f" X{target_pos['X']}"
|
|
342
464
|
if has_y:
|
|
343
|
-
|
|
344
|
-
move_cmd += f" Y{target_pos['Y']}"
|
|
345
|
-
else:
|
|
346
|
-
move_cmd += f" Y{y}"
|
|
465
|
+
move_cmd += f" Y{target_pos['Y']}"
|
|
347
466
|
if has_a:
|
|
348
|
-
|
|
349
|
-
move_cmd += f" A{target_pos['A']}"
|
|
350
|
-
else:
|
|
351
|
-
move_cmd += f" A{a}"
|
|
467
|
+
move_cmd += f" A{target_pos['A']}"
|
|
352
468
|
move_cmd += f" F{feed_rate}"
|
|
353
469
|
|
|
354
470
|
self._logger.info("Executing XY move command: %s", move_cmd)
|
|
@@ -366,11 +482,7 @@ class GCodeController(SerialController):
|
|
|
366
482
|
if has_z:
|
|
367
483
|
z_needs_move = abs(target_z - (0.0 if needs_xy_move else current_z)) > 0.001
|
|
368
484
|
if z_needs_move:
|
|
369
|
-
|
|
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}"
|
|
485
|
+
move_cmd = f"G1 Z{target_z} F{feed_rate}"
|
|
374
486
|
|
|
375
487
|
if needs_xy_move:
|
|
376
488
|
self._logger.info(
|
|
@@ -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.
|
|
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+)")
|
|
@@ -491,8 +602,6 @@ class GCodeController(SerialController):
|
|
|
491
602
|
self._logger.error("Synchronization move failed: %s", e)
|
|
492
603
|
adjustment_needed = False
|
|
493
604
|
|
|
494
|
-
final_position = self.current_position.copy()
|
|
495
|
-
|
|
496
605
|
if adjustment_needed:
|
|
497
606
|
self._logger.info(
|
|
498
607
|
"Position check complete. Internal position is synchronized with machine."
|
|
@@ -500,7 +609,7 @@ class GCodeController(SerialController):
|
|
|
500
609
|
else:
|
|
501
610
|
self._logger.info("No adjustment was made.")
|
|
502
611
|
|
|
503
|
-
return adjustment_needed,
|
|
612
|
+
return adjustment_needed, self.current_position.copy()
|
|
504
613
|
|
|
505
614
|
def get_info(self) -> str:
|
|
506
615
|
"""
|
|
@@ -510,9 +619,7 @@ class GCodeController(SerialController):
|
|
|
510
619
|
Machine information string from the device
|
|
511
620
|
"""
|
|
512
621
|
self._logger.info("Querying machine information (M115).")
|
|
513
|
-
self.
|
|
514
|
-
res: str = self._read_response()
|
|
515
|
-
return res
|
|
622
|
+
return self.execute(self._build_command("M115"))
|
|
516
623
|
|
|
517
624
|
def get_internal_position(self) -> Dict[str, float]:
|
|
518
625
|
"""
|
|
@@ -28,26 +28,47 @@ def main():
|
|
|
28
28
|
# Instantiate the qubot controller
|
|
29
29
|
qubot = GCodeController(port_name=PORT_NAME)
|
|
30
30
|
|
|
31
|
+
# Example: Get current axis limits
|
|
32
|
+
print("\n--- Current Axis Limits ---")
|
|
33
|
+
all_limits = qubot.get_axis_limits()
|
|
34
|
+
for axis, limits in all_limits.items():
|
|
35
|
+
print(f"{axis}: [{limits.min}, {limits.max}]")
|
|
36
|
+
|
|
37
|
+
# Example: Get limits for a specific axis
|
|
38
|
+
x_limits = qubot.get_axis_limits("X")
|
|
39
|
+
print(f"\nX axis limits: [{x_limits.min}, {x_limits.max}]")
|
|
40
|
+
|
|
41
|
+
# Example: Set custom axis limits
|
|
42
|
+
qubot.set_axis_limits("X", -200, 200)
|
|
43
|
+
qubot.set_axis_limits("Y", -200, 200)
|
|
44
|
+
qubot.set_axis_limits("Z", -100, 100)
|
|
45
|
+
qubot.set_axis_limits("A", -180, 180)
|
|
46
|
+
|
|
31
47
|
qubot.query_position()
|
|
32
48
|
# Always start with homing
|
|
33
49
|
qubot.home()
|
|
34
|
-
|
|
35
|
-
# qubot.query_position()
|
|
36
|
-
# qubot.sync_position()
|
|
50
|
+
qubot.sync_position()
|
|
37
51
|
|
|
38
52
|
# Should generate WARNING due to exceeding MAX_FEEDRATE (3000)
|
|
39
53
|
# qubot.feed = 5000
|
|
40
54
|
|
|
55
|
+
# Relative moves are converted to absolute internally, but works the same
|
|
41
56
|
# qubot.move_relative(x=20.0, y=-10.0)
|
|
42
57
|
#
|
|
43
|
-
# qubot.move_absolute(x=50.0, y=-
|
|
58
|
+
# qubot.move_absolute(x=50.0, y=-50.0, z=-10.0)
|
|
44
59
|
|
|
45
|
-
# Example of an ERROR
|
|
60
|
+
# Example of an ERROR - invalid axis
|
|
46
61
|
# try:
|
|
47
62
|
# qubot.home(axis="B") # Generates ERROR
|
|
48
63
|
# except ValueError:
|
|
49
64
|
# pass
|
|
50
65
|
|
|
66
|
+
# Example of an ERROR - position outside limits
|
|
67
|
+
# try:
|
|
68
|
+
# qubot.move_absolute(x=150.0) # May raise ValueError if outside limits
|
|
69
|
+
# except ValueError as e:
|
|
70
|
+
# print(f"Position validation error: {e}")
|
|
71
|
+
|
|
51
72
|
except Exception as e:
|
|
52
73
|
logging.getLogger(__name__).error(f"An unrecoverable error occurred: {e}")
|
|
53
74
|
|
|
@@ -13,14 +13,15 @@ TRANSFER_VOLUME = 50.0 # uL
|
|
|
13
13
|
TIP_LENGTH = 70 # mm
|
|
14
14
|
|
|
15
15
|
# Define mock coordinates (assuming the Qubot operating space is in mm)
|
|
16
|
-
|
|
16
|
+
# Note: These coordinates must be within the axis limits set below
|
|
17
|
+
ASPIRATE_POS = {"X": 50.0, "Y": -50.0, "Z": -20.0, "A": -20.0} # Source well location
|
|
17
18
|
DISPENSE_POS = {
|
|
18
|
-
"X":
|
|
19
|
-
"Y": -
|
|
19
|
+
"X": 80.0,
|
|
20
|
+
"Y": -80.0,
|
|
20
21
|
"Z": -20.0,
|
|
21
|
-
"A": 20.0,
|
|
22
|
+
"A": -20.0,
|
|
22
23
|
} # Destination well location
|
|
23
|
-
SAFE_Z_POS =
|
|
24
|
+
SAFE_Z_POS = -10.0 # High Z position to prevent collisions (within Z limits)
|
|
24
25
|
|
|
25
26
|
qubot = None
|
|
26
27
|
pipette = None
|
|
@@ -32,6 +33,19 @@ try:
|
|
|
32
33
|
port_name=QUBOT_PORT, baudrate=QUBOT_BAUDRATE, feed=QUBOT_FEEDRATE
|
|
33
34
|
)
|
|
34
35
|
qubot.connect()
|
|
36
|
+
|
|
37
|
+
# Set axis limits to accommodate the automation coordinates
|
|
38
|
+
# Adjust these based on your actual hardware limits
|
|
39
|
+
qubot.set_axis_limits("X", 0, 200)
|
|
40
|
+
qubot.set_axis_limits("Y", -200, 0)
|
|
41
|
+
qubot.set_axis_limits("Z", -100, 0)
|
|
42
|
+
qubot.set_axis_limits("A", -180, 180)
|
|
43
|
+
|
|
44
|
+
print("Axis limits configured:")
|
|
45
|
+
all_limits = qubot.get_axis_limits()
|
|
46
|
+
for axis, limits in all_limits.items():
|
|
47
|
+
print(f" {axis}: [{limits.min}, {limits.max}]")
|
|
48
|
+
|
|
35
49
|
qubot.home()
|
|
36
50
|
except (IOError, ValueError, serial.SerialException) as e:
|
|
37
51
|
print(f"FATAL ERROR: Could not connect to Qubot GCode Controller: \n{e}")
|
|
@@ -64,7 +78,7 @@ def run_automation():
|
|
|
64
78
|
print("Protocol Step 1: Attaching Tip")
|
|
65
79
|
# Simulate moving to a tip rack position and attaching a tip
|
|
66
80
|
print("Moving to Tip Rack and Attaching Tip...")
|
|
67
|
-
qubot.move_absolute(x=10.0, y
|
|
81
|
+
qubot.move_absolute(x=10.0, y=-10.0, z=-10.0, feed=3000)
|
|
68
82
|
|
|
69
83
|
# 4. Protocol Step 2: Aspirate Liquid
|
|
70
84
|
print(f"\nProtocol Step 2: Aspirating {TRANSFER_VOLUME} uL")
|
|
@@ -107,7 +121,7 @@ def run_automation():
|
|
|
107
121
|
qubot.move_absolute(z=SAFE_Z_POS)
|
|
108
122
|
|
|
109
123
|
# Simulate moving to a trash bin and ejecting the tip
|
|
110
|
-
qubot.move_absolute(x=10.0, y
|
|
124
|
+
qubot.move_absolute(x=10.0, y=-10.0)
|
|
111
125
|
pipette.eject_tip()
|
|
112
126
|
|
|
113
127
|
except Exception as e:
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{puda_drivers-0.0.3 → puda_drivers-0.0.4}/src/puda_drivers/transfer/liquid/sartorius/__init__.py
RENAMED
|
File without changes
|
|
File without changes
|
{puda_drivers-0.0.3 → puda_drivers-0.0.4}/src/puda_drivers/transfer/liquid/sartorius/constants.py
RENAMED
|
File without changes
|
{puda_drivers-0.0.3 → puda_drivers-0.0.4}/src/puda_drivers/transfer/liquid/sartorius/sartorius.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|