puda-drivers 0.0.3__tar.gz → 0.0.5__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.5}/PKG-INFO +44 -5
- {puda_drivers-0.0.3 → puda_drivers-0.0.5}/README.md +43 -4
- {puda_drivers-0.0.3 → puda_drivers-0.0.5}/pyproject.toml +1 -1
- {puda_drivers-0.0.3 → puda_drivers-0.0.5}/src/puda_drivers/core/serialcontroller.py +62 -47
- puda_drivers-0.0.5/src/puda_drivers/move/gcode.py +627 -0
- {puda_drivers-0.0.3 → puda_drivers-0.0.5}/tests/example.py +2 -2
- puda_drivers-0.0.3/tests/sartorius.py → puda_drivers-0.0.5/tests/pipette.py +21 -22
- puda_drivers-0.0.5/tests/qubot.py +109 -0
- {puda_drivers-0.0.3 → puda_drivers-0.0.5}/tests/together.py +47 -19
- {puda_drivers-0.0.3 → puda_drivers-0.0.5}/uv.lock +1 -1
- puda_drivers-0.0.3/src/puda_drivers/move/gcode.py +0 -525
- puda_drivers-0.0.3/tests/qubot.py +0 -56
- {puda_drivers-0.0.3 → puda_drivers-0.0.5}/.gitignore +0 -0
- {puda_drivers-0.0.3 → puda_drivers-0.0.5}/LICENSE +0 -0
- {puda_drivers-0.0.3 → puda_drivers-0.0.5}/src/puda_drivers/__init__.py +0 -0
- {puda_drivers-0.0.3 → puda_drivers-0.0.5}/src/puda_drivers/core/__init__.py +0 -0
- {puda_drivers-0.0.3 → puda_drivers-0.0.5}/src/puda_drivers/move/__init__.py +0 -0
- {puda_drivers-0.0.3 → puda_drivers-0.0.5}/src/puda_drivers/move/grbl/__init__.py +0 -0
- {puda_drivers-0.0.3 → puda_drivers-0.0.5}/src/puda_drivers/move/grbl/api.py +0 -0
- {puda_drivers-0.0.3 → puda_drivers-0.0.5}/src/puda_drivers/move/grbl/constants.py +0 -0
- {puda_drivers-0.0.3 → puda_drivers-0.0.5}/src/puda_drivers/py.typed +0 -0
- {puda_drivers-0.0.3 → puda_drivers-0.0.5}/src/puda_drivers/transfer/liquid/sartorius/__init__.py +0 -0
- {puda_drivers-0.0.3 → puda_drivers-0.0.5}/src/puda_drivers/transfer/liquid/sartorius/api.py +0 -0
- {puda_drivers-0.0.3 → puda_drivers-0.0.5}/src/puda_drivers/transfer/liquid/sartorius/constants.py +0 -0
- {puda_drivers-0.0.3 → puda_drivers-0.0.5}/src/puda_drivers/transfer/liquid/sartorius/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.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
|
|
@@ -36,13 +36,19 @@ from puda_drivers.move import GCodeController
|
|
|
36
36
|
gantry = GCodeController(port_name="/dev/ttyACM0", feed=3000)
|
|
37
37
|
gantry.connect()
|
|
38
38
|
|
|
39
|
+
# Configure axis limits for safety (recommended)
|
|
40
|
+
gantry.set_axis_limits("X", 0, 200)
|
|
41
|
+
gantry.set_axis_limits("Y", -200, 0)
|
|
42
|
+
gantry.set_axis_limits("Z", -100, 0)
|
|
43
|
+
gantry.set_axis_limits("A", -180, 180)
|
|
44
|
+
|
|
39
45
|
# Home the gantry
|
|
40
46
|
gantry.home()
|
|
41
47
|
|
|
42
|
-
# Move to absolute position
|
|
48
|
+
# Move to absolute position (validated against limits)
|
|
43
49
|
gantry.move_absolute(x=50.0, y=-100.0, z=-10.0)
|
|
44
50
|
|
|
45
|
-
# Move relative to current position
|
|
51
|
+
# Move relative to current position (validated after conversion to absolute)
|
|
46
52
|
gantry.move_relative(x=20.0, y=-10.0)
|
|
47
53
|
|
|
48
54
|
# Query current position
|
|
@@ -53,6 +59,8 @@ print(f"Current position: {position}")
|
|
|
53
59
|
gantry.disconnect()
|
|
54
60
|
```
|
|
55
61
|
|
|
62
|
+
**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.
|
|
63
|
+
|
|
56
64
|
### Liquid Handling (Sartorius)
|
|
57
65
|
|
|
58
66
|
```python
|
|
@@ -114,6 +122,7 @@ pipette.disconnect()
|
|
|
114
122
|
- Supports X, Y, Z, and A axes
|
|
115
123
|
- Configurable feed rates
|
|
116
124
|
- Position synchronization and homing
|
|
125
|
+
- Automatic axis limit validation for safe operation
|
|
117
126
|
|
|
118
127
|
### Liquid Handling
|
|
119
128
|
|
|
@@ -122,6 +131,38 @@ pipette.disconnect()
|
|
|
122
131
|
- Tip attachment and ejection
|
|
123
132
|
- Configurable speeds and volumes
|
|
124
133
|
|
|
134
|
+
## Error Handling
|
|
135
|
+
|
|
136
|
+
### Axis Limit Validation
|
|
137
|
+
|
|
138
|
+
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:
|
|
139
|
+
|
|
140
|
+
```python
|
|
141
|
+
from puda_drivers.move import GCodeController
|
|
142
|
+
|
|
143
|
+
gantry = GCodeController(port_name="/dev/ttyACM0")
|
|
144
|
+
gantry.connect()
|
|
145
|
+
|
|
146
|
+
# Set axis limits
|
|
147
|
+
gantry.set_axis_limits("X", 0, 200)
|
|
148
|
+
gantry.set_axis_limits("Y", -200, 0)
|
|
149
|
+
|
|
150
|
+
try:
|
|
151
|
+
# This will raise ValueError: Value 250 outside axis limits [0, 200]
|
|
152
|
+
gantry.move_absolute(x=250.0, y=-50.0)
|
|
153
|
+
except ValueError as e:
|
|
154
|
+
print(f"Move rejected: {e}")
|
|
155
|
+
|
|
156
|
+
# Relative moves are also validated after conversion to absolute positions
|
|
157
|
+
try:
|
|
158
|
+
# If current X is 150, moving 100 more would exceed the limit
|
|
159
|
+
gantry.move_relative(x=100.0)
|
|
160
|
+
except ValueError as e:
|
|
161
|
+
print(f"Move rejected: {e}")
|
|
162
|
+
```
|
|
163
|
+
|
|
164
|
+
Validation errors are automatically logged at the ERROR level before the exception is raised.
|
|
165
|
+
|
|
125
166
|
## Finding Serial Ports
|
|
126
167
|
|
|
127
168
|
To discover available serial ports on your system:
|
|
@@ -138,8 +179,6 @@ for port, desc, hwid in ports:
|
|
|
138
179
|
sartorius_ports = list_serial_ports(filter_desc="Sartorius")
|
|
139
180
|
```
|
|
140
181
|
|
|
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
182
|
## Requirements
|
|
144
183
|
|
|
145
184
|
- Python >= 3.14
|
|
@@ -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
|
|
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 =
|
|
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
|
-
|
|
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(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(
|
|
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()
|