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.
@@ -0,0 +1,4 @@
1
+ from .camera import CameraController, list_cameras
2
+
3
+ __all__ = ["CameraController", "list_cameras"]
4
+
@@ -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
+
@@ -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, and liquid handling.
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=-self.deck[slot].get_insert_depth()),
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
- print("moving Z to", pos)
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
- print("moving Z to", pos)
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
- print("moving Z to", pos)
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
- return self.SLOT_ORIGINS[slot]
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.8
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
- ### Gantry Control (GCode)
83
+ ### First Machine Example
83
84
 
84
- ```python
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
- # Move to absolute position (validated against limits)
101
- gantry.move_absolute(x=50.0, y=-100.0, z=-10.0)
102
-
103
- # Move relative to current position (validated after conversion to absolute)
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
- # Disconnect when done
111
- gantry.disconnect()
112
- ```
92
+ # Configure logging
93
+ setup_logging(
94
+ enable_file_logging=False,
95
+ log_level=logging.DEBUG,
96
+ )
113
97
 
114
- **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.
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
- ### Liquid Handling (Sartorius)
105
+ # Connect all devices
106
+ machine.connect()
117
107
 
118
- ```python
119
- from puda_drivers.transfer.liquid.sartorius import SartoriusController
108
+ # Home the gantry
109
+ machine.qubot.home()
120
110
 
121
- # Initialize and connect to pipette
122
- pipette = SartoriusController(port_name="/dev/ttyUSB0")
123
- pipette.connect()
124
- pipette.initialize()
111
+ # Initialize the pipette
112
+ machine.pipette.initialize()
125
113
 
126
- # Attach tip
127
- pipette.attach_tip()
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
- # Aspirate liquid
130
- pipette.aspirate(amount=50.0) # 50 µL
121
+ # Start video recording
122
+ machine.camera.start_video_recording()
131
123
 
132
- # Dispense liquid
133
- pipette.dispense(amount=50.0)
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
- # Eject tip
136
- pipette.eject_tip()
130
+ # Stop video recording
131
+ machine.camera.stop_video_recording()
137
132
 
138
- # Disconnect when done
139
- pipette.disconnect()
133
+ # Disconnect all devices
134
+ machine.disconnect()
140
135
  ```
141
136
 
142
- ### Combined Workflow
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
- from puda_drivers.move import GCodeController
146
- from puda_drivers.transfer.liquid.sartorius import SartoriusController
147
-
148
- # Initialize both devices
149
- gantry = GCodeController(port_name="/dev/ttyACM0")
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
- ## Device Support
170
-
171
- ### Motion Systems
147
+ Alternatively, you can read the source code directly in the `src/puda_drivers/` directory.
172
148
 
173
- - **QuBot** (GCode) - Multi-axis gantry systems compatible with G-code commands
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
- ### Liquid Handling
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
- - Aspirate and dispense operations
183
- - Tip attachment and ejection
184
- - Configurable speeds and volumes
155
+ - **Camera** - Webcams and USB cameras for image and video capture
185
156
 
186
- ## Error Handling
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="gantry_operation"
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=McnZ4q_ykv56aWFqphoZFqXqbhDGZAWCk40CPblgPsw,8492
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.8.dist-info/METADATA,sha256=_dQYo29lMIclzyd3HGZBvmOidliS92UWTQPPO0g79V0,8597
25
- puda_drivers-0.0.8.dist-info/WHEEL,sha256=WLgqFyCfm_KASv4WHyYy0P3pM_m7J5L9k2skdKLirC8,87
26
- puda_drivers-0.0.8.dist-info/licenses/LICENSE,sha256=7EI8xVBu6h_7_JlVw-yPhhOZlpY9hP8wal7kHtqKT_E,1074
27
- puda_drivers-0.0.8.dist-info/RECORD,,
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,,