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.
- puda_drivers/core/serialcontroller.py +62 -47
- puda_drivers/move/gcode.py +270 -168
- {puda_drivers-0.0.3.dist-info → puda_drivers-0.0.5.dist-info}/METADATA +44 -5
- {puda_drivers-0.0.3.dist-info → puda_drivers-0.0.5.dist-info}/RECORD +6 -6
- {puda_drivers-0.0.3.dist-info → puda_drivers-0.0.5.dist-info}/WHEEL +0 -0
- {puda_drivers-0.0.3.dist-info → puda_drivers-0.0.5.dist-info}/licenses/LICENSE +0 -0
|
@@ -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()
|
puda_drivers/move/gcode.py
CHANGED
|
@@ -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)
|
|
@@ -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.
|
|
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.
|
|
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.
|
|
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.
|
|
313
|
+
self._current_position[axis] = 0.0
|
|
172
314
|
else:
|
|
173
|
-
for key in self.
|
|
174
|
-
self.
|
|
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.
|
|
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
|
-
) ->
|
|
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
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
356
|
+
target_x,
|
|
357
|
+
target_y,
|
|
358
|
+
target_z,
|
|
359
|
+
target_a,
|
|
206
360
|
feed_rate,
|
|
207
361
|
)
|
|
208
362
|
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
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
|
-
) ->
|
|
375
|
+
) -> Dict[str, float]:
|
|
225
376
|
"""
|
|
226
|
-
Move relative to the current position (
|
|
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
|
-
#
|
|
246
|
-
if self.
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
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
|
-
|
|
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
|
-
|
|
256
|
-
|
|
257
|
-
|
|
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
|
|
267
|
-
3. Finally move Z to
|
|
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
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
"""
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
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
|
-
#
|
|
290
|
-
|
|
291
|
-
|
|
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
|
|
319
|
-
if needs_xy_move
|
|
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 (
|
|
456
|
+
"Safe move: Raising Z and A to safe height (%s) before XY movement", self.SAFE_MOVE_HEIGHT
|
|
322
457
|
)
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
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
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
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.
|
|
475
|
+
self.execute(self._build_command(move_cmd))
|
|
476
|
+
self.wait_for_move()
|
|
356
477
|
|
|
357
478
|
# Update position for moved axes
|
|
358
|
-
if
|
|
359
|
-
self.
|
|
360
|
-
if
|
|
361
|
-
self.
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
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.
|
|
496
|
+
"Move complete. Final position: %s", self._current_position
|
|
387
497
|
)
|
|
388
|
-
self._logger.debug("New internal position: %s", self.
|
|
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.
|
|
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.
|
|
448
|
-
|
|
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.
|
|
567
|
+
axis in self._current_position
|
|
457
568
|
and axis in queried_position
|
|
458
|
-
and abs(self.
|
|
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.
|
|
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.
|
|
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
|
|
476
|
-
self.
|
|
586
|
+
"** DISCREPANCY DETECTED. Moving robot to internal position: %s **",
|
|
587
|
+
self._current_position,
|
|
477
588
|
)
|
|
478
589
|
|
|
479
590
|
try:
|
|
480
|
-
|
|
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,
|
|
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.
|
|
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.
|
|
525
|
-
return self.
|
|
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
|
+
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
|
|
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=
|
|
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.
|
|
15
|
-
puda_drivers-0.0.
|
|
16
|
-
puda_drivers-0.0.
|
|
17
|
-
puda_drivers-0.0.
|
|
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,,
|
|
File without changes
|
|
File without changes
|