puda-drivers 0.0.15__tar.gz → 0.0.17__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.
- {puda_drivers-0.0.15 → puda_drivers-0.0.17}/PKG-INFO +60 -19
- {puda_drivers-0.0.15 → puda_drivers-0.0.17}/README.md +56 -16
- {puda_drivers-0.0.15 → puda_drivers-0.0.17}/pyproject.toml +4 -3
- {puda_drivers-0.0.15 → puda_drivers-0.0.17}/src/puda_drivers/core/serialcontroller.py +36 -36
- {puda_drivers-0.0.15 → puda_drivers-0.0.17}/src/puda_drivers/labware/trash_bin.json +2 -1
- {puda_drivers-0.0.15 → puda_drivers-0.0.17}/src/puda_drivers/machines/first.py +54 -6
- {puda_drivers-0.0.15 → puda_drivers-0.0.17}/src/puda_drivers/move/deck.py +35 -1
- {puda_drivers-0.0.15 → puda_drivers-0.0.17}/src/puda_drivers/move/gcode.py +34 -21
- puda_drivers-0.0.17/tests/poll_position.py +126 -0
- puda_drivers-0.0.17/tests/test_deck.py +172 -0
- puda_drivers-0.0.17/tests/test_position_polling.py +115 -0
- puda_drivers-0.0.17/tests/webcam.py +59 -0
- puda_drivers-0.0.15/tests/webcam.py +0 -28
- puda_drivers-0.0.15/uv.lock +0 -114
- {puda_drivers-0.0.15 → puda_drivers-0.0.17}/.gitignore +0 -0
- {puda_drivers-0.0.15 → puda_drivers-0.0.17}/LICENSE +0 -0
- {puda_drivers-0.0.15 → puda_drivers-0.0.17}/src/puda_drivers/__init__.py +0 -0
- {puda_drivers-0.0.15 → puda_drivers-0.0.17}/src/puda_drivers/core/__init__.py +0 -0
- {puda_drivers-0.0.15 → puda_drivers-0.0.17}/src/puda_drivers/core/logging.py +0 -0
- {puda_drivers-0.0.15 → puda_drivers-0.0.17}/src/puda_drivers/core/position.py +0 -0
- {puda_drivers-0.0.15 → puda_drivers-0.0.17}/src/puda_drivers/cv/__init__.py +0 -0
- {puda_drivers-0.0.15 → puda_drivers-0.0.17}/src/puda_drivers/cv/camera.py +0 -0
- {puda_drivers-0.0.15 → puda_drivers-0.0.17}/src/puda_drivers/labware/__init__.py +0 -0
- {puda_drivers-0.0.15 → puda_drivers-0.0.17}/src/puda_drivers/labware/labware.py +0 -0
- {puda_drivers-0.0.15 → puda_drivers-0.0.17}/src/puda_drivers/labware/opentrons_96_tiprack_300ul.json +0 -0
- {puda_drivers-0.0.15 → puda_drivers-0.0.17}/src/puda_drivers/labware/polyelectric_8_wellplate_30000ul.json +0 -0
- {puda_drivers-0.0.15 → puda_drivers-0.0.17}/src/puda_drivers/machines/__init__.py +0 -0
- {puda_drivers-0.0.15 → puda_drivers-0.0.17}/src/puda_drivers/move/__init__.py +0 -0
- {puda_drivers-0.0.15 → puda_drivers-0.0.17}/src/puda_drivers/move/grbl/__init__.py +0 -0
- {puda_drivers-0.0.15 → puda_drivers-0.0.17}/src/puda_drivers/move/grbl/api.py +0 -0
- {puda_drivers-0.0.15 → puda_drivers-0.0.17}/src/puda_drivers/move/grbl/constants.py +0 -0
- {puda_drivers-0.0.15 → puda_drivers-0.0.17}/src/puda_drivers/py.typed +0 -0
- {puda_drivers-0.0.15 → puda_drivers-0.0.17}/src/puda_drivers/transfer/liquid/sartorius/__init__.py +0 -0
- {puda_drivers-0.0.15 → puda_drivers-0.0.17}/src/puda_drivers/transfer/liquid/sartorius/api.py +0 -0
- {puda_drivers-0.0.15 → puda_drivers-0.0.17}/src/puda_drivers/transfer/liquid/sartorius/constants.py +0 -0
- {puda_drivers-0.0.15 → puda_drivers-0.0.17}/src/puda_drivers/transfer/liquid/sartorius/rLine.py +0 -0
- {puda_drivers-0.0.15 → puda_drivers-0.0.17}/tests/example.py +0 -0
- {puda_drivers-0.0.15 → puda_drivers-0.0.17}/tests/first.py +0 -0
- {puda_drivers-0.0.15 → puda_drivers-0.0.17}/tests/pipette.py +0 -0
- {puda_drivers-0.0.15 → puda_drivers-0.0.17}/tests/qubot.py +0 -0
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: puda-drivers
|
|
3
|
-
Version: 0.0.
|
|
3
|
+
Version: 0.0.17
|
|
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-drivers
|
|
6
|
+
Project-URL: Issues, https://github.com/PUDAP/puda-drivers/issues
|
|
7
7
|
Author-email: zhao <20024592+agentzhao@users.noreply.github.com>
|
|
8
8
|
License-Expression: MIT
|
|
9
9
|
License-File: LICENSE
|
|
@@ -18,6 +18,7 @@ Requires-Python: >=3.10
|
|
|
18
18
|
Requires-Dist: nats-py>=2.12.0
|
|
19
19
|
Requires-Dist: opencv-python>=4.12.0.88
|
|
20
20
|
Requires-Dist: pyserial~=3.5
|
|
21
|
+
Requires-Dist: pytest>=9.0.2
|
|
21
22
|
Description-Content-Type: text/markdown
|
|
22
23
|
|
|
23
24
|
# puda-drivers
|
|
@@ -40,14 +41,6 @@ Hardware drivers for the PUDA (Physical Unified Device Architecture) platform. T
|
|
|
40
41
|
pip install puda-drivers
|
|
41
42
|
```
|
|
42
43
|
|
|
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
44
|
## Quick Start
|
|
52
45
|
|
|
53
46
|
### Logging Configuration
|
|
@@ -197,22 +190,70 @@ sartorius_ports = list_serial_ports(filter_desc="Sartorius")
|
|
|
197
190
|
|
|
198
191
|
### Setup Development Environment
|
|
199
192
|
|
|
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.
|
|
193
|
+
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.
|
|
194
|
+
|
|
195
|
+
**From the repository root:**
|
|
201
196
|
|
|
202
197
|
```bash
|
|
203
|
-
#
|
|
204
|
-
uv
|
|
198
|
+
# Or install dependencies for all workspace packages
|
|
199
|
+
uv sync --all-packages
|
|
200
|
+
```
|
|
201
|
+
|
|
202
|
+
This will:
|
|
203
|
+
- Create a virtual environment at the repository root (`.venv/`)
|
|
204
|
+
- Install all dependencies for all workspace packages
|
|
205
|
+
- Install `puda-drivers` and other workspace packages in editable mode automatically
|
|
205
206
|
|
|
206
|
-
|
|
207
|
+
**Using the package:**
|
|
208
|
+
|
|
209
|
+
```bash
|
|
210
|
+
# Run Python scripts with workspace context (recommended, works from anywhere in the workspace)
|
|
211
|
+
uv run python your_script.py
|
|
212
|
+
|
|
213
|
+
# Or activate the virtual environment (from repository root where .venv is located)
|
|
207
214
|
source .venv/bin/activate # On Windows: .venv\Scripts\activate
|
|
215
|
+
python your_script.py
|
|
216
|
+
```
|
|
217
|
+
|
|
218
|
+
**Adding dependencies:**
|
|
208
219
|
|
|
209
|
-
|
|
210
|
-
|
|
220
|
+
```bash
|
|
221
|
+
# From the package directory
|
|
222
|
+
cd libs/drivers
|
|
223
|
+
uv add some-package
|
|
211
224
|
|
|
212
|
-
#
|
|
213
|
-
|
|
225
|
+
# Or from repository root
|
|
226
|
+
uv add --package puda-drivers some-package
|
|
214
227
|
```
|
|
215
228
|
|
|
229
|
+
**Note:** Workspace packages are automatically installed in editable mode, so code changes are immediately available without reinstalling.
|
|
230
|
+
|
|
231
|
+
### Testing
|
|
232
|
+
|
|
233
|
+
Run tests using pytest with `uv run`:
|
|
234
|
+
|
|
235
|
+
```bash
|
|
236
|
+
# Run all tests
|
|
237
|
+
uv run pytest tests/
|
|
238
|
+
|
|
239
|
+
# Run a specific test file
|
|
240
|
+
uv run pytest tests/test_deck.py
|
|
241
|
+
|
|
242
|
+
# Run a specific test class
|
|
243
|
+
uv run pytest tests/test_deck.py::TestDeckToDict
|
|
244
|
+
|
|
245
|
+
# Run a specific test function
|
|
246
|
+
uv run pytest tests/test_deck.py::TestDeckToDict::test_to_dict_empty_deck
|
|
247
|
+
|
|
248
|
+
# Run with verbose output
|
|
249
|
+
uv run pytest tests/ -v
|
|
250
|
+
|
|
251
|
+
# Run with coverage report
|
|
252
|
+
uv run pytest tests/ --cov=puda_drivers --cov-report=html
|
|
253
|
+
```
|
|
254
|
+
|
|
255
|
+
**Note:** Make sure you're in the `libs/drivers` directory or use the full path to the tests directory when running pytest commands.
|
|
256
|
+
|
|
216
257
|
### Building and Publishing
|
|
217
258
|
|
|
218
259
|
```bash
|
|
@@ -18,14 +18,6 @@ Hardware drivers for the PUDA (Physical Unified Device Architecture) platform. T
|
|
|
18
18
|
pip install puda-drivers
|
|
19
19
|
```
|
|
20
20
|
|
|
21
|
-
### From Source
|
|
22
|
-
|
|
23
|
-
```bash
|
|
24
|
-
git clone https://github.com/zhao-bears/puda-drivers.git
|
|
25
|
-
cd puda-drivers
|
|
26
|
-
pip install -e .
|
|
27
|
-
```
|
|
28
|
-
|
|
29
21
|
## Quick Start
|
|
30
22
|
|
|
31
23
|
### Logging Configuration
|
|
@@ -175,22 +167,70 @@ sartorius_ports = list_serial_ports(filter_desc="Sartorius")
|
|
|
175
167
|
|
|
176
168
|
### Setup Development Environment
|
|
177
169
|
|
|
178
|
-
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.
|
|
170
|
+
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.
|
|
171
|
+
|
|
172
|
+
**From the repository root:**
|
|
179
173
|
|
|
180
174
|
```bash
|
|
181
|
-
#
|
|
182
|
-
uv
|
|
175
|
+
# Or install dependencies for all workspace packages
|
|
176
|
+
uv sync --all-packages
|
|
177
|
+
```
|
|
178
|
+
|
|
179
|
+
This will:
|
|
180
|
+
- Create a virtual environment at the repository root (`.venv/`)
|
|
181
|
+
- Install all dependencies for all workspace packages
|
|
182
|
+
- Install `puda-drivers` and other workspace packages in editable mode automatically
|
|
183
183
|
|
|
184
|
-
|
|
184
|
+
**Using the package:**
|
|
185
|
+
|
|
186
|
+
```bash
|
|
187
|
+
# Run Python scripts with workspace context (recommended, works from anywhere in the workspace)
|
|
188
|
+
uv run python your_script.py
|
|
189
|
+
|
|
190
|
+
# Or activate the virtual environment (from repository root where .venv is located)
|
|
185
191
|
source .venv/bin/activate # On Windows: .venv\Scripts\activate
|
|
192
|
+
python your_script.py
|
|
193
|
+
```
|
|
194
|
+
|
|
195
|
+
**Adding dependencies:**
|
|
186
196
|
|
|
187
|
-
|
|
188
|
-
|
|
197
|
+
```bash
|
|
198
|
+
# From the package directory
|
|
199
|
+
cd libs/drivers
|
|
200
|
+
uv add some-package
|
|
189
201
|
|
|
190
|
-
#
|
|
191
|
-
|
|
202
|
+
# Or from repository root
|
|
203
|
+
uv add --package puda-drivers some-package
|
|
192
204
|
```
|
|
193
205
|
|
|
206
|
+
**Note:** Workspace packages are automatically installed in editable mode, so code changes are immediately available without reinstalling.
|
|
207
|
+
|
|
208
|
+
### Testing
|
|
209
|
+
|
|
210
|
+
Run tests using pytest with `uv run`:
|
|
211
|
+
|
|
212
|
+
```bash
|
|
213
|
+
# Run all tests
|
|
214
|
+
uv run pytest tests/
|
|
215
|
+
|
|
216
|
+
# Run a specific test file
|
|
217
|
+
uv run pytest tests/test_deck.py
|
|
218
|
+
|
|
219
|
+
# Run a specific test class
|
|
220
|
+
uv run pytest tests/test_deck.py::TestDeckToDict
|
|
221
|
+
|
|
222
|
+
# Run a specific test function
|
|
223
|
+
uv run pytest tests/test_deck.py::TestDeckToDict::test_to_dict_empty_deck
|
|
224
|
+
|
|
225
|
+
# Run with verbose output
|
|
226
|
+
uv run pytest tests/ -v
|
|
227
|
+
|
|
228
|
+
# Run with coverage report
|
|
229
|
+
uv run pytest tests/ --cov=puda_drivers --cov-report=html
|
|
230
|
+
```
|
|
231
|
+
|
|
232
|
+
**Note:** Make sure you're in the `libs/drivers` directory or use the full path to the tests directory when running pytest commands.
|
|
233
|
+
|
|
194
234
|
### Building and Publishing
|
|
195
235
|
|
|
196
236
|
```bash
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
[project]
|
|
2
2
|
name = "puda-drivers"
|
|
3
|
-
version = "0.0.
|
|
3
|
+
version = "0.0.17"
|
|
4
4
|
description = "Hardware drivers for the PUDA platform."
|
|
5
5
|
readme = "README.md"
|
|
6
6
|
authors = [
|
|
@@ -22,6 +22,7 @@ dependencies = [
|
|
|
22
22
|
"nats-py>=2.12.0",
|
|
23
23
|
"opencv-python>=4.12.0.88",
|
|
24
24
|
"pyserial~=3.5",
|
|
25
|
+
"pytest>=9.0.2",
|
|
25
26
|
]
|
|
26
27
|
|
|
27
28
|
[build-system]
|
|
@@ -29,5 +30,5 @@ requires = ["hatchling >= 1.26"]
|
|
|
29
30
|
build-backend = "hatchling.build"
|
|
30
31
|
|
|
31
32
|
[project.urls]
|
|
32
|
-
Homepage = "https://github.com/
|
|
33
|
-
Issues = "https://github.com/
|
|
33
|
+
Homepage = "https://github.com/PUDAP/puda-drivers"
|
|
34
|
+
Issues = "https://github.com/PUDAP/puda-drivers/issues"
|
|
@@ -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
|
|
@@ -47,8 +48,11 @@ class SerialController(ABC):
|
|
|
47
48
|
self._serial = None
|
|
48
49
|
self.port_name = port_name
|
|
49
50
|
self.baudrate = baudrate
|
|
50
|
-
self.
|
|
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
|
"""
|
|
@@ -71,7 +75,7 @@ class SerialController(ABC):
|
|
|
71
75
|
self._serial = serial.Serial(
|
|
72
76
|
port=self.port_name,
|
|
73
77
|
baudrate=self.baudrate,
|
|
74
|
-
timeout=self.
|
|
78
|
+
timeout=self._timeout,
|
|
75
79
|
)
|
|
76
80
|
self._serial.flush()
|
|
77
81
|
self._logger.info("Successfully connected to %s.", self.port_name)
|
|
@@ -103,20 +107,20 @@ class SerialController(ABC):
|
|
|
103
107
|
|
|
104
108
|
def _send_command(self, command: str) -> None:
|
|
105
109
|
"""
|
|
106
|
-
Sends a
|
|
107
|
-
|
|
110
|
+
Sends a command to the device.
|
|
111
|
+
Note: This method should be called while holding self._lock to ensure
|
|
112
|
+
atomic command/response pairing.
|
|
108
113
|
"""
|
|
114
|
+
self._logger.info("-> Sending: %r", command)
|
|
115
|
+
|
|
109
116
|
if not self.is_connected or not self._serial:
|
|
110
117
|
self._logger.error(
|
|
111
118
|
"Attempt to send command '%s' failed: Device not connected.",
|
|
112
119
|
command,
|
|
113
120
|
)
|
|
114
121
|
# Retain raising an error for being disconnected, as that's a connection state issue
|
|
115
|
-
raise serial.SerialException("Device
|
|
122
|
+
raise serial.SerialException("Device disconnected. Call connect() first.")
|
|
116
123
|
|
|
117
|
-
self._logger.info("-> Sending: %r", command)
|
|
118
|
-
|
|
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,13 +134,13 @@ class SerialController(ABC):
|
|
|
130
134
|
|
|
131
135
|
except serial.SerialException as e:
|
|
132
136
|
self._logger.error(
|
|
133
|
-
"Serial error writing
|
|
137
|
+
"Serial error writing command '%s'. Error: %s",
|
|
134
138
|
command,
|
|
135
139
|
e,
|
|
136
140
|
)
|
|
137
141
|
return None
|
|
138
142
|
|
|
139
|
-
def _read_response(self) -> str:
|
|
143
|
+
def _read_response(self, timeout: int = None) -> str:
|
|
140
144
|
"""
|
|
141
145
|
Generic, blocking read that respects timeout and returns
|
|
142
146
|
all data that arrived within the timeout period.
|
|
@@ -144,10 +148,13 @@ class SerialController(ABC):
|
|
|
144
148
|
if not self.is_connected or not self._serial:
|
|
145
149
|
raise serial.SerialException("Device not connected.")
|
|
146
150
|
|
|
151
|
+
if timeout is None:
|
|
152
|
+
timeout = self._timeout
|
|
153
|
+
|
|
147
154
|
start_time = time.time()
|
|
148
155
|
response = b""
|
|
149
156
|
|
|
150
|
-
while time.time() - start_time <
|
|
157
|
+
while time.time() - start_time < timeout:
|
|
151
158
|
if self._serial.in_waiting > 0:
|
|
152
159
|
# Read all available bytes
|
|
153
160
|
response += self._serial.read(self._serial.in_waiting)
|
|
@@ -164,14 +171,12 @@ class SerialController(ABC):
|
|
|
164
171
|
|
|
165
172
|
# Timeout reached - check what we got
|
|
166
173
|
if not response:
|
|
167
|
-
self._logger.error("No response within %s seconds.",
|
|
174
|
+
self._logger.error("No response within %s seconds.", timeout)
|
|
168
175
|
raise serial.SerialTimeoutException(
|
|
169
|
-
f"No response received within {
|
|
176
|
+
f"No response received within {timeout} seconds."
|
|
170
177
|
)
|
|
171
178
|
|
|
172
|
-
# Decode once and check the decoded string
|
|
173
179
|
decoded_response = response.decode("utf-8", errors="ignore").strip()
|
|
174
|
-
|
|
175
180
|
if "ok" in decoded_response.lower():
|
|
176
181
|
self._logger.debug("<- Received response: %r", decoded_response)
|
|
177
182
|
elif "err" in decoded_response.lower():
|
|
@@ -189,20 +194,25 @@ class SerialController(ABC):
|
|
|
189
194
|
def _build_command(self, command: str, value: Optional[str] = None) -> str:
|
|
190
195
|
"""
|
|
191
196
|
Build a command string according to the device protocol.
|
|
197
|
+
|
|
198
|
+
There might be special starting and ending characters for devices
|
|
192
199
|
"""
|
|
193
|
-
|
|
200
|
+
raise NotImplementedError
|
|
194
201
|
|
|
195
|
-
def execute(self, command: str, value: Optional[str] = None) -> str:
|
|
202
|
+
def execute(self, command: str, value: Optional[str] = None, timeout: int = None) -> str:
|
|
196
203
|
"""
|
|
197
204
|
Send a command and read the response atomically.
|
|
198
205
|
|
|
199
206
|
This method combines sending a command and reading its response into a
|
|
200
207
|
single atomic operation, ensuring the response corresponds to the command
|
|
201
|
-
that was just sent.
|
|
202
|
-
|
|
208
|
+
that was just sent. The entire operation is protected by a lock to prevent
|
|
209
|
+
concurrent commands from interfering with each other.
|
|
210
|
+
|
|
211
|
+
This is the preferred method for commands that require a response.
|
|
203
212
|
|
|
204
213
|
Args:
|
|
205
214
|
command: Command string to send (should include protocol terminator if needed)
|
|
215
|
+
value: Optional value parameter for the command
|
|
206
216
|
|
|
207
217
|
Returns:
|
|
208
218
|
Response string from the device
|
|
@@ -211,20 +221,10 @@ class SerialController(ABC):
|
|
|
211
221
|
serial.SerialException: If device is not connected or communication fails
|
|
212
222
|
serial.SerialTimeoutException: If no response is received within timeout
|
|
213
223
|
"""
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
#
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
self.
|
|
220
|
-
|
|
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
|
|
224
|
+
if timeout is None:
|
|
225
|
+
timeout = self._timeout
|
|
226
|
+
# Hold the lock for the entire send+read operation to ensure atomicity
|
|
227
|
+
# This prevents concurrent commands from mixing up responses
|
|
228
|
+
with self._lock:
|
|
229
|
+
self._send_command(self._build_command(command, value))
|
|
230
|
+
return self._read_response(timeout=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
|
|
|
@@ -176,6 +176,8 @@ class First:
|
|
|
176
176
|
"pipette": sartorius_position,
|
|
177
177
|
}
|
|
178
178
|
|
|
179
|
+
### Labware management ###
|
|
180
|
+
|
|
179
181
|
def load_labware(self, slot: str, labware_name: str):
|
|
180
182
|
"""
|
|
181
183
|
Load a labware object into a slot.
|
|
@@ -190,7 +192,33 @@ class First:
|
|
|
190
192
|
self._logger.info("Loading labware '%s' into slot '%s'", labware_name, slot)
|
|
191
193
|
self.deck.load_labware(slot=slot, labware_name=labware_name)
|
|
192
194
|
self._logger.debug("Labware '%s' loaded into slot '%s'", labware_name, slot)
|
|
195
|
+
|
|
196
|
+
def remove_labware(self, slot: str):
|
|
197
|
+
"""
|
|
198
|
+
Remove labware from a slot.
|
|
199
|
+
|
|
200
|
+
Args:
|
|
201
|
+
slot: Slot name (e.g., 'A1', 'B2')
|
|
202
|
+
|
|
203
|
+
Raises:
|
|
204
|
+
KeyError: If slot is not found in deck
|
|
205
|
+
"""
|
|
206
|
+
self.deck.empty_slot(slot=slot)
|
|
207
|
+
self._logger.debug("Slot '%s' emptied", slot)
|
|
193
208
|
|
|
209
|
+
def get_deck(self):
|
|
210
|
+
"""
|
|
211
|
+
Get the current deck layout.
|
|
212
|
+
|
|
213
|
+
Returns:
|
|
214
|
+
Dictionary mapping slot names (e.g., "A1") to labware classes.
|
|
215
|
+
|
|
216
|
+
Raises:
|
|
217
|
+
None
|
|
218
|
+
"""
|
|
219
|
+
return self.deck.to_dict()
|
|
220
|
+
|
|
221
|
+
|
|
194
222
|
def load_deck(self, deck_layout: Dict[str, Type[StandardLabware]]):
|
|
195
223
|
"""
|
|
196
224
|
Load multiple labware into the deck at once.
|
|
@@ -211,6 +239,8 @@ class First:
|
|
|
211
239
|
self.load_labware(slot=slot, labware_name=labware_name)
|
|
212
240
|
self._logger.info("Deck layout loaded successfully")
|
|
213
241
|
|
|
242
|
+
### Liquid handling ###
|
|
243
|
+
|
|
214
244
|
def attach_tip(self, slot: str, well: Optional[str] = None):
|
|
215
245
|
"""
|
|
216
246
|
Attach a tip from a slot.
|
|
@@ -234,7 +264,11 @@ class First:
|
|
|
234
264
|
self.qubot.move_absolute(position=pos)
|
|
235
265
|
|
|
236
266
|
# attach tip (move slowly down)
|
|
237
|
-
|
|
267
|
+
labware = self.deck[slot]
|
|
268
|
+
if labware is None:
|
|
269
|
+
self._logger.error("Cannot attach tip: no labware loaded in slot '%s'", slot)
|
|
270
|
+
raise ValueError(f"No labware loaded in slot '{slot}'. Load labware before attaching tips.")
|
|
271
|
+
insert_depth = labware.get_insert_depth()
|
|
238
272
|
self._logger.debug("Moving down by %s mm to insert tip", insert_depth)
|
|
239
273
|
self.qubot.move_relative(
|
|
240
274
|
position=Position(z=-insert_depth),
|
|
@@ -378,17 +412,24 @@ class First:
|
|
|
378
412
|
|
|
379
413
|
Returns:
|
|
380
414
|
Position with absolute coordinates
|
|
415
|
+
|
|
416
|
+
Raises:
|
|
417
|
+
ValueError: If well is specified but no labware is loaded in the slot
|
|
381
418
|
"""
|
|
382
419
|
# Get slot origin
|
|
383
420
|
pos = self.get_slot_origin(slot)
|
|
384
421
|
|
|
385
422
|
# relative well position from slot origin
|
|
386
423
|
if well:
|
|
387
|
-
|
|
424
|
+
labware = self.deck[slot]
|
|
425
|
+
if labware is None:
|
|
426
|
+
self._logger.error("Cannot get well position: no labware loaded in slot '%s'", slot)
|
|
427
|
+
raise ValueError(f"No labware loaded in slot '{slot}'. Load labware before accessing wells.")
|
|
428
|
+
well_pos = labware.get_well_position(well).get_xy()
|
|
388
429
|
# the deck is rotated 90 degrees clockwise for this machine
|
|
389
430
|
pos += well_pos.swap_xy()
|
|
390
431
|
# get z
|
|
391
|
-
pos += Position(z=
|
|
432
|
+
pos += Position(z=labware.get_height() - self.CEILING_HEIGHT)
|
|
392
433
|
self._logger.debug("Absolute Z position for slot '%s', well '%s': %s", slot, well, pos)
|
|
393
434
|
else:
|
|
394
435
|
self._logger.debug("Absolute Z position for slot '%s': %s", slot, pos)
|
|
@@ -404,15 +445,22 @@ class First:
|
|
|
404
445
|
|
|
405
446
|
Returns:
|
|
406
447
|
Position with absolute coordinates
|
|
448
|
+
|
|
449
|
+
Raises:
|
|
450
|
+
ValueError: If well is specified but no labware is loaded in the slot
|
|
407
451
|
"""
|
|
408
452
|
pos = self.get_slot_origin(slot)
|
|
409
453
|
|
|
410
454
|
if well:
|
|
411
|
-
|
|
455
|
+
labware = self.deck[slot]
|
|
456
|
+
if labware is None:
|
|
457
|
+
self._logger.error("Cannot get well position: no labware loaded in slot '%s'", slot)
|
|
458
|
+
raise ValueError(f"No labware loaded in slot '{slot}'. Load labware before accessing wells.")
|
|
459
|
+
well_pos = labware.get_well_position(well).get_xy()
|
|
412
460
|
pos += well_pos.swap_xy()
|
|
413
461
|
|
|
414
462
|
# get a
|
|
415
|
-
a = Position(a=
|
|
463
|
+
a = Position(a=labware.get_height() - self.CEILING_HEIGHT)
|
|
416
464
|
pos += a
|
|
417
465
|
self._logger.debug("Absolute A position for slot '%s', well '%s': %s", slot, well, pos)
|
|
418
466
|
else:
|
|
@@ -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)
|
|
@@ -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
|
-
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
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
|
-
|
|
540
|
+
position_data: Dict[str, float] = {}
|
|
533
541
|
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
|
|
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.info(
|
|
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
|
"""
|