puda-drivers 0.0.8__py3-none-any.whl → 0.0.10__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/cv/__init__.py +4 -0
- puda_drivers/cv/camera.py +434 -0
- puda_drivers/machines/first.py +66 -7
- {puda_drivers-0.0.8.dist-info → puda_drivers-0.0.10.dist-info}/METADATA +57 -118
- {puda_drivers-0.0.8.dist-info → puda_drivers-0.0.10.dist-info}/RECORD +7 -5
- {puda_drivers-0.0.8.dist-info → puda_drivers-0.0.10.dist-info}/WHEEL +0 -0
- {puda_drivers-0.0.8.dist-info → puda_drivers-0.0.10.dist-info}/licenses/LICENSE +0 -0
|
@@ -0,0 +1,434 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Camera controller for image and video capture.
|
|
3
|
+
|
|
4
|
+
This module provides a Python interface for controlling cameras and webcams
|
|
5
|
+
to capture images and videos for laboratory automation applications using OpenCV.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
import logging
|
|
9
|
+
import time
|
|
10
|
+
import threading
|
|
11
|
+
from datetime import datetime
|
|
12
|
+
from typing import Optional, Union, List, Tuple
|
|
13
|
+
from pathlib import Path
|
|
14
|
+
import cv2
|
|
15
|
+
import numpy as np
|
|
16
|
+
|
|
17
|
+
logger = logging.getLogger(__name__)
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def list_cameras(max_index: int = 10) -> List[Tuple[int, bool, Optional[tuple[int, int]]]]:
|
|
21
|
+
"""
|
|
22
|
+
Lists available cameras on the system by testing camera indices.
|
|
23
|
+
|
|
24
|
+
This is a utility function that can be used independently of any controller instance.
|
|
25
|
+
It's useful for discovering available cameras before initializing a controller.
|
|
26
|
+
|
|
27
|
+
Args:
|
|
28
|
+
max_index: Maximum camera index to test (default: 10). The function will test
|
|
29
|
+
indices from 0 to max_index-1.
|
|
30
|
+
|
|
31
|
+
Returns:
|
|
32
|
+
List of tuples, where each tuple contains (index, is_available, resolution).
|
|
33
|
+
resolution is a (width, height) tuple if available, None otherwise.
|
|
34
|
+
"""
|
|
35
|
+
available_cameras = []
|
|
36
|
+
|
|
37
|
+
for index in range(max_index):
|
|
38
|
+
cap = cv2.VideoCapture(index)
|
|
39
|
+
if cap.isOpened():
|
|
40
|
+
# Try to read a frame to confirm it's actually working
|
|
41
|
+
ret, _ = cap.read()
|
|
42
|
+
if ret:
|
|
43
|
+
# Get resolution
|
|
44
|
+
width = int(cap.get(cv2.CAP_PROP_FRAME_WIDTH))
|
|
45
|
+
height = int(cap.get(cv2.CAP_PROP_FRAME_HEIGHT))
|
|
46
|
+
resolution = (width, height) if width > 0 and height > 0 else None
|
|
47
|
+
available_cameras.append((index, resolution))
|
|
48
|
+
cap.release()
|
|
49
|
+
else:
|
|
50
|
+
cap.release()
|
|
51
|
+
|
|
52
|
+
return available_cameras
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
class CameraController:
|
|
56
|
+
"""
|
|
57
|
+
Controller for cameras and webcams using OpenCV.
|
|
58
|
+
|
|
59
|
+
This class provides methods for capturing images and videos from cameras.
|
|
60
|
+
Images can be returned as numpy arrays and optionally saved to the captures folder.
|
|
61
|
+
Videos can be recorded for a specified duration or controlled manually with start/stop.
|
|
62
|
+
|
|
63
|
+
Attributes:
|
|
64
|
+
camera_index: Index or identifier for the camera device
|
|
65
|
+
resolution: Camera resolution as (width, height) tuple
|
|
66
|
+
captures_folder: Path to the folder where captured images and videos are saved
|
|
67
|
+
"""
|
|
68
|
+
|
|
69
|
+
DEFAULT_CAPTURES_FOLDER = "captures"
|
|
70
|
+
|
|
71
|
+
def __init__(
|
|
72
|
+
self,
|
|
73
|
+
camera_index: Union[int, str] = 0,
|
|
74
|
+
resolution: Optional[tuple[int, int]] = None,
|
|
75
|
+
captures_folder: Union[str, Path, None] = None,
|
|
76
|
+
):
|
|
77
|
+
"""
|
|
78
|
+
Initialize the camera controller.
|
|
79
|
+
|
|
80
|
+
Args:
|
|
81
|
+
camera_index: Camera device index (0 for default) or device path/identifier
|
|
82
|
+
resolution: Optional resolution as (width, height) tuple
|
|
83
|
+
captures_folder: Path to folder for saving captured images.
|
|
84
|
+
Defaults to "captures" in the current working directory.
|
|
85
|
+
"""
|
|
86
|
+
self.camera_index = camera_index
|
|
87
|
+
self.resolution = resolution
|
|
88
|
+
self.captures_folder = Path(captures_folder) if captures_folder else Path(self.DEFAULT_CAPTURES_FOLDER)
|
|
89
|
+
self._logger = logging.getLogger(__name__)
|
|
90
|
+
self._camera: Optional[cv2.VideoCapture] = None
|
|
91
|
+
self._is_connected = False
|
|
92
|
+
|
|
93
|
+
# Video recording state
|
|
94
|
+
self._video_writer: Optional[cv2.VideoWriter] = None
|
|
95
|
+
self._is_recording = False
|
|
96
|
+
self._video_file_path: Optional[Path] = None
|
|
97
|
+
self._fps: float = 30.0 # Default FPS for video recording
|
|
98
|
+
self._recording_thread: Optional[threading.Thread] = None
|
|
99
|
+
self._stop_recording_event: Optional[threading.Event] = None
|
|
100
|
+
|
|
101
|
+
# Create captures folder if it doesn't exist
|
|
102
|
+
self.captures_folder.mkdir(parents=True, exist_ok=True)
|
|
103
|
+
|
|
104
|
+
self._logger.info(
|
|
105
|
+
"Camera Controller initialized with camera_index='%s', resolution=%s, captures_folder='%s'",
|
|
106
|
+
camera_index,
|
|
107
|
+
resolution,
|
|
108
|
+
self.captures_folder,
|
|
109
|
+
)
|
|
110
|
+
|
|
111
|
+
def connect(self) -> None:
|
|
112
|
+
"""
|
|
113
|
+
Connect to the camera device.
|
|
114
|
+
|
|
115
|
+
Raises:
|
|
116
|
+
IOError: If camera connection fails
|
|
117
|
+
"""
|
|
118
|
+
if self._is_connected:
|
|
119
|
+
self._logger.warning("Camera already connected. Disconnecting and reconnecting...")
|
|
120
|
+
self.disconnect()
|
|
121
|
+
|
|
122
|
+
self._logger.info("Connecting to camera %s...", self.camera_index)
|
|
123
|
+
|
|
124
|
+
try:
|
|
125
|
+
self._camera = cv2.VideoCapture(self.camera_index)
|
|
126
|
+
|
|
127
|
+
if not self._camera.isOpened():
|
|
128
|
+
raise IOError(f"Could not open camera {self.camera_index}")
|
|
129
|
+
|
|
130
|
+
# Set resolution if specified
|
|
131
|
+
if self.resolution:
|
|
132
|
+
width, height = self.resolution
|
|
133
|
+
self._camera.set(cv2.CAP_PROP_FRAME_WIDTH, width)
|
|
134
|
+
self._camera.set(cv2.CAP_PROP_FRAME_HEIGHT, height)
|
|
135
|
+
self._logger.info("Resolution set to %dx%d", width, height)
|
|
136
|
+
|
|
137
|
+
self._is_connected = True
|
|
138
|
+
self._logger.info("Successfully connected to camera %s", self.camera_index)
|
|
139
|
+
|
|
140
|
+
except Exception as e:
|
|
141
|
+
self._camera = None
|
|
142
|
+
self._is_connected = False
|
|
143
|
+
self._logger.error("Error connecting to camera %s: %s", self.camera_index, e)
|
|
144
|
+
raise IOError(f"Error connecting to camera {self.camera_index}: {e}") from e
|
|
145
|
+
|
|
146
|
+
def disconnect(self) -> None:
|
|
147
|
+
"""
|
|
148
|
+
Disconnect from the camera device.
|
|
149
|
+
"""
|
|
150
|
+
# Stop any ongoing video recording before disconnecting
|
|
151
|
+
if self._is_recording:
|
|
152
|
+
self._logger.warning("Stopping video recording before disconnecting...")
|
|
153
|
+
self.stop_video_recording()
|
|
154
|
+
|
|
155
|
+
if self._camera is not None:
|
|
156
|
+
self._logger.info("Disconnecting from camera %s...", self.camera_index)
|
|
157
|
+
self._camera.release()
|
|
158
|
+
self._camera = None
|
|
159
|
+
self._is_connected = False
|
|
160
|
+
self._logger.info("Camera disconnected")
|
|
161
|
+
else:
|
|
162
|
+
self._logger.warning("Camera already disconnected or was never connected")
|
|
163
|
+
|
|
164
|
+
@property
|
|
165
|
+
def is_connected(self) -> bool:
|
|
166
|
+
"""
|
|
167
|
+
Check if the camera is currently connected.
|
|
168
|
+
|
|
169
|
+
Returns:
|
|
170
|
+
True if connected, False otherwise
|
|
171
|
+
"""
|
|
172
|
+
return self._is_connected and self._camera is not None and self._camera.isOpened()
|
|
173
|
+
|
|
174
|
+
def capture_image(
|
|
175
|
+
self,
|
|
176
|
+
save: bool = False,
|
|
177
|
+
filename: Optional[Union[str, Path]] = None
|
|
178
|
+
) -> np.ndarray:
|
|
179
|
+
"""
|
|
180
|
+
Capture a single image from the camera.
|
|
181
|
+
|
|
182
|
+
Args:
|
|
183
|
+
save: If True, save the image to the captures folder
|
|
184
|
+
filename: Optional filename for the saved image. If not provided and save=True,
|
|
185
|
+
a timestamped filename will be generated. If provided without extension,
|
|
186
|
+
.jpg will be added.
|
|
187
|
+
|
|
188
|
+
Returns:
|
|
189
|
+
Captured image as a numpy array (BGR format)
|
|
190
|
+
|
|
191
|
+
Raises:
|
|
192
|
+
IOError: If camera is not connected or capture fails
|
|
193
|
+
"""
|
|
194
|
+
if not self.is_connected:
|
|
195
|
+
raise IOError("Camera is not connected. Call connect() first.")
|
|
196
|
+
|
|
197
|
+
self._logger.info("Capturing image...")
|
|
198
|
+
|
|
199
|
+
ret, frame = self._camera.read()
|
|
200
|
+
|
|
201
|
+
if not ret or frame is None:
|
|
202
|
+
self._logger.error("Failed to capture frame from camera")
|
|
203
|
+
raise IOError("Failed to capture frame from camera")
|
|
204
|
+
|
|
205
|
+
self._logger.info("Image captured successfully (shape: %s)", frame.shape)
|
|
206
|
+
|
|
207
|
+
# Save image if requested
|
|
208
|
+
if save:
|
|
209
|
+
if filename is None:
|
|
210
|
+
timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
|
|
211
|
+
filename = f"capture_{timestamp}.jpg"
|
|
212
|
+
|
|
213
|
+
file_path = Path(filename)
|
|
214
|
+
# If filename doesn't have an extension, add .jpg
|
|
215
|
+
if not file_path.suffix:
|
|
216
|
+
file_path = file_path.with_suffix(".jpg")
|
|
217
|
+
|
|
218
|
+
# If filename is not absolute, save to captures folder
|
|
219
|
+
if not file_path.is_absolute():
|
|
220
|
+
file_path = self.captures_folder / file_path
|
|
221
|
+
|
|
222
|
+
# Ensure parent directory exists
|
|
223
|
+
file_path.parent.mkdir(parents=True, exist_ok=True)
|
|
224
|
+
|
|
225
|
+
cv2.imwrite(str(file_path), frame)
|
|
226
|
+
self._logger.info("Image saved to %s", file_path)
|
|
227
|
+
|
|
228
|
+
return frame
|
|
229
|
+
|
|
230
|
+
def set_resolution(self, width: int, height: int) -> None:
|
|
231
|
+
"""
|
|
232
|
+
Set the camera resolution.
|
|
233
|
+
|
|
234
|
+
Args:
|
|
235
|
+
width: Image width in pixels
|
|
236
|
+
height: Image height in pixels
|
|
237
|
+
"""
|
|
238
|
+
self.resolution = (width, height)
|
|
239
|
+
self._logger.info("Resolution set to %dx%d", width, height)
|
|
240
|
+
|
|
241
|
+
# Apply resolution to camera if connected
|
|
242
|
+
if self.is_connected:
|
|
243
|
+
self._camera.set(cv2.CAP_PROP_FRAME_WIDTH, width)
|
|
244
|
+
self._camera.set(cv2.CAP_PROP_FRAME_HEIGHT, height)
|
|
245
|
+
self._logger.info("Resolution applied to camera")
|
|
246
|
+
|
|
247
|
+
def start_video_recording(
|
|
248
|
+
self,
|
|
249
|
+
filename: Optional[Union[str, Path]] = None,
|
|
250
|
+
fps: Optional[float] = None
|
|
251
|
+
) -> Path:
|
|
252
|
+
"""
|
|
253
|
+
Start recording a video.
|
|
254
|
+
|
|
255
|
+
Args:
|
|
256
|
+
filename: Optional filename for the video. If not provided, a timestamped
|
|
257
|
+
filename will be generated. If provided without extension, .mp4 will be added.
|
|
258
|
+
fps: Optional frames per second for the video. Defaults to 30.0 if not specified.
|
|
259
|
+
|
|
260
|
+
Returns:
|
|
261
|
+
Path to the video file where recording is being saved
|
|
262
|
+
|
|
263
|
+
Raises:
|
|
264
|
+
IOError: If camera is not connected or recording fails to start
|
|
265
|
+
ValueError: If already recording
|
|
266
|
+
"""
|
|
267
|
+
if not self.is_connected:
|
|
268
|
+
raise IOError("Camera is not connected. Call connect() first.")
|
|
269
|
+
|
|
270
|
+
if self._is_recording:
|
|
271
|
+
raise ValueError("Video recording is already in progress. Call stop_video_recording() first.")
|
|
272
|
+
|
|
273
|
+
# Generate filename if not provided
|
|
274
|
+
if filename is None:
|
|
275
|
+
timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
|
|
276
|
+
filename = f"video_{timestamp}.mp4"
|
|
277
|
+
|
|
278
|
+
file_path = Path(filename)
|
|
279
|
+
# If filename doesn't have an extension, add .mp4
|
|
280
|
+
if not file_path.suffix:
|
|
281
|
+
file_path = file_path.with_suffix(".mp4")
|
|
282
|
+
|
|
283
|
+
# If filename is not absolute, save to captures folder
|
|
284
|
+
if not file_path.is_absolute():
|
|
285
|
+
file_path = self.captures_folder / file_path
|
|
286
|
+
|
|
287
|
+
# Ensure parent directory exists
|
|
288
|
+
file_path.parent.mkdir(parents=True, exist_ok=True)
|
|
289
|
+
|
|
290
|
+
# Get current camera resolution
|
|
291
|
+
width = int(self._camera.get(cv2.CAP_PROP_FRAME_WIDTH))
|
|
292
|
+
height = int(self._camera.get(cv2.CAP_PROP_FRAME_HEIGHT))
|
|
293
|
+
|
|
294
|
+
# Use provided FPS or default
|
|
295
|
+
fps = fps if fps is not None else self._fps
|
|
296
|
+
|
|
297
|
+
# Define codec and create VideoWriter
|
|
298
|
+
fourcc = cv2.VideoWriter_fourcc(*'mp4v')
|
|
299
|
+
self._video_writer = cv2.VideoWriter(str(file_path), fourcc, fps, (width, height))
|
|
300
|
+
|
|
301
|
+
if not self._video_writer.isOpened():
|
|
302
|
+
self._video_writer = None
|
|
303
|
+
raise IOError(f"Failed to initialize video writer for {file_path}")
|
|
304
|
+
|
|
305
|
+
self._is_recording = True
|
|
306
|
+
self._video_file_path = file_path
|
|
307
|
+
self._fps = fps
|
|
308
|
+
|
|
309
|
+
# Start background thread to continuously capture frames
|
|
310
|
+
self._stop_recording_event = threading.Event()
|
|
311
|
+
self._recording_thread = threading.Thread(target=self._capture_frames_loop, daemon=True)
|
|
312
|
+
self._recording_thread.start()
|
|
313
|
+
|
|
314
|
+
self._logger.info("Started video recording to %s (FPS: %.1f, Resolution: %dx%d)",
|
|
315
|
+
file_path, fps, width, height)
|
|
316
|
+
|
|
317
|
+
return file_path
|
|
318
|
+
|
|
319
|
+
def _capture_frames_loop(self) -> None:
|
|
320
|
+
"""
|
|
321
|
+
Internal method that continuously captures frames and writes them to the video.
|
|
322
|
+
Runs in a background thread while recording is active.
|
|
323
|
+
"""
|
|
324
|
+
frame_interval = 1.0 / self._fps if self._fps > 0 else 0.033 # Default to ~30 FPS
|
|
325
|
+
|
|
326
|
+
while self._is_recording and not self._stop_recording_event.is_set():
|
|
327
|
+
if self._video_writer is None or self._camera is None:
|
|
328
|
+
break
|
|
329
|
+
|
|
330
|
+
ret, frame = self._camera.read()
|
|
331
|
+
if ret and frame is not None:
|
|
332
|
+
self._video_writer.write(frame)
|
|
333
|
+
|
|
334
|
+
time.sleep(frame_interval)
|
|
335
|
+
|
|
336
|
+
self._logger.debug("Frame capture loop stopped")
|
|
337
|
+
|
|
338
|
+
def stop_video_recording(self) -> Optional[Path]:
|
|
339
|
+
"""
|
|
340
|
+
Stop recording a video.
|
|
341
|
+
|
|
342
|
+
Returns:
|
|
343
|
+
Path to the saved video file, or None if no recording was in progress
|
|
344
|
+
|
|
345
|
+
Raises:
|
|
346
|
+
IOError: If video writer fails to release
|
|
347
|
+
"""
|
|
348
|
+
if not self._is_recording:
|
|
349
|
+
self._logger.warning("No video recording in progress")
|
|
350
|
+
return None
|
|
351
|
+
|
|
352
|
+
self._logger.info("Stopping video recording...")
|
|
353
|
+
|
|
354
|
+
# Signal the recording thread to stop
|
|
355
|
+
self._is_recording = False
|
|
356
|
+
if self._stop_recording_event is not None:
|
|
357
|
+
self._stop_recording_event.set()
|
|
358
|
+
|
|
359
|
+
# Wait for the recording thread to finish
|
|
360
|
+
if self._recording_thread is not None and self._recording_thread.is_alive():
|
|
361
|
+
self._recording_thread.join(timeout=2.0)
|
|
362
|
+
|
|
363
|
+
# Release video writer
|
|
364
|
+
if self._video_writer is not None:
|
|
365
|
+
self._video_writer.release()
|
|
366
|
+
self._video_writer = None
|
|
367
|
+
|
|
368
|
+
file_path = self._video_file_path
|
|
369
|
+
self._video_file_path = None
|
|
370
|
+
self._recording_thread = None
|
|
371
|
+
self._stop_recording_event = None
|
|
372
|
+
|
|
373
|
+
if file_path and file_path.exists():
|
|
374
|
+
self._logger.info("Video saved to %s", file_path)
|
|
375
|
+
else:
|
|
376
|
+
self._logger.warning("Video file may not have been saved correctly")
|
|
377
|
+
|
|
378
|
+
return file_path
|
|
379
|
+
|
|
380
|
+
def record_video(
|
|
381
|
+
self,
|
|
382
|
+
duration_seconds: float,
|
|
383
|
+
filename: Optional[Union[str, Path]] = None,
|
|
384
|
+
fps: Optional[float] = None
|
|
385
|
+
) -> Path:
|
|
386
|
+
"""
|
|
387
|
+
Record a video for a specified duration.
|
|
388
|
+
|
|
389
|
+
Args:
|
|
390
|
+
duration_seconds: Duration of the video in seconds
|
|
391
|
+
filename: Optional filename for the video. If not provided, a timestamped
|
|
392
|
+
filename will be generated. If provided without extension, .mp4 will be added.
|
|
393
|
+
fps: Optional frames per second for the video. Defaults to 30.0 if not specified.
|
|
394
|
+
|
|
395
|
+
Returns:
|
|
396
|
+
Path to the saved video file
|
|
397
|
+
|
|
398
|
+
Raises:
|
|
399
|
+
IOError: If camera is not connected or recording fails
|
|
400
|
+
ValueError: If duration is not positive
|
|
401
|
+
"""
|
|
402
|
+
if not self.is_connected:
|
|
403
|
+
raise IOError("Camera is not connected. Call connect() first.")
|
|
404
|
+
|
|
405
|
+
if duration_seconds <= 0:
|
|
406
|
+
raise ValueError(f"Duration must be positive, got {duration_seconds}")
|
|
407
|
+
|
|
408
|
+
# Start recording
|
|
409
|
+
file_path = self.start_video_recording(filename=filename, fps=fps)
|
|
410
|
+
|
|
411
|
+
self._logger.info("Recording video for %.2f seconds...", duration_seconds)
|
|
412
|
+
|
|
413
|
+
start_time = time.time()
|
|
414
|
+
frame_count = 0
|
|
415
|
+
|
|
416
|
+
try:
|
|
417
|
+
while time.time() - start_time < duration_seconds:
|
|
418
|
+
ret, frame = self._camera.read()
|
|
419
|
+
if ret and frame is not None:
|
|
420
|
+
self._video_writer.write(frame)
|
|
421
|
+
frame_count += 1
|
|
422
|
+
else:
|
|
423
|
+
self._logger.warning("Failed to read frame during video recording")
|
|
424
|
+
break
|
|
425
|
+
finally:
|
|
426
|
+
# Always stop recording, even if there was an error
|
|
427
|
+
self.stop_video_recording()
|
|
428
|
+
|
|
429
|
+
actual_duration = time.time() - start_time
|
|
430
|
+
self._logger.info("Video recording completed: %.2f seconds, %d frames (%.1f FPS actual)",
|
|
431
|
+
actual_duration, frame_count, frame_count / actual_duration if actual_duration > 0 else 0)
|
|
432
|
+
|
|
433
|
+
return file_path
|
|
434
|
+
|
puda_drivers/machines/first.py
CHANGED
|
@@ -7,17 +7,19 @@ This class demonstrates the integration of:
|
|
|
7
7
|
- SartoriusController: Handles liquid handling operations
|
|
8
8
|
"""
|
|
9
9
|
|
|
10
|
+
import logging
|
|
10
11
|
import time
|
|
11
|
-
from typing import Optional, Dict, Tuple, Type
|
|
12
|
+
from typing import Optional, Dict, Tuple, Type, Union
|
|
12
13
|
from puda_drivers.move import GCodeController, Deck
|
|
13
14
|
from puda_drivers.core import Position
|
|
14
15
|
from puda_drivers.transfer.liquid.sartorius import SartoriusController
|
|
15
16
|
from puda_drivers.labware import StandardLabware
|
|
17
|
+
from puda_drivers.cv import CameraController
|
|
16
18
|
|
|
17
19
|
|
|
18
20
|
class First:
|
|
19
21
|
"""
|
|
20
|
-
First machine class integrating motion control, deck management,
|
|
22
|
+
First machine class integrating motion control, deck management, liquid handling, and camera.
|
|
21
23
|
|
|
22
24
|
The deck has 16 slots arranged in a 4x4 grid (A1-D4).
|
|
23
25
|
Each slot's origin location is stored for absolute movement calculation.
|
|
@@ -31,6 +33,8 @@ class First:
|
|
|
31
33
|
DEFAULT_SARTORIUS_PORT = "/dev/ttyUSB0"
|
|
32
34
|
DEFAULT_SARTORIUS_BAUDRATE = 9600
|
|
33
35
|
|
|
36
|
+
DEFAULT_CAMERA_INDEX = 0
|
|
37
|
+
|
|
34
38
|
# origin position of Z and A axes
|
|
35
39
|
Z_ORIGIN = Position(x=0, y=0, z=0)
|
|
36
40
|
A_ORIGIN = Position(x=60, y=0, a=0)
|
|
@@ -69,6 +73,7 @@ class First:
|
|
|
69
73
|
self,
|
|
70
74
|
qubot_port: Optional[str] = None,
|
|
71
75
|
sartorius_port: Optional[str] = None,
|
|
76
|
+
camera_index: Optional[Union[int, str]] = None,
|
|
72
77
|
axis_limits: Optional[Dict[str, Tuple[float, float]]] = None,
|
|
73
78
|
):
|
|
74
79
|
"""
|
|
@@ -77,6 +82,8 @@ class First:
|
|
|
77
82
|
Args:
|
|
78
83
|
qubot_port: Serial port for GCodeController (e.g., '/dev/ttyACM0')
|
|
79
84
|
sartorius_port: Serial port for SartoriusController (e.g., '/dev/ttyUSB0')
|
|
85
|
+
camera_index: Camera device index (0 for default) or device path/identifier.
|
|
86
|
+
Defaults to 0.
|
|
80
87
|
axis_limits: Dictionary mapping axis names to (min, max) limits.
|
|
81
88
|
Defaults to DEFAULT_AXIS_LIMITS.
|
|
82
89
|
"""
|
|
@@ -97,19 +104,41 @@ class First:
|
|
|
97
104
|
port_name=sartorius_port or self.DEFAULT_SARTORIUS_PORT,
|
|
98
105
|
)
|
|
99
106
|
|
|
107
|
+
# Initialize camera
|
|
108
|
+
self.camera = CameraController(
|
|
109
|
+
camera_index=camera_index if camera_index is not None else self.DEFAULT_CAMERA_INDEX,
|
|
110
|
+
)
|
|
111
|
+
|
|
112
|
+
# Initialize logger
|
|
113
|
+
self._logger = logging.getLogger(__name__)
|
|
114
|
+
self._logger.info(
|
|
115
|
+
"First machine initialized with qubot_port='%s', sartorius_port='%s', camera_index=%s",
|
|
116
|
+
qubot_port or self.DEFAULT_QUBOT_PORT,
|
|
117
|
+
sartorius_port or self.DEFAULT_SARTORIUS_PORT,
|
|
118
|
+
camera_index if camera_index is not None else self.DEFAULT_CAMERA_INDEX,
|
|
119
|
+
)
|
|
120
|
+
|
|
100
121
|
def connect(self):
|
|
101
122
|
"""Connect all controllers."""
|
|
123
|
+
self._logger.info("Connecting all controllers")
|
|
102
124
|
self.qubot.connect()
|
|
103
125
|
self.pipette.connect()
|
|
126
|
+
self.camera.connect()
|
|
127
|
+
self._logger.info("All controllers connected successfully")
|
|
104
128
|
|
|
105
129
|
def disconnect(self):
|
|
106
130
|
"""Disconnect all controllers."""
|
|
131
|
+
self._logger.info("Disconnecting all controllers")
|
|
107
132
|
self.qubot.disconnect()
|
|
108
133
|
self.pipette.disconnect()
|
|
134
|
+
self.camera.disconnect()
|
|
135
|
+
self._logger.info("All controllers disconnected successfully")
|
|
109
136
|
|
|
110
137
|
def load_labware(self, slot: str, labware_name: str):
|
|
111
138
|
"""Load a labware object into a slot."""
|
|
139
|
+
self._logger.info("Loading labware '%s' into slot '%s'", labware_name, slot)
|
|
112
140
|
self.deck.load_labware(slot=slot, labware_name=labware_name)
|
|
141
|
+
self._logger.debug("Labware '%s' loaded into slot '%s'", labware_name, slot)
|
|
113
142
|
|
|
114
143
|
def load_deck(self, deck_layout: Dict[str, Type[StandardLabware]]):
|
|
115
144
|
"""
|
|
@@ -126,63 +155,84 @@ class First:
|
|
|
126
155
|
"C1": Rubbish,
|
|
127
156
|
})
|
|
128
157
|
"""
|
|
158
|
+
self._logger.info("Loading deck layout with %d labware items", len(deck_layout))
|
|
129
159
|
for slot, labware_name in deck_layout.items():
|
|
130
160
|
self.load_labware(slot=slot, labware_name=labware_name)
|
|
161
|
+
self._logger.info("Deck layout loaded successfully")
|
|
131
162
|
|
|
132
163
|
def attach_tip(self, slot: str, well: Optional[str] = None):
|
|
133
164
|
"""Attach a tip from a slot."""
|
|
134
165
|
if self.pipette.is_tip_attached():
|
|
166
|
+
self._logger.error("Cannot attach tip: tip already attached")
|
|
135
167
|
raise ValueError("Tip already attached")
|
|
136
168
|
|
|
169
|
+
self._logger.info("Attaching tip from slot '%s'%s", slot, f", well '{well}'" if well else "")
|
|
137
170
|
pos = self.get_absolute_z_position(slot, well)
|
|
171
|
+
self._logger.debug("Moving to position %s for tip attachment", pos)
|
|
138
172
|
# return the offset from the origin
|
|
139
173
|
self.qubot.move_absolute(position=pos)
|
|
140
174
|
|
|
141
175
|
# attach tip (move slowly down)
|
|
176
|
+
insert_depth = self.deck[slot].get_insert_depth()
|
|
177
|
+
self._logger.debug("Moving down by %s mm to insert tip", insert_depth)
|
|
142
178
|
self.qubot.move_relative(
|
|
143
|
-
position=Position(z=-
|
|
179
|
+
position=Position(z=-insert_depth),
|
|
144
180
|
feed=500
|
|
145
181
|
)
|
|
146
182
|
self.pipette.set_tip_attached(attached=True)
|
|
183
|
+
self._logger.info("Tip attached successfully, homing Z axis")
|
|
147
184
|
# must home Z axis after, as pressing in tip might cause it to lose steps
|
|
148
185
|
self.qubot.home(axis="Z")
|
|
186
|
+
self._logger.debug("Z axis homed after tip attachment")
|
|
149
187
|
|
|
150
188
|
def drop_tip(self, slot: str, well: str):
|
|
151
189
|
"""Drop a tip into a slot."""
|
|
152
190
|
if not self.pipette.is_tip_attached():
|
|
191
|
+
self._logger.error("Cannot drop tip: no tip attached")
|
|
153
192
|
raise ValueError("Tip not attached")
|
|
154
193
|
|
|
194
|
+
self._logger.info("Dropping tip into slot '%s', well '%s'", slot, well)
|
|
155
195
|
pos = self.get_absolute_z_position(slot, well)
|
|
156
196
|
# move up by the tip length
|
|
157
197
|
pos += Position(z=self.TIP_LENGTH)
|
|
158
|
-
|
|
198
|
+
self._logger.debug("Moving to position %s (adjusted for tip length) for tip drop", pos)
|
|
159
199
|
self.qubot.move_absolute(position=pos)
|
|
160
200
|
|
|
201
|
+
self._logger.debug("Ejecting tip")
|
|
161
202
|
self.pipette.eject_tip()
|
|
162
203
|
time.sleep(5)
|
|
163
204
|
self.pipette.set_tip_attached(attached=False)
|
|
205
|
+
self._logger.info("Tip dropped successfully")
|
|
164
206
|
|
|
165
207
|
def aspirate_from(self, slot:str, well:str, amount:int):
|
|
166
208
|
"""Aspirate a volume of liquid from a slot."""
|
|
167
209
|
if not self.pipette.is_tip_attached():
|
|
210
|
+
self._logger.error("Cannot aspirate: no tip attached")
|
|
168
211
|
raise ValueError("Tip not attached")
|
|
169
212
|
|
|
213
|
+
self._logger.info("Aspirating %d µL from slot '%s', well '%s'", amount, slot, well)
|
|
170
214
|
pos = self.get_absolute_z_position(slot, well)
|
|
171
|
-
|
|
215
|
+
self._logger.debug("Moving Z axis to position %s", pos)
|
|
172
216
|
self.qubot.move_absolute(position=pos)
|
|
217
|
+
self._logger.debug("Aspirating %d µL", amount)
|
|
173
218
|
self.pipette.aspirate(amount=amount)
|
|
174
219
|
time.sleep(5)
|
|
220
|
+
self._logger.info("Aspiration completed: %d µL from slot '%s', well '%s'", amount, slot, well)
|
|
175
221
|
|
|
176
222
|
def dispense_to(self, slot:str, well:str, amount:int):
|
|
177
223
|
"""Dispense a volume of liquid to a slot."""
|
|
178
224
|
if not self.pipette.is_tip_attached():
|
|
225
|
+
self._logger.error("Cannot dispense: no tip attached")
|
|
179
226
|
raise ValueError("Tip not attached")
|
|
180
227
|
|
|
228
|
+
self._logger.info("Dispensing %d µL to slot '%s', well '%s'", amount, slot, well)
|
|
181
229
|
pos = self.get_absolute_z_position(slot, well)
|
|
182
|
-
|
|
230
|
+
self._logger.debug("Moving Z axis to position %s", pos)
|
|
183
231
|
self.qubot.move_absolute(position=pos)
|
|
232
|
+
self._logger.debug("Dispensing %d µL", amount)
|
|
184
233
|
self.pipette.dispense(amount=amount)
|
|
185
234
|
time.sleep(5)
|
|
235
|
+
self._logger.info("Dispense completed: %d µL to slot '%s', well '%s'", amount, slot, well)
|
|
186
236
|
|
|
187
237
|
def get_slot_origin(self, slot: str) -> Position:
|
|
188
238
|
"""
|
|
@@ -199,8 +249,11 @@ class First:
|
|
|
199
249
|
"""
|
|
200
250
|
slot = slot.upper()
|
|
201
251
|
if slot not in self.SLOT_ORIGINS:
|
|
252
|
+
self._logger.error("Invalid slot name: '%s'. Must be one of %s", slot, list(self.SLOT_ORIGINS.keys()))
|
|
202
253
|
raise KeyError(f"Invalid slot name: {slot}. Must be one of {list(self.SLOT_ORIGINS.keys())}")
|
|
203
|
-
|
|
254
|
+
pos = self.SLOT_ORIGINS[slot]
|
|
255
|
+
self._logger.debug("Slot origin for '%s': %s", slot, pos)
|
|
256
|
+
return pos
|
|
204
257
|
|
|
205
258
|
def get_absolute_z_position(self, slot: str, well: Optional[str] = None) -> Position:
|
|
206
259
|
"""
|
|
@@ -224,6 +277,9 @@ class First:
|
|
|
224
277
|
# get z
|
|
225
278
|
z = Position(z=self.deck[slot].get_height() - self.CEILING_HEIGHT)
|
|
226
279
|
pos += z
|
|
280
|
+
self._logger.debug("Absolute Z position for slot '%s', well '%s': %s", slot, well, pos)
|
|
281
|
+
else:
|
|
282
|
+
self._logger.debug("Absolute Z position for slot '%s': %s", slot, pos)
|
|
227
283
|
return pos
|
|
228
284
|
|
|
229
285
|
def get_absolute_a_position(self, slot: str, well: Optional[str] = None) -> Position:
|
|
@@ -239,4 +295,7 @@ class First:
|
|
|
239
295
|
# get a
|
|
240
296
|
a = Position(a=self.deck[slot].get_height() - self.CEILING_HEIGHT)
|
|
241
297
|
pos += a
|
|
298
|
+
self._logger.debug("Absolute A position for slot '%s', well '%s': %s", slot, well, pos)
|
|
299
|
+
else:
|
|
300
|
+
self._logger.debug("Absolute A position for slot '%s': %s", slot, pos)
|
|
242
301
|
return pos
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: puda-drivers
|
|
3
|
-
Version: 0.0.
|
|
3
|
+
Version: 0.0.10
|
|
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
|
|
@@ -15,6 +15,7 @@ Classifier: Programming Language :: Python :: 3.14
|
|
|
15
15
|
Classifier: Topic :: Software Development :: Libraries :: Python Modules
|
|
16
16
|
Classifier: Topic :: System :: Hardware
|
|
17
17
|
Requires-Python: >=3.8
|
|
18
|
+
Requires-Dist: opencv-python>=4.12.0.88
|
|
18
19
|
Requires-Dist: pyserial~=3.5
|
|
19
20
|
Description-Content-Type: text/markdown
|
|
20
21
|
|
|
@@ -79,160 +80,96 @@ setup_logging(
|
|
|
79
80
|
|
|
80
81
|
When file logging is enabled, logs are saved to timestamped files (unless a custom name is provided) in the `logs/` folder. The logs folder is created automatically if it doesn't exist.
|
|
81
82
|
|
|
82
|
-
###
|
|
83
|
+
### First Machine Example
|
|
83
84
|
|
|
84
|
-
|
|
85
|
-
from puda_drivers.move import GCodeController
|
|
86
|
-
|
|
87
|
-
# Initialize and connect to a G-code device
|
|
88
|
-
gantry = GCodeController(port_name="/dev/ttyACM0", feed=3000)
|
|
89
|
-
gantry.connect()
|
|
90
|
-
|
|
91
|
-
# Configure axis limits for safety (recommended)
|
|
92
|
-
gantry.set_axis_limits("X", 0, 200)
|
|
93
|
-
gantry.set_axis_limits("Y", -200, 0)
|
|
94
|
-
gantry.set_axis_limits("Z", -100, 0)
|
|
95
|
-
gantry.set_axis_limits("A", -180, 180)
|
|
96
|
-
|
|
97
|
-
# Home the gantry
|
|
98
|
-
gantry.home()
|
|
85
|
+
The `First` machine integrates motion control, deck management, liquid handling, and camera capabilities:
|
|
99
86
|
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
gantry.move_relative(x=20.0, y=-10.0)
|
|
105
|
-
|
|
106
|
-
# Query current position
|
|
107
|
-
position = gantry.query_position()
|
|
108
|
-
print(f"Current position: {position}")
|
|
87
|
+
```python
|
|
88
|
+
import logging
|
|
89
|
+
from puda_drivers.machines import First
|
|
90
|
+
from puda_drivers.core.logging import setup_logging
|
|
109
91
|
|
|
110
|
-
#
|
|
111
|
-
|
|
112
|
-
|
|
92
|
+
# Configure logging
|
|
93
|
+
setup_logging(
|
|
94
|
+
enable_file_logging=False,
|
|
95
|
+
log_level=logging.DEBUG,
|
|
96
|
+
)
|
|
113
97
|
|
|
114
|
-
|
|
98
|
+
# Initialize the First machine
|
|
99
|
+
machine = First(
|
|
100
|
+
qubot_port="/dev/ttyACM0",
|
|
101
|
+
sartorius_port="/dev/ttyUSB0",
|
|
102
|
+
camera_index=0,
|
|
103
|
+
)
|
|
115
104
|
|
|
116
|
-
|
|
105
|
+
# Connect all devices
|
|
106
|
+
machine.connect()
|
|
117
107
|
|
|
118
|
-
|
|
119
|
-
|
|
108
|
+
# Home the gantry
|
|
109
|
+
machine.qubot.home()
|
|
120
110
|
|
|
121
|
-
# Initialize
|
|
122
|
-
pipette
|
|
123
|
-
pipette.connect()
|
|
124
|
-
pipette.initialize()
|
|
111
|
+
# Initialize the pipette
|
|
112
|
+
machine.pipette.initialize()
|
|
125
113
|
|
|
126
|
-
#
|
|
127
|
-
|
|
114
|
+
# Load labware onto the deck
|
|
115
|
+
machine.load_deck({
|
|
116
|
+
"C1": "trash_bin",
|
|
117
|
+
"C2": "polyelectric_8_wellplate_30000ul",
|
|
118
|
+
"A3": "opentrons_96_tiprack_300ul",
|
|
119
|
+
})
|
|
128
120
|
|
|
129
|
-
#
|
|
130
|
-
|
|
121
|
+
# Start video recording
|
|
122
|
+
machine.camera.start_video_recording()
|
|
131
123
|
|
|
132
|
-
#
|
|
133
|
-
|
|
124
|
+
# Perform liquid handling operations
|
|
125
|
+
machine.attach_tip(slot="A3", well="G8")
|
|
126
|
+
machine.aspirate_from(slot="C2", well="A1", amount=100)
|
|
127
|
+
machine.dispense_to(slot="C2", well="B4", amount=100)
|
|
128
|
+
machine.drop_tip(slot="C1", well="A1")
|
|
134
129
|
|
|
135
|
-
#
|
|
136
|
-
|
|
130
|
+
# Stop video recording
|
|
131
|
+
machine.camera.stop_video_recording()
|
|
137
132
|
|
|
138
|
-
# Disconnect
|
|
139
|
-
|
|
133
|
+
# Disconnect all devices
|
|
134
|
+
machine.disconnect()
|
|
140
135
|
```
|
|
141
136
|
|
|
142
|
-
|
|
137
|
+
**Discovering Available Methods**: To explore what methods are available on any class instance, you can use Python's built-in `help()` function:
|
|
143
138
|
|
|
144
139
|
```python
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
#
|
|
149
|
-
|
|
150
|
-
pipette = SartoriusController(port_name="/dev/ttyUSB0")
|
|
151
|
-
|
|
152
|
-
gantry.connect()
|
|
153
|
-
pipette.connect()
|
|
154
|
-
|
|
155
|
-
# Move to source well
|
|
156
|
-
gantry.move_absolute(x=50.0, y=-50.0, z=-20.0)
|
|
157
|
-
pipette.aspirate(amount=50.0)
|
|
158
|
-
|
|
159
|
-
# Move to destination well
|
|
160
|
-
gantry.move_absolute(x=150.0, y=-150.0, z=-20.0)
|
|
161
|
-
pipette.dispense(amount=50.0)
|
|
162
|
-
|
|
163
|
-
# Cleanup
|
|
164
|
-
pipette.eject_tip()
|
|
165
|
-
gantry.disconnect()
|
|
166
|
-
pipette.disconnect()
|
|
140
|
+
machine = First()
|
|
141
|
+
help(machine) # See methods for the First machine
|
|
142
|
+
help(machine.qubot) # See GCodeController methods
|
|
143
|
+
help(machine.pipette) # See SartoriusController methods
|
|
144
|
+
help(machine.camera) # See CameraController methods
|
|
167
145
|
```
|
|
168
146
|
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
### Motion Systems
|
|
147
|
+
Alternatively, you can read the source code directly in the `src/puda_drivers/` directory.
|
|
172
148
|
|
|
173
|
-
|
|
174
|
-
- Supports X, Y, Z, and A axes
|
|
175
|
-
- Configurable feed rates
|
|
176
|
-
- Position synchronization and homing
|
|
177
|
-
- Automatic axis limit validation for safe operation
|
|
149
|
+
## Device Support
|
|
178
150
|
|
|
179
|
-
|
|
151
|
+
The following device types are supported:
|
|
180
152
|
|
|
153
|
+
- **GCode** - G-code compatible motion systems (e.g., QuBot)
|
|
181
154
|
- **Sartorius rLINE®** - Electronic pipettes and robotic dispensers
|
|
182
|
-
|
|
183
|
-
- Tip attachment and ejection
|
|
184
|
-
- Configurable speeds and volumes
|
|
155
|
+
- **Camera** - Webcams and USB cameras for image and video capture
|
|
185
156
|
|
|
186
|
-
##
|
|
187
|
-
|
|
188
|
-
### Axis Limit Validation
|
|
189
|
-
|
|
190
|
-
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:
|
|
191
|
-
|
|
192
|
-
```python
|
|
193
|
-
from puda_drivers.move import GCodeController
|
|
194
|
-
|
|
195
|
-
gantry = GCodeController(port_name="/dev/ttyACM0")
|
|
196
|
-
gantry.connect()
|
|
197
|
-
|
|
198
|
-
# Set axis limits
|
|
199
|
-
gantry.set_axis_limits("X", 0, 200)
|
|
200
|
-
gantry.set_axis_limits("Y", -200, 0)
|
|
201
|
-
|
|
202
|
-
try:
|
|
203
|
-
# This will raise ValueError: Value 250 outside axis limits [0, 200]
|
|
204
|
-
gantry.move_absolute(x=250.0, y=-50.0)
|
|
205
|
-
except ValueError as e:
|
|
206
|
-
print(f"Move rejected: {e}")
|
|
207
|
-
|
|
208
|
-
# Relative moves are also validated after conversion to absolute positions
|
|
209
|
-
try:
|
|
210
|
-
# If current X is 150, moving 100 more would exceed the limit
|
|
211
|
-
gantry.move_relative(x=100.0)
|
|
212
|
-
except ValueError as e:
|
|
213
|
-
print(f"Move rejected: {e}")
|
|
214
|
-
```
|
|
215
|
-
|
|
216
|
-
Validation errors are automatically logged at the ERROR level before the exception is raised.
|
|
217
|
-
|
|
218
|
-
### Logging Best Practices
|
|
157
|
+
## Logging Best Practices
|
|
219
158
|
|
|
220
159
|
For production applications, configure logging at the start of your script:
|
|
221
160
|
|
|
222
161
|
```python
|
|
223
162
|
import logging
|
|
224
163
|
from puda_drivers.core.logging import setup_logging
|
|
225
|
-
from puda_drivers.move import GCodeController
|
|
226
164
|
|
|
227
165
|
# Configure logging first, before initializing devices
|
|
228
166
|
setup_logging(
|
|
229
167
|
enable_file_logging=True,
|
|
230
168
|
log_level=logging.INFO,
|
|
231
|
-
log_file_name="
|
|
169
|
+
log_file_name="experiment"
|
|
232
170
|
)
|
|
233
171
|
|
|
234
172
|
# Now all device operations will be logged
|
|
235
|
-
gantry = GCodeController(port_name="/dev/ttyACM0")
|
|
236
173
|
# ... rest of your code
|
|
237
174
|
```
|
|
238
175
|
|
|
@@ -264,6 +201,8 @@ sartorius_ports = list_serial_ports(filter_desc="Sartorius")
|
|
|
264
201
|
|
|
265
202
|
### Setup Development Environment
|
|
266
203
|
|
|
204
|
+
First, install `uv` if you haven't already. See the [uv installation guide](https://docs.astral.sh/uv/getting-started/installation/) for platform-specific instructions.
|
|
205
|
+
|
|
267
206
|
```bash
|
|
268
207
|
# Create virtual environment
|
|
269
208
|
uv venv
|
|
@@ -4,13 +4,15 @@ puda_drivers/core/__init__.py,sha256=XbCdXsU6NMDsmEAtavAGiSZZPla5d7zc2L7Qx9qKHdY
|
|
|
4
4
|
puda_drivers/core/logging.py,sha256=prOeJ3CGEbm37TtMRyAOTQQiMU5_ImZTRXmcUJxkenc,2892
|
|
5
5
|
puda_drivers/core/position.py,sha256=f4efmDSrKKCtqrR-GUJxVitPG20MiuGSDOWt-9TVISk,12628
|
|
6
6
|
puda_drivers/core/serialcontroller.py,sha256=38mKas1iJaOkAE0_V4tmqgZz7RxMMEWfGqA0Ma_Dt2A,8604
|
|
7
|
+
puda_drivers/cv/__init__.py,sha256=DYiPwYOLSUsZ9ivWiHoMGGD3MZ3Ygu9qLYja_OiUodU,100
|
|
8
|
+
puda_drivers/cv/camera.py,sha256=Tzxt1h3W5Z8XFanud_z-PhhJ2UYsC4OEhcak9TVu8jM,16490
|
|
7
9
|
puda_drivers/labware/__init__.py,sha256=RlRxrJn2zyzyxv4c1KGt8Gmxv2cRO8V4zZVnnyL-I00,288
|
|
8
10
|
puda_drivers/labware/labware.py,sha256=hZhOzSyb1GP_bm1LvQsREx4YSqLCWBTKNWDgDqfFavI,5317
|
|
9
11
|
puda_drivers/labware/opentrons_96_tiprack_300ul.json,sha256=jmNaworu688GEgFdxMxNRSaEp4ZOg9IumFk8bVHSzYY,19796
|
|
10
12
|
puda_drivers/labware/polyelectric_8_wellplate_30000ul.json,sha256=NwXMgHBYnIIFasmrthTDTTV7n1M5DYQBDWh-KYsq1gI,3104
|
|
11
13
|
puda_drivers/labware/trash_bin.json,sha256=X4TDNDzGbCtJSWlYgGYHUzdnVKj62SggGDNf7z5S0OE,581
|
|
12
14
|
puda_drivers/machines/__init__.py,sha256=zmIk_r2T8nbPA68h3Cko8N6oL7ncoBpmvhNcAqzHmc4,45
|
|
13
|
-
puda_drivers/machines/first.py,sha256=
|
|
15
|
+
puda_drivers/machines/first.py,sha256=9Im7jiw2duOBbBQEOxVkqFApPDRlGfI87OoyJ-M4SJE,12122
|
|
14
16
|
puda_drivers/move/__init__.py,sha256=NKIKckcqgyviPM0EGFcmIoaqkJM4qekR4babfdddRzM,96
|
|
15
17
|
puda_drivers/move/deck.py,sha256=yq2B4WMqj0hQvHt8HoJskP10u1DUyKwUnjP2c9gJ174,1397
|
|
16
18
|
puda_drivers/move/gcode.py,sha256=Aw7la4RkPw737hW5sKl6WiPCmmTnsjLvG4mOb-RwVSc,22592
|
|
@@ -21,7 +23,7 @@ puda_drivers/transfer/liquid/sartorius/__init__.py,sha256=QGpKz5YUwa8xCdSMXeZ0iR
|
|
|
21
23
|
puda_drivers/transfer/liquid/sartorius/api.py,sha256=jxwIJmY2k1K2ts6NC2ZgFTe4MOiH8TGnJeqYOqNa3rE,28250
|
|
22
24
|
puda_drivers/transfer/liquid/sartorius/constants.py,sha256=mcsjLrVBH-RSodH-pszstwcEL9wwbV0vOgHbGNxZz9w,2770
|
|
23
25
|
puda_drivers/transfer/liquid/sartorius/sartorius.py,sha256=bW838jYOAfLlbUqtsKRipZ-RLjrNTcZ7riYV6I4w8G8,13728
|
|
24
|
-
puda_drivers-0.0.
|
|
25
|
-
puda_drivers-0.0.
|
|
26
|
-
puda_drivers-0.0.
|
|
27
|
-
puda_drivers-0.0.
|
|
26
|
+
puda_drivers-0.0.10.dist-info/METADATA,sha256=oEbn6NvV9bXfJZusYV49ri1V1aPFR3n2qjKTp3vJ5R0,6991
|
|
27
|
+
puda_drivers-0.0.10.dist-info/WHEEL,sha256=WLgqFyCfm_KASv4WHyYy0P3pM_m7J5L9k2skdKLirC8,87
|
|
28
|
+
puda_drivers-0.0.10.dist-info/licenses/LICENSE,sha256=7EI8xVBu6h_7_JlVw-yPhhOZlpY9hP8wal7kHtqKT_E,1074
|
|
29
|
+
puda_drivers-0.0.10.dist-info/RECORD,,
|
|
File without changes
|
|
File without changes
|