labmcp-cavro 0.1.0__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.
- labmcp_cavro-0.1.0/.gitignore +15 -0
- labmcp_cavro-0.1.0/PKG-INFO +118 -0
- labmcp_cavro-0.1.0/README.md +106 -0
- labmcp_cavro-0.1.0/pyproject.toml +34 -0
- labmcp_cavro-0.1.0/server.json +46 -0
- labmcp_cavro-0.1.0/src/labmcp_cavro/__init__.py +1 -0
- labmcp_cavro-0.1.0/src/labmcp_cavro/driver.py +276 -0
- labmcp_cavro-0.1.0/src/labmcp_cavro/server.py +354 -0
- labmcp_cavro-0.1.0/src/labmcp_cavro/simulator.py +304 -0
- labmcp_cavro-0.1.0/tests/test_server.py +204 -0
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: labmcp-cavro
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: MCP server for Tecan Cavro syringe pumps (XLP 6000, XMP 6000, XCalibur) over the DT protocol: initialize, aspirate/dispense µL, switch valve ports, terminate.
|
|
5
|
+
Project-URL: Homepage, https://github.com/K-Dense-AI/lab-instrument-mcps/tree/main/servers/biology/tecan-cavro-pump
|
|
6
|
+
Author: K-Dense and LabMCP contributors
|
|
7
|
+
License-Expression: Apache-2.0
|
|
8
|
+
Keywords: cavro,lab-instrument,liquid-handling,mcp,syringe-pump,tecan,xcalibur,xlp6000
|
|
9
|
+
Requires-Python: >=3.10
|
|
10
|
+
Requires-Dist: labmcp<0.2,>=0.1
|
|
11
|
+
Description-Content-Type: text/markdown
|
|
12
|
+
|
|
13
|
+
# Tecan Cavro Syringe Pump — MCP Server
|
|
14
|
+
|
|
15
|
+
<!-- mcp-name: io.github.K-Dense-AI/labmcp-cavro -->
|
|
16
|
+
|
|
17
|
+
Let an AI agent initialize, aspirate and dispense microlitre volumes at set flow rates, switch valve ports and stop **Tecan Cavro OEM syringe pumps** through the documented **Cavro Data Terminal (DT) protocol**.
|
|
18
|
+
|
|
19
|
+
| | |
|
|
20
|
+
|---|---|
|
|
21
|
+
| **Package** | `labmcp-cavro` |
|
|
22
|
+
| **Instruments** | Cavro XLP 6000, Cavro XMP 6000, Cavro XCalibur (with 3-port, 4-port or distribution valves, or valveless) |
|
|
23
|
+
| **Interfaces** | RS-232, RS-485 (with an RS-485 adapter), serial-to-Ethernet adapters |
|
|
24
|
+
| **Protocol** | Cavro DT protocol ([XLP 6000 Operating Manual, 734237-C, chapter 3 and appendix G](https://www.manualslib.com/manual/1214060/Tecan-Cavro-Xlp-6000.html)); step resolution per [Tecan XCalibur](https://partnering.tecan.com/cavro-xcalibur-pump-for-oem-liquid-handling) / [XMP 6000](https://partnering.tecan.com/cavro-xmp-6000-pump-for-oem-liquid-handling) specifications |
|
|
25
|
+
| **Status** | 🧪 **simulated**: tested against a wire-level simulator, not yet verified on hardware. [Report a hardware test](https://github.com/K-Dense-AI/lab-instrument-mcps/issues/new?template=hardware-verification.yml) |
|
|
26
|
+
|
|
27
|
+
## Try it without hardware
|
|
28
|
+
|
|
29
|
+
```bash
|
|
30
|
+
uvx labmcp-cavro --simulate --check
|
|
31
|
+
```
|
|
32
|
+
|
|
33
|
+
The simulator is an XLP 6000 with a 1 mL syringe and a 3-port valve. Moves take real time; add `--option sim_speed=10` to speed it up.
|
|
34
|
+
|
|
35
|
+
## Connect your pump
|
|
36
|
+
|
|
37
|
+
1. **Pump setup:** set the **address switch** (switch 0 is DT address `1`, switch 1 is `2`, … switch 8 is `9`, switches 9–E are `:` `;` `<` `=` `>` `?`). The pump detects DT versus OEM protocol from the first message after power-up. The default is **9600 baud, 8N1**; 38400 baud is selected with the `U47` configuration command.
|
|
38
|
+
2. **Tell the server what is installed.** The pump cannot report its syringe, so these options are **required** on real hardware:
|
|
39
|
+
- `--option syringe_ul=1000`: syringe volume in µL.
|
|
40
|
+
- `--option model=xlp6000` (or `xmp6000`, 6000 increments per stroke; `xcalibur`, 3000). For another DT-compatible pump, give `--option steps_per_stroke=<increments per full stroke in standard mode>` instead.
|
|
41
|
+
- Optional: `--option resolution=fine` (N1, 8× finer volume steps; applied by `initialize`) and `--option pump_address=2`.
|
|
42
|
+
3. **Find the port:** `uvx labmcp ports`
|
|
43
|
+
4. **Test the connection:**
|
|
44
|
+
```bash
|
|
45
|
+
uvx labmcp-cavro --address /dev/ttyUSB0 --option syringe_ul=1000 --option model=xcalibur --check
|
|
46
|
+
uvx labmcp-cavro --address COM3 --option syringe_ul=500 --option model=xlp6000 --check
|
|
47
|
+
```
|
|
48
|
+
Non-default baud rates go in the address: `serial://COM3?baudrate=38400`.
|
|
49
|
+
|
|
50
|
+
## Add to your MCP client
|
|
51
|
+
|
|
52
|
+
**Claude Code**
|
|
53
|
+
```bash
|
|
54
|
+
claude mcp add cavro -- uvx labmcp-cavro --address /dev/ttyUSB0 --option syringe_ul=1000 --option model=xcalibur
|
|
55
|
+
```
|
|
56
|
+
|
|
57
|
+
**Claude Desktop / Cursor / Windsurf** (`claude_desktop_config.json`, `.cursor/mcp.json`, …)
|
|
58
|
+
```json
|
|
59
|
+
{
|
|
60
|
+
"mcpServers": {
|
|
61
|
+
"cavro": {
|
|
62
|
+
"command": "uvx",
|
|
63
|
+
"args": ["labmcp-cavro", "--address", "/dev/ttyUSB0",
|
|
64
|
+
"--option", "syringe_ul=1000", "--option", "model=xcalibur"]
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
```
|
|
69
|
+
|
|
70
|
+
Add `--read-only` to allow status reads but block every move.
|
|
71
|
+
|
|
72
|
+
## Tools
|
|
73
|
+
|
|
74
|
+
<!-- TOOLS:START -->
|
|
75
|
+
| Tool | Kind | Description |
|
|
76
|
+
|---|---|---|
|
|
77
|
+
| `aspirate_ul` | ⚠️ hazard | Draw `volume_ul` into the syringe at `flow_ul_s` through the current (or given) valve port, and wait until the move has finished. The pump must be initialized and have room for the volume. |
|
|
78
|
+
| `dispense_ul` | ⚠️ hazard | Push `volume_ul` out of the syringe at `flow_ul_s` through the current (or given) valve port, and wait until the move has finished. The syringe must contain at least that volume. |
|
|
79
|
+
| `get_command_log` | 👁 read | Return the most recent raw commands sent to / replies received from the instrument (newest last). Useful for debugging and for recording what was done. |
|
|
80
|
+
| `get_connection_info` | 👁 read | Report which instrument is connected (identity, address, simulated or real), whether the server is read-only, and the active safety limits. Call this first. |
|
|
81
|
+
| `get_status` | 👁 read | Report whether the pump is ready or busy, any error (decoded), the plunger position as the volume in the syringe, the valve position, resolution mode and current top speed. |
|
|
82
|
+
| `initialize` | ⚠️ hazard | Initialize the pump: drive the plunger to the top of the syringe (expelling its contents through the valve), set that as position 0 and home the valve. Needed after power-up, a plunger overload or `terminate`. Route the valve output to waste first. Takes a few seconds. |
|
|
83
|
+
| `reconnect` | 🛑 safety | Close and re-open the connection to the instrument (e.g. after it was power cycled or a cable was re-plugged). |
|
|
84
|
+
| `set_valve` | ⚠️ hazard | Turn the valve to a named position (3/4-port valves) or to a numbered port (distribution valves). In bypass the plunger cannot move. Returns the new status. |
|
|
85
|
+
| `terminate` | 🛑 safety | Stop any plunger move, loop or delay immediately (DT command T). A valve move in progress still completes. Re-initialize afterwards: the plunger may have lost steps. |
|
|
86
|
+
<!-- TOOLS:END -->
|
|
87
|
+
|
|
88
|
+
## Safety limits
|
|
89
|
+
|
|
90
|
+
| Limit | Default | Meaning |
|
|
91
|
+
|---|---|---|
|
|
92
|
+
| `max_volume_ul` | 5000 µL | Largest volume one `aspirate_ul`/`dispense_ul` may move |
|
|
93
|
+
| `max_flow_ul_s` | 500 µL/s | Highest plunger flow rate an agent may use |
|
|
94
|
+
|
|
95
|
+
Override at launch: `--limit max_flow_ul_s=50`. Independently of the limits, the server refuses moves that would overfill or over-empty the syringe, and flows the pump cannot produce with the installed syringe (top speed 5–6000 pulses/s).
|
|
96
|
+
|
|
97
|
+
## Example prompts
|
|
98
|
+
|
|
99
|
+
- "Initialize the pump, then prime it: aspirate 1 mL from the input port and dispense it to the output port, three times."
|
|
100
|
+
- "Aspirate 150 µL of sample slowly (20 µL/s) from port 3 and dispense 50 µL into port 5."
|
|
101
|
+
- "How much liquid is in the syringe right now and which port is the valve on?"
|
|
102
|
+
- "The pump reported a plunger overload. What does that mean and what should I check?"
|
|
103
|
+
- "Stop the pump now."
|
|
104
|
+
|
|
105
|
+
## Notes
|
|
106
|
+
|
|
107
|
+
- **Conversion.** Steps = volume × steps-per-stroke ÷ syringe volume, where steps-per-stroke is 6000 (XLP/XMP) or 3000 (XCalibur) in standard mode and 8× that in fine-positioning mode (N1). The mode is read from the pump (`?28`) before every move. Top speed `V` = flow × 6000 ÷ syringe volume: on all three models a full stroke is 6000 speed pulses in N0/N1. For the XCalibur this is derived from Tecan's published 1.2 s–20 min per stroke, not from its operating manual; please confirm on hardware.
|
|
108
|
+
- **Busy/ready** is read only from `Q`, as the manual requires. Invalid commands are reported immediately; invalid operands (e.g. a move past the end of the syringe) are reported by the next `Q`, and the server turns both into clear errors.
|
|
109
|
+
- **Initialization force** is chosen from the syringe size (full ≥ 1 mL, half for 250/500 µL, third for 50/100 µL; manual table 3-6) unless you pick one.
|
|
110
|
+
- **`terminate`** sends `T` without `R`: in the DT command set `R` resumes a terminated string. `T` does not stop a valve move in progress. Re-initialize after terminating.
|
|
111
|
+
- The server does not use microstep mode (N2), the OEM protocol (checksums, sequence numbers) or CAN.
|
|
112
|
+
- **Cavro Centris** has a different resolution (181,490 increments per stroke) and volume-based firmware commands that we could not verify, so it is not supported. XE 1000 and older XL/XP 3000 pumps may work with `--option steps_per_stroke=…`, but they are untested.
|
|
113
|
+
|
|
114
|
+
## Hardware verification
|
|
115
|
+
|
|
116
|
+
| Model | Firmware | Interface | Verified by | Date |
|
|
117
|
+
|---|---|---|---|---|
|
|
118
|
+
| *none yet: [be the first](https://github.com/K-Dense-AI/lab-instrument-mcps/issues/new?template=hardware-verification.yml)* | | | | |
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
# Tecan Cavro Syringe Pump — MCP Server
|
|
2
|
+
|
|
3
|
+
<!-- mcp-name: io.github.K-Dense-AI/labmcp-cavro -->
|
|
4
|
+
|
|
5
|
+
Let an AI agent initialize, aspirate and dispense microlitre volumes at set flow rates, switch valve ports and stop **Tecan Cavro OEM syringe pumps** through the documented **Cavro Data Terminal (DT) protocol**.
|
|
6
|
+
|
|
7
|
+
| | |
|
|
8
|
+
|---|---|
|
|
9
|
+
| **Package** | `labmcp-cavro` |
|
|
10
|
+
| **Instruments** | Cavro XLP 6000, Cavro XMP 6000, Cavro XCalibur (with 3-port, 4-port or distribution valves, or valveless) |
|
|
11
|
+
| **Interfaces** | RS-232, RS-485 (with an RS-485 adapter), serial-to-Ethernet adapters |
|
|
12
|
+
| **Protocol** | Cavro DT protocol ([XLP 6000 Operating Manual, 734237-C, chapter 3 and appendix G](https://www.manualslib.com/manual/1214060/Tecan-Cavro-Xlp-6000.html)); step resolution per [Tecan XCalibur](https://partnering.tecan.com/cavro-xcalibur-pump-for-oem-liquid-handling) / [XMP 6000](https://partnering.tecan.com/cavro-xmp-6000-pump-for-oem-liquid-handling) specifications |
|
|
13
|
+
| **Status** | 🧪 **simulated**: tested against a wire-level simulator, not yet verified on hardware. [Report a hardware test](https://github.com/K-Dense-AI/lab-instrument-mcps/issues/new?template=hardware-verification.yml) |
|
|
14
|
+
|
|
15
|
+
## Try it without hardware
|
|
16
|
+
|
|
17
|
+
```bash
|
|
18
|
+
uvx labmcp-cavro --simulate --check
|
|
19
|
+
```
|
|
20
|
+
|
|
21
|
+
The simulator is an XLP 6000 with a 1 mL syringe and a 3-port valve. Moves take real time; add `--option sim_speed=10` to speed it up.
|
|
22
|
+
|
|
23
|
+
## Connect your pump
|
|
24
|
+
|
|
25
|
+
1. **Pump setup:** set the **address switch** (switch 0 is DT address `1`, switch 1 is `2`, … switch 8 is `9`, switches 9–E are `:` `;` `<` `=` `>` `?`). The pump detects DT versus OEM protocol from the first message after power-up. The default is **9600 baud, 8N1**; 38400 baud is selected with the `U47` configuration command.
|
|
26
|
+
2. **Tell the server what is installed.** The pump cannot report its syringe, so these options are **required** on real hardware:
|
|
27
|
+
- `--option syringe_ul=1000`: syringe volume in µL.
|
|
28
|
+
- `--option model=xlp6000` (or `xmp6000`, 6000 increments per stroke; `xcalibur`, 3000). For another DT-compatible pump, give `--option steps_per_stroke=<increments per full stroke in standard mode>` instead.
|
|
29
|
+
- Optional: `--option resolution=fine` (N1, 8× finer volume steps; applied by `initialize`) and `--option pump_address=2`.
|
|
30
|
+
3. **Find the port:** `uvx labmcp ports`
|
|
31
|
+
4. **Test the connection:**
|
|
32
|
+
```bash
|
|
33
|
+
uvx labmcp-cavro --address /dev/ttyUSB0 --option syringe_ul=1000 --option model=xcalibur --check
|
|
34
|
+
uvx labmcp-cavro --address COM3 --option syringe_ul=500 --option model=xlp6000 --check
|
|
35
|
+
```
|
|
36
|
+
Non-default baud rates go in the address: `serial://COM3?baudrate=38400`.
|
|
37
|
+
|
|
38
|
+
## Add to your MCP client
|
|
39
|
+
|
|
40
|
+
**Claude Code**
|
|
41
|
+
```bash
|
|
42
|
+
claude mcp add cavro -- uvx labmcp-cavro --address /dev/ttyUSB0 --option syringe_ul=1000 --option model=xcalibur
|
|
43
|
+
```
|
|
44
|
+
|
|
45
|
+
**Claude Desktop / Cursor / Windsurf** (`claude_desktop_config.json`, `.cursor/mcp.json`, …)
|
|
46
|
+
```json
|
|
47
|
+
{
|
|
48
|
+
"mcpServers": {
|
|
49
|
+
"cavro": {
|
|
50
|
+
"command": "uvx",
|
|
51
|
+
"args": ["labmcp-cavro", "--address", "/dev/ttyUSB0",
|
|
52
|
+
"--option", "syringe_ul=1000", "--option", "model=xcalibur"]
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
```
|
|
57
|
+
|
|
58
|
+
Add `--read-only` to allow status reads but block every move.
|
|
59
|
+
|
|
60
|
+
## Tools
|
|
61
|
+
|
|
62
|
+
<!-- TOOLS:START -->
|
|
63
|
+
| Tool | Kind | Description |
|
|
64
|
+
|---|---|---|
|
|
65
|
+
| `aspirate_ul` | ⚠️ hazard | Draw `volume_ul` into the syringe at `flow_ul_s` through the current (or given) valve port, and wait until the move has finished. The pump must be initialized and have room for the volume. |
|
|
66
|
+
| `dispense_ul` | ⚠️ hazard | Push `volume_ul` out of the syringe at `flow_ul_s` through the current (or given) valve port, and wait until the move has finished. The syringe must contain at least that volume. |
|
|
67
|
+
| `get_command_log` | 👁 read | Return the most recent raw commands sent to / replies received from the instrument (newest last). Useful for debugging and for recording what was done. |
|
|
68
|
+
| `get_connection_info` | 👁 read | Report which instrument is connected (identity, address, simulated or real), whether the server is read-only, and the active safety limits. Call this first. |
|
|
69
|
+
| `get_status` | 👁 read | Report whether the pump is ready or busy, any error (decoded), the plunger position as the volume in the syringe, the valve position, resolution mode and current top speed. |
|
|
70
|
+
| `initialize` | ⚠️ hazard | Initialize the pump: drive the plunger to the top of the syringe (expelling its contents through the valve), set that as position 0 and home the valve. Needed after power-up, a plunger overload or `terminate`. Route the valve output to waste first. Takes a few seconds. |
|
|
71
|
+
| `reconnect` | 🛑 safety | Close and re-open the connection to the instrument (e.g. after it was power cycled or a cable was re-plugged). |
|
|
72
|
+
| `set_valve` | ⚠️ hazard | Turn the valve to a named position (3/4-port valves) or to a numbered port (distribution valves). In bypass the plunger cannot move. Returns the new status. |
|
|
73
|
+
| `terminate` | 🛑 safety | Stop any plunger move, loop or delay immediately (DT command T). A valve move in progress still completes. Re-initialize afterwards: the plunger may have lost steps. |
|
|
74
|
+
<!-- TOOLS:END -->
|
|
75
|
+
|
|
76
|
+
## Safety limits
|
|
77
|
+
|
|
78
|
+
| Limit | Default | Meaning |
|
|
79
|
+
|---|---|---|
|
|
80
|
+
| `max_volume_ul` | 5000 µL | Largest volume one `aspirate_ul`/`dispense_ul` may move |
|
|
81
|
+
| `max_flow_ul_s` | 500 µL/s | Highest plunger flow rate an agent may use |
|
|
82
|
+
|
|
83
|
+
Override at launch: `--limit max_flow_ul_s=50`. Independently of the limits, the server refuses moves that would overfill or over-empty the syringe, and flows the pump cannot produce with the installed syringe (top speed 5–6000 pulses/s).
|
|
84
|
+
|
|
85
|
+
## Example prompts
|
|
86
|
+
|
|
87
|
+
- "Initialize the pump, then prime it: aspirate 1 mL from the input port and dispense it to the output port, three times."
|
|
88
|
+
- "Aspirate 150 µL of sample slowly (20 µL/s) from port 3 and dispense 50 µL into port 5."
|
|
89
|
+
- "How much liquid is in the syringe right now and which port is the valve on?"
|
|
90
|
+
- "The pump reported a plunger overload. What does that mean and what should I check?"
|
|
91
|
+
- "Stop the pump now."
|
|
92
|
+
|
|
93
|
+
## Notes
|
|
94
|
+
|
|
95
|
+
- **Conversion.** Steps = volume × steps-per-stroke ÷ syringe volume, where steps-per-stroke is 6000 (XLP/XMP) or 3000 (XCalibur) in standard mode and 8× that in fine-positioning mode (N1). The mode is read from the pump (`?28`) before every move. Top speed `V` = flow × 6000 ÷ syringe volume: on all three models a full stroke is 6000 speed pulses in N0/N1. For the XCalibur this is derived from Tecan's published 1.2 s–20 min per stroke, not from its operating manual; please confirm on hardware.
|
|
96
|
+
- **Busy/ready** is read only from `Q`, as the manual requires. Invalid commands are reported immediately; invalid operands (e.g. a move past the end of the syringe) are reported by the next `Q`, and the server turns both into clear errors.
|
|
97
|
+
- **Initialization force** is chosen from the syringe size (full ≥ 1 mL, half for 250/500 µL, third for 50/100 µL; manual table 3-6) unless you pick one.
|
|
98
|
+
- **`terminate`** sends `T` without `R`: in the DT command set `R` resumes a terminated string. `T` does not stop a valve move in progress. Re-initialize after terminating.
|
|
99
|
+
- The server does not use microstep mode (N2), the OEM protocol (checksums, sequence numbers) or CAN.
|
|
100
|
+
- **Cavro Centris** has a different resolution (181,490 increments per stroke) and volume-based firmware commands that we could not verify, so it is not supported. XE 1000 and older XL/XP 3000 pumps may work with `--option steps_per_stroke=…`, but they are untested.
|
|
101
|
+
|
|
102
|
+
## Hardware verification
|
|
103
|
+
|
|
104
|
+
| Model | Firmware | Interface | Verified by | Date |
|
|
105
|
+
|---|---|---|---|---|
|
|
106
|
+
| *none yet: [be the first](https://github.com/K-Dense-AI/lab-instrument-mcps/issues/new?template=hardware-verification.yml)* | | | | |
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
[project]
|
|
2
|
+
name = "labmcp-cavro"
|
|
3
|
+
version = "0.1.0"
|
|
4
|
+
description = "MCP server for Tecan Cavro syringe pumps (XLP 6000, XMP 6000, XCalibur) over the DT protocol: initialize, aspirate/dispense µL, switch valve ports, terminate."
|
|
5
|
+
readme = "README.md"
|
|
6
|
+
license = "Apache-2.0"
|
|
7
|
+
requires-python = ">=3.10"
|
|
8
|
+
authors = [{ name = "K-Dense and LabMCP contributors" }]
|
|
9
|
+
keywords = ["mcp", "lab-instrument", "syringe-pump", "tecan", "cavro", "xcalibur", "xlp6000", "liquid-handling"]
|
|
10
|
+
dependencies = ["labmcp>=0.1,<0.2"]
|
|
11
|
+
|
|
12
|
+
[project.scripts]
|
|
13
|
+
labmcp-cavro = "labmcp_cavro.server:main"
|
|
14
|
+
|
|
15
|
+
[project.urls]
|
|
16
|
+
Homepage = "https://github.com/K-Dense-AI/lab-instrument-mcps/tree/main/servers/biology/tecan-cavro-pump"
|
|
17
|
+
|
|
18
|
+
[tool.labmcp]
|
|
19
|
+
name = "Tecan Cavro Syringe Pump"
|
|
20
|
+
domain = "biology"
|
|
21
|
+
category = "Liquid handling"
|
|
22
|
+
vendor = "Tecan"
|
|
23
|
+
models = ["Cavro XLP 6000", "Cavro XMP 6000", "Cavro XCalibur"]
|
|
24
|
+
interfaces = ["RS-232", "RS-485 (via adapter)", "Serial-to-Ethernet"]
|
|
25
|
+
protocol = "Cavro Data Terminal (DT) protocol"
|
|
26
|
+
summary = "Initialize, aspirate and dispense microlitre volumes at set flow rates, switch valve ports, read plunger/valve status, terminate moves."
|
|
27
|
+
status = "simulated"
|
|
28
|
+
|
|
29
|
+
[build-system]
|
|
30
|
+
requires = ["hatchling"]
|
|
31
|
+
build-backend = "hatchling.build"
|
|
32
|
+
|
|
33
|
+
[tool.hatch.build.targets.wheel]
|
|
34
|
+
packages = ["src/labmcp_cavro"]
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
{
|
|
2
|
+
"$schema": "https://static.modelcontextprotocol.io/schemas/2025-12-11/server.schema.json",
|
|
3
|
+
"name": "io.github.K-Dense-AI/labmcp-cavro",
|
|
4
|
+
"title": "Tecan Cavro Syringe Pump",
|
|
5
|
+
"description": "MCP server for Tecan Cavro syringe pumps (XLP 6000, XMP 6000, XCalibur) over the DT protocol: initia",
|
|
6
|
+
"version": "0.1.0",
|
|
7
|
+
"repository": {
|
|
8
|
+
"url": "https://github.com/K-Dense-AI/lab-instrument-mcps",
|
|
9
|
+
"source": "github",
|
|
10
|
+
"subfolder": "servers/biology/tecan-cavro-pump"
|
|
11
|
+
},
|
|
12
|
+
"websiteUrl": "https://github.com/K-Dense-AI/lab-instrument-mcps/tree/main/servers/biology/tecan-cavro-pump",
|
|
13
|
+
"packages": [
|
|
14
|
+
{
|
|
15
|
+
"registryType": "pypi",
|
|
16
|
+
"registryBaseUrl": "https://pypi.org",
|
|
17
|
+
"identifier": "labmcp-cavro",
|
|
18
|
+
"version": "0.1.0",
|
|
19
|
+
"transport": {
|
|
20
|
+
"type": "stdio"
|
|
21
|
+
},
|
|
22
|
+
"environmentVariables": [
|
|
23
|
+
{
|
|
24
|
+
"name": "LABMCP_ADDRESS",
|
|
25
|
+
"description": "Instrument address, e.g. serial:///dev/ttyUSB0 or tcp://192.168.1.50:5025",
|
|
26
|
+
"isRequired": false
|
|
27
|
+
},
|
|
28
|
+
{
|
|
29
|
+
"name": "LABMCP_SIMULATE",
|
|
30
|
+
"description": "Set to 1 to use the built-in simulator (no hardware)",
|
|
31
|
+
"isRequired": false
|
|
32
|
+
},
|
|
33
|
+
{
|
|
34
|
+
"name": "LABMCP_READ_ONLY",
|
|
35
|
+
"description": "Set to 1 to disable all state-changing tools",
|
|
36
|
+
"isRequired": false
|
|
37
|
+
},
|
|
38
|
+
{
|
|
39
|
+
"name": "LABMCP_LIMITS",
|
|
40
|
+
"description": "Safety limit overrides, e.g. max_temperature_c=80",
|
|
41
|
+
"isRequired": false
|
|
42
|
+
}
|
|
43
|
+
]
|
|
44
|
+
}
|
|
45
|
+
]
|
|
46
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""LabMCP server for Tecan Cavro syringe pumps (XLP 6000, XMP 6000, XCalibur; DT protocol)."""
|
|
@@ -0,0 +1,276 @@
|
|
|
1
|
+
"""Tecan Cavro syringe-pump driver (Data Terminal "DT" protocol over RS-232/RS-485).
|
|
2
|
+
|
|
3
|
+
Command set, framing, status byte and error codes as documented in the
|
|
4
|
+
"Cavro XLP 6000 Modular Syringe Pump Operating Manual", Tecan Systems, document 734237-C
|
|
5
|
+
(October 2005), chapter 3 "Software Communication" and appendix G "Command Quick Reference":
|
|
6
|
+
https://www.manualslib.com/manual/1214060/Tecan-Cavro-Xlp-6000.html
|
|
7
|
+
|
|
8
|
+
Wire format (DT protocol, 9600 baud 8N1 by default)::
|
|
9
|
+
|
|
10
|
+
to pump: "/" <address> <commands> CR e.g. "/1ZR\\r" (switch 0 = address "1")
|
|
11
|
+
from pump: "/" "0" <status> [<data>] ETX CR LF e.g. "/0`3000\\x03\\r\\n"
|
|
12
|
+
|
|
13
|
+
Status byte: ``0 1 X 0 e3 e2 e1 e0`` - bit 5 (X) is 1 when the pump is ready and 0 when busy;
|
|
14
|
+
bits 0-3 hold the error code. Only the ``Q`` reply's busy bit is authoritative (manual 3.6.1);
|
|
15
|
+
the error bits are valid in every reply.
|
|
16
|
+
|
|
17
|
+
Resolution (manual 3.3.2 "N" command, appendix E): N0 = standard mode, positions in
|
|
18
|
+
half-steps; N1 = fine positioning, positions in microsteps (x8) with speeds still in half-steps/s;
|
|
19
|
+
N2 = microstep mode (x8 positions and speeds). Full stroke is 6000 / 48000 increments on the
|
|
20
|
+
XLP 6000 and XMP 6000, 3000 / 24000 on the XCalibur (Tecan product specifications). Speeds
|
|
21
|
+
``V`` (5-6000 pulses/s) span 6000 pulses per full stroke in N0/N1 on all three models, which
|
|
22
|
+
gives the documented 1.2 s - 20 min per stroke.
|
|
23
|
+
"""
|
|
24
|
+
|
|
25
|
+
from __future__ import annotations
|
|
26
|
+
|
|
27
|
+
import contextlib
|
|
28
|
+
import threading
|
|
29
|
+
import time
|
|
30
|
+
from dataclasses import dataclass
|
|
31
|
+
|
|
32
|
+
from labmcp import InstrumentProtocolError, InstrumentTimeout, Transport
|
|
33
|
+
|
|
34
|
+
#: DT address characters for address-switch positions 0..E (manual table 3-1/3-2).
|
|
35
|
+
ADDRESSES = "123456789:;<=>?"
|
|
36
|
+
|
|
37
|
+
#: Pump families with a verified number of plunger increments per full stroke in N0.
|
|
38
|
+
MODELS: dict[str, tuple[str, int]] = {
|
|
39
|
+
"xlp6000": ("Cavro XLP 6000", 6000),
|
|
40
|
+
"xmp6000": ("Cavro XMP 6000", 6000),
|
|
41
|
+
"xcalibur": ("Cavro XCalibur", 3000),
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
#: Speed pulses per full stroke in N0/N1 (V is in half-steps/s): V5 = 20 min/stroke, V6000 ~ 1 s.
|
|
45
|
+
SPEED_PULSES_PER_STROKE = 6000
|
|
46
|
+
TOP_SPEED_MIN, TOP_SPEED_MAX = 5, 6000
|
|
47
|
+
|
|
48
|
+
ERROR_CODES: dict[int, str] = {
|
|
49
|
+
0: "no error",
|
|
50
|
+
1: "initialization error: the pump failed to initialize (check for blockages and loose "
|
|
51
|
+
"connections, then initialize again)",
|
|
52
|
+
2: "invalid command",
|
|
53
|
+
3: "invalid operand (parameter out of range, e.g. a move beyond the end of the syringe)",
|
|
54
|
+
6: "EEPROM failure (contact Tecan service)",
|
|
55
|
+
7: "device not initialized: run `initialize` first",
|
|
56
|
+
8: "internal failure (contact Tecan service)",
|
|
57
|
+
9: "plunger overload: plunger movement blocked by excessive back-pressure; the pump must be "
|
|
58
|
+
"re-initialized",
|
|
59
|
+
10: "valve overload: the valve drive is blocked; re-initialize (or send a valve command)",
|
|
60
|
+
11: "plunger move not allowed: the valve is in the bypass/throughput position",
|
|
61
|
+
12: "internal failure (contact Tecan service)",
|
|
62
|
+
14: "A/D converter failure (contact Tecan service)",
|
|
63
|
+
15: "command overflow: a command was sent while the pump was still busy",
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
#: Initialization force codes (manual table 3-6): 0 full (>= 1 mL), 1 half (250/500 µL), 2 third (50/100 µL).
|
|
67
|
+
FORCE_CODES = {"full": 0, "half": 1, "third": 2}
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def force_for_syringe(syringe_ul: float) -> int:
|
|
71
|
+
"""Recommended initialization force code for a syringe size (manual table 3-6)."""
|
|
72
|
+
if syringe_ul >= 1000:
|
|
73
|
+
return 0
|
|
74
|
+
if syringe_ul >= 250:
|
|
75
|
+
return 1
|
|
76
|
+
return 2
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
class CavroError(InstrumentProtocolError):
|
|
80
|
+
"""The pump reported a non-zero error code in its status byte."""
|
|
81
|
+
|
|
82
|
+
def __init__(self, code: int, context: str) -> None:
|
|
83
|
+
self.code = code
|
|
84
|
+
meaning = ERROR_CODES.get(code, "unknown error (see your pump's operating manual)")
|
|
85
|
+
super().__init__(f"Pump reported error {code} {context}: {meaning}.")
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
@dataclass
|
|
89
|
+
class Reply:
|
|
90
|
+
status: int
|
|
91
|
+
data: str
|
|
92
|
+
|
|
93
|
+
@property
|
|
94
|
+
def ready(self) -> bool:
|
|
95
|
+
return bool(self.status & 0x20)
|
|
96
|
+
|
|
97
|
+
@property
|
|
98
|
+
def error(self) -> int:
|
|
99
|
+
return self.status & 0x0F
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
@dataclass
|
|
103
|
+
class MoveResult:
|
|
104
|
+
steps: int
|
|
105
|
+
top_speed: int
|
|
106
|
+
position_steps: int
|
|
107
|
+
terminated: bool
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
class CavroPump:
|
|
111
|
+
"""One Cavro pump addressed with the DT protocol."""
|
|
112
|
+
|
|
113
|
+
def __init__(
|
|
114
|
+
self,
|
|
115
|
+
transport: Transport,
|
|
116
|
+
*,
|
|
117
|
+
address: str = "1",
|
|
118
|
+
syringe_ul: float = 1000.0,
|
|
119
|
+
model: str = "xlp6000",
|
|
120
|
+
standard_steps: int | None = None,
|
|
121
|
+
resolution_mode: int = 0,
|
|
122
|
+
) -> None:
|
|
123
|
+
if address not in ADDRESSES or len(address) != 1:
|
|
124
|
+
raise ValueError(f"Pump address must be one of {ADDRESSES!r} (switch 0 = '1'), got {address!r}")
|
|
125
|
+
if resolution_mode not in (0, 1):
|
|
126
|
+
raise ValueError("resolution_mode must be 0 (standard) or 1 (fine positioning)")
|
|
127
|
+
self.t = transport
|
|
128
|
+
self.address = address
|
|
129
|
+
self.syringe_ul = float(syringe_ul)
|
|
130
|
+
self.model_name, default_steps = MODELS.get(model, (model, 0))
|
|
131
|
+
self.standard_steps = int(standard_steps or default_steps)
|
|
132
|
+
if self.standard_steps <= 0:
|
|
133
|
+
raise ValueError(f"Unknown pump model {model!r}; give the steps per stroke explicitly")
|
|
134
|
+
self.resolution_mode = resolution_mode
|
|
135
|
+
self._terminate = threading.Event()
|
|
136
|
+
|
|
137
|
+
# ------------------------------------------------------------ low level
|
|
138
|
+
|
|
139
|
+
def send(self, cmd: str, timeout: float | None = None) -> Reply:
|
|
140
|
+
"""Send one DT command string and parse the answer block (errors are returned, not raised)."""
|
|
141
|
+
raw = self.t.query(f"/{self.address}{cmd}", timeout)
|
|
142
|
+
start = raw.rfind("/0")
|
|
143
|
+
if start < 0 or len(raw) < start + 3:
|
|
144
|
+
raise InstrumentProtocolError(
|
|
145
|
+
f"Unexpected answer to {cmd!r}: {raw!r} (expected '/0<status>...'). Check the pump "
|
|
146
|
+
"address switch and that the pump uses the DT protocol at this baud rate."
|
|
147
|
+
)
|
|
148
|
+
status = ord(raw[start + 2])
|
|
149
|
+
if status & 0xD0 != 0x40: # bits 7..6 must be 01 and bit 4 must be 0
|
|
150
|
+
raise InstrumentProtocolError(f"Invalid status byte {status:#04x} in answer to {cmd!r}: {raw!r}")
|
|
151
|
+
return Reply(status, raw[start + 3 :])
|
|
152
|
+
|
|
153
|
+
def command(self, cmd: str, timeout: float | None = None) -> Reply:
|
|
154
|
+
"""Send ``cmd`` and raise :class:`CavroError` if the answer carries an error code."""
|
|
155
|
+
reply = self.send(cmd, timeout)
|
|
156
|
+
if reply.error:
|
|
157
|
+
raise CavroError(reply.error, f"in reply to {cmd!r}")
|
|
158
|
+
return reply
|
|
159
|
+
|
|
160
|
+
def query_status(self) -> Reply:
|
|
161
|
+
"""``Q``: the only authoritative ready/busy report (manual 3.6.1). Errors are returned."""
|
|
162
|
+
return self.send("Q")
|
|
163
|
+
|
|
164
|
+
def report(self, cmd: str) -> str:
|
|
165
|
+
return self.command(cmd).data.strip()
|
|
166
|
+
|
|
167
|
+
def _report_int(self, cmd: str) -> int:
|
|
168
|
+
data = self.report(cmd)
|
|
169
|
+
try:
|
|
170
|
+
return int(data)
|
|
171
|
+
except ValueError as exc:
|
|
172
|
+
raise InstrumentProtocolError(f"Expected an integer in answer to {cmd!r}, got {data!r}") from exc
|
|
173
|
+
|
|
174
|
+
def wait_ready(self, timeout: float, poll: float = 0.1, context: str = "") -> None:
|
|
175
|
+
"""Poll ``Q`` until the pump is ready; raise on any error code."""
|
|
176
|
+
deadline = time.monotonic() + timeout
|
|
177
|
+
while True:
|
|
178
|
+
reply = self.query_status()
|
|
179
|
+
if reply.error:
|
|
180
|
+
raise CavroError(reply.error, context or "while waiting for the pump")
|
|
181
|
+
if reply.ready:
|
|
182
|
+
return
|
|
183
|
+
if time.monotonic() > deadline:
|
|
184
|
+
raise InstrumentTimeout(
|
|
185
|
+
f"Pump still busy after {timeout:.0f} s {context}. Call `terminate` if it should stop."
|
|
186
|
+
)
|
|
187
|
+
time.sleep(poll)
|
|
188
|
+
|
|
189
|
+
# ------------------------------------------------------------ reports
|
|
190
|
+
|
|
191
|
+
def plunger_steps(self) -> int:
|
|
192
|
+
return self._report_int("?")
|
|
193
|
+
|
|
194
|
+
def valve_position(self) -> str:
|
|
195
|
+
return self.report("?6")
|
|
196
|
+
|
|
197
|
+
def mode(self) -> int:
|
|
198
|
+
return self._report_int("?28")
|
|
199
|
+
|
|
200
|
+
def top_speed(self) -> int:
|
|
201
|
+
return self._report_int("?2")
|
|
202
|
+
|
|
203
|
+
def firmware(self) -> str:
|
|
204
|
+
return self.report("&")
|
|
205
|
+
|
|
206
|
+
def steps_per_stroke(self, mode: int | None = None) -> int:
|
|
207
|
+
mode = self.mode() if mode is None else mode
|
|
208
|
+
return self.standard_steps * (8 if mode in (1, 2) else 1)
|
|
209
|
+
|
|
210
|
+
def identify(self) -> dict[str, str]:
|
|
211
|
+
info = {"manufacturer": "Tecan (Cavro)", "model": self.model_name, "pump_address": self.address}
|
|
212
|
+
with contextlib.suppress(InstrumentProtocolError):
|
|
213
|
+
info["firmware"] = self.firmware()
|
|
214
|
+
info["syringe_ul"] = f"{self.syringe_ul:g}"
|
|
215
|
+
info["steps_per_stroke_standard"] = str(self.standard_steps)
|
|
216
|
+
return info
|
|
217
|
+
|
|
218
|
+
# ------------------------------------------------------------ actions
|
|
219
|
+
|
|
220
|
+
def initialize(
|
|
221
|
+
self,
|
|
222
|
+
kind: str = "Z",
|
|
223
|
+
force: int | None = None,
|
|
224
|
+
input_port: int | None = None,
|
|
225
|
+
output_port: int | None = None,
|
|
226
|
+
timeout: float = 60.0,
|
|
227
|
+
) -> None:
|
|
228
|
+
"""``Z`` (valve homes CW), ``Y`` (CCW) or ``W`` (plunger only), then set N mode."""
|
|
229
|
+
if kind not in {"Z", "Y", "W"}:
|
|
230
|
+
raise ValueError("kind must be 'Z', 'Y' or 'W'")
|
|
231
|
+
force = force_for_syringe(self.syringe_ul) if force is None else force
|
|
232
|
+
if kind == "W" or (input_port is None and output_port is None):
|
|
233
|
+
cmd = f"{kind}{force}R"
|
|
234
|
+
else:
|
|
235
|
+
cmd = f"{kind}{force},{input_port or 0},{output_port or 0}R"
|
|
236
|
+
self._require_ready("initialize")
|
|
237
|
+
reply = self.send(cmd)
|
|
238
|
+
# Errors 1/7/9/10 describe the state initialization is meant to clear; the outcome is read
|
|
239
|
+
# from Q afterwards (manual 3.6.3 "Initialization Errors").
|
|
240
|
+
if reply.error and reply.error not in {1, 7, 9, 10}:
|
|
241
|
+
raise CavroError(reply.error, f"in reply to {cmd!r}")
|
|
242
|
+
self.wait_ready(timeout, context="during initialization")
|
|
243
|
+
self.command(f"N{self.resolution_mode}R")
|
|
244
|
+
self.wait_ready(5.0, context="after setting the resolution mode")
|
|
245
|
+
|
|
246
|
+
def _require_ready(self, action: str) -> None:
|
|
247
|
+
reply = self.query_status()
|
|
248
|
+
if not reply.ready:
|
|
249
|
+
raise InstrumentProtocolError(f"Cannot {action}: the pump is busy. Wait, or call `terminate`.")
|
|
250
|
+
|
|
251
|
+
def move_plunger(self, kind: str, steps: int, top_speed: int, timeout: float) -> MoveResult:
|
|
252
|
+
"""``A`` absolute, ``P`` pick-up (down) or ``D`` dispense (up) at top speed ``V``."""
|
|
253
|
+
if kind not in {"A", "P", "D"}:
|
|
254
|
+
raise ValueError("kind must be 'A', 'P' or 'D'")
|
|
255
|
+
if not TOP_SPEED_MIN <= top_speed <= TOP_SPEED_MAX:
|
|
256
|
+
raise ValueError(f"top speed must be {TOP_SPEED_MIN}-{TOP_SPEED_MAX} pulses/s")
|
|
257
|
+
self._require_ready("move the plunger")
|
|
258
|
+
self._terminate.clear()
|
|
259
|
+
self.command(f"V{top_speed}{kind}{steps}R")
|
|
260
|
+
self.wait_ready(timeout, context=f"during the plunger move {kind}{steps}")
|
|
261
|
+
return MoveResult(steps, top_speed, self.plunger_steps(), self._terminate.is_set())
|
|
262
|
+
|
|
263
|
+
def move_valve(self, code: str, timeout: float = 10.0) -> str:
|
|
264
|
+
"""Valve command: ``I``/``O``/``B``/``E`` (non-distribution) or ``I<n>``/``O<n>`` (distribution)."""
|
|
265
|
+
self._require_ready("move the valve")
|
|
266
|
+
self.command(f"{code}R")
|
|
267
|
+
self.wait_ready(timeout, context=f"during the valve move {code}")
|
|
268
|
+
return self.valve_position()
|
|
269
|
+
|
|
270
|
+
def terminate(self) -> Reply:
|
|
271
|
+
"""``T``: stop plunger moves, loops and delays immediately (valve moves finish)."""
|
|
272
|
+
self._terminate.set()
|
|
273
|
+
return self.send("T")
|
|
274
|
+
|
|
275
|
+
def close(self) -> None:
|
|
276
|
+
self.t.close()
|