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.
@@ -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.timeout = timeout
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.timeout,
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 custom protocol command.
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 not connected. Call connect() first.")
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 < self.timeout:
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.", self.timeout)
174
+ self._logger.error("No response within %s seconds.", timeout)
172
175
  raise serial.SerialTimeoutException(
173
- f"No response received within {self.timeout} seconds."
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
- pass
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
- # Increase timeout by 60 seconds for G28 (homing) command
225
- original_timeout = self.timeout
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)
@@ -3,9 +3,8 @@
3
3
  import json
4
4
  import inspect
5
5
  from pathlib import Path
6
- from typing import Dict, Any
6
+ from typing import Dict, Any, List
7
7
  from abc import ABC
8
- from typing import List
9
8
  from puda_drivers.core import Position
10
9
 
11
10
 
@@ -20,7 +20,8 @@
20
20
  "width": 10.0,
21
21
  "height": 10.0,
22
22
  "x": 60.0,
23
- "y": 42.0
23
+ "y": 42.0,
24
+ "z": 0.0
24
25
  }
25
26
  },
26
27
  "groups": [
@@ -141,10 +141,10 @@ class First:
141
141
  self._logger.info("Homing gantry...")
142
142
  self.qubot.home()
143
143
 
144
- # Initialize the pipette (all pipette operations need to wait 5 seconds for completion)
144
+ # Initialize the pipette
145
145
  self._logger.info("Initializing pipette...")
146
146
  self.pipette.initialize()
147
- time.sleep(5)
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.get_absolute_z_position(slot, well)
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.get_absolute_z_position(slot, well)
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.get_absolute_z_position(slot, well)
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.get_absolute_z_position(slot, well)
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
- def get_slot_origin(self, slot: str) -> Position:
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 get_absolute_z_position(self, slot: str, well: Optional[str] = None) -> Position:
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.get_slot_origin(slot)
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 get_absolute_a_position(self, slot: str, well: Optional[str] = None) -> Position:
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.get_slot_origin(slot)
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
- def start_video_recording(
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 stop_video_recording(self) -> Optional[Path]:
554
+ def pause(self):
463
555
  """
464
- Stop recording a video.
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
- return self.camera.stop_video_recording()
558
+ print("Pausing machine")
473
559
 
474
- def record_video(
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
- Record a video for a specified duration.
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
- return self.camera.record_video(
496
- duration_seconds=duration_seconds,
497
- filename=filename,
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
- Capture a single image from the camera.
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
- return self.camera.capture_image(save=save, filename=filename)
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)
@@ -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.warning(
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.16
3
+ Version: 0.0.18
4
4
  Summary: Hardware drivers for the PUDA platform.
5
- Project-URL: Homepage, https://github.com/zhao-bears/puda-drivers
6
- Project-URL: Issues, https://github.com/zhao-bears/puda-drivers/issues
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
- # Create virtual environment
204
- uv venv
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
- # Activate virtual environment
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
- # Install dependencies
210
- uv sync
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
- # Install package in editable mode
213
- pip install -e .
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=aIyEpA8J4R7yBfIq0roMqbD-nxEwuwaB8c_7zaOXg_A,9100
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=hZhOzSyb1GP_bm1LvQsREx4YSqLCWBTKNWDgDqfFavI,5317
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=Hk4MXO48P28jG7F87DUd9Ja4c_P7kAy3karPQ965i9Y,580
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=5xFjMwCHM4wZz2_VjEMXPMyXndtXjcnt1WL_GWSSRbs,20725
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=yq2B4WMqj0hQvHt8HoJskP10u1DUyKwUnjP2c9gJ174,1397
18
- puda_drivers/move/gcode.py,sha256=nS1dmt90_89BUjlHh0VNqGLeRhyIdJ4-8Ps_7YlW-B0,23735
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.16.dist-info/METADATA,sha256=kD7z3BQmYQAihFsf7T_f_iL3ttiLyY4nDegdEfmuElk,7102
27
- puda_drivers-0.0.16.dist-info/WHEEL,sha256=WLgqFyCfm_KASv4WHyYy0P3pM_m7J5L9k2skdKLirC8,87
28
- puda_drivers-0.0.16.dist-info/licenses/LICENSE,sha256=7EI8xVBu6h_7_JlVw-yPhhOZlpY9hP8wal7kHtqKT_E,1074
29
- puda_drivers-0.0.16.dist-info/RECORD,,
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,,