puda-drivers 0.0.2__py3-none-any.whl → 0.0.3__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.
@@ -1,22 +1,38 @@
1
1
  """
2
- A standalone Python class for controlling G-code devices
3
- via serial communication.
2
+ G-code controller for motion systems.
4
3
 
5
- Provides methods for connection management, basic movement commands, and port discovery.
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.
6
7
  """
7
8
 
8
9
  import re
9
- from typing import Optional, Dict, Tuple
10
10
  import logging
11
+ from typing import Optional, Dict, Tuple
12
+
11
13
  from puda_drivers.core.serialcontroller import SerialController
12
14
 
13
15
 
14
16
  class GCodeController(SerialController):
17
+ """
18
+ Controller for G-code compatible motion systems.
19
+
20
+ 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.
23
+
24
+ Attributes:
25
+ DEFAULT_FEEDRATE: Default feed rate in mm/min (3000)
26
+ MAX_FEEDRATE: Maximum allowed feed rate in mm/min (3000)
27
+ TOLERANCE: Position synchronization tolerance in mm (0.01)
28
+ """
29
+
15
30
  DEFAULT_FEEDRATE = 3000 # mm/min
16
31
  MAX_FEEDRATE = 3000 # mm/min
17
32
  TOLERANCE = 0.01 # tolerance for position sync in mm
18
33
 
19
34
  PROTOCOL_TERMINATOR = "\r"
35
+ VALID_AXES = "XYZA"
20
36
 
21
37
  def __init__(
22
38
  self,
@@ -26,19 +42,22 @@ class GCodeController(SerialController):
26
42
  feed: int = DEFAULT_FEEDRATE,
27
43
  ):
28
44
  """
29
- Initializes the GCodeController.
45
+ Initialize the G-code controller.
30
46
 
31
47
  Args:
32
- port (Optional[str]): The serial port (e.g., 'COM5'). Defaults to None.
33
- baudrate (int): The baud rate for serial communication. Defaults to 9600.
34
- timeout (int): The read/write timeout in seconds. Defaults to 5.
48
+ port_name: Serial port name (e.g., '/dev/ttyACM0' or 'COM3')
49
+ baudrate: Baud rate for serial communication. Defaults to 9600.
50
+ timeout: Timeout in seconds for operations. Defaults to 20.
51
+ feed: Initial feed rate in mm/min. Defaults to 3000.
35
52
  """
36
53
  super().__init__(port_name, baudrate, timeout)
37
54
 
38
- # Initialize instance-specific logger
39
55
  self._logger = logging.getLogger(__name__)
40
56
  self._logger.info(
41
- f"GCodeController initialized with port='{port_name}', baudrate={baudrate}, timeout={timeout}"
57
+ "GCodeController initialized with port='%s', baudrate=%s, timeout=%s",
58
+ port_name,
59
+ baudrate,
60
+ timeout,
42
61
  )
43
62
 
44
63
  # Tracks internal position state
@@ -52,78 +71,102 @@ class GCodeController(SerialController):
52
71
  self._is_absolute_mode: bool = True # absolute mode by default
53
72
 
54
73
  @property
55
- def feed(self):
56
- """The current feed rate."""
74
+ def feed(self) -> int:
75
+ """Get the current feed rate in mm/min."""
57
76
  return self._feed
58
77
 
59
78
  @feed.setter
60
- def feed(self, new_feed: int):
61
- """Set the movement feed rate, enforcing the maximum limit."""
79
+ def feed(self, new_feed: int) -> None:
80
+ """
81
+ Set the movement feed rate, enforcing the maximum limit.
62
82
 
63
- # 1. Ensure the value is positive
83
+ Args:
84
+ new_feed: New feed rate in mm/min (must be > 0)
85
+
86
+ Raises:
87
+ ValueError: If feed rate is not positive
88
+ """
64
89
  if new_feed <= 0:
65
- error_msg = f"Attempted to set invalid feed rate: {new_feed}. Must be > 0."
90
+ error_msg = (
91
+ f"Attempted to set invalid feed rate: {new_feed}. Must be > 0."
92
+ )
66
93
  self._logger.error(error_msg)
67
94
  raise ValueError(error_msg)
68
95
 
69
- # 2. Check and enforce the maximum rate
70
96
  if new_feed > self.MAX_FEEDRATE:
71
- # Log the change and cap the value
72
97
  self._logger.warning(
73
- f"Requested feed rate ({new_feed}) exceeds maximum ({self.MAX_FEEDRATE}). "
74
- f"Setting feed rate to maximum: {self.MAX_FEEDRATE}."
98
+ "Requested feed rate (%s) exceeds maximum (%s). "
99
+ "Setting feed rate to maximum: %s.",
100
+ new_feed,
101
+ self.MAX_FEEDRATE,
102
+ self.MAX_FEEDRATE,
75
103
  )
76
104
  self._feed = self.MAX_FEEDRATE
77
105
  else:
78
- # Set the value normally
79
106
  self._feed = new_feed
80
- self._logger.debug(f"Feed rate set to: {self._feed} mm/min.")
107
+ self._logger.debug("Feed rate set to: %s mm/min.", self._feed)
81
108
 
82
109
  def _build_command(self, command: str) -> str:
83
- """Helper function to build a G-code command with terminator."""
110
+ """
111
+ Build a G-code command with terminator.
112
+
113
+ Args:
114
+ command: G-code command string (without terminator)
115
+
116
+ Returns:
117
+ Complete command string with terminator
118
+ """
84
119
  return f"{command}{self.PROTOCOL_TERMINATOR}"
85
120
 
86
- def home(self, axis: Optional[str] = None) -> None:
121
+ def _validate_axis(self, axis: str) -> str:
87
122
  """
88
- Homes one or all axes (G28 command).
123
+ Validate and normalize an axis name.
89
124
 
90
125
  Args:
91
- axis (str, optional): The axis to home ('X', 'Y', 'Z', 'A', etc.).
92
- If None, homes all axes (G28).
126
+ axis: Axis name to validate
127
+
128
+ Returns:
129
+ Uppercase axis name
93
130
 
94
131
  Raises:
95
- ValueError: If an invalid axis character is provided.
132
+ ValueError: If axis is not valid
96
133
  """
97
- valid_axes = "XYZA"
134
+ axis_upper = axis.upper()
135
+ if axis_upper not in self.VALID_AXES:
136
+ self._logger.error(
137
+ "Invalid axis '%s' provided. Must be one of: %s.",
138
+ axis_upper,
139
+ ", ".join(self.VALID_AXES),
140
+ )
141
+ raise ValueError(
142
+ f"Invalid axis. Must be one of: {', '.join(self.VALID_AXES)}."
143
+ )
144
+ return axis_upper
98
145
 
99
- # 1. Axis Validation and Normalization, Command Construction
100
- if axis:
101
- axis = axis.upper()
102
- if axis not in valid_axes:
103
- self._logger.error(
104
- f"Invalid axis '{axis}' provided for homing. Must be one of: {', '.join(valid_axes)}."
105
- )
106
- raise ValueError(
107
- f"Invalid axis. Must be one of: {', '.join(valid_axes)}."
108
- )
146
+ def home(self, axis: Optional[str] = None) -> None:
147
+ """
148
+ Home one or all axes (G28 command).
109
149
 
150
+ Args:
151
+ axis: Optional axis to home ('X', 'Y', 'Z', 'A').
152
+ If None, homes all axes.
153
+
154
+ Raises:
155
+ ValueError: If an invalid axis is provided
156
+ """
157
+ if axis:
158
+ axis = self._validate_axis(axis)
110
159
  cmd = f"G28 {axis}"
111
160
  home_target = axis
112
161
  else:
113
162
  cmd = "G28"
114
163
  home_target = "All"
115
164
 
116
- # 2. Command Execution
117
- self._logger.info(f"[{cmd}] homing axis/axes: {home_target} **")
118
- # self._send_command(self._build_command(cmd))
119
- self._send_command("G28\r")
120
- self._logger.info(f"Homing of {home_target} completed.")
165
+ self._logger.info("[%s] homing axis/axes: %s **", cmd, home_target)
166
+ self._send_command(self._build_command(cmd))
167
+ self._logger.info("Homing of %s completed.", home_target)
121
168
 
122
- # Verify position after homing
123
- # self.sync_position()
124
-
125
- # 3. Internal Position Update (Optimistic Zeroing)
126
- # If the homing was for a specific axis, zero only that axis.
169
+ # Update internal position (optimistic zeroing)
127
170
  if axis:
128
171
  self.current_position[axis] = 0.0
129
172
  else:
@@ -131,7 +174,8 @@ class GCodeController(SerialController):
131
174
  self.current_position[key] = 0.0
132
175
 
133
176
  self._logger.debug(
134
- f"Internal position updated (optimistically zeroed) to {self.current_position}"
177
+ "Internal position updated (optimistically zeroed) to %s",
178
+ self.current_position,
135
179
  )
136
180
 
137
181
  def move_absolute(
@@ -143,10 +187,23 @@ class GCodeController(SerialController):
143
187
  feed: Optional[int] = None,
144
188
  ) -> None:
145
189
  """
146
- Moves the device to an absolute position (G90 + G1 command).
190
+ Move to an absolute position (G90 + G1 command).
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
+ feed: Feed rate for this move (optional, uses current feed if not specified)
147
198
  """
199
+ feed_rate = feed if feed is not None else self._feed
148
200
  self._logger.info(
149
- f"Preparing absolute move to X:{x}, Y:{y}, Z:{z}, A:{a} at F:{feed if feed is not None else self._feed}"
201
+ "Preparing absolute move to X:%s, Y:%s, Z:%s, A:%s at F:%s",
202
+ x,
203
+ y,
204
+ z,
205
+ a,
206
+ feed_rate,
150
207
  )
151
208
 
152
209
  # Ensure absolute mode is active
@@ -166,10 +223,23 @@ class GCodeController(SerialController):
166
223
  feed: Optional[int] = None,
167
224
  ) -> None:
168
225
  """
169
- Moves the device relative to the current position (G91 + G1 command).
226
+ Move relative to the current position (G91 + G1 command).
227
+
228
+ Args:
229
+ x: Relative X movement (optional)
230
+ y: Relative Y movement (optional)
231
+ z: Relative Z movement (optional)
232
+ a: Relative A movement (optional)
233
+ feed: Feed rate for this move (optional, uses current feed if not specified)
170
234
  """
235
+ feed_rate = feed if feed is not None else self._feed
171
236
  self._logger.info(
172
- f"Preparing relative move by dX:{x}, dY:{y}, dZ:{z}, dA:{a} at F:{feed if feed is not None else self._feed}"
237
+ "Preparing relative move by dX:%s, dY:%s, dZ:%s, dA:%s at F:%s",
238
+ x,
239
+ y,
240
+ z,
241
+ a,
242
+ feed_rate,
173
243
  )
174
244
 
175
245
  # Ensure relative mode is active
@@ -188,70 +258,154 @@ class GCodeController(SerialController):
188
258
  a: Optional[float] = None,
189
259
  feed: Optional[int] = None,
190
260
  ) -> None:
191
- """Internal helper for executing G1 move commands."""
261
+ """
262
+ Internal helper for executing G1 move commands with safe movement pattern.
192
263
 
193
- # 1. Map axis names to their input values
194
- move_deltas = {"X": x, "Y": y, "Z": z, "A": a}
264
+ Safe move pattern:
265
+ 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)
195
268
 
196
- move_cmd = "G1"
269
+ 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
197
277
  target_pos = self.current_position.copy()
198
- movement_occurred = False
199
-
200
- # 2. Build the G-code command and calculate target position
201
- for axis, value in move_deltas.items():
202
- if value is not None:
203
- move_cmd += f" {axis}{value}"
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
204
282
 
205
- # Calculate the target position
206
- if self._is_absolute_mode:
207
- target_pos[axis] = value
208
- else:
209
- target_pos[axis] = self.current_position[axis] + value
283
+ if not (has_x or has_y or has_z or has_a):
284
+ self._logger.warning(
285
+ "Move command issued without any axis movement. Skipping transmission."
286
+ )
287
+ return
210
288
 
211
- movement_occurred = True
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
212
308
 
213
- # 3. Add on Feed Rate to command
214
309
  feed_rate = feed if feed is not None else self._feed
215
- # Final safety check on the rate used in the command
216
310
  if feed_rate > self.MAX_FEEDRATE:
217
311
  feed_rate = self.MAX_FEEDRATE
218
- move_cmd += f" F{feed_rate}"
219
312
 
220
- # 4. Check for movement and return if none
221
- if not movement_occurred:
222
- self._logger.warning(
223
- "Move command issued without any axis movement. Skipping transmission."
224
- )
225
- return
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
226
317
 
227
- # 5. Execute command and update position (Optimistic update)
228
- self._logger.info(f"Executing move command: {move_cmd}")
229
- self._send_command(self._build_command(move_cmd))
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
320
+ self._logger.info(
321
+ "Safe move: Raising Z to safe height (0) before XY movement"
322
+ )
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:
335
+ 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}"
353
+
354
+ self._logger.info("Executing XY move command: %s", move_cmd)
355
+ self._send_command(self._build_command(move_cmd))
356
+
357
+ # 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
230
384
 
231
385
  self._logger.info(
232
- f"Move complete. Updating internal position from {self.current_position} to {target_pos}"
386
+ "Move complete. Final position: %s", self.current_position
233
387
  )
388
+ self._logger.debug("New internal position: %s", self.current_position)
234
389
 
235
- self.current_position = target_pos
236
- self._logger.debug(f"New internal position: {self.current_position}")
237
-
238
- # 6. Post-Move Position Synchronization Check
390
+ # Post-move position synchronization check
239
391
  self.sync_position()
240
392
 
241
393
  def query_position(self) -> Dict[str, float]:
242
394
  """
243
- Queries the Qubot for its current machine position (M114 command).
395
+ Query the current machine position (M114 command).
244
396
 
245
397
  Returns:
246
- Dict[str, float]: A dictionary containing the updated X, Y, Z, and A positions.
398
+ Dictionary containing X, Y, Z, and A positions
399
+
400
+ Note:
401
+ Returns an empty dictionary if the query fails or no positions are found.
247
402
  """
248
403
  self._logger.info("Querying current machine position (M114).")
249
404
  self._send_command(self._build_command("M114"))
250
405
  res: str = self._read_response()
251
406
 
252
407
  # Extract position values using regex
253
- pattern = re.compile(r"([XYZAE]):(\-?\d+\.\d+)")
254
- # Find all matches (e.g., [('X', '0.000'), ('Y', '0.000'), ...])
408
+ pattern = re.compile(r"([XYZA]):(-?\d+\.\d+)")
255
409
  matches = pattern.findall(res)
256
410
 
257
411
  position_data: Dict[str, float] = {}
@@ -261,7 +415,9 @@ class GCodeController(SerialController):
261
415
  position_data[axis] = float(value_str)
262
416
  except ValueError:
263
417
  self._logger.error(
264
- f"Failed to convert position value '{value_str}' for axis {axis} to float."
418
+ "Failed to convert position value '%s' for axis %s to float.",
419
+ value_str,
420
+ axis,
265
421
  )
266
422
  continue
267
423
 
@@ -269,31 +425,33 @@ class GCodeController(SerialController):
269
425
 
270
426
  def sync_position(self) -> Tuple[bool, Dict[str, float]]:
271
427
  """
272
- Queries the Qubot for its current machine position (M114) and
273
- automatically adjusts the controller's internal 'current_position'
274
- if a discrepancy is found.
428
+ Synchronize internal position with actual machine position.
429
+
430
+ Queries the machine position and compares it with the internal position.
431
+ If a discrepancy greater than the tolerance is found, attempts to correct
432
+ it by moving to the internal position.
275
433
 
276
434
  Returns:
277
- Tuple[bool, Dict[str, float]]: A tuple where the first element
278
- is True if a synchronization/adjustment occurred, and the second
279
- is the final, synchronized position dictionary.
435
+ Tuple of (adjustment_occurred: bool, final_position: Dict[str, float])
436
+ where adjustment_occurred is True if a correction move was made.
437
+
438
+ Note:
439
+ This method may recursively call itself if a correction move is made.
280
440
  """
281
441
  self._logger.info("Starting position synchronization check (M114).")
282
442
 
283
- # 1. Query the actual machine position
443
+ # Query the actual machine position
284
444
  queried_position = self.query_position()
285
445
 
286
- # Check if the query was unsuccessful (only returns empty dict)
287
446
  if not queried_position:
288
447
  self._logger.warning("Query position failed. Cannot synchronize.")
289
448
  return False, self.current_position
290
449
 
291
- # 2. Compare internal vs. queried position
450
+ # Compare internal vs. queried position
292
451
  axis_keys = ["X", "Y", "Z", "A"]
293
452
  adjustment_needed = False
294
453
 
295
454
  for axis in axis_keys:
296
- # Check if axis exists in both dictionaries and if they differ significantly
297
455
  if (
298
456
  axis in self.current_position
299
457
  and axis in queried_position
@@ -301,25 +459,24 @@ class GCodeController(SerialController):
301
459
  > self.TOLERANCE
302
460
  ):
303
461
  self._logger.warning(
304
- f"Position mismatch found on {axis} axis: "
305
- f"Internal={self.current_position[axis]:.3f}, "
306
- f"Queried={queried_position[axis]:.3f}"
462
+ "Position mismatch found on %s axis: Internal=%.3f, Queried=%.3f",
463
+ axis,
464
+ self.current_position[axis],
465
+ queried_position[axis],
307
466
  )
308
-
309
467
  adjustment_needed = True
310
-
311
468
  elif axis in queried_position:
312
- # Update internal position with actual queried position if it differs very slightly
469
+ # Update internal position with queried position if it differs slightly
313
470
  self.current_position[axis] = queried_position[axis]
314
471
 
315
- # 3. Perform Re-Synchronization Move if needed
472
+ # Perform re-synchronization move if needed
316
473
  if adjustment_needed:
317
474
  self._logger.info(
318
- f"** DISCREPANCY DETECTED. Moving robot back to internal position: {self.current_position} **"
475
+ "** DISCREPANCY DETECTED. Moving robot back to internal position: %s **",
476
+ self.current_position,
319
477
  )
320
478
 
321
479
  try:
322
- # Extract the X, Y, Z, A coordinates from the internal position for the move
323
480
  target_x = self.current_position.get("X")
324
481
  target_y = self.current_position.get("Y")
325
482
  target_z = self.current_position.get("Z")
@@ -328,31 +485,29 @@ class GCodeController(SerialController):
328
485
  self.move_absolute(x=target_x, y=target_y, z=target_z, a=target_a)
329
486
  self._logger.info("Synchronization move successfully completed.")
330
487
 
331
- # recursive call to verify position after move
332
- self.sync_position()
333
- except Exception as e:
334
- self._logger.error(f"Synchronization move failed: {e}")
335
- adjustment_needed = False # Indicate failure in adjustment, though original discrepancy remains.
488
+ # Recursive call to verify position after move
489
+ return self.sync_position()
490
+ except (ValueError, RuntimeError, OSError) as e:
491
+ self._logger.error("Synchronization move failed: %s", e)
492
+ adjustment_needed = False
336
493
 
337
494
  final_position = self.current_position.copy()
338
495
 
339
496
  if adjustment_needed:
340
497
  self._logger.info(
341
- f"Position check complete. Internal position is synchronized with machine."
498
+ "Position check complete. Internal position is synchronized with machine."
342
499
  )
343
500
  else:
344
501
  self._logger.info("No adjustment was made.")
345
502
 
346
- # Note: If adjustment_needed was True, it means a move was attempted.
347
- # We return the initial state of adjustment_needed (i.e., if a move was triggered).
348
503
  return adjustment_needed, final_position
349
504
 
350
505
  def get_info(self) -> str:
351
506
  """
352
- Queries the Qubot for machine information (M115 command).
507
+ Query machine information (M115 command).
353
508
 
354
509
  Returns:
355
- str: The machine information string.
510
+ Machine information string from the device
356
511
  """
357
512
  self._logger.info("Querying machine information (M115).")
358
513
  self._send_command(self._build_command("M115"))
@@ -361,7 +516,10 @@ class GCodeController(SerialController):
361
516
 
362
517
  def get_internal_position(self) -> Dict[str, float]:
363
518
  """
364
- Returns the internally tracked position.
519
+ Get the internally tracked position.
520
+
521
+ Returns:
522
+ Dictionary containing the current internal position for all axes
365
523
  """
366
- self._logger.debug(f"Returning internal position: {self.current_position}")
367
- return self.current_position
524
+ self._logger.debug("Returning internal position: %s", self.current_position)
525
+ return self.current_position.copy()
@@ -1,34 +1,53 @@
1
- import time
1
+ """
2
+ Sartorius rLINE pipette controller.
3
+
4
+ This module provides a Python interface for controlling Sartorius rLINE® electronic
5
+ pipettes and robotic dispensers via serial communication.
6
+
7
+ Reference: https://api.sartorius.com/document-hub/dam/download/34901/Sartorius-rLine-technical-user-manual-v1.1.pdf
8
+ """
9
+
2
10
  import logging
3
11
  from typing import Optional
12
+
4
13
  from puda_drivers.core.serialcontroller import SerialController
14
+
5
15
  from .constants import STATUS_CODES
6
16
 
7
17
 
8
18
  class SartoriusDeviceError(Exception):
9
19
  """Custom exception raised when the Sartorius device reports an error."""
10
20
 
11
- pass
12
-
13
21
 
14
22
  class SartoriusController(SerialController):
15
23
  """
16
- A standalone Python class for controlling a Sartorius pipette or robotic
17
- dispenser via serial communication, using a custom, byte-based command protocol.
24
+ Controller for Sartorius rLINE® pipettes and robotic dispensers.
25
+
26
+ This class provides methods for controlling pipette operations including
27
+ aspiration, dispensing, tip ejection, and speed control via serial communication.
28
+
29
+ Attributes:
30
+ DEFAULT_BAUDRATE: Default baud rate for serial communication (9600)
31
+ DEFAULT_TIMEOUT: Default timeout for operations (10 seconds)
32
+ MICROLITER_PER_STEP: Conversion factor from steps to microliters (0.5 µL/step)
33
+ MIN_SPEED: Minimum speed setting (1)
34
+ MAX_SPEED: Maximum speed setting (6)
18
35
  """
19
36
 
20
- # --- Protocol Constants (Hypothetical) ---
37
+ # Protocol Constants
21
38
  DEFAULT_BAUDRATE = 9600
22
39
  DEFAULT_TIMEOUT = 10
23
40
 
24
41
  PROTOCOL_SOH = "\x01"
25
42
  SLAVE_ADDRESS = "1"
26
- PROTOCOL_TERMINATOR = "º\r" # Assuming 'º' is part of the terminator sequence
43
+ PROTOCOL_TERMINATOR = "º\r"
27
44
 
28
- # --- Sartorius rLine Settings ---
29
- STEPS_PER_MICROLITER = 2
45
+ # Sartorius rLine Settings
46
+ MICROLITER_PER_STEP = 0.5
30
47
  SUCCESS_RESPONSE = "ok"
31
48
  ERROR_RESPONSE = "err"
49
+ MIN_SPEED = 1
50
+ MAX_SPEED = 6
32
51
 
33
52
  def __init__(
34
53
  self,
@@ -36,15 +55,37 @@ class SartoriusController(SerialController):
36
55
  baudrate: int = DEFAULT_BAUDRATE,
37
56
  timeout: int = DEFAULT_TIMEOUT,
38
57
  ):
58
+ """
59
+ Initialize the Sartorius controller.
60
+
61
+ Args:
62
+ port_name: Serial port name (e.g., '/dev/ttyUSB0' or 'COM3')
63
+ baudrate: Baud rate for serial communication. Defaults to 9600.
64
+ timeout: Timeout in seconds for operations. Defaults to 10.
65
+ """
39
66
  super().__init__(port_name, baudrate, timeout)
40
67
  self._logger = logging.getLogger(__name__)
41
68
  self._logger.info(
42
- f"Sartorius Controller initialized with port='{port_name}', baudrate={baudrate}, timeout={timeout}"
69
+ "Sartorius Controller initialized with port='%s', baudrate=%s, timeout=%s",
70
+ port_name,
71
+ baudrate,
72
+ timeout,
43
73
  )
44
74
 
45
75
  def _build_command(self, command_code: str, value: str = "") -> str:
46
- """Helper to assemble the custom command string."""
47
- cmd = (
76
+ """
77
+ Build a command string according to the Sartorius protocol.
78
+
79
+ Command format: <SOH><SLAVE_ADDRESS>R<COMMAND_CODE><VALUE><TERMINATOR>
80
+
81
+ Args:
82
+ command_code: Single character command code
83
+ value: Optional value string to append to the command
84
+
85
+ Returns:
86
+ Complete command string ready to send
87
+ """
88
+ return (
48
89
  self.PROTOCOL_SOH
49
90
  + self.SLAVE_ADDRESS
50
91
  + "R"
@@ -52,207 +93,306 @@ class SartoriusController(SerialController):
52
93
  + value
53
94
  + self.PROTOCOL_TERMINATOR
54
95
  )
55
- return cmd
56
96
 
57
- def initialize(self) -> None:
58
- """
59
- Initializes the pipette unit (RZ command).
97
+ def _check_response_error(self, response: str, operation: str) -> None:
60
98
  """
61
- command = self._build_command(command_code="Z")
62
- self._logger.info("** Initializing Pipette Head (RZ) **")
99
+ Check if a response contains an error and raise an exception if so.
63
100
 
64
- self._send_command(command)
65
- res: str = self._read_response()
101
+ Args:
102
+ response: Response string from the device
103
+ operation: Description of the operation being performed (for error message)
66
104
 
67
- if "err" in res:
105
+ Raises:
106
+ SartoriusDeviceError: If the response contains an error
107
+ """
108
+ if self.ERROR_RESPONSE in response.lower():
68
109
  raise SartoriusDeviceError(
69
- f"Pipette initialization failed with error: {res}"
110
+ f"{operation} failed. Device returned error: {response}"
111
+ )
112
+
113
+ def _validate_speed(self, speed: int, direction: str = "speed") -> None:
114
+ """
115
+ Validate that a speed value is within the allowed range.
116
+
117
+ Args:
118
+ speed: Speed value to validate
119
+ direction: Direction description for error message (e.g., "Inward", "Outward")
120
+
121
+ Raises:
122
+ ValueError: If speed is outside the valid range
123
+ """
124
+ if not self.MIN_SPEED <= speed <= self.MAX_SPEED:
125
+ raise ValueError(
126
+ f"{direction} speed must be between {self.MIN_SPEED} and {self.MAX_SPEED}, "
127
+ f"got {speed}"
70
128
  )
129
+
130
+ def _validate_no_leading_zeros(self, value: int, command_name: str) -> str:
131
+ """
132
+ Validate that a numeric value has no leading zeros when converted to string.
133
+
134
+ Args:
135
+ value: Numeric value to validate
136
+ command_name: Command name for error message (e.g., "RP", "RE")
137
+
138
+ Returns:
139
+ String representation of the value
140
+
141
+ Raises:
142
+ ValueError: If the value has leading zeros
143
+ """
144
+ value_str = str(value)
145
+ if len(value_str) > 1 and value_str.startswith("0"):
146
+ raise ValueError(
147
+ f"{command_name} command value must not have leading zeros. "
148
+ f"Got: {value_str}"
149
+ )
150
+ return value_str
151
+
152
+ def _execute_command(
153
+ self, command_code: str, value: str = "", operation: str = ""
154
+ ) -> str:
155
+ """
156
+ Execute a command and return the response.
157
+
158
+ Args:
159
+ command_code: Command code to execute
160
+ value: Optional value for the command
161
+ operation: Description of the operation (for logging and error messages)
162
+
163
+ Returns:
164
+ Response string from the device
165
+
166
+ Raises:
167
+ SartoriusDeviceError: If the device returns an error
168
+ """
169
+ command = self._build_command(command_code, value)
170
+ self._send_command(command)
171
+ response = self._read_response()
172
+ self._check_response_error(response, operation or f"Command {command_code}")
173
+ return response
174
+
175
+ def initialize(self) -> None:
176
+ """
177
+ Initialize the pipette unit (RZ command).
178
+
179
+ This command resets the pipette to its initial state and should be called
180
+ before performing other operations.
181
+
182
+ Raises:
183
+ SartoriusDeviceError: If initialization fails
184
+ """
185
+ self._logger.info("** Initializing Pipette Head (RZ) **")
186
+ self._execute_command("Z", operation="Pipette initialization")
71
187
  self._logger.info("** Pipette Initialization Complete **")
72
188
 
73
- # different from docs
74
189
  def get_inward_speed(self) -> int:
75
190
  """
76
- Queries the current aspirating speed (SI command). Speed is 1-6.
191
+ Query the current aspirating speed (DI command).
192
+
193
+ Returns:
194
+ Current inward speed setting (1-6)
195
+
196
+ Raises:
197
+ SartoriusDeviceError: If the query fails
77
198
  """
78
- command = self._build_command(command_code="DI")
79
199
  self._logger.info("** Querying Inward Speed (DI) **")
200
+ response = self._execute_command("DI", operation="Inward speed query")
80
201
 
81
- self._send_command(command)
82
- res: str = self._read_response()
83
- if "err" in res:
84
- raise SartoriusDeviceError(f"Inward speed query failed with error: {res}")
202
+ if len(response) < 2:
203
+ raise SartoriusDeviceError(
204
+ f"Invalid response format for inward speed query: {response}"
205
+ )
85
206
 
86
- self._logger.info(f"** Current Inward Speed: {res[1]} **")
87
- return int(res[1])
207
+ speed = int(response[1])
208
+ self._logger.info("** Current Inward Speed: %s **", speed)
209
+ return speed
88
210
 
89
211
  def set_inward_speed(self, speed: int) -> None:
90
212
  """
91
- Sets the aspirating speed (SI command). Speed is 1-6.
92
- """
93
- if not 1 <= speed <= 6:
94
- raise ValueError("Inward speed must be between 1 and 6.")
213
+ Set the aspirating speed (SI command).
95
214
 
96
- command = self._build_command(command_code="I", value=str(speed))
97
- self._logger.info(f"** Setting Inward Speed (SI, Speed: {speed}) **")
215
+ Args:
216
+ speed: Speed setting (1-6, where 1 is slowest and 6 is fastest)
98
217
 
99
- self._send_command(command)
100
- res: str = self._read_response()
101
- if "err" in res:
102
- raise SartoriusDeviceError(f"Setting inward speed failed with error: {res}")
103
- self._logger.info(f"** Inward Speed Set to {speed} Successfully **")
218
+ Raises:
219
+ ValueError: If speed is outside the valid range
220
+ SartoriusDeviceError: If setting the speed fails
221
+ """
222
+ self._validate_speed(speed, "Inward")
223
+ self._logger.info("** Setting Inward Speed (SI, Speed: %s) **", speed)
224
+ self._execute_command("I", value=str(speed), operation="Setting inward speed")
225
+ self._logger.info("** Inward Speed Set to %s Successfully **", speed)
104
226
 
105
227
  def get_outward_speed(self) -> int:
106
228
  """
107
- Queries the current dispensing speed (DO command). Speed is 1-6.
229
+ Query the current dispensing speed (DO command).
230
+
231
+ Returns:
232
+ Current outward speed setting (1-6)
233
+
234
+ Raises:
235
+ SartoriusDeviceError: If the query fails
108
236
  """
109
- command = self._build_command(command_code="DO")
110
237
  self._logger.info("** Querying Outward Speed (DO) **")
238
+ response = self._execute_command("DO", operation="Outward speed query")
111
239
 
112
- self._send_command(command)
113
- res: str = self._read_response()
114
- if "err" in res:
115
- raise SartoriusDeviceError(f"Outward speed query failed with error: {res}")
240
+ if len(response) < 2:
241
+ raise SartoriusDeviceError(
242
+ f"Invalid response format for outward speed query: {response}"
243
+ )
116
244
 
117
- self._logger.info(f"** Current Outward Speed: {res[1]} **")
118
- return int(res[1])
245
+ speed = int(response[1])
246
+ self._logger.info("** Current Outward Speed: %s **", speed)
247
+ return speed
119
248
 
120
249
  def set_outward_speed(self, speed: int) -> None:
121
250
  """
122
- Sets the dispensing speed (SO command). Speed is 1-6.
123
- """
124
- if not 1 <= speed <= 6:
125
- raise ValueError("Outward speed must be between 1 and 6.")
251
+ Set the dispensing speed (SO command).
126
252
 
127
- self._logger.info(f"** Setting Outward Speed (SO, Speed: {speed}) **")
253
+ Args:
254
+ speed: Speed setting (1-6, where 1 is slowest and 6 is fastest)
128
255
 
129
- command = self._build_command(command_code="O", value=str(speed))
130
- self._send_command(command)
131
- res: str = self._read_response()
132
- if "err" in res:
133
- raise SartoriusDeviceError(
134
- f"Setting outward speed failed with error: {res}"
135
- )
136
- self._logger.info(f"** Outward Speed Set to {speed} Successfully **")
256
+ Raises:
257
+ ValueError: If speed is outside the valid range
258
+ SartoriusDeviceError: If setting the speed fails
259
+ """
260
+ self._validate_speed(speed, "Outward")
261
+ self._logger.info("** Setting Outward Speed (SO, Speed: %s) **", speed)
262
+ self._execute_command("O", value=str(speed), operation="Setting outward speed")
263
+ self._logger.info("** Outward Speed Set to %s Successfully **", speed)
137
264
 
138
265
  def run_to_position(self, position: int) -> None:
139
266
  """
140
- Drives the piston to the absolute step position nnn (RP command).
141
- Steps must be given without leading zeros.
142
- """
143
- position_str = str(position)
144
- # Check if position has leading zeros (RP030 is incorrect)
145
- if len(position_str) > 1 and position_str.startswith("0"):
146
- raise ValueError("Position value for RP must not have leading zeros.")
147
-
148
- command = self._build_command(command_code="P", value=position_str)
149
- self._logger.info(f"** Run to absolute Position (RP, Position: {position}) **")
150
-
151
- self._send_command(command)
152
- res: str = self._read_response()
153
- if "err" in res:
154
- raise SartoriusDeviceError(f"Run to position failed with error: {res}")
267
+ Drive the piston to an absolute step position (RP command).
155
268
 
156
- self._logger.info(f"** Reached Position {position} Successfully **")
269
+ Args:
270
+ position: Target position in steps (must not have leading zeros)
157
271
 
158
- def aspirate(self, amount: int) -> None:
272
+ Raises:
273
+ ValueError: If position has leading zeros
274
+ SartoriusDeviceError: If the command fails
159
275
  """
160
- Aspirates fluid, amount in microliters.
276
+ position_str = self._validate_no_leading_zeros(position, "RP")
277
+ self._logger.info("** Run to absolute Position (RP, Position: %s) **", position)
278
+ self._execute_command("P", value=position_str, operation="Run to position")
279
+ self._logger.info("** Reached Position %s Successfully **", position)
280
+
281
+ def aspirate(self, amount: float) -> None:
161
282
  """
162
- steps = amount * self.STEPS_PER_MICROLITER
163
- command = self._build_command(command_code="I", value=str(steps))
164
- self._logger.info(f"** Aspirating {amount} uL (RI{steps}) **")
283
+ Aspirate fluid from the current location.
165
284
 
166
- self._send_command(command)
167
- res: str = self._read_response()
168
- if "err" in res:
169
- raise SartoriusDeviceError(f"Aspirate failed with error: {res}")
170
- self._logger.info(f"** Aspirated {amount} uL Successfully **")
285
+ Args:
286
+ amount: Volume to aspirate in microliters (µL)
171
287
 
172
- def dispense(self, amount: int) -> None:
288
+ Raises:
289
+ ValueError: If amount is negative or zero
290
+ SartoriusDeviceError: If aspiration fails
173
291
  """
174
- Dispenses fluid, moving the plunger outwards by nnn steps (RO command).
175
- Steps must be given without leading zeros.
176
- """
177
- steps = amount * self.STEPS_PER_MICROLITER
292
+ if amount <= 0:
293
+ raise ValueError(f"Aspiration amount must be positive, got {amount}")
178
294
 
179
- command = self._build_command(command_code="O", value=str(steps))
180
- self._logger.info(f"** Dispensing {amount} uL (RO{steps}) **")
295
+ steps = int(amount / self.MICROLITER_PER_STEP)
296
+ self._logger.info("** Aspirating %s uL (RI%s steps) **", amount, steps)
297
+ self._execute_command("I", value=str(steps), operation="Aspirate")
298
+ self._logger.info("** Aspirated %s uL Successfully **", amount)
181
299
 
182
- self._send_command(command)
183
- res: str = self._read_response()
184
- if "err" in res:
185
- raise SartoriusDeviceError(f"Dispense failed with error: {res}")
186
- self._logger.info(f"** Dispensed {amount} uL Successfully **")
300
+ def dispense(self, amount: float) -> None:
301
+ """
302
+ Dispense fluid at the current location.
187
303
 
188
- def eject_tip(self, return_position: Optional[int] = None) -> None:
304
+ Args:
305
+ amount: Volume to dispense in microliters (µL)
306
+
307
+ Raises:
308
+ ValueError: If amount is negative or zero
309
+ SartoriusDeviceError: If dispensing fails
189
310
  """
190
- Runs the tip eject cycle (RE command).
191
- If return_position is None (RE), returns to position 0.
192
- If return_position is specified (RE nnn), returns to that position.
311
+ if amount <= 0:
312
+ raise ValueError(f"Dispense amount must be positive, got {amount}")
313
+
314
+ steps = int(amount / self.MICROLITER_PER_STEP)
315
+ self._logger.info("** Dispensing %s uL (RO%s steps) **", amount, steps)
316
+ self._execute_command("O", value=str(steps), operation="Dispense")
317
+ self._logger.info("** Dispensed %s uL Successfully **", amount)
318
+
319
+ def eject_tip(self, return_position: int = 30) -> None:
193
320
  """
194
- if return_position is not None:
195
- position_str = str(return_position)
196
- if len(position_str) > 1 and position_str.startswith("0"):
197
- raise ValueError(
198
- "Return position value for RE must not have leading zeros."
199
- )
200
- command = self._build_command(command_code="E", value=position_str)
201
- self._logger.info(
202
- f"** Ejecting Tip and returning to position {return_position} (RE {return_position}) **"
203
- )
204
- else:
205
- command = self._build_command(command_code="E")
206
- self._logger.info("** Ejecting Tip and returning to position 0 (RE) **")
321
+ Eject the pipette tip (RE command).
207
322
 
208
- self._send_command(command)
209
- res: str = self._read_response()
210
- if "err" in res:
211
- raise SartoriusDeviceError(f"Tip ejection failed with error: {res}")
323
+ Args:
324
+ return_position: Position to return to after ejection. Defaults to 30.
325
+
326
+ Raises:
327
+ ValueError: If return_position has leading zeros
328
+ SartoriusDeviceError: If tip ejection fails
329
+ """
330
+ position_str = self._validate_no_leading_zeros(return_position, "RE")
331
+ self._logger.info(
332
+ "** Ejecting Tip and returning to position %s (RE %s) **",
333
+ return_position,
334
+ return_position,
335
+ )
336
+ self._execute_command(
337
+ "E", value=position_str, operation="Eject tip with return position"
338
+ )
212
339
  self._logger.info("** Tip Ejection Complete **")
213
340
 
214
341
  def run_blowout(self, return_position: Optional[int] = None) -> None:
215
342
  """
216
- Runs the blowout cycle (RB command).
217
- If return_position is None (RB), completes the blowout.
218
- If return_position is specified (RB nnn), returns to that position.
343
+ Run the blowout cycle to clear residual liquid (RB command).
344
+
345
+ Args:
346
+ return_position: Optional position to return to after blowout.
347
+ If None, completes blowout without returning.
348
+
349
+ Raises:
350
+ ValueError: If return_position has leading zeros
351
+ SartoriusDeviceError: If blowout fails
219
352
  """
220
353
  if return_position is not None:
221
- position_str = str(return_position)
222
- if len(position_str) > 1 and position_str.startswith("0"):
223
- raise ValueError(
224
- "Return position value for RB must not have leading zeros."
225
- )
226
- command = self._build_command(command_code="B", value=position_str)
354
+ position_str = self._validate_no_leading_zeros(
355
+ return_position, "RB"
356
+ )
227
357
  self._logger.info(
228
- f"** Running Blowout and returning to position {return_position} (RB {return_position}) **"
358
+ "** Running Blowout and returning to position %s (RB %s) **",
359
+ return_position,
360
+ return_position,
361
+ )
362
+ self._execute_command(
363
+ "B", value=position_str, operation="Blowout with return position"
229
364
  )
230
365
  else:
231
- command = self._build_command(command_code="B")
232
366
  self._logger.info("** Running Blowout (RB) **")
367
+ self._execute_command("B", operation="Blowout")
233
368
 
234
- self._send_command(command)
235
- res: str = self._read_response()
236
- if "err" in res:
237
- raise SartoriusDeviceError(f"Blowout failed with error: {res}")
238
369
  self._logger.info("** Blowout Complete **")
239
370
 
240
371
  def get_status(self) -> str:
241
372
  """
242
- Queries the current status of the pipette (DS command).
243
- Returns a status code string.
373
+ Query the current status of the pipette (DS command).
374
+
375
+ Returns:
376
+ Status code character (single character string)
377
+
378
+ Raises:
379
+ SartoriusDeviceError: If the status query fails
244
380
  """
245
- command = self._build_command(command_code="DS")
246
381
  self._logger.info("** Querying Pipette Status (DS) **")
382
+ response = self._execute_command("DS", operation="Status query")
247
383
 
248
- self._send_command(command)
249
- res: str = self._read_response()
250
- if "err" in res:
251
- raise SartoriusDeviceError(f"Status query failed with error: {res}")
384
+ if len(response) < 2:
385
+ raise SartoriusDeviceError(
386
+ f"Invalid response format for status query: {response}"
387
+ )
252
388
 
253
- if len(res) > 1 and res[1] in STATUS_CODES:
254
- status_message = STATUS_CODES[res[1]]
255
- self._logger.info(f"Pipette Status Code [{res[1]}]: {status_message}")
389
+ status_code = response[1]
390
+ if status_code in STATUS_CODES:
391
+ status_message = STATUS_CODES[status_code]
392
+ self._logger.info("Pipette Status Code [%s]: %s", status_code, status_message)
256
393
  else:
257
- self._logger.info(f"Pipette Status Code [{res[1]}]: Unknown Status Code")
258
- return res[1]
394
+ self._logger.warning(
395
+ "Pipette Status Code [%s]: Unknown Status Code", status_code
396
+ )
397
+
398
+ return status_code
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: puda-drivers
3
- Version: 0.0.2
3
+ Version: 0.0.3
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
@@ -3,15 +3,15 @@ puda_drivers/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
3
3
  puda_drivers/core/__init__.py,sha256=JM6eWTelwcmjTGM3gprQlJWzPGEpIdRrDmbCHtGoKyM,119
4
4
  puda_drivers/core/serialcontroller.py,sha256=-wdQqq30yb6dSsNwA2FQec5HUh0U3j71OOMt6pbQIpc,7230
5
5
  puda_drivers/move/__init__.py,sha256=i7G5VKD5FgnmC21TLxoASVtC88IrPUTLDJrTnp99u-0,35
6
- puda_drivers/move/gcode.py,sha256=DjXxLO5UVaL7-BffRlvC1MQI21bX14zTTwXymLAEugc,13390
6
+ puda_drivers/move/gcode.py,sha256=1KkpgwXBiNrcV4Ilc-6n-ADQwOVwVgK_9x08LKhbE5g,18106
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
10
10
  puda_drivers/transfer/liquid/sartorius/__init__.py,sha256=QGpKz5YUwa8xCdSMXeZ0iRU-hRVqAWNPK0mlMTuzv8I,101
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
- puda_drivers/transfer/liquid/sartorius/sartorius.py,sha256=Nf2EkKWcPOnzFSmAvxs2zwGXFeOC98Nsg1FtArts45w,9885
14
- puda_drivers-0.0.2.dist-info/METADATA,sha256=rLtN2A0N7z90pHiPHTSbDJMPnyKz-6ZJGIJ5JQlb0ZI,5153
15
- puda_drivers-0.0.2.dist-info/WHEEL,sha256=WLgqFyCfm_KASv4WHyYy0P3pM_m7J5L9k2skdKLirC8,87
16
- puda_drivers-0.0.2.dist-info/licenses/LICENSE,sha256=7EI8xVBu6h_7_JlVw-yPhhOZlpY9hP8wal7kHtqKT_E,1074
17
- puda_drivers-0.0.2.dist-info/RECORD,,
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,,