puda-drivers 0.0.14__tar.gz → 0.0.16__tar.gz

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.
Files changed (37) hide show
  1. {puda_drivers-0.0.14 → puda_drivers-0.0.16}/PKG-INFO +1 -1
  2. {puda_drivers-0.0.14 → puda_drivers-0.0.16}/pyproject.toml +1 -1
  3. {puda_drivers-0.0.14 → puda_drivers-0.0.16}/src/puda_drivers/core/serialcontroller.py +32 -23
  4. {puda_drivers-0.0.14 → puda_drivers-0.0.16}/src/puda_drivers/machines/first.py +26 -8
  5. {puda_drivers-0.0.14 → puda_drivers-0.0.16}/src/puda_drivers/move/gcode.py +44 -25
  6. {puda_drivers-0.0.14 → puda_drivers-0.0.16}/src/puda_drivers/transfer/liquid/sartorius/rLine.py +8 -5
  7. {puda_drivers-0.0.14 → puda_drivers-0.0.16}/tests/pipette.py +2 -1
  8. puda_drivers-0.0.16/tests/test_position_polling.py +115 -0
  9. {puda_drivers-0.0.14 → puda_drivers-0.0.16}/uv.lock +1 -1
  10. {puda_drivers-0.0.14 → puda_drivers-0.0.16}/.gitignore +0 -0
  11. {puda_drivers-0.0.14 → puda_drivers-0.0.16}/LICENSE +0 -0
  12. {puda_drivers-0.0.14 → puda_drivers-0.0.16}/README.md +0 -0
  13. {puda_drivers-0.0.14 → puda_drivers-0.0.16}/src/puda_drivers/__init__.py +0 -0
  14. {puda_drivers-0.0.14 → puda_drivers-0.0.16}/src/puda_drivers/core/__init__.py +0 -0
  15. {puda_drivers-0.0.14 → puda_drivers-0.0.16}/src/puda_drivers/core/logging.py +0 -0
  16. {puda_drivers-0.0.14 → puda_drivers-0.0.16}/src/puda_drivers/core/position.py +0 -0
  17. {puda_drivers-0.0.14 → puda_drivers-0.0.16}/src/puda_drivers/cv/__init__.py +0 -0
  18. {puda_drivers-0.0.14 → puda_drivers-0.0.16}/src/puda_drivers/cv/camera.py +0 -0
  19. {puda_drivers-0.0.14 → puda_drivers-0.0.16}/src/puda_drivers/labware/__init__.py +0 -0
  20. {puda_drivers-0.0.14 → puda_drivers-0.0.16}/src/puda_drivers/labware/labware.py +0 -0
  21. {puda_drivers-0.0.14 → puda_drivers-0.0.16}/src/puda_drivers/labware/opentrons_96_tiprack_300ul.json +0 -0
  22. {puda_drivers-0.0.14 → puda_drivers-0.0.16}/src/puda_drivers/labware/polyelectric_8_wellplate_30000ul.json +0 -0
  23. {puda_drivers-0.0.14 → puda_drivers-0.0.16}/src/puda_drivers/labware/trash_bin.json +0 -0
  24. {puda_drivers-0.0.14 → puda_drivers-0.0.16}/src/puda_drivers/machines/__init__.py +0 -0
  25. {puda_drivers-0.0.14 → puda_drivers-0.0.16}/src/puda_drivers/move/__init__.py +0 -0
  26. {puda_drivers-0.0.14 → puda_drivers-0.0.16}/src/puda_drivers/move/deck.py +0 -0
  27. {puda_drivers-0.0.14 → puda_drivers-0.0.16}/src/puda_drivers/move/grbl/__init__.py +0 -0
  28. {puda_drivers-0.0.14 → puda_drivers-0.0.16}/src/puda_drivers/move/grbl/api.py +0 -0
  29. {puda_drivers-0.0.14 → puda_drivers-0.0.16}/src/puda_drivers/move/grbl/constants.py +0 -0
  30. {puda_drivers-0.0.14 → puda_drivers-0.0.16}/src/puda_drivers/py.typed +0 -0
  31. {puda_drivers-0.0.14 → puda_drivers-0.0.16}/src/puda_drivers/transfer/liquid/sartorius/__init__.py +0 -0
  32. {puda_drivers-0.0.14 → puda_drivers-0.0.16}/src/puda_drivers/transfer/liquid/sartorius/api.py +0 -0
  33. {puda_drivers-0.0.14 → puda_drivers-0.0.16}/src/puda_drivers/transfer/liquid/sartorius/constants.py +0 -0
  34. {puda_drivers-0.0.14 → puda_drivers-0.0.16}/tests/example.py +0 -0
  35. {puda_drivers-0.0.14 → puda_drivers-0.0.16}/tests/first.py +0 -0
  36. {puda_drivers-0.0.14 → puda_drivers-0.0.16}/tests/qubot.py +0 -0
  37. {puda_drivers-0.0.14 → puda_drivers-0.0.16}/tests/webcam.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: puda-drivers
3
- Version: 0.0.14
3
+ Version: 0.0.16
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
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "puda-drivers"
3
- version = "0.0.14"
3
+ version = "0.0.16"
4
4
  description = "Hardware drivers for the PUDA platform."
5
5
  readme = "README.md"
6
6
  authors = [
@@ -4,6 +4,7 @@ Generic Serial Controller for communicating with devices over serial ports.
4
4
 
5
5
  import time
6
6
  import logging
7
+ import threading
7
8
  from typing import Optional, List, Tuple
8
9
  from abc import ABC, abstractmethod
9
10
  import serial
@@ -49,6 +50,9 @@ class SerialController(ABC):
49
50
  self.baudrate = baudrate
50
51
  self.timeout = timeout
51
52
  self._logger = logger
53
+
54
+ # lock to prevent concurrent access to the serial port
55
+ self._lock = threading.Lock()
52
56
 
53
57
  def connect(self) -> None:
54
58
  """
@@ -103,8 +107,9 @@ class SerialController(ABC):
103
107
 
104
108
  def _send_command(self, command: str) -> None:
105
109
  """
106
- Sends a custom protocol command, reads, and returns the response.
107
- If a timeout occurs, it logs the error and returns None instead of raising an exception.
110
+ Sends a custom protocol command.
111
+ Note: This method should be called while holding self._lock to ensure
112
+ atomic command/response pairing.
108
113
  """
109
114
  if not self.is_connected or not self._serial:
110
115
  self._logger.error(
@@ -116,7 +121,6 @@ class SerialController(ABC):
116
121
 
117
122
  self._logger.info("-> Sending: %r", command)
118
123
 
119
- # Send the command
120
124
  try:
121
125
  self._serial.reset_input_buffer() # clear input buffer
122
126
  self._serial.reset_output_buffer() # clear output buffer
@@ -130,7 +134,7 @@ class SerialController(ABC):
130
134
 
131
135
  except serial.SerialException as e:
132
136
  self._logger.error(
133
- "Serial error writing or reading command '%s'. Error: %s",
137
+ "Serial error writing command '%s'. Error: %s",
134
138
  command,
135
139
  e,
136
140
  )
@@ -198,11 +202,14 @@ class SerialController(ABC):
198
202
 
199
203
  This method combines sending a command and reading its response into a
200
204
  single atomic operation, ensuring the response corresponds to the command
201
- that was just sent. This is the preferred method for commands that
202
- require a response.
205
+ that was just sent. The entire operation is protected by a lock to prevent
206
+ concurrent commands from interfering with each other.
207
+
208
+ This is the preferred method for commands that require a response.
203
209
 
204
210
  Args:
205
211
  command: Command string to send (should include protocol terminator if needed)
212
+ value: Optional value parameter for the command
206
213
 
207
214
  Returns:
208
215
  Response string from the device
@@ -211,20 +218,22 @@ class SerialController(ABC):
211
218
  serial.SerialException: If device is not connected or communication fails
212
219
  serial.SerialTimeoutException: If no response is received within timeout
213
220
  """
214
- self._send_command(self._build_command(command, value))
215
-
216
- # Increase timeout by 60 seconds for G28 (homing) command
217
- original_timeout = self.timeout
218
- if "G28" in command.upper():
219
- self.timeout = original_timeout + 60
220
- # Also update the serial connection's timeout if connected
221
- if self.is_connected and self._serial:
222
- self._serial.timeout = self.timeout
223
-
224
- try:
225
- return self._read_response()
226
- finally:
227
- # Restore original timeout
228
- self.timeout = original_timeout
229
- if self.is_connected and self._serial:
230
- self._serial.timeout = original_timeout
221
+ # Hold the lock for the entire send+read operation to ensure atomicity
222
+ # This prevents concurrent commands from mixing up responses
223
+ 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
@@ -159,7 +159,7 @@ class First:
159
159
  self.camera.disconnect()
160
160
  self._logger.info("Machine shutdown complete")
161
161
 
162
- def get_position(self) -> Dict[str, float]:
162
+ async def get_position(self) -> Dict[str, Union[Dict[str, float], int]]:
163
163
  """
164
164
  Get the current position of the machine. Both QuBot and Sartorius are queried.
165
165
 
@@ -168,8 +168,8 @@ class First:
168
168
  Returns:
169
169
  Dictionary containing the current position of the machine and it's components.
170
170
  """
171
- qubot_position = self.qubot.get_position()
172
- sartorius_position = self.pipette.get_position()
171
+ qubot_position = await self.qubot.get_position()
172
+ sartorius_position = await self.pipette.get_position()
173
173
 
174
174
  return {
175
175
  "qubot": qubot_position.to_dict(),
@@ -234,7 +234,11 @@ class First:
234
234
  self.qubot.move_absolute(position=pos)
235
235
 
236
236
  # attach tip (move slowly down)
237
- insert_depth = self.deck[slot].get_insert_depth()
237
+ labware = self.deck[slot]
238
+ if labware is None:
239
+ self._logger.error("Cannot attach tip: no labware loaded in slot '%s'", slot)
240
+ raise ValueError(f"No labware loaded in slot '{slot}'. Load labware before attaching tips.")
241
+ insert_depth = labware.get_insert_depth()
238
242
  self._logger.debug("Moving down by %s mm to insert tip", insert_depth)
239
243
  self.qubot.move_relative(
240
244
  position=Position(z=-insert_depth),
@@ -378,17 +382,24 @@ class First:
378
382
 
379
383
  Returns:
380
384
  Position with absolute coordinates
385
+
386
+ Raises:
387
+ ValueError: If well is specified but no labware is loaded in the slot
381
388
  """
382
389
  # Get slot origin
383
390
  pos = self.get_slot_origin(slot)
384
391
 
385
392
  # relative well position from slot origin
386
393
  if well:
387
- well_pos = self.deck[slot].get_well_position(well).get_xy()
394
+ labware = self.deck[slot]
395
+ if labware is None:
396
+ self._logger.error("Cannot get well position: no labware loaded in slot '%s'", slot)
397
+ raise ValueError(f"No labware loaded in slot '{slot}'. Load labware before accessing wells.")
398
+ well_pos = labware.get_well_position(well).get_xy()
388
399
  # the deck is rotated 90 degrees clockwise for this machine
389
400
  pos += well_pos.swap_xy()
390
401
  # get z
391
- pos += Position(z=self.deck[slot].get_height() - self.CEILING_HEIGHT)
402
+ pos += Position(z=labware.get_height() - self.CEILING_HEIGHT)
392
403
  self._logger.debug("Absolute Z position for slot '%s', well '%s': %s", slot, well, pos)
393
404
  else:
394
405
  self._logger.debug("Absolute Z position for slot '%s': %s", slot, pos)
@@ -404,15 +415,22 @@ class First:
404
415
 
405
416
  Returns:
406
417
  Position with absolute coordinates
418
+
419
+ Raises:
420
+ ValueError: If well is specified but no labware is loaded in the slot
407
421
  """
408
422
  pos = self.get_slot_origin(slot)
409
423
 
410
424
  if well:
411
- well_pos = self.deck[slot].get_well_position(well).get_xy()
425
+ labware = self.deck[slot]
426
+ if labware is None:
427
+ self._logger.error("Cannot get well position: no labware loaded in slot '%s'", slot)
428
+ raise ValueError(f"No labware loaded in slot '{slot}'. Load labware before accessing wells.")
429
+ well_pos = labware.get_well_position(well).get_xy()
412
430
  pos += well_pos.swap_xy()
413
431
 
414
432
  # get a
415
- a = Position(a=self.deck[slot].get_height() - self.CEILING_HEIGHT)
433
+ a = Position(a=labware.get_height() - self.CEILING_HEIGHT)
416
434
  pos += a
417
435
  self._logger.debug("Absolute A position for slot '%s', well '%s': %s", slot, well, pos)
418
436
  else:
@@ -7,9 +7,9 @@ absolute coordinates, with relative moves converted to absolute internally.
7
7
  Supports homing and position synchronization.
8
8
  """
9
9
 
10
- import time
11
10
  import re
12
11
  import logging
12
+ import asyncio
13
13
  from dataclasses import dataclass
14
14
  from typing import Optional, Dict, Tuple, Union
15
15
 
@@ -508,9 +508,15 @@ class GCodeController(SerialController):
508
508
 
509
509
  return self._current_position
510
510
 
511
- def get_position(self) -> Position:
511
+ async def get_position(self) -> Position:
512
512
  """
513
- Get the current machine position (M114 command).
513
+ Get the current machine position (M114 command) asynchronously.
514
+
515
+ This method can be called even when the machine is moving from other commands,
516
+ as it runs the blocking serial communication in a separate thread.
517
+
518
+ If the M114 command takes more than 1 second to respond, falls back to
519
+ returning the internally tracked position instead.
514
520
 
515
521
  Returns:
516
522
  Position containing X, Y, Z, and A positions
@@ -519,28 +525,39 @@ class GCodeController(SerialController):
519
525
  Returns an empty Position if the query fails or no positions are found.
520
526
  """
521
527
  self._logger.info("Querying current machine position (M114).")
522
- res: str = self.execute("M114")
523
-
524
- # Extract position values using regex
525
- pattern = re.compile(r"([XYZA]):(-?\d+\.\d+)")
526
- matches = pattern.findall(res)
527
-
528
- position_data: Dict[str, float] = {}
528
+ # Run the blocking execute call in a thread pool to allow concurrent operations
529
+ # with a 1 second timeout - if it takes longer, fall back to internal position
530
+ try:
531
+ res: str = await asyncio.wait_for(
532
+ asyncio.to_thread(self.execute, "M114"),
533
+ timeout=1.0
534
+ )
535
+
536
+ # Extract position values using regex
537
+ pattern = re.compile(r"([XYZA]):(-?\d+\.\d+)")
538
+ matches = pattern.findall(res)
529
539
 
530
- for axis, value_str in matches:
531
- try:
532
- position_data[axis.lower()] = float(value_str)
533
- except ValueError:
534
- self._logger.error(
535
- "Failed to convert position value '%s' for axis %s to float.",
536
- value_str,
537
- axis,
538
- )
539
- continue
540
+ position_data: Dict[str, float] = {}
540
541
 
541
- position = Position.from_dict(position_data)
542
- self._logger.info("Query position complete. Retrieved positions: %s", position)
543
- return position
542
+ for axis, value_str in matches:
543
+ try:
544
+ position_data[axis.lower()] = float(value_str)
545
+ except ValueError:
546
+ self._logger.error(
547
+ "Failed to convert position value '%s' for axis %s to float.",
548
+ value_str,
549
+ axis,
550
+ )
551
+ continue
552
+
553
+ position = Position.from_dict(position_data)
554
+ self._logger.info("Query position complete. Retrieved positions: %s", position)
555
+ return position
556
+ except asyncio.TimeoutError:
557
+ self._logger.warning(
558
+ "M114 command timed out after 1 second. Falling back to internal position."
559
+ )
560
+ return self.get_internal_position()
544
561
 
545
562
  def _sync_position(self) -> Tuple[bool, Position]:
546
563
  """
@@ -556,11 +573,13 @@ class GCodeController(SerialController):
556
573
 
557
574
  Note:
558
575
  This method may recursively call itself if a correction move is made.
576
+ Since this method calls move_absolute (which is blocking), it remains
577
+ synchronous. The async get_position() is called using asyncio.run().
559
578
  """
560
579
  self._logger.info("Starting position synchronization check (M114).")
561
580
 
562
- # Query the actual machine position
563
- queried_position = self.get_position()
581
+ # Query the actual machine position (async method called from sync context)
582
+ queried_position = asyncio.run(self.get_position())
564
583
 
565
584
  if not queried_position.get_axes():
566
585
  self._logger.error("Query position failed. Cannot synchronize.")
@@ -9,14 +9,13 @@ Reference: https://api.sartorius.com/document-hub/dam/download/34901/Sartorius-r
9
9
 
10
10
  import json
11
11
  import logging
12
+ import asyncio
12
13
  from typing import Optional
13
14
  from puda_drivers.core.serialcontroller import SerialController
14
15
  from .constants import STATUS_CODES
15
16
 
16
17
  class SartoriusDeviceError(Exception):
17
18
  """Custom exception raised when the Sartorius device reports an error."""
18
-
19
- pass
20
19
 
21
20
  class SartoriusController(SerialController):
22
21
  """
@@ -372,15 +371,19 @@ class SartoriusController(SerialController):
372
371
 
373
372
  return json.dumps(status_data)
374
373
 
375
- def get_position(self) -> int:
374
+ async def get_position(self) -> int:
376
375
  """
377
- Query the current position of the pipette (DP command).
376
+ Query the current position of the pipette (DP command) asynchronously.
377
+
378
+ This method can be called even when the pipette is performing other operations,
379
+ as it runs the blocking serial communication in a separate thread.
378
380
 
379
381
  Returns:
380
382
  Current position in steps
381
383
  """
382
384
  self._logger.info("** Querying Position (DP) **")
383
- response = self.execute(command="DP")
385
+ # Run the blocking execute call in a thread pool to allow concurrent operations
386
+ response = await asyncio.to_thread(self.execute, command="DP")
384
387
  self._logger.info("** Position: %s steps **\n", response)
385
388
  return response
386
389
 
@@ -1,4 +1,5 @@
1
1
  import logging
2
+ import asyncio
2
3
  from puda_drivers.transfer.liquid.sartorius import SartoriusController
3
4
  from puda_drivers.core.logging import setup_logging
4
5
 
@@ -52,7 +53,7 @@ def test_pipette_operations():
52
53
  pipette.set_outward_speed(3)
53
54
  pipette.get_outward_speed()
54
55
  pipette.run_to_position(100)
55
- pipette.get_position()
56
+ asyncio.run(pipette.get_position())
56
57
 
57
58
  # 3. Eject Tip (if any)
58
59
  print("[STEP 3] Ejecting Tip (if any)...")
@@ -0,0 +1,115 @@
1
+ """Test script to poll get_position every second during machine operations."""
2
+
3
+ import asyncio
4
+ import json
5
+ import logging
6
+ from datetime import datetime
7
+ from puda_drivers.machines import First
8
+ from puda_drivers.labware import get_available_labware
9
+ from puda_drivers.core import setup_logging
10
+
11
+ setup_logging(
12
+ enable_file_logging=False,
13
+ log_level=logging.INFO,
14
+ )
15
+
16
+
17
+ async def poll_position(machine: First, stop_event: asyncio.Event):
18
+ """
19
+ Poll get_position every second and print the results.
20
+
21
+ Args:
22
+ machine: First machine instance
23
+ stop_event: Event to signal when to stop polling
24
+ """
25
+ while not stop_event.is_set():
26
+ try:
27
+ position = await machine.get_position()
28
+ timestamp = datetime.utcnow().isoformat() + "Z"
29
+ result = {
30
+ "timestamp": timestamp,
31
+ "qubot": position.get("qubot", {}),
32
+ "pipette": position.get("pipette", ""),
33
+ }
34
+ print(json.dumps(result))
35
+ except (ValueError, IOError, RuntimeError) as e:
36
+ timestamp = datetime.utcnow().isoformat() + "Z"
37
+ result = {
38
+ "timestamp": timestamp,
39
+ "qubot": {},
40
+ "pipette": "",
41
+ "error": str(e),
42
+ }
43
+ print(json.dumps(result))
44
+
45
+ # Wait 1 second, but check stop_event periodically
46
+ try:
47
+ await asyncio.wait_for(stop_event.wait(), timeout=1.0)
48
+ break
49
+ except asyncio.TimeoutError:
50
+ continue
51
+
52
+
53
+ def run_operations(machine: First):
54
+ """
55
+ Run the machine operations synchronously.
56
+
57
+ Args:
58
+ machine: First machine instance
59
+ """
60
+ machine.start_video_recording()
61
+ machine.attach_tip(slot="A3", well="G8")
62
+ machine.aspirate_from(slot="C2", well="A1", amount=100, height_from_bottom=10)
63
+ machine.dispense_to(slot="C2", well="B4", amount=100, height_from_bottom=50)
64
+ machine.drop_tip(slot="C1", well="A1", height_from_bottom=10)
65
+ machine.stop_video_recording()
66
+
67
+
68
+ async def main():
69
+ """Main async function to run operations and position polling concurrently."""
70
+ # Connect machine
71
+ machine = First(
72
+ qubot_port="/dev/ttyACM0",
73
+ sartorius_port="/dev/ttyUSB0",
74
+ camera_index=0,
75
+ )
76
+
77
+ # View available labware
78
+ print(get_available_labware())
79
+
80
+ # Define deck layout declaratively and load all at once
81
+ machine.load_deck(
82
+ {
83
+ "C1": "trash_bin",
84
+ "C2": "polyelectric_8_wellplate_30000ul",
85
+ "A3": "opentrons_96_tiprack_300ul",
86
+ }
87
+ )
88
+
89
+ print(machine.deck)
90
+ print(machine.deck["C2"])
91
+
92
+ machine.startup() # Connects all controllers, homes gantry, and initializes pipette
93
+
94
+ # Create event to signal when operations are done
95
+ stop_event = asyncio.Event()
96
+
97
+ # Start position polling task
98
+ polling_task = asyncio.create_task(poll_position(machine, stop_event))
99
+
100
+ # Run operations in a thread pool (since they're blocking)
101
+ try:
102
+ await asyncio.to_thread(run_operations, machine)
103
+ finally:
104
+ # Signal polling to stop
105
+ stop_event.set()
106
+ # Wait for polling task to finish
107
+ await polling_task
108
+
109
+ # Shutdown machine
110
+ machine.shutdown()
111
+
112
+
113
+ if __name__ == "__main__":
114
+ asyncio.run(main())
115
+
@@ -89,7 +89,7 @@ wheels = [
89
89
 
90
90
  [[package]]
91
91
  name = "puda-drivers"
92
- version = "0.0.14"
92
+ version = "0.0.16"
93
93
  source = { editable = "." }
94
94
  dependencies = [
95
95
  { name = "nats-py" },
File without changes
File without changes
File without changes