puda-drivers 0.0.3__tar.gz → 0.0.5__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 (25) hide show
  1. {puda_drivers-0.0.3 → puda_drivers-0.0.5}/PKG-INFO +44 -5
  2. {puda_drivers-0.0.3 → puda_drivers-0.0.5}/README.md +43 -4
  3. {puda_drivers-0.0.3 → puda_drivers-0.0.5}/pyproject.toml +1 -1
  4. {puda_drivers-0.0.3 → puda_drivers-0.0.5}/src/puda_drivers/core/serialcontroller.py +62 -47
  5. puda_drivers-0.0.5/src/puda_drivers/move/gcode.py +627 -0
  6. {puda_drivers-0.0.3 → puda_drivers-0.0.5}/tests/example.py +2 -2
  7. puda_drivers-0.0.3/tests/sartorius.py → puda_drivers-0.0.5/tests/pipette.py +21 -22
  8. puda_drivers-0.0.5/tests/qubot.py +109 -0
  9. {puda_drivers-0.0.3 → puda_drivers-0.0.5}/tests/together.py +47 -19
  10. {puda_drivers-0.0.3 → puda_drivers-0.0.5}/uv.lock +1 -1
  11. puda_drivers-0.0.3/src/puda_drivers/move/gcode.py +0 -525
  12. puda_drivers-0.0.3/tests/qubot.py +0 -56
  13. {puda_drivers-0.0.3 → puda_drivers-0.0.5}/.gitignore +0 -0
  14. {puda_drivers-0.0.3 → puda_drivers-0.0.5}/LICENSE +0 -0
  15. {puda_drivers-0.0.3 → puda_drivers-0.0.5}/src/puda_drivers/__init__.py +0 -0
  16. {puda_drivers-0.0.3 → puda_drivers-0.0.5}/src/puda_drivers/core/__init__.py +0 -0
  17. {puda_drivers-0.0.3 → puda_drivers-0.0.5}/src/puda_drivers/move/__init__.py +0 -0
  18. {puda_drivers-0.0.3 → puda_drivers-0.0.5}/src/puda_drivers/move/grbl/__init__.py +0 -0
  19. {puda_drivers-0.0.3 → puda_drivers-0.0.5}/src/puda_drivers/move/grbl/api.py +0 -0
  20. {puda_drivers-0.0.3 → puda_drivers-0.0.5}/src/puda_drivers/move/grbl/constants.py +0 -0
  21. {puda_drivers-0.0.3 → puda_drivers-0.0.5}/src/puda_drivers/py.typed +0 -0
  22. {puda_drivers-0.0.3 → puda_drivers-0.0.5}/src/puda_drivers/transfer/liquid/sartorius/__init__.py +0 -0
  23. {puda_drivers-0.0.3 → puda_drivers-0.0.5}/src/puda_drivers/transfer/liquid/sartorius/api.py +0 -0
  24. {puda_drivers-0.0.3 → puda_drivers-0.0.5}/src/puda_drivers/transfer/liquid/sartorius/constants.py +0 -0
  25. {puda_drivers-0.0.3 → puda_drivers-0.0.5}/src/puda_drivers/transfer/liquid/sartorius/sartorius.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: puda-drivers
3
- Version: 0.0.3
3
+ Version: 0.0.5
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
@@ -56,13 +56,19 @@ from puda_drivers.move import GCodeController
56
56
  gantry = GCodeController(port_name="/dev/ttyACM0", feed=3000)
57
57
  gantry.connect()
58
58
 
59
+ # Configure axis limits for safety (recommended)
60
+ gantry.set_axis_limits("X", 0, 200)
61
+ gantry.set_axis_limits("Y", -200, 0)
62
+ gantry.set_axis_limits("Z", -100, 0)
63
+ gantry.set_axis_limits("A", -180, 180)
64
+
59
65
  # Home the gantry
60
66
  gantry.home()
61
67
 
62
- # Move to absolute position
68
+ # Move to absolute position (validated against limits)
63
69
  gantry.move_absolute(x=50.0, y=-100.0, z=-10.0)
64
70
 
65
- # Move relative to current position
71
+ # Move relative to current position (validated after conversion to absolute)
66
72
  gantry.move_relative(x=20.0, y=-10.0)
67
73
 
68
74
  # Query current position
@@ -73,6 +79,8 @@ print(f"Current position: {position}")
73
79
  gantry.disconnect()
74
80
  ```
75
81
 
82
+ **Axis Limits and Validation**: The `move_absolute()` and `move_relative()` methods automatically validate that target positions are within configured axis limits. If a position is outside the limits, a `ValueError` is raised before any movement is executed. Use `set_axis_limits()` to configure limits for each axis.
83
+
76
84
  ### Liquid Handling (Sartorius)
77
85
 
78
86
  ```python
@@ -134,6 +142,7 @@ pipette.disconnect()
134
142
  - Supports X, Y, Z, and A axes
135
143
  - Configurable feed rates
136
144
  - Position synchronization and homing
145
+ - Automatic axis limit validation for safe operation
137
146
 
138
147
  ### Liquid Handling
139
148
 
@@ -142,6 +151,38 @@ pipette.disconnect()
142
151
  - Tip attachment and ejection
143
152
  - Configurable speeds and volumes
144
153
 
154
+ ## Error Handling
155
+
156
+ ### Axis Limit Validation
157
+
158
+ Both `move_absolute()` and `move_relative()` validate positions against configured axis limits before executing any movement. If a position is outside the limits, a `ValueError` is raised:
159
+
160
+ ```python
161
+ from puda_drivers.move import GCodeController
162
+
163
+ gantry = GCodeController(port_name="/dev/ttyACM0")
164
+ gantry.connect()
165
+
166
+ # Set axis limits
167
+ gantry.set_axis_limits("X", 0, 200)
168
+ gantry.set_axis_limits("Y", -200, 0)
169
+
170
+ try:
171
+ # This will raise ValueError: Value 250 outside axis limits [0, 200]
172
+ gantry.move_absolute(x=250.0, y=-50.0)
173
+ except ValueError as e:
174
+ print(f"Move rejected: {e}")
175
+
176
+ # Relative moves are also validated after conversion to absolute positions
177
+ try:
178
+ # If current X is 150, moving 100 more would exceed the limit
179
+ gantry.move_relative(x=100.0)
180
+ except ValueError as e:
181
+ print(f"Move rejected: {e}")
182
+ ```
183
+
184
+ Validation errors are automatically logged at the ERROR level before the exception is raised.
185
+
145
186
  ## Finding Serial Ports
146
187
 
147
188
  To discover available serial ports on your system:
@@ -158,8 +199,6 @@ for port, desc, hwid in ports:
158
199
  sartorius_ports = list_serial_ports(filter_desc="Sartorius")
159
200
  ```
160
201
 
161
- **Note:** The `list_ports()` method is also available as a static method on `SerialController` for backward compatibility, but the module-level `list_serial_ports()` function is the recommended approach.
162
-
163
202
  ## Requirements
164
203
 
165
204
  - Python >= 3.14
@@ -36,13 +36,19 @@ from puda_drivers.move import GCodeController
36
36
  gantry = GCodeController(port_name="/dev/ttyACM0", feed=3000)
37
37
  gantry.connect()
38
38
 
39
+ # Configure axis limits for safety (recommended)
40
+ gantry.set_axis_limits("X", 0, 200)
41
+ gantry.set_axis_limits("Y", -200, 0)
42
+ gantry.set_axis_limits("Z", -100, 0)
43
+ gantry.set_axis_limits("A", -180, 180)
44
+
39
45
  # Home the gantry
40
46
  gantry.home()
41
47
 
42
- # Move to absolute position
48
+ # Move to absolute position (validated against limits)
43
49
  gantry.move_absolute(x=50.0, y=-100.0, z=-10.0)
44
50
 
45
- # Move relative to current position
51
+ # Move relative to current position (validated after conversion to absolute)
46
52
  gantry.move_relative(x=20.0, y=-10.0)
47
53
 
48
54
  # Query current position
@@ -53,6 +59,8 @@ print(f"Current position: {position}")
53
59
  gantry.disconnect()
54
60
  ```
55
61
 
62
+ **Axis Limits and Validation**: The `move_absolute()` and `move_relative()` methods automatically validate that target positions are within configured axis limits. If a position is outside the limits, a `ValueError` is raised before any movement is executed. Use `set_axis_limits()` to configure limits for each axis.
63
+
56
64
  ### Liquid Handling (Sartorius)
57
65
 
58
66
  ```python
@@ -114,6 +122,7 @@ pipette.disconnect()
114
122
  - Supports X, Y, Z, and A axes
115
123
  - Configurable feed rates
116
124
  - Position synchronization and homing
125
+ - Automatic axis limit validation for safe operation
117
126
 
118
127
  ### Liquid Handling
119
128
 
@@ -122,6 +131,38 @@ pipette.disconnect()
122
131
  - Tip attachment and ejection
123
132
  - Configurable speeds and volumes
124
133
 
134
+ ## Error Handling
135
+
136
+ ### Axis Limit Validation
137
+
138
+ Both `move_absolute()` and `move_relative()` validate positions against configured axis limits before executing any movement. If a position is outside the limits, a `ValueError` is raised:
139
+
140
+ ```python
141
+ from puda_drivers.move import GCodeController
142
+
143
+ gantry = GCodeController(port_name="/dev/ttyACM0")
144
+ gantry.connect()
145
+
146
+ # Set axis limits
147
+ gantry.set_axis_limits("X", 0, 200)
148
+ gantry.set_axis_limits("Y", -200, 0)
149
+
150
+ try:
151
+ # This will raise ValueError: Value 250 outside axis limits [0, 200]
152
+ gantry.move_absolute(x=250.0, y=-50.0)
153
+ except ValueError as e:
154
+ print(f"Move rejected: {e}")
155
+
156
+ # Relative moves are also validated after conversion to absolute positions
157
+ try:
158
+ # If current X is 150, moving 100 more would exceed the limit
159
+ gantry.move_relative(x=100.0)
160
+ except ValueError as e:
161
+ print(f"Move rejected: {e}")
162
+ ```
163
+
164
+ Validation errors are automatically logged at the ERROR level before the exception is raised.
165
+
125
166
  ## Finding Serial Ports
126
167
 
127
168
  To discover available serial ports on your system:
@@ -138,8 +179,6 @@ for port, desc, hwid in ports:
138
179
  sartorius_ports = list_serial_ports(filter_desc="Sartorius")
139
180
  ```
140
181
 
141
- **Note:** The `list_ports()` method is also available as a static method on `SerialController` for backward compatibility, but the module-level `list_serial_ports()` function is the recommended approach.
142
-
143
182
  ## Requirements
144
183
 
145
184
  - Python >= 3.14
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "puda-drivers"
3
- version = "0.0.3"
3
+ version = "0.0.5"
4
4
  description = "Hardware drivers for the PUDA platform."
5
5
  readme = "README.md"
6
6
  authors = [
@@ -2,13 +2,12 @@
2
2
  Generic Serial Controller for communicating with devices over serial ports.
3
3
  """
4
4
 
5
- import serial
6
- import sys
7
5
  import time
8
- import serial.tools.list_ports
9
6
  import logging
10
7
  from typing import Optional, List, Tuple
11
- from abc import ABC, abstractmethod
8
+ from abc import ABC
9
+ import serial
10
+ import serial.tools.list_ports
12
11
 
13
12
  logger = logging.getLogger(__name__)
14
13
 
@@ -46,7 +45,7 @@ def list_serial_ports(filter_desc: Optional[str] = None) -> List[Tuple[str, str,
46
45
 
47
46
  class SerialController(ABC):
48
47
  DEFAULT_BAUDRATE = 9600
49
- DEFAULT_TIMEOUT = 20 # seconds
48
+ DEFAULT_TIMEOUT = 30 # seconds
50
49
  POLL_INTERVAL = 0.1 # seconds
51
50
 
52
51
  def __init__(self, port_name, baudrate=DEFAULT_BAUDRATE, timeout=DEFAULT_TIMEOUT):
@@ -58,23 +57,6 @@ class SerialController(ABC):
58
57
 
59
58
  self.connect()
60
59
 
61
- @staticmethod
62
- def list_ports(filter_desc: Optional[str] = None) -> List[Tuple[str, str, str]]:
63
- """
64
- Lists available serial ports on the system.
65
-
66
- .. deprecated:: 0.0.1
67
- Use the module-level function :func:`list_serial_ports` instead.
68
- This method is kept for backward compatibility.
69
-
70
- Args:
71
- filter_desc: Optional string to filter ports by description (case-insensitive).
72
-
73
- Returns:
74
- List of tuples, where each tuple contains (port_name, description, hwid).
75
- """
76
- return list_serial_ports(filter_desc)
77
-
78
60
  def connect(self) -> None:
79
61
  """
80
62
  Establishes the serial connection to the port.
@@ -89,7 +71,9 @@ class SerialController(ABC):
89
71
 
90
72
  try:
91
73
  self._logger.info(
92
- f"Attempting connection to {self.port_name} at {self.baudrate} baud."
74
+ "Attempting connection to %s at %s baud.",
75
+ self.port_name,
76
+ self.baudrate,
93
77
  )
94
78
  self._serial = serial.Serial(
95
79
  port=self.port_name,
@@ -97,10 +81,10 @@ class SerialController(ABC):
97
81
  timeout=self.timeout,
98
82
  )
99
83
  self._serial.flush()
100
- self._logger.info(f"Successfully connected to {self.port_name}.")
84
+ self._logger.info("Successfully connected to %s.", self.port_name)
101
85
  except serial.SerialException as e:
102
86
  self._serial = None
103
- self._logger.error(f"Error connecting to port {self.port_name}: {e}")
87
+ self._logger.error("Error connecting to port %s: %s", self.port_name, e)
104
88
  raise serial.SerialException(
105
89
  f"Error connecting to port {self.port_name}: {e}"
106
90
  )
@@ -113,7 +97,7 @@ class SerialController(ABC):
113
97
  port_name = self._serial.port
114
98
  self._serial.close()
115
99
  self._serial = None
116
- self._logger.info(f"Serial connection to {port_name} closed.")
100
+ self._logger.info("Serial connection to %s closed.", port_name)
117
101
  else:
118
102
  self._logger.warning(
119
103
  "Serial port already disconnected or was never connected."
@@ -131,29 +115,31 @@ class SerialController(ABC):
131
115
  """
132
116
  if not self.is_connected or not self._serial:
133
117
  self._logger.error(
134
- f"Attempt to send command '{command}' failed: Device not connected."
118
+ "Attempt to send command '%s' failed: Device not connected.",
119
+ command,
135
120
  )
136
121
  # Retain raising an error for being disconnected, as that's a connection state issue
137
122
  raise serial.SerialException("Device not connected. Call connect() first.")
138
123
 
139
- command_bytes = bytes(command, "utf-8")
140
- self._logger.info(f"-> Sending: {repr(command)}")
124
+ self._logger.info("-> Sending: %r", command)
141
125
 
142
126
  # Send the command
143
127
  try:
144
128
  self._serial.reset_input_buffer() # clear input buffer
145
129
  self._serial.reset_output_buffer() # clear output buffer
146
- self._serial.write(command_bytes)
147
130
  self._serial.flush()
131
+ self._serial.write(bytes(command, "utf-8"))
148
132
 
149
133
  except serial.SerialTimeoutException as e:
150
134
  # Log the timeout error and return None as requested (no re-raise)
151
- self._logger.error(f"Timeout on command '{command}'. Error: {e}")
135
+ self._logger.error("Timeout on command '%s'. Error: %s", command, e)
152
136
  return None
153
137
 
154
138
  except serial.SerialException as e:
155
139
  self._logger.error(
156
- f"Serial error writing or reading command '{command}'. Error: {e}"
140
+ "Serial error writing or reading command '%s'. Error: %s",
141
+ command,
142
+ e,
157
143
  )
158
144
  return None
159
145
 
@@ -173,22 +159,51 @@ class SerialController(ABC):
173
159
  # Read all available bytes
174
160
  response += self._serial.read(self._serial.in_waiting)
175
161
 
176
- # 5. Check if the 'ok' response is in the accumulated data
177
- if b"ok" in response:
178
- self._logger.debug(f"<- Received response: {response!r}")
179
- return response.decode("utf-8", errors="ignore").strip()
180
- if b"err" in response:
181
- self._logger.error(f"<- Received error: {response!r}")
182
- return response.decode("utf-8", errors="ignore").strip()
162
+ # Check for expected response markers for early return
163
+ if b"ok" in response or b"err" in response:
164
+ break
183
165
  else:
184
166
  time.sleep(0.1)
185
167
 
168
+ # Timeout reached - check what we got
186
169
  if not response:
187
- self._logger.warning("<- Received no data (empty response).")
188
- self._logger.error(f"No response within {self.timeout} seconds.")
189
- sys.exit(124) # Exit code for timeout
190
-
191
- self._logger.info(
192
- f"<- Received: {response.decode('utf-8', errors='ignore').strip()!r}"
193
- )
194
- return response.decode("utf-8", errors="ignore").strip()
170
+ self._logger.error("No response within %s seconds.", self.timeout)
171
+ raise serial.SerialTimeoutException(
172
+ f"No response received within {self.timeout} seconds."
173
+ )
174
+
175
+ # Decode once and check the decoded string
176
+ decoded_response = response.decode("utf-8", errors="ignore").strip()
177
+
178
+ if "ok" in decoded_response.lower():
179
+ self._logger.debug("<- Received response: %r", decoded_response)
180
+ elif "err" in decoded_response.lower():
181
+ self._logger.error("<- Received error: %r", decoded_response)
182
+ else:
183
+ self._logger.warning(
184
+ "<- Received unexpected response (no 'ok' or 'err'): %r", decoded_response
185
+ )
186
+
187
+ return decoded_response
188
+
189
+ def execute(self, command: str) -> str:
190
+ """
191
+ Send a command and read the response atomically.
192
+
193
+ This method combines sending a command and reading its response into a
194
+ single atomic operation, ensuring the response corresponds to the command
195
+ that was just sent. This is the preferred method for commands that
196
+ require a response.
197
+
198
+ Args:
199
+ command: Command string to send (should include protocol terminator if needed)
200
+
201
+ Returns:
202
+ Response string from the device
203
+
204
+ Raises:
205
+ serial.SerialException: If device is not connected or communication fails
206
+ serial.SerialTimeoutException: If no response is received within timeout
207
+ """
208
+ self._send_command(command)
209
+ return self._read_response()