puda-drivers 0.0.15__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.15 → puda_drivers-0.0.16}/PKG-INFO +1 -1
  2. {puda_drivers-0.0.15 → puda_drivers-0.0.16}/pyproject.toml +1 -1
  3. {puda_drivers-0.0.15 → puda_drivers-0.0.16}/src/puda_drivers/core/serialcontroller.py +32 -23
  4. {puda_drivers-0.0.15 → puda_drivers-0.0.16}/src/puda_drivers/machines/first.py +24 -6
  5. {puda_drivers-0.0.15 → puda_drivers-0.0.16}/src/puda_drivers/move/gcode.py +33 -20
  6. puda_drivers-0.0.16/tests/test_position_polling.py +115 -0
  7. {puda_drivers-0.0.15 → puda_drivers-0.0.16}/uv.lock +1 -1
  8. {puda_drivers-0.0.15 → puda_drivers-0.0.16}/.gitignore +0 -0
  9. {puda_drivers-0.0.15 → puda_drivers-0.0.16}/LICENSE +0 -0
  10. {puda_drivers-0.0.15 → puda_drivers-0.0.16}/README.md +0 -0
  11. {puda_drivers-0.0.15 → puda_drivers-0.0.16}/src/puda_drivers/__init__.py +0 -0
  12. {puda_drivers-0.0.15 → puda_drivers-0.0.16}/src/puda_drivers/core/__init__.py +0 -0
  13. {puda_drivers-0.0.15 → puda_drivers-0.0.16}/src/puda_drivers/core/logging.py +0 -0
  14. {puda_drivers-0.0.15 → puda_drivers-0.0.16}/src/puda_drivers/core/position.py +0 -0
  15. {puda_drivers-0.0.15 → puda_drivers-0.0.16}/src/puda_drivers/cv/__init__.py +0 -0
  16. {puda_drivers-0.0.15 → puda_drivers-0.0.16}/src/puda_drivers/cv/camera.py +0 -0
  17. {puda_drivers-0.0.15 → puda_drivers-0.0.16}/src/puda_drivers/labware/__init__.py +0 -0
  18. {puda_drivers-0.0.15 → puda_drivers-0.0.16}/src/puda_drivers/labware/labware.py +0 -0
  19. {puda_drivers-0.0.15 → puda_drivers-0.0.16}/src/puda_drivers/labware/opentrons_96_tiprack_300ul.json +0 -0
  20. {puda_drivers-0.0.15 → puda_drivers-0.0.16}/src/puda_drivers/labware/polyelectric_8_wellplate_30000ul.json +0 -0
  21. {puda_drivers-0.0.15 → puda_drivers-0.0.16}/src/puda_drivers/labware/trash_bin.json +0 -0
  22. {puda_drivers-0.0.15 → puda_drivers-0.0.16}/src/puda_drivers/machines/__init__.py +0 -0
  23. {puda_drivers-0.0.15 → puda_drivers-0.0.16}/src/puda_drivers/move/__init__.py +0 -0
  24. {puda_drivers-0.0.15 → puda_drivers-0.0.16}/src/puda_drivers/move/deck.py +0 -0
  25. {puda_drivers-0.0.15 → puda_drivers-0.0.16}/src/puda_drivers/move/grbl/__init__.py +0 -0
  26. {puda_drivers-0.0.15 → puda_drivers-0.0.16}/src/puda_drivers/move/grbl/api.py +0 -0
  27. {puda_drivers-0.0.15 → puda_drivers-0.0.16}/src/puda_drivers/move/grbl/constants.py +0 -0
  28. {puda_drivers-0.0.15 → puda_drivers-0.0.16}/src/puda_drivers/py.typed +0 -0
  29. {puda_drivers-0.0.15 → puda_drivers-0.0.16}/src/puda_drivers/transfer/liquid/sartorius/__init__.py +0 -0
  30. {puda_drivers-0.0.15 → puda_drivers-0.0.16}/src/puda_drivers/transfer/liquid/sartorius/api.py +0 -0
  31. {puda_drivers-0.0.15 → puda_drivers-0.0.16}/src/puda_drivers/transfer/liquid/sartorius/constants.py +0 -0
  32. {puda_drivers-0.0.15 → puda_drivers-0.0.16}/src/puda_drivers/transfer/liquid/sartorius/rLine.py +0 -0
  33. {puda_drivers-0.0.15 → puda_drivers-0.0.16}/tests/example.py +0 -0
  34. {puda_drivers-0.0.15 → puda_drivers-0.0.16}/tests/first.py +0 -0
  35. {puda_drivers-0.0.15 → puda_drivers-0.0.16}/tests/pipette.py +0 -0
  36. {puda_drivers-0.0.15 → puda_drivers-0.0.16}/tests/qubot.py +0 -0
  37. {puda_drivers-0.0.15 → 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.15
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.15"
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
- async 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
 
@@ -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:
@@ -514,6 +514,9 @@ class GCodeController(SerialController):
514
514
 
515
515
  This method can be called even when the machine is moving from other commands,
516
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.
517
520
 
518
521
  Returns:
519
522
  Position containing X, Y, Z, and A positions
@@ -523,28 +526,38 @@ class GCodeController(SerialController):
523
526
  """
524
527
  self._logger.info("Querying current machine position (M114).")
525
528
  # Run the blocking execute call in a thread pool to allow concurrent operations
526
- res: str = await asyncio.to_thread(self.execute, "M114")
527
-
528
- # Extract position values using regex
529
- pattern = re.compile(r"([XYZA]):(-?\d+\.\d+)")
530
- matches = pattern.findall(res)
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)
531
539
 
532
- position_data: Dict[str, float] = {}
540
+ position_data: Dict[str, float] = {}
533
541
 
534
- for axis, value_str in matches:
535
- try:
536
- position_data[axis.lower()] = float(value_str)
537
- except ValueError:
538
- self._logger.error(
539
- "Failed to convert position value '%s' for axis %s to float.",
540
- value_str,
541
- axis,
542
- )
543
- continue
544
-
545
- position = Position.from_dict(position_data)
546
- self._logger.info("Query position complete. Retrieved positions: %s", position)
547
- 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()
548
561
 
549
562
  def _sync_position(self) -> Tuple[bool, Position]:
550
563
  """
@@ -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.15"
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