puda-drivers 0.0.16__py3-none-any.whl → 0.0.18__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- puda_drivers/core/serialcontroller.py +21 -30
- puda_drivers/labware/labware.py +1 -2
- puda_drivers/labware/trash_bin.json +2 -1
- puda_drivers/machines/first.py +138 -89
- puda_drivers/move/deck.py +35 -1
- puda_drivers/move/gcode.py +2 -2
- {puda_drivers-0.0.16.dist-info → puda_drivers-0.0.18.dist-info}/METADATA +62 -19
- {puda_drivers-0.0.16.dist-info → puda_drivers-0.0.18.dist-info}/RECORD +10 -10
- {puda_drivers-0.0.16.dist-info → puda_drivers-0.0.18.dist-info}/WHEEL +0 -0
- {puda_drivers-0.0.16.dist-info → puda_drivers-0.0.18.dist-info}/licenses/LICENSE +0 -0
|
@@ -48,7 +48,7 @@ class SerialController(ABC):
|
|
|
48
48
|
self._serial = None
|
|
49
49
|
self.port_name = port_name
|
|
50
50
|
self.baudrate = baudrate
|
|
51
|
-
self.
|
|
51
|
+
self._timeout = timeout
|
|
52
52
|
self._logger = logger
|
|
53
53
|
|
|
54
54
|
# lock to prevent concurrent access to the serial port
|
|
@@ -75,7 +75,7 @@ class SerialController(ABC):
|
|
|
75
75
|
self._serial = serial.Serial(
|
|
76
76
|
port=self.port_name,
|
|
77
77
|
baudrate=self.baudrate,
|
|
78
|
-
timeout=self.
|
|
78
|
+
timeout=self._timeout,
|
|
79
79
|
)
|
|
80
80
|
self._serial.flush()
|
|
81
81
|
self._logger.info("Successfully connected to %s.", self.port_name)
|
|
@@ -107,19 +107,19 @@ class SerialController(ABC):
|
|
|
107
107
|
|
|
108
108
|
def _send_command(self, command: str) -> None:
|
|
109
109
|
"""
|
|
110
|
-
Sends a
|
|
110
|
+
Sends a command to the device.
|
|
111
111
|
Note: This method should be called while holding self._lock to ensure
|
|
112
112
|
atomic command/response pairing.
|
|
113
113
|
"""
|
|
114
|
+
self._logger.info("-> Sending: %r", command)
|
|
115
|
+
|
|
114
116
|
if not self.is_connected or not self._serial:
|
|
115
117
|
self._logger.error(
|
|
116
118
|
"Attempt to send command '%s' failed: Device not connected.",
|
|
117
119
|
command,
|
|
118
120
|
)
|
|
119
121
|
# Retain raising an error for being disconnected, as that's a connection state issue
|
|
120
|
-
raise serial.SerialException("Device
|
|
121
|
-
|
|
122
|
-
self._logger.info("-> Sending: %r", command)
|
|
122
|
+
raise serial.SerialException("Device disconnected. Call connect() first.")
|
|
123
123
|
|
|
124
124
|
try:
|
|
125
125
|
self._serial.reset_input_buffer() # clear input buffer
|
|
@@ -140,7 +140,7 @@ class SerialController(ABC):
|
|
|
140
140
|
)
|
|
141
141
|
return None
|
|
142
142
|
|
|
143
|
-
def _read_response(self) -> str:
|
|
143
|
+
def _read_response(self, timeout: int = None) -> str:
|
|
144
144
|
"""
|
|
145
145
|
Generic, blocking read that respects timeout and returns
|
|
146
146
|
all data that arrived within the timeout period.
|
|
@@ -148,10 +148,13 @@ class SerialController(ABC):
|
|
|
148
148
|
if not self.is_connected or not self._serial:
|
|
149
149
|
raise serial.SerialException("Device not connected.")
|
|
150
150
|
|
|
151
|
+
if timeout is None:
|
|
152
|
+
timeout = self._timeout
|
|
153
|
+
|
|
151
154
|
start_time = time.time()
|
|
152
155
|
response = b""
|
|
153
156
|
|
|
154
|
-
while time.time() - start_time <
|
|
157
|
+
while time.time() - start_time < timeout:
|
|
155
158
|
if self._serial.in_waiting > 0:
|
|
156
159
|
# Read all available bytes
|
|
157
160
|
response += self._serial.read(self._serial.in_waiting)
|
|
@@ -168,14 +171,12 @@ class SerialController(ABC):
|
|
|
168
171
|
|
|
169
172
|
# Timeout reached - check what we got
|
|
170
173
|
if not response:
|
|
171
|
-
self._logger.error("No response within %s seconds.",
|
|
174
|
+
self._logger.error("No response within %s seconds.", timeout)
|
|
172
175
|
raise serial.SerialTimeoutException(
|
|
173
|
-
f"No response received within {
|
|
176
|
+
f"No response received within {timeout} seconds."
|
|
174
177
|
)
|
|
175
178
|
|
|
176
|
-
# Decode once and check the decoded string
|
|
177
179
|
decoded_response = response.decode("utf-8", errors="ignore").strip()
|
|
178
|
-
|
|
179
180
|
if "ok" in decoded_response.lower():
|
|
180
181
|
self._logger.debug("<- Received response: %r", decoded_response)
|
|
181
182
|
elif "err" in decoded_response.lower():
|
|
@@ -193,10 +194,12 @@ class SerialController(ABC):
|
|
|
193
194
|
def _build_command(self, command: str, value: Optional[str] = None) -> str:
|
|
194
195
|
"""
|
|
195
196
|
Build a command string according to the device protocol.
|
|
197
|
+
|
|
198
|
+
There might be special starting and ending characters for devices
|
|
196
199
|
"""
|
|
197
|
-
|
|
200
|
+
raise NotImplementedError
|
|
198
201
|
|
|
199
|
-
def execute(self, command: str, value: Optional[str] = None) -> str:
|
|
202
|
+
def execute(self, command: str, value: Optional[str] = None, timeout: int = None) -> str:
|
|
200
203
|
"""
|
|
201
204
|
Send a command and read the response atomically.
|
|
202
205
|
|
|
@@ -218,22 +221,10 @@ class SerialController(ABC):
|
|
|
218
221
|
serial.SerialException: If device is not connected or communication fails
|
|
219
222
|
serial.SerialTimeoutException: If no response is received within timeout
|
|
220
223
|
"""
|
|
224
|
+
if timeout is None:
|
|
225
|
+
timeout = self._timeout
|
|
221
226
|
# Hold the lock for the entire send+read operation to ensure atomicity
|
|
222
227
|
# This prevents concurrent commands from mixing up responses
|
|
223
228
|
with self._lock:
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
if "G28" in command.upper():
|
|
227
|
-
self.timeout = original_timeout + 60
|
|
228
|
-
# Also update the serial connection's timeout if connected
|
|
229
|
-
if self.is_connected and self._serial:
|
|
230
|
-
self._serial.timeout = self.timeout
|
|
231
|
-
|
|
232
|
-
try:
|
|
233
|
-
self._send_command(self._build_command(command, value))
|
|
234
|
-
return self._read_response()
|
|
235
|
-
finally:
|
|
236
|
-
# Restore original timeout
|
|
237
|
-
self.timeout = original_timeout
|
|
238
|
-
if self.is_connected and self._serial:
|
|
239
|
-
self._serial.timeout = original_timeout
|
|
229
|
+
self._send_command(self._build_command(command, value))
|
|
230
|
+
return self._read_response(timeout=timeout)
|
puda_drivers/labware/labware.py
CHANGED
puda_drivers/machines/first.py
CHANGED
|
@@ -141,10 +141,10 @@ class First:
|
|
|
141
141
|
self._logger.info("Homing gantry...")
|
|
142
142
|
self.qubot.home()
|
|
143
143
|
|
|
144
|
-
# Initialize the pipette
|
|
144
|
+
# Initialize the pipette
|
|
145
145
|
self._logger.info("Initializing pipette...")
|
|
146
146
|
self.pipette.initialize()
|
|
147
|
-
time.sleep(
|
|
147
|
+
time.sleep(3) # need to wait for the pipette to initialize
|
|
148
148
|
self._logger.info("Machine startup complete - ready for operations")
|
|
149
149
|
|
|
150
150
|
def shutdown(self):
|
|
@@ -159,6 +159,8 @@ class First:
|
|
|
159
159
|
self.camera.disconnect()
|
|
160
160
|
self._logger.info("Machine shutdown complete")
|
|
161
161
|
|
|
162
|
+
### Queue (public commands) ###
|
|
163
|
+
|
|
162
164
|
async def get_position(self) -> Dict[str, Union[Dict[str, float], int]]:
|
|
163
165
|
"""
|
|
164
166
|
Get the current position of the machine. Both QuBot and Sartorius are queried.
|
|
@@ -175,6 +177,18 @@ class First:
|
|
|
175
177
|
"qubot": qubot_position.to_dict(),
|
|
176
178
|
"pipette": sartorius_position,
|
|
177
179
|
}
|
|
180
|
+
|
|
181
|
+
def get_deck(self):
|
|
182
|
+
"""
|
|
183
|
+
Get the current deck layout.
|
|
184
|
+
|
|
185
|
+
Returns:
|
|
186
|
+
Dictionary mapping slot names (e.g., "A1") to labware classes.
|
|
187
|
+
|
|
188
|
+
Raises:
|
|
189
|
+
None
|
|
190
|
+
"""
|
|
191
|
+
return self.deck.to_dict()
|
|
178
192
|
|
|
179
193
|
def load_labware(self, slot: str, labware_name: str):
|
|
180
194
|
"""
|
|
@@ -190,7 +204,20 @@ class First:
|
|
|
190
204
|
self._logger.info("Loading labware '%s' into slot '%s'", labware_name, slot)
|
|
191
205
|
self.deck.load_labware(slot=slot, labware_name=labware_name)
|
|
192
206
|
self._logger.debug("Labware '%s' loaded into slot '%s'", labware_name, slot)
|
|
193
|
-
|
|
207
|
+
|
|
208
|
+
def remove_labware(self, slot: str):
|
|
209
|
+
"""
|
|
210
|
+
Remove labware from a slot.
|
|
211
|
+
|
|
212
|
+
Args:
|
|
213
|
+
slot: Slot name (e.g., 'A1', 'B2')
|
|
214
|
+
|
|
215
|
+
Raises:
|
|
216
|
+
KeyError: If slot is not found in deck
|
|
217
|
+
"""
|
|
218
|
+
self.deck.empty_slot(slot=slot)
|
|
219
|
+
self._logger.debug("Slot '%s' emptied", slot)
|
|
220
|
+
|
|
194
221
|
def load_deck(self, deck_layout: Dict[str, Type[StandardLabware]]):
|
|
195
222
|
"""
|
|
196
223
|
Load multiple labware into the deck at once.
|
|
@@ -228,7 +255,7 @@ class First:
|
|
|
228
255
|
return
|
|
229
256
|
|
|
230
257
|
self._logger.info("Attaching tip from slot '%s'%s", slot, f", well '{well}'" if well else "")
|
|
231
|
-
pos = self.
|
|
258
|
+
pos = self._get_absolute_z_position(slot, well)
|
|
232
259
|
self._logger.debug("Moving to position %s for tip attachment", pos)
|
|
233
260
|
# return the offset from the origin
|
|
234
261
|
self.qubot.move_absolute(position=pos)
|
|
@@ -250,7 +277,7 @@ class First:
|
|
|
250
277
|
self.qubot.home(axis="Z")
|
|
251
278
|
self._logger.debug("Z axis homed after tip attachment")
|
|
252
279
|
|
|
253
|
-
def drop_tip(self, slot: str, well: str, height_from_bottom: float = 0.0):
|
|
280
|
+
def drop_tip(self, *, slot: str, well: str, height_from_bottom: float = 0.0):
|
|
254
281
|
"""
|
|
255
282
|
Drop a tip into a slot.
|
|
256
283
|
|
|
@@ -271,7 +298,7 @@ class First:
|
|
|
271
298
|
raise ValueError("Tip not attached")
|
|
272
299
|
|
|
273
300
|
self._logger.info("Dropping tip into slot '%s', well '%s'", slot, well)
|
|
274
|
-
pos = self.
|
|
301
|
+
pos = self._get_absolute_z_position(slot, well)
|
|
275
302
|
# add height from bottom
|
|
276
303
|
pos += Position(z=height_from_bottom)
|
|
277
304
|
# move up by the tip length
|
|
@@ -285,7 +312,7 @@ class First:
|
|
|
285
312
|
self.pipette.set_tip_attached(attached=False)
|
|
286
313
|
self._logger.info("Tip dropped successfully")
|
|
287
314
|
|
|
288
|
-
def aspirate_from(self, slot:str, well:str, amount:int, height_from_bottom: float = 0.0):
|
|
315
|
+
def aspirate_from(self, *, slot: str, well: str, amount: int, height_from_bottom: float = 0.0):
|
|
289
316
|
"""
|
|
290
317
|
Aspirate a volume of liquid from a slot.
|
|
291
318
|
|
|
@@ -307,7 +334,7 @@ class First:
|
|
|
307
334
|
raise ValueError("Tip not attached")
|
|
308
335
|
|
|
309
336
|
self._logger.info("Aspirating %d µL from slot '%s', well '%s'", amount, slot, well)
|
|
310
|
-
pos = self.
|
|
337
|
+
pos = self._get_absolute_z_position(slot, well)
|
|
311
338
|
# add height from bottom
|
|
312
339
|
pos += Position(z=height_from_bottom)
|
|
313
340
|
self._logger.debug("Moving Z axis to position %s", pos)
|
|
@@ -318,7 +345,7 @@ class First:
|
|
|
318
345
|
time.sleep(5)
|
|
319
346
|
self._logger.info("Aspiration completed: %d µL from slot '%s', well '%s'", amount, slot, well)
|
|
320
347
|
|
|
321
|
-
def dispense_to(self, slot:str, well:str, amount:int, height_from_bottom: float = 0.0):
|
|
348
|
+
def dispense_to(self, *, slot: str, well: str, amount: int, height_from_bottom: float = 0.0):
|
|
322
349
|
"""
|
|
323
350
|
Dispense a volume of liquid to a slot.
|
|
324
351
|
|
|
@@ -340,7 +367,7 @@ class First:
|
|
|
340
367
|
raise ValueError("Tip not attached")
|
|
341
368
|
|
|
342
369
|
self._logger.info("Dispensing %d µL to slot '%s', well '%s'", amount, slot, well)
|
|
343
|
-
pos = self.
|
|
370
|
+
pos = self._get_absolute_z_position(slot, well)
|
|
344
371
|
# add height from bottom
|
|
345
372
|
pos += Position(z=height_from_bottom)
|
|
346
373
|
self._logger.debug("Moving Z axis to position %s", pos)
|
|
@@ -350,8 +377,93 @@ class First:
|
|
|
350
377
|
self.pipette.dispense(amount=amount)
|
|
351
378
|
time.sleep(5)
|
|
352
379
|
self._logger.info("Dispense completed: %d µL to slot '%s', well '%s'", amount, slot, well)
|
|
380
|
+
|
|
381
|
+
def start_video_recording(
|
|
382
|
+
self,
|
|
383
|
+
filename: Optional[Union[str, Path]] = None,
|
|
384
|
+
fps: Optional[float] = None
|
|
385
|
+
) -> Path:
|
|
386
|
+
"""
|
|
387
|
+
Start recording a video.
|
|
388
|
+
|
|
389
|
+
Args:
|
|
390
|
+
filename: Optional filename for the video. If not provided, a timestamped
|
|
391
|
+
filename will be generated. If provided without extension, .mp4 will be added.
|
|
392
|
+
fps: Optional frames per second for the video. Defaults to 30.0 if not specified.
|
|
393
|
+
|
|
394
|
+
Returns:
|
|
395
|
+
Path to the video file where recording is being saved
|
|
396
|
+
|
|
397
|
+
Raises:
|
|
398
|
+
IOError: If camera is not connected or recording fails to start
|
|
399
|
+
ValueError: If already recording
|
|
400
|
+
"""
|
|
401
|
+
return self.camera.start_video_recording(filename=filename, fps=fps)
|
|
402
|
+
|
|
403
|
+
def stop_video_recording(self) -> Optional[Path]:
|
|
404
|
+
"""
|
|
405
|
+
Stop recording a video.
|
|
406
|
+
|
|
407
|
+
Returns:
|
|
408
|
+
Path to the saved video file, or None if no recording was in progress
|
|
409
|
+
|
|
410
|
+
Raises:
|
|
411
|
+
IOError: If video writer fails to release
|
|
412
|
+
"""
|
|
413
|
+
return self.camera.stop_video_recording()
|
|
414
|
+
|
|
415
|
+
def record_video(
|
|
416
|
+
self,
|
|
417
|
+
duration_seconds: float,
|
|
418
|
+
filename: Optional[Union[str, Path]] = None,
|
|
419
|
+
fps: Optional[float] = None
|
|
420
|
+
) -> Path:
|
|
421
|
+
"""
|
|
422
|
+
Record a video for a specified duration.
|
|
423
|
+
|
|
424
|
+
Args:
|
|
425
|
+
duration_seconds: Duration of the video in seconds
|
|
426
|
+
filename: Optional filename for the video. If not provided, a timestamped
|
|
427
|
+
filename will be generated. If provided without extension, .mp4 will be added.
|
|
428
|
+
fps: Optional frames per second for the video. Defaults to 30.0 if not specified.
|
|
353
429
|
|
|
354
|
-
|
|
430
|
+
Returns:
|
|
431
|
+
Path to the saved video file
|
|
432
|
+
|
|
433
|
+
Raises:
|
|
434
|
+
IOError: If camera is not connected or recording fails
|
|
435
|
+
"""
|
|
436
|
+
return self.camera.record_video(
|
|
437
|
+
duration_seconds=duration_seconds,
|
|
438
|
+
filename=filename,
|
|
439
|
+
fps=fps
|
|
440
|
+
)
|
|
441
|
+
|
|
442
|
+
def capture_image(
|
|
443
|
+
self,
|
|
444
|
+
save: bool = False,
|
|
445
|
+
filename: Optional[Union[str, Path]] = None
|
|
446
|
+
) -> np.ndarray:
|
|
447
|
+
"""
|
|
448
|
+
Capture a single image from the camera.
|
|
449
|
+
|
|
450
|
+
Args:
|
|
451
|
+
save: If True, save the image to the captures folder
|
|
452
|
+
filename: Optional filename for the saved image. If not provided and save=True,
|
|
453
|
+
a timestamped filename will be generated. If provided without extension,
|
|
454
|
+
.jpg will be added.
|
|
455
|
+
|
|
456
|
+
Returns:
|
|
457
|
+
Captured image as a numpy array (BGR format)
|
|
458
|
+
|
|
459
|
+
Raises:
|
|
460
|
+
IOError: If camera is not connected or capture fails
|
|
461
|
+
"""
|
|
462
|
+
return self.camera.capture_image(save=save, filename=filename)
|
|
463
|
+
|
|
464
|
+
### Private methods ###
|
|
465
|
+
|
|
466
|
+
def _get_slot_origin(self, slot: str) -> Position:
|
|
355
467
|
"""
|
|
356
468
|
Get the origin coordinates of a slot.
|
|
357
469
|
|
|
@@ -372,7 +484,7 @@ class First:
|
|
|
372
484
|
self._logger.debug("Slot origin for '%s': %s", slot, pos)
|
|
373
485
|
return pos
|
|
374
486
|
|
|
375
|
-
def
|
|
487
|
+
def _get_absolute_z_position(self, slot: str, well: Optional[str] = None) -> Position:
|
|
376
488
|
"""
|
|
377
489
|
Get the absolute position for a slot (and optionally a well within that slot) based on the origin
|
|
378
490
|
|
|
@@ -387,7 +499,7 @@ class First:
|
|
|
387
499
|
ValueError: If well is specified but no labware is loaded in the slot
|
|
388
500
|
"""
|
|
389
501
|
# Get slot origin
|
|
390
|
-
pos = self.
|
|
502
|
+
pos = self._get_slot_origin(slot)
|
|
391
503
|
|
|
392
504
|
# relative well position from slot origin
|
|
393
505
|
if well:
|
|
@@ -405,7 +517,7 @@ class First:
|
|
|
405
517
|
self._logger.debug("Absolute Z position for slot '%s': %s", slot, pos)
|
|
406
518
|
return pos
|
|
407
519
|
|
|
408
|
-
def
|
|
520
|
+
def _get_absolute_a_position(self, slot: str, well: Optional[str] = None) -> Position:
|
|
409
521
|
"""
|
|
410
522
|
Get the absolute position for a slot (and optionally a well within that slot) based on the origin
|
|
411
523
|
|
|
@@ -419,7 +531,7 @@ class First:
|
|
|
419
531
|
Raises:
|
|
420
532
|
ValueError: If well is specified but no labware is loaded in the slot
|
|
421
533
|
"""
|
|
422
|
-
pos = self.
|
|
534
|
+
pos = self._get_slot_origin(slot)
|
|
423
535
|
|
|
424
536
|
if well:
|
|
425
537
|
labware = self.deck[slot]
|
|
@@ -437,85 +549,22 @@ class First:
|
|
|
437
549
|
self._logger.debug("Absolute A position for slot '%s': %s", slot, pos)
|
|
438
550
|
return pos
|
|
439
551
|
|
|
440
|
-
|
|
441
|
-
self,
|
|
442
|
-
filename: Optional[Union[str, Path]] = None,
|
|
443
|
-
fps: Optional[float] = None
|
|
444
|
-
) -> Path:
|
|
445
|
-
"""
|
|
446
|
-
Start recording a video.
|
|
447
|
-
|
|
448
|
-
Args:
|
|
449
|
-
filename: Optional filename for the video. If not provided, a timestamped
|
|
450
|
-
filename will be generated. If provided without extension, .mp4 will be added.
|
|
451
|
-
fps: Optional frames per second for the video. Defaults to 30.0 if not specified.
|
|
452
|
-
|
|
453
|
-
Returns:
|
|
454
|
-
Path to the video file where recording is being saved
|
|
455
|
-
|
|
456
|
-
Raises:
|
|
457
|
-
IOError: If camera is not connected or recording fails to start
|
|
458
|
-
ValueError: If already recording
|
|
459
|
-
"""
|
|
460
|
-
return self.camera.start_video_recording(filename=filename, fps=fps)
|
|
552
|
+
### Control (immediate commands) ###
|
|
461
553
|
|
|
462
|
-
def
|
|
554
|
+
def pause(self):
|
|
463
555
|
"""
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
Returns:
|
|
467
|
-
Path to the saved video file, or None if no recording was in progress
|
|
468
|
-
|
|
469
|
-
Raises:
|
|
470
|
-
IOError: If video writer fails to release
|
|
556
|
+
Pause the execution of queued commands.
|
|
471
557
|
"""
|
|
472
|
-
|
|
558
|
+
print("Pausing machine")
|
|
473
559
|
|
|
474
|
-
def
|
|
475
|
-
self,
|
|
476
|
-
duration_seconds: float,
|
|
477
|
-
filename: Optional[Union[str, Path]] = None,
|
|
478
|
-
fps: Optional[float] = None
|
|
479
|
-
) -> Path:
|
|
560
|
+
def resume(self):
|
|
480
561
|
"""
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
Args:
|
|
484
|
-
duration_seconds: Duration of the video in seconds
|
|
485
|
-
filename: Optional filename for the video. If not provided, a timestamped
|
|
486
|
-
filename will be generated. If provided without extension, .mp4 will be added.
|
|
487
|
-
fps: Optional frames per second for the video. Defaults to 30.0 if not specified.
|
|
488
|
-
|
|
489
|
-
Returns:
|
|
490
|
-
Path to the saved video file
|
|
491
|
-
|
|
492
|
-
Raises:
|
|
493
|
-
IOError: If camera is not connected or recording fails
|
|
562
|
+
Resume the execution of queued commands.
|
|
494
563
|
"""
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
fps=fps
|
|
499
|
-
)
|
|
500
|
-
|
|
501
|
-
def capture_image(
|
|
502
|
-
self,
|
|
503
|
-
save: bool = False,
|
|
504
|
-
filename: Optional[Union[str, Path]] = None
|
|
505
|
-
) -> np.ndarray:
|
|
564
|
+
print("Resuming machine")
|
|
565
|
+
|
|
566
|
+
def cancel(self):
|
|
506
567
|
"""
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
Args:
|
|
510
|
-
save: If True, save the image to the captures folder
|
|
511
|
-
filename: Optional filename for the saved image. If not provided and save=True,
|
|
512
|
-
a timestamped filename will be generated. If provided without extension,
|
|
513
|
-
.jpg will be added.
|
|
514
|
-
|
|
515
|
-
Returns:
|
|
516
|
-
Captured image as a numpy array (BGR format)
|
|
517
|
-
|
|
518
|
-
Raises:
|
|
519
|
-
IOError: If camera is not connected or capture fails
|
|
568
|
+
Cancel the execution of queued commands.
|
|
520
569
|
"""
|
|
521
|
-
|
|
570
|
+
print("Cancelling machine")
|
puda_drivers/move/deck.py
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
# src/puda_drivers/move/deck.py
|
|
2
2
|
|
|
3
|
+
import json
|
|
3
4
|
from puda_drivers.labware import StandardLabware
|
|
4
5
|
|
|
5
6
|
|
|
@@ -29,6 +30,20 @@ class Deck:
|
|
|
29
30
|
if slot.upper() not in self.slots:
|
|
30
31
|
raise KeyError(f"Slot {slot} not found in deck")
|
|
31
32
|
self.slots[slot.upper()] = StandardLabware(labware_name=labware_name)
|
|
33
|
+
|
|
34
|
+
def empty_slot(self, slot: str):
|
|
35
|
+
"""
|
|
36
|
+
Empty a slot (remove labware from it).
|
|
37
|
+
|
|
38
|
+
Args:
|
|
39
|
+
slot: Slot name (e.g., 'A1', 'B2')
|
|
40
|
+
|
|
41
|
+
Raises:
|
|
42
|
+
KeyError: If slot is not found in deck
|
|
43
|
+
"""
|
|
44
|
+
if slot.upper() not in self.slots:
|
|
45
|
+
raise KeyError(f"Slot {slot} not found in deck")
|
|
46
|
+
self.slots[slot.upper()] = None
|
|
32
47
|
|
|
33
48
|
def __str__(self):
|
|
34
49
|
"""
|
|
@@ -44,4 +59,23 @@ class Deck:
|
|
|
44
59
|
|
|
45
60
|
def __getitem__(self, key):
|
|
46
61
|
"""Allows syntax for: my_deck['B4']"""
|
|
47
|
-
return self.slots[key.upper()]
|
|
62
|
+
return self.slots[key.upper()]
|
|
63
|
+
|
|
64
|
+
def to_dict(self) -> dict:
|
|
65
|
+
"""
|
|
66
|
+
Return the deck layout as a dictionary.
|
|
67
|
+
"""
|
|
68
|
+
deck_data = {}
|
|
69
|
+
for slot, labware in self.slots.items():
|
|
70
|
+
if labware is None:
|
|
71
|
+
deck_data[slot] = None
|
|
72
|
+
else:
|
|
73
|
+
deck_data[slot] = labware.name
|
|
74
|
+
return deck_data
|
|
75
|
+
|
|
76
|
+
def to_json(self) -> str:
|
|
77
|
+
"""
|
|
78
|
+
Return the deck layout as a JSON string.
|
|
79
|
+
"""
|
|
80
|
+
# Re-use the logic from to_dict() so you don't have to update it in two places
|
|
81
|
+
return json.dumps(self.to_dict(), indent=2)
|
puda_drivers/move/gcode.py
CHANGED
|
@@ -296,7 +296,7 @@ class GCodeController(SerialController):
|
|
|
296
296
|
home_target = "All"
|
|
297
297
|
|
|
298
298
|
self._logger.info("[%s] homing axis/axes: %s **", cmd, home_target)
|
|
299
|
-
self.execute(cmd)
|
|
299
|
+
self.execute(command=cmd, timeout=90) # 90 seconds timeout for homing
|
|
300
300
|
self._logger.info("Homing of %s completed.\n", home_target)
|
|
301
301
|
|
|
302
302
|
# Update internal position (optimistic zeroing)
|
|
@@ -554,7 +554,7 @@ class GCodeController(SerialController):
|
|
|
554
554
|
self._logger.info("Query position complete. Retrieved positions: %s", position)
|
|
555
555
|
return position
|
|
556
556
|
except asyncio.TimeoutError:
|
|
557
|
-
self._logger.
|
|
557
|
+
self._logger.info(
|
|
558
558
|
"M114 command timed out after 1 second. Falling back to internal position."
|
|
559
559
|
)
|
|
560
560
|
return self.get_internal_position()
|
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: puda-drivers
|
|
3
|
-
Version: 0.0.
|
|
3
|
+
Version: 0.0.18
|
|
4
4
|
Summary: Hardware drivers for the PUDA platform.
|
|
5
|
-
Project-URL: Homepage, https://github.com/
|
|
6
|
-
Project-URL: Issues, https://github.com/
|
|
5
|
+
Project-URL: Homepage, https://github.com/PUDAP/puda
|
|
6
|
+
Project-URL: Issues, https://github.com/PUDAP/puda/issues
|
|
7
7
|
Author-email: zhao <20024592+agentzhao@users.noreply.github.com>
|
|
8
8
|
License-Expression: MIT
|
|
9
9
|
License-File: LICENSE
|
|
@@ -40,14 +40,6 @@ Hardware drivers for the PUDA (Physical Unified Device Architecture) platform. T
|
|
|
40
40
|
pip install puda-drivers
|
|
41
41
|
```
|
|
42
42
|
|
|
43
|
-
### From Source
|
|
44
|
-
|
|
45
|
-
```bash
|
|
46
|
-
git clone https://github.com/zhao-bears/puda-drivers.git
|
|
47
|
-
cd puda-drivers
|
|
48
|
-
pip install -e .
|
|
49
|
-
```
|
|
50
|
-
|
|
51
43
|
## Quick Start
|
|
52
44
|
|
|
53
45
|
### Logging Configuration
|
|
@@ -197,28 +189,79 @@ sartorius_ports = list_serial_ports(filter_desc="Sartorius")
|
|
|
197
189
|
|
|
198
190
|
### Setup Development Environment
|
|
199
191
|
|
|
200
|
-
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.
|
|
192
|
+
This package is part of a UV workspace monorepo. 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.
|
|
193
|
+
|
|
194
|
+
**From the repository root:**
|
|
201
195
|
|
|
202
196
|
```bash
|
|
203
|
-
#
|
|
204
|
-
uv
|
|
197
|
+
# Or install dependencies for all workspace packages
|
|
198
|
+
uv sync --all-packages
|
|
199
|
+
```
|
|
200
|
+
|
|
201
|
+
This will:
|
|
202
|
+
- Create a virtual environment at the repository root (`.venv/`)
|
|
203
|
+
- Install all dependencies for all workspace packages
|
|
204
|
+
- Install `puda-drivers` and other workspace packages in editable mode automatically
|
|
205
205
|
|
|
206
|
-
|
|
206
|
+
**Using the package:**
|
|
207
|
+
|
|
208
|
+
```bash
|
|
209
|
+
# Run Python scripts with workspace context (recommended, works from anywhere in the workspace)
|
|
210
|
+
uv run python your_script.py
|
|
211
|
+
|
|
212
|
+
# Or activate the virtual environment (from repository root where .venv is located)
|
|
207
213
|
source .venv/bin/activate # On Windows: .venv\Scripts\activate
|
|
214
|
+
python your_script.py
|
|
215
|
+
```
|
|
216
|
+
|
|
217
|
+
**Adding dependencies:**
|
|
218
|
+
|
|
219
|
+
```bash
|
|
220
|
+
# From the package directory
|
|
221
|
+
cd libs/drivers
|
|
222
|
+
uv add some-package
|
|
223
|
+
|
|
224
|
+
# Or from repository root
|
|
225
|
+
uv add --package puda-drivers some-package
|
|
226
|
+
```
|
|
227
|
+
|
|
228
|
+
**Note:** Workspace packages are automatically installed in editable mode, so code changes are immediately available without reinstalling.
|
|
208
229
|
|
|
209
|
-
|
|
210
|
-
|
|
230
|
+
### Testing
|
|
231
|
+
|
|
232
|
+
Run tests using pytest with `uv run`:
|
|
233
|
+
|
|
234
|
+
```bash
|
|
235
|
+
# Run all tests
|
|
236
|
+
uv run pytest tests/
|
|
211
237
|
|
|
212
|
-
#
|
|
213
|
-
|
|
238
|
+
# Run a specific test file
|
|
239
|
+
uv run pytest tests/test_deck.py
|
|
240
|
+
|
|
241
|
+
# Run a specific test class
|
|
242
|
+
uv run pytest tests/test_deck.py::TestDeckToDict
|
|
243
|
+
|
|
244
|
+
# Run a specific test function
|
|
245
|
+
uv run pytest tests/test_deck.py::TestDeckToDict::test_to_dict_empty_deck
|
|
246
|
+
|
|
247
|
+
# Run with verbose output
|
|
248
|
+
uv run pytest tests/ -v
|
|
249
|
+
|
|
250
|
+
# Run with coverage report
|
|
251
|
+
uv run pytest tests/ --cov=puda_drivers --cov-report=html
|
|
214
252
|
```
|
|
215
253
|
|
|
254
|
+
**Note:** Make sure you're in the `libs/drivers` directory or use the full path to the tests directory when running pytest commands.
|
|
255
|
+
|
|
216
256
|
### Building and Publishing
|
|
217
257
|
|
|
218
258
|
```bash
|
|
219
259
|
# Build distribution packages
|
|
220
260
|
uv build
|
|
221
261
|
|
|
262
|
+
# cd to puda project root
|
|
263
|
+
cd ...
|
|
264
|
+
|
|
222
265
|
# Publish to PyPI
|
|
223
266
|
uv publish
|
|
224
267
|
# Username: __token__
|
|
@@ -3,19 +3,19 @@ puda_drivers/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
|
3
3
|
puda_drivers/core/__init__.py,sha256=XbCdXsU6NMDsmEAtavAGiSZZPla5d7zc2L7Qx9qKHdY,214
|
|
4
4
|
puda_drivers/core/logging.py,sha256=prOeJ3CGEbm37TtMRyAOTQQiMU5_ImZTRXmcUJxkenc,2892
|
|
5
5
|
puda_drivers/core/position.py,sha256=f4efmDSrKKCtqrR-GUJxVitPG20MiuGSDOWt-9TVISk,12628
|
|
6
|
-
puda_drivers/core/serialcontroller.py,sha256=
|
|
6
|
+
puda_drivers/core/serialcontroller.py,sha256=8Yf9DYIhtQTIVGB1Pm6COa6qgJcM0ZxdGwOGW8Om9ss,8654
|
|
7
7
|
puda_drivers/cv/__init__.py,sha256=DYiPwYOLSUsZ9ivWiHoMGGD3MZ3Ygu9qLYja_OiUodU,100
|
|
8
8
|
puda_drivers/cv/camera.py,sha256=Tzxt1h3W5Z8XFanud_z-PhhJ2UYsC4OEhcak9TVu8jM,16490
|
|
9
9
|
puda_drivers/labware/__init__.py,sha256=RlRxrJn2zyzyxv4c1KGt8Gmxv2cRO8V4zZVnnyL-I00,288
|
|
10
|
-
puda_drivers/labware/labware.py,sha256=
|
|
10
|
+
puda_drivers/labware/labware.py,sha256=zugMMMLWAOINj8K5czc62AvmvlFbb0vkQDAWw_cBMyE,5299
|
|
11
11
|
puda_drivers/labware/opentrons_96_tiprack_300ul.json,sha256=jmNaworu688GEgFdxMxNRSaEp4ZOg9IumFk8bVHSzYY,19796
|
|
12
12
|
puda_drivers/labware/polyelectric_8_wellplate_30000ul.json,sha256=esu2tej0ORs7Pfd4HwoQVUpU5mPvp2AYzE3zsCC2FDk,3104
|
|
13
|
-
puda_drivers/labware/trash_bin.json,sha256=
|
|
13
|
+
puda_drivers/labware/trash_bin.json,sha256=6dH0prXKJVXMRm096rPSiJgMr0nPRtHUKo7Z6xIxdnA,596
|
|
14
14
|
puda_drivers/machines/__init__.py,sha256=zmIk_r2T8nbPA68h3Cko8N6oL7ncoBpmvhNcAqzHmc4,45
|
|
15
|
-
puda_drivers/machines/first.py,sha256=
|
|
15
|
+
puda_drivers/machines/first.py,sha256=JFah4Y4X55nigwgkEcebOvPdWHNXudfkQpVyHzp5xSQ,21856
|
|
16
16
|
puda_drivers/move/__init__.py,sha256=NKIKckcqgyviPM0EGFcmIoaqkJM4qekR4babfdddRzM,96
|
|
17
|
-
puda_drivers/move/deck.py,sha256=
|
|
18
|
-
puda_drivers/move/gcode.py,sha256=
|
|
17
|
+
puda_drivers/move/deck.py,sha256=CprlB8i9jvNImrc5IpHUZSSvvFe8uei76fNQ4iN6n34,2394
|
|
18
|
+
puda_drivers/move/gcode.py,sha256=ioqRS-Kom5S7Pyp9-wg4MRXGIbIDEz7SsihBBOM2m30,23784
|
|
19
19
|
puda_drivers/move/grbl/__init__.py,sha256=vBeeti8DVN2dACi1rLmHN_UGIOdo0s-HZX6mIepLV5I,98
|
|
20
20
|
puda_drivers/move/grbl/api.py,sha256=loj8_Vap7S9qaD0ReHhgxr9Vkl6Wp7DGzyLkZyZ6v_k,16995
|
|
21
21
|
puda_drivers/move/grbl/constants.py,sha256=4736CRDzLGWVqGscLajMlrIQMyubsHfthXi4RF1CHNg,9585
|
|
@@ -23,7 +23,7 @@ puda_drivers/transfer/liquid/sartorius/__init__.py,sha256=7fljIbu0KkumsI3NI3O64d
|
|
|
23
23
|
puda_drivers/transfer/liquid/sartorius/api.py,sha256=jxwIJmY2k1K2ts6NC2ZgFTe4MOiH8TGnJeqYOqNa3rE,28250
|
|
24
24
|
puda_drivers/transfer/liquid/sartorius/constants.py,sha256=mcsjLrVBH-RSodH-pszstwcEL9wwbV0vOgHbGNxZz9w,2770
|
|
25
25
|
puda_drivers/transfer/liquid/sartorius/rLine.py,sha256=FWEFO9tZN3cbneiozXRs77O-47Jg_8vYzWJvUrWpYxA,14531
|
|
26
|
-
puda_drivers-0.0.
|
|
27
|
-
puda_drivers-0.0.
|
|
28
|
-
puda_drivers-0.0.
|
|
29
|
-
puda_drivers-0.0.
|
|
26
|
+
puda_drivers-0.0.18.dist-info/METADATA,sha256=JQCr3vVSNyE7DLRxJT7_w3X4v9d_U-JpCnrQwLtJrgw,8409
|
|
27
|
+
puda_drivers-0.0.18.dist-info/WHEEL,sha256=WLgqFyCfm_KASv4WHyYy0P3pM_m7J5L9k2skdKLirC8,87
|
|
28
|
+
puda_drivers-0.0.18.dist-info/licenses/LICENSE,sha256=7EI8xVBu6h_7_JlVw-yPhhOZlpY9hP8wal7kHtqKT_E,1074
|
|
29
|
+
puda_drivers-0.0.18.dist-info/RECORD,,
|
|
File without changes
|
|
File without changes
|