labmcp-julabo 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_julabo-0.1.0/.gitignore +15 -0
- labmcp_julabo-0.1.0/PKG-INFO +119 -0
- labmcp_julabo-0.1.0/README.md +107 -0
- labmcp_julabo-0.1.0/pyproject.toml +34 -0
- labmcp_julabo-0.1.0/server.json +46 -0
- labmcp_julabo-0.1.0/src/labmcp_julabo/__init__.py +1 -0
- labmcp_julabo-0.1.0/src/labmcp_julabo/driver.py +320 -0
- labmcp_julabo-0.1.0/src/labmcp_julabo/server.py +316 -0
- labmcp_julabo-0.1.0/src/labmcp_julabo/simulator.py +169 -0
- labmcp_julabo-0.1.0/tests/test_server.py +242 -0
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: labmcp-julabo
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: MCP server for JULABO heating and refrigerated circulators (CORIO, MAGIO, DYNEO): read temperatures, set setpoint, start/stop, alarms.
|
|
5
|
+
Project-URL: Homepage, https://github.com/K-Dense-AI/lab-instrument-mcps/tree/main/servers/chemistry/julabo-circulator
|
|
6
|
+
Author: K-Dense and LabMCP contributors
|
|
7
|
+
License-Expression: Apache-2.0
|
|
8
|
+
Keywords: chiller,circulator,julabo,lab-instrument,mcp,temperature-control,thermostat
|
|
9
|
+
Requires-Python: >=3.10
|
|
10
|
+
Requires-Dist: labmcp<0.2,>=0.1
|
|
11
|
+
Description-Content-Type: text/markdown
|
|
12
|
+
|
|
13
|
+
# JULABO Circulator โ MCP Server
|
|
14
|
+
|
|
15
|
+
<!-- mcp-name: io.github.K-Dense-AI/labmcp-julabo -->
|
|
16
|
+
|
|
17
|
+
Let an AI agent run **JULABO heating and refrigerated circulators** through JULABO's documented interface commands: read the bath and external temperatures and the heating/cooling power, set the setpoint, start and stop temperature control, decode status and alarm messages, and wait until the bath is stable at temperature.
|
|
18
|
+
|
|
19
|
+
| | |
|
|
20
|
+
|---|---|
|
|
21
|
+
| **Package** | `labmcp-julabo` |
|
|
22
|
+
| **Instruments** | CORIO CD, CORIO CP, MAGIO MS (and MX), DYNEO DD circulators. PRESTO and older JULABO models use a similar command set but are unverified |
|
|
23
|
+
| **Interfaces** | USB (virtual COM port), RS-232 (CORIO CP, MAGIO, DYNEO) |
|
|
24
|
+
| **Protocol** | JULABO interface commands, appendix "Interface commands" of the original operating manuals: [CORIO CD](https://pim-resources.coleparmer.com/instruction-manual/corio-cd-circulator-manual.pdf) (1.950.0800.us.V10), [CORIO CP](https://pim-resources.coleparmer.com/instruction-manual/corio-cp-operating-manual-1-950-0900-us.pdf) (1.950.0900.us.V04), [MAGIO MS](https://pim-resources.coleparmer.com/instruction-manual/magio-ms-operating-manual-1-950-1700-us-v05.pdf) (1.950.1700.us.V05), [DYNEO DD](https://pim-resources.coleparmer.com/instruction-manual/dyneo-dd-operating-manual-1-950-1300-us-v03.pdf) (1.950.1300.us.V03). Official downloads: [julabo.com](https://www.julabo.com/en/service/downloads/operating-manuals) |
|
|
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-julabo --simulate --check
|
|
31
|
+
```
|
|
32
|
+
|
|
33
|
+
## Connect your circulator
|
|
34
|
+
|
|
35
|
+
1. **Enable remote control on the circulator.** Without it the circulator answers queries but ignores every setting command.
|
|
36
|
+
- CORIO CD/CP: `MENU` โ `IntE` โ `rEM` โ `USb` (or `232` for RS-232). `rOFF` appears in standby.
|
|
37
|
+
- MAGIO / DYNEO: Main menu โ Connect unit โ Remote control โ USB or RS232. An "R" appears in the status bar.
|
|
38
|
+
2. **Cable:** USB-B (the circulator appears as a virtual COM port; on Windows install JULABO's USB driver) or a **null-modem** RS-232 cable. RS-232 factory settings: **4800 baud, 7 data bits, even parity, 1 stop bit, hardware handshake (RTS/CTS)**. Interface parameters can only be changed while remote control is off. The server uses these settings but does not enforce the handshake by default (it keeps RTS asserted, which lets the circulator transmit, and USB virtual COM ports often never assert CTS); add `?rtscts=true` to the address to enforce it.
|
|
39
|
+
3. **Find the port:** `uvx labmcp ports`
|
|
40
|
+
4. **Test the connection:**
|
|
41
|
+
```bash
|
|
42
|
+
uvx labmcp-julabo --address /dev/ttyACM0 --check # Linux (USB)
|
|
43
|
+
uvx labmcp-julabo --address /dev/tty.usbmodem1101 --check # macOS
|
|
44
|
+
uvx labmcp-julabo --address COM3 --check # Windows
|
|
45
|
+
uvx labmcp-julabo --address "serial://COM3?baudrate=9600" --check # RS-232, non-default baud rate
|
|
46
|
+
```
|
|
47
|
+
`--check` prints the firmware string (`version`) and the current status, e.g. `02 REMOTE STOP`.
|
|
48
|
+
|
|
49
|
+
## Add to your MCP client
|
|
50
|
+
|
|
51
|
+
**Claude Code**
|
|
52
|
+
```bash
|
|
53
|
+
claude mcp add julabo -- uvx labmcp-julabo --address /dev/ttyACM0
|
|
54
|
+
```
|
|
55
|
+
|
|
56
|
+
**Claude Desktop / Cursor / Windsurf** (`claude_desktop_config.json`, `.cursor/mcp.json`, โฆ)
|
|
57
|
+
```json
|
|
58
|
+
{
|
|
59
|
+
"mcpServers": {
|
|
60
|
+
"julabo": {
|
|
61
|
+
"command": "uvx",
|
|
62
|
+
"args": ["labmcp-julabo", "--address", "/dev/ttyACM0", "--limit", "max_temperature_c=120"]
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
```
|
|
67
|
+
|
|
68
|
+
Add `--read-only` to allow monitoring only (`stop_circulation` stays available). For other clients: `uvx labmcp config julabo-circulator --address /dev/ttyACM0 --client vscode`.
|
|
69
|
+
|
|
70
|
+
## Tools
|
|
71
|
+
|
|
72
|
+
<!-- TOOLS:START -->
|
|
73
|
+
| Tool | Kind | Description |
|
|
74
|
+
|---|---|---|
|
|
75
|
+
| `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. |
|
|
76
|
+
| `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. |
|
|
77
|
+
| `get_setpoint` | ๐ read | Read the current temperature setpoint and whether temperature control is running. |
|
|
78
|
+
| `get_status` | ๐ read | Report the circulator's status message (decoded: operating state, rejected command or alarm), whether temperature control is running, the setpoint, the device's own excess-temperature protection setting and warning limits, and the firmware version. |
|
|
79
|
+
| `read_temperatures` | ๐ read | Read the bath temperature, the heating/cooling power in % and the safety-sensor temperature, and optionally the external Pt100 sensor. |
|
|
80
|
+
| `reconnect` | ๐ safety | Close and re-open the connection to the instrument (e.g. after it was power cycled or a cable was re-plugged). |
|
|
81
|
+
| `set_setpoint` | โ ๏ธ hazard | Set the circulator's temperature setpoint (out_sp_00). If temperature control is running, the circulator starts heating or cooling to it immediately. Refused outside `min_temperature_c`..`max_temperature_c`; the circulator itself also rejects values outside its range or warning limits (reported as an error). Needs remote control enabled. |
|
|
82
|
+
| `start_circulation` | โ ๏ธ hazard | Start temperature control (out_mode_05 1): the pump runs and the bath heats or cools to the setpoint, which is checked against the safety limits first (it may have been changed on the front panel). Make sure the bath is filled and any external hoses are connected and secured. Needs remote control enabled. |
|
|
83
|
+
| `stop_circulation` | ๐ safety | Stop temperature control and the pump (out_mode_05 0), then confirm with in_mode_05. Also ends a running `wait_for_temperature`. The bath stays hot/cold after stopping. |
|
|
84
|
+
| `wait_for_temperature` | ๐ read | Poll the bath (or external) temperature until it has been within `tolerance_c` of the target for `stable_for_s` seconds, or `timeout_s` passes. Does not change anything; start circulation first. Stops early (reached = false) if the circulator raises an alarm or a stop command is sent. Returns a short temperature trace. |
|
|
85
|
+
<!-- TOOLS:END -->
|
|
86
|
+
|
|
87
|
+
## Safety limits
|
|
88
|
+
|
|
89
|
+
| Limit | Default | Meaning |
|
|
90
|
+
|---|---|---|
|
|
91
|
+
| `max_temperature_c` | 90 ยฐC | Highest setpoint an agent may set (also checked before `start_circulation`) |
|
|
92
|
+
| `min_temperature_c` | 5 ยฐC | Lowest setpoint an agent may set (also checked before `start_circulation`) |
|
|
93
|
+
| `max_wait_s` | 7200 s | Longest `wait_for_temperature` |
|
|
94
|
+
|
|
95
|
+
The defaults suit a **water** bath. Set them to your bath fluid's working range, e.g. `--limit min_temperature_c=-30 --limit max_temperature_c=150` for a silicone oil / glycol-rated setup. The circulator additionally enforces its own working range, warning limits and excess-temperature protection; rejections come back as errors such as `-11 VALUE TOO LARGE`.
|
|
96
|
+
|
|
97
|
+
## Example prompts
|
|
98
|
+
|
|
99
|
+
- "Set the circulator to 37 ยฐC, start it and tell me when the bath has been stable within ยฑ0.1 ยฐC for five minutes."
|
|
100
|
+
- "What's the bath temperature and how hard is the circulator working right now?"
|
|
101
|
+
- "Cool the jacketed reactor to 10 ยฐC using the external Pt100 as the reference and let me know when it gets there."
|
|
102
|
+
- "Is there any alarm on the circulator? Explain what it means."
|
|
103
|
+
- "Stop the circulator."
|
|
104
|
+
|
|
105
|
+
## Notes
|
|
106
|
+
|
|
107
|
+
- **Remote control is a front-panel setting.** In manual mode (`00 MANUAL STOP` / `01 MANUAL START`) the server refuses to send settings and tells you how to enable remote control.
|
|
108
|
+
- **Every setting is verified.** OUT commands have no reply, so after each one the server queries `status` (to catch `-08 INVALID COMMAND`, `-09 COMMAND NOT ALLOWED IN CURRENT OPERATING MODE`, `-10 VALUE TOO SMALL`, `-11 VALUE TOO LARGE`, `-13 VALUE EXCEEDS TEMPERATURE LIMITS`) and reads the setpoint or `in_mode_05` back. `stop_circulation` retries once and reports an error if the circulator still says it is running.
|
|
109
|
+
- **Model differences.** CORIO CD has only the basic set (actual value, power, safety sensor, setpoint, start/stop). Warning limits (`in_sp_03/04`) need CORIO CP, MAGIO or DYNEO; the external Pt100 (`in_pv_02`) needs MAGIO or DYNEO. The server detects missing commands (no reply) and reports those values as `null`.
|
|
110
|
+
- **Command pacing (unverified).** The manuals give no minimum time between commands. The server waits `command_delay_s` (default 0.25 s) after each setting command before checking `status`; change it with `--option command_delay_s=0.5` if your circulator misses commands.
|
|
111
|
+
- **Watchdog.** MAGIO/DYNEO circulators have a watchdog configured in their own menu (mode, timeout, fallback setpoint, reset on "setpoint only" or "all valid commands"). The server does not configure it. With restart mode "all valid commands" you can keep it fed while the server runs with `--option keepalive_s=10` (queries `status` every 10 s). JULABO's alarm list notes that it expects the setpoint at least every 30 s in "setpoint" restart mode, which this keep-alive does not do.
|
|
112
|
+
- Status and alarm texts are decoded from the code (`-01`, `-14`, `-63`, ...) using the alarm tables in the manuals; the exact wording the circulator sends is passed through unchanged.
|
|
113
|
+
- Not implemented: pump stage/capacity (`out_sp_07/27`), warning-limit writes (`out_sp_03/04`), controller parameters (`out_par_*`), external/internal control switch (`out_mode_04`), EPROG and ATC calibration commands.
|
|
114
|
+
|
|
115
|
+
## Hardware verification
|
|
116
|
+
|
|
117
|
+
| Model | Firmware | Interface | Verified by | Date |
|
|
118
|
+
|---|---|---|---|---|
|
|
119
|
+
| *none yet: [be the first](https://github.com/K-Dense-AI/lab-instrument-mcps/issues/new?template=hardware-verification.yml)* | | | | |
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
# JULABO Circulator โ MCP Server
|
|
2
|
+
|
|
3
|
+
<!-- mcp-name: io.github.K-Dense-AI/labmcp-julabo -->
|
|
4
|
+
|
|
5
|
+
Let an AI agent run **JULABO heating and refrigerated circulators** through JULABO's documented interface commands: read the bath and external temperatures and the heating/cooling power, set the setpoint, start and stop temperature control, decode status and alarm messages, and wait until the bath is stable at temperature.
|
|
6
|
+
|
|
7
|
+
| | |
|
|
8
|
+
|---|---|
|
|
9
|
+
| **Package** | `labmcp-julabo` |
|
|
10
|
+
| **Instruments** | CORIO CD, CORIO CP, MAGIO MS (and MX), DYNEO DD circulators. PRESTO and older JULABO models use a similar command set but are unverified |
|
|
11
|
+
| **Interfaces** | USB (virtual COM port), RS-232 (CORIO CP, MAGIO, DYNEO) |
|
|
12
|
+
| **Protocol** | JULABO interface commands, appendix "Interface commands" of the original operating manuals: [CORIO CD](https://pim-resources.coleparmer.com/instruction-manual/corio-cd-circulator-manual.pdf) (1.950.0800.us.V10), [CORIO CP](https://pim-resources.coleparmer.com/instruction-manual/corio-cp-operating-manual-1-950-0900-us.pdf) (1.950.0900.us.V04), [MAGIO MS](https://pim-resources.coleparmer.com/instruction-manual/magio-ms-operating-manual-1-950-1700-us-v05.pdf) (1.950.1700.us.V05), [DYNEO DD](https://pim-resources.coleparmer.com/instruction-manual/dyneo-dd-operating-manual-1-950-1300-us-v03.pdf) (1.950.1300.us.V03). Official downloads: [julabo.com](https://www.julabo.com/en/service/downloads/operating-manuals) |
|
|
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-julabo --simulate --check
|
|
19
|
+
```
|
|
20
|
+
|
|
21
|
+
## Connect your circulator
|
|
22
|
+
|
|
23
|
+
1. **Enable remote control on the circulator.** Without it the circulator answers queries but ignores every setting command.
|
|
24
|
+
- CORIO CD/CP: `MENU` โ `IntE` โ `rEM` โ `USb` (or `232` for RS-232). `rOFF` appears in standby.
|
|
25
|
+
- MAGIO / DYNEO: Main menu โ Connect unit โ Remote control โ USB or RS232. An "R" appears in the status bar.
|
|
26
|
+
2. **Cable:** USB-B (the circulator appears as a virtual COM port; on Windows install JULABO's USB driver) or a **null-modem** RS-232 cable. RS-232 factory settings: **4800 baud, 7 data bits, even parity, 1 stop bit, hardware handshake (RTS/CTS)**. Interface parameters can only be changed while remote control is off. The server uses these settings but does not enforce the handshake by default (it keeps RTS asserted, which lets the circulator transmit, and USB virtual COM ports often never assert CTS); add `?rtscts=true` to the address to enforce it.
|
|
27
|
+
3. **Find the port:** `uvx labmcp ports`
|
|
28
|
+
4. **Test the connection:**
|
|
29
|
+
```bash
|
|
30
|
+
uvx labmcp-julabo --address /dev/ttyACM0 --check # Linux (USB)
|
|
31
|
+
uvx labmcp-julabo --address /dev/tty.usbmodem1101 --check # macOS
|
|
32
|
+
uvx labmcp-julabo --address COM3 --check # Windows
|
|
33
|
+
uvx labmcp-julabo --address "serial://COM3?baudrate=9600" --check # RS-232, non-default baud rate
|
|
34
|
+
```
|
|
35
|
+
`--check` prints the firmware string (`version`) and the current status, e.g. `02 REMOTE STOP`.
|
|
36
|
+
|
|
37
|
+
## Add to your MCP client
|
|
38
|
+
|
|
39
|
+
**Claude Code**
|
|
40
|
+
```bash
|
|
41
|
+
claude mcp add julabo -- uvx labmcp-julabo --address /dev/ttyACM0
|
|
42
|
+
```
|
|
43
|
+
|
|
44
|
+
**Claude Desktop / Cursor / Windsurf** (`claude_desktop_config.json`, `.cursor/mcp.json`, โฆ)
|
|
45
|
+
```json
|
|
46
|
+
{
|
|
47
|
+
"mcpServers": {
|
|
48
|
+
"julabo": {
|
|
49
|
+
"command": "uvx",
|
|
50
|
+
"args": ["labmcp-julabo", "--address", "/dev/ttyACM0", "--limit", "max_temperature_c=120"]
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
```
|
|
55
|
+
|
|
56
|
+
Add `--read-only` to allow monitoring only (`stop_circulation` stays available). For other clients: `uvx labmcp config julabo-circulator --address /dev/ttyACM0 --client vscode`.
|
|
57
|
+
|
|
58
|
+
## Tools
|
|
59
|
+
|
|
60
|
+
<!-- TOOLS:START -->
|
|
61
|
+
| Tool | Kind | Description |
|
|
62
|
+
|---|---|---|
|
|
63
|
+
| `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. |
|
|
64
|
+
| `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. |
|
|
65
|
+
| `get_setpoint` | ๐ read | Read the current temperature setpoint and whether temperature control is running. |
|
|
66
|
+
| `get_status` | ๐ read | Report the circulator's status message (decoded: operating state, rejected command or alarm), whether temperature control is running, the setpoint, the device's own excess-temperature protection setting and warning limits, and the firmware version. |
|
|
67
|
+
| `read_temperatures` | ๐ read | Read the bath temperature, the heating/cooling power in % and the safety-sensor temperature, and optionally the external Pt100 sensor. |
|
|
68
|
+
| `reconnect` | ๐ safety | Close and re-open the connection to the instrument (e.g. after it was power cycled or a cable was re-plugged). |
|
|
69
|
+
| `set_setpoint` | โ ๏ธ hazard | Set the circulator's temperature setpoint (out_sp_00). If temperature control is running, the circulator starts heating or cooling to it immediately. Refused outside `min_temperature_c`..`max_temperature_c`; the circulator itself also rejects values outside its range or warning limits (reported as an error). Needs remote control enabled. |
|
|
70
|
+
| `start_circulation` | โ ๏ธ hazard | Start temperature control (out_mode_05 1): the pump runs and the bath heats or cools to the setpoint, which is checked against the safety limits first (it may have been changed on the front panel). Make sure the bath is filled and any external hoses are connected and secured. Needs remote control enabled. |
|
|
71
|
+
| `stop_circulation` | ๐ safety | Stop temperature control and the pump (out_mode_05 0), then confirm with in_mode_05. Also ends a running `wait_for_temperature`. The bath stays hot/cold after stopping. |
|
|
72
|
+
| `wait_for_temperature` | ๐ read | Poll the bath (or external) temperature until it has been within `tolerance_c` of the target for `stable_for_s` seconds, or `timeout_s` passes. Does not change anything; start circulation first. Stops early (reached = false) if the circulator raises an alarm or a stop command is sent. Returns a short temperature trace. |
|
|
73
|
+
<!-- TOOLS:END -->
|
|
74
|
+
|
|
75
|
+
## Safety limits
|
|
76
|
+
|
|
77
|
+
| Limit | Default | Meaning |
|
|
78
|
+
|---|---|---|
|
|
79
|
+
| `max_temperature_c` | 90 ยฐC | Highest setpoint an agent may set (also checked before `start_circulation`) |
|
|
80
|
+
| `min_temperature_c` | 5 ยฐC | Lowest setpoint an agent may set (also checked before `start_circulation`) |
|
|
81
|
+
| `max_wait_s` | 7200 s | Longest `wait_for_temperature` |
|
|
82
|
+
|
|
83
|
+
The defaults suit a **water** bath. Set them to your bath fluid's working range, e.g. `--limit min_temperature_c=-30 --limit max_temperature_c=150` for a silicone oil / glycol-rated setup. The circulator additionally enforces its own working range, warning limits and excess-temperature protection; rejections come back as errors such as `-11 VALUE TOO LARGE`.
|
|
84
|
+
|
|
85
|
+
## Example prompts
|
|
86
|
+
|
|
87
|
+
- "Set the circulator to 37 ยฐC, start it and tell me when the bath has been stable within ยฑ0.1 ยฐC for five minutes."
|
|
88
|
+
- "What's the bath temperature and how hard is the circulator working right now?"
|
|
89
|
+
- "Cool the jacketed reactor to 10 ยฐC using the external Pt100 as the reference and let me know when it gets there."
|
|
90
|
+
- "Is there any alarm on the circulator? Explain what it means."
|
|
91
|
+
- "Stop the circulator."
|
|
92
|
+
|
|
93
|
+
## Notes
|
|
94
|
+
|
|
95
|
+
- **Remote control is a front-panel setting.** In manual mode (`00 MANUAL STOP` / `01 MANUAL START`) the server refuses to send settings and tells you how to enable remote control.
|
|
96
|
+
- **Every setting is verified.** OUT commands have no reply, so after each one the server queries `status` (to catch `-08 INVALID COMMAND`, `-09 COMMAND NOT ALLOWED IN CURRENT OPERATING MODE`, `-10 VALUE TOO SMALL`, `-11 VALUE TOO LARGE`, `-13 VALUE EXCEEDS TEMPERATURE LIMITS`) and reads the setpoint or `in_mode_05` back. `stop_circulation` retries once and reports an error if the circulator still says it is running.
|
|
97
|
+
- **Model differences.** CORIO CD has only the basic set (actual value, power, safety sensor, setpoint, start/stop). Warning limits (`in_sp_03/04`) need CORIO CP, MAGIO or DYNEO; the external Pt100 (`in_pv_02`) needs MAGIO or DYNEO. The server detects missing commands (no reply) and reports those values as `null`.
|
|
98
|
+
- **Command pacing (unverified).** The manuals give no minimum time between commands. The server waits `command_delay_s` (default 0.25 s) after each setting command before checking `status`; change it with `--option command_delay_s=0.5` if your circulator misses commands.
|
|
99
|
+
- **Watchdog.** MAGIO/DYNEO circulators have a watchdog configured in their own menu (mode, timeout, fallback setpoint, reset on "setpoint only" or "all valid commands"). The server does not configure it. With restart mode "all valid commands" you can keep it fed while the server runs with `--option keepalive_s=10` (queries `status` every 10 s). JULABO's alarm list notes that it expects the setpoint at least every 30 s in "setpoint" restart mode, which this keep-alive does not do.
|
|
100
|
+
- Status and alarm texts are decoded from the code (`-01`, `-14`, `-63`, ...) using the alarm tables in the manuals; the exact wording the circulator sends is passed through unchanged.
|
|
101
|
+
- Not implemented: pump stage/capacity (`out_sp_07/27`), warning-limit writes (`out_sp_03/04`), controller parameters (`out_par_*`), external/internal control switch (`out_mode_04`), EPROG and ATC calibration commands.
|
|
102
|
+
|
|
103
|
+
## Hardware verification
|
|
104
|
+
|
|
105
|
+
| Model | Firmware | Interface | Verified by | Date |
|
|
106
|
+
|---|---|---|---|---|
|
|
107
|
+
| *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-julabo"
|
|
3
|
+
version = "0.1.0"
|
|
4
|
+
description = "MCP server for JULABO heating and refrigerated circulators (CORIO, MAGIO, DYNEO): read temperatures, set setpoint, start/stop, alarms."
|
|
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", "julabo", "circulator", "thermostat", "chiller", "temperature-control"]
|
|
10
|
+
dependencies = ["labmcp>=0.1,<0.2"]
|
|
11
|
+
|
|
12
|
+
[project.scripts]
|
|
13
|
+
labmcp-julabo = "labmcp_julabo.server:main"
|
|
14
|
+
|
|
15
|
+
[project.urls]
|
|
16
|
+
Homepage = "https://github.com/K-Dense-AI/lab-instrument-mcps/tree/main/servers/chemistry/julabo-circulator"
|
|
17
|
+
|
|
18
|
+
[tool.labmcp]
|
|
19
|
+
name = "JULABO Circulator"
|
|
20
|
+
domain = "chemistry"
|
|
21
|
+
category = "Temperature control"
|
|
22
|
+
vendor = "JULABO"
|
|
23
|
+
models = ["CORIO CD", "CORIO CP", "MAGIO MS", "DYNEO DD"]
|
|
24
|
+
interfaces = ["USB (virtual COM)", "RS-232"]
|
|
25
|
+
protocol = "JULABO interface commands (in_/out_/status/version)"
|
|
26
|
+
summary = "Read bath/external temperature and heating power, set setpoint, start/stop tempering, decode status and alarms, wait for temperature."
|
|
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_julabo"]
|
|
@@ -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-julabo",
|
|
4
|
+
"title": "JULABO Circulator",
|
|
5
|
+
"description": "MCP server for JULABO heating and refrigerated circulators (CORIO, MAGIO, DYNEO): read temperatures,",
|
|
6
|
+
"version": "0.1.0",
|
|
7
|
+
"repository": {
|
|
8
|
+
"url": "https://github.com/K-Dense-AI/lab-instrument-mcps",
|
|
9
|
+
"source": "github",
|
|
10
|
+
"subfolder": "servers/chemistry/julabo-circulator"
|
|
11
|
+
},
|
|
12
|
+
"websiteUrl": "https://github.com/K-Dense-AI/lab-instrument-mcps/tree/main/servers/chemistry/julabo-circulator",
|
|
13
|
+
"packages": [
|
|
14
|
+
{
|
|
15
|
+
"registryType": "pypi",
|
|
16
|
+
"registryBaseUrl": "https://pypi.org",
|
|
17
|
+
"identifier": "labmcp-julabo",
|
|
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 JULABO heating and refrigerated circulators."""
|
|
@@ -0,0 +1,320 @@
|
|
|
1
|
+
"""Driver for JULABO heating and refrigerated circulators (JULABO interface commands).
|
|
2
|
+
|
|
3
|
+
Commands, status messages, alarm codes and interface settings were verified in the
|
|
4
|
+
"Interface commands" appendix (chapter 11) of these JULABO original operating manuals:
|
|
5
|
+
|
|
6
|
+
* CORIO CD, 1.950.0800.us.V10 (03/2022),
|
|
7
|
+
https://pim-resources.coleparmer.com/instruction-manual/corio-cd-circulator-manual.pdf
|
|
8
|
+
* CORIO CP, 1.950.0900.us.V04 (04/2022),
|
|
9
|
+
https://pim-resources.coleparmer.com/instruction-manual/corio-cp-operating-manual-1-950-0900-us.pdf
|
|
10
|
+
* MAGIO MS, 1.950.1700.us.V05 (05/2022),
|
|
11
|
+
https://pim-resources.coleparmer.com/instruction-manual/magio-ms-operating-manual-1-950-1700-us-v05.pdf
|
|
12
|
+
* DYNEO DD, 1.950.1300.us.V03 (05/2022),
|
|
13
|
+
https://pim-resources.coleparmer.com/instruction-manual/dyneo-dd-operating-manual-1-950-1300-us-v03.pdf
|
|
14
|
+
|
|
15
|
+
Wire format: a command is sent as ``command CR`` or ``command SPACE parameter CR``
|
|
16
|
+
(e.g. ``out_sp_00 55.5``); IN commands answer with one line ending in CR LF (``55.5``).
|
|
17
|
+
OUT commands send no reply and are only accepted in remote-control mode; whether one was
|
|
18
|
+
rejected is reported by the next ``status`` query (``-08 INVALID COMMAND``, ``-10 VALUE TOO
|
|
19
|
+
SMALL``...), which also reports pending alarms. RS-232 factory settings: 4800 baud, 7 data
|
|
20
|
+
bits, even ("straight") parity, 1 stop bit, hardware (RTS/CTS) handshake. On USB the
|
|
21
|
+
circulator is a virtual COM port and these settings do not matter.
|
|
22
|
+
|
|
23
|
+
Command availability differs by family: CORIO CD only has in_pv_00/01/03/04, in_sp_00,
|
|
24
|
+
in_mode_05, out_sp_00, out_mode_05, version and status. CORIO CP adds the warning limits
|
|
25
|
+
(in_sp_03/04); MAGIO and DYNEO add the external Pt100 sensor (in_pv_02) and more.
|
|
26
|
+
"""
|
|
27
|
+
|
|
28
|
+
from __future__ import annotations
|
|
29
|
+
|
|
30
|
+
import contextlib
|
|
31
|
+
import logging
|
|
32
|
+
import re
|
|
33
|
+
import threading
|
|
34
|
+
import time
|
|
35
|
+
from dataclasses import dataclass
|
|
36
|
+
|
|
37
|
+
from labmcp import InstrumentError, InstrumentProtocolError, InstrumentTimeout, Transport
|
|
38
|
+
|
|
39
|
+
log = logging.getLogger("labmcp.julabo")
|
|
40
|
+
|
|
41
|
+
_STATUS_RE = re.compile(r"^\s*(-?\d+)\s*(.*?)\s*$")
|
|
42
|
+
_NUMBER_RE = re.compile(r"^\s*[-+]?(?:\d+(?:\.\d*)?|\.\d+)\s*$")
|
|
43
|
+
|
|
44
|
+
#: Status messages (manual 11.1.4). Codes >= 0 are operating states.
|
|
45
|
+
STATES = {
|
|
46
|
+
0: ("MANUAL STOP", "standby, manual operation (remote control is not enabled)"),
|
|
47
|
+
1: ("MANUAL START", "running, manual operation (remote control is not enabled)"),
|
|
48
|
+
2: ("REMOTE STOP", "standby, remote control operation"),
|
|
49
|
+
3: ("REMOTE START", "running, remote control operation"),
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
#: Replies to a rejected command (manual 11.1.4).
|
|
53
|
+
COMMAND_ERRORS = {
|
|
54
|
+
-8: "the circulator did not recognise the last command",
|
|
55
|
+
-9: "the last command is not permitted in the current operating mode (is remote control enabled?)",
|
|
56
|
+
-10: "the last value sent was too small",
|
|
57
|
+
-11: "the last value sent was too large",
|
|
58
|
+
-13: "the value is not within the temperature limits set on the circulator",
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
#: Alarms and warnings (manual 11.2; the list depends on the model).
|
|
62
|
+
ALARMS = {
|
|
63
|
+
-1: "bath fluid level too low; top up the bath fluid and check the hoses",
|
|
64
|
+
-3: "measured temperature is above the high-temperature warning limit",
|
|
65
|
+
-4: "measured temperature is below the low-temperature warning limit",
|
|
66
|
+
-5: "working temperature sensor cable broken or short-circuited",
|
|
67
|
+
-6: "temperature difference between working sensor and high-temperature protection sensor too large "
|
|
68
|
+
"(increase pump capacity)",
|
|
69
|
+
-14: "the set excess-temperature protection value has been exceeded",
|
|
70
|
+
-15: "external temperature sensor line short-circuited or interrupted",
|
|
71
|
+
-33: "high-temperature protection sensor line short-circuited or interrupted",
|
|
72
|
+
-38: "setpoint is set to the external sensor but no signal is available",
|
|
73
|
+
-40: "low-level early warning: bath fluid level is critical",
|
|
74
|
+
-41: "high-level early warning: bath fluid level is critical",
|
|
75
|
+
-60: "internal read/write error; switch off at the mains, wait 4 s, switch on",
|
|
76
|
+
-61: "communication (CAN bus) error between circulator and refrigeration unit",
|
|
77
|
+
-62: "CAN bus error; switch off at the mains, wait 4 s, switch on",
|
|
78
|
+
-63: "the watchdog function has responded",
|
|
79
|
+
-70: "units with incompatible voltage/frequency variants connected, or incorrectly configured",
|
|
80
|
+
-72: "configuration between circulator and refrigeration unit failed",
|
|
81
|
+
-83: "excessive power consumption via the USB-A port",
|
|
82
|
+
-108: "alarm latch of the protective equipment is still active; power-cycle the unit",
|
|
83
|
+
-116: "alarm latch of the protective equipment is still active; power-cycle the unit",
|
|
84
|
+
-143: "over-temperature alarm limit exceeded",
|
|
85
|
+
-144: "under-temperature alarm limit exceeded",
|
|
86
|
+
-421: "ambient temperature outside the specification",
|
|
87
|
+
-427: "pressure sensor detects excessive condensation pressure",
|
|
88
|
+
-431: "maximum permissible compressor current exceeded",
|
|
89
|
+
-503: "external setpoint via EPROG selected but no analog module connected",
|
|
90
|
+
-504: "external actuating variable via EPROG selected but no analog module connected",
|
|
91
|
+
-505: "the analog module transmits an invalid setpoint",
|
|
92
|
+
-1109: "bath fluid viscosity too high or circulation rate too low",
|
|
93
|
+
-1305: "pump speed limit of the heating block not reached (motor defective or fluid too viscous)",
|
|
94
|
+
-1427: "pressure sensor detects excessive condensation pressure",
|
|
95
|
+
-1431: "compressor current below the permissible minimum",
|
|
96
|
+
-1501: "timeout on the serial interface (watchdog: send the setpoint at least every 30 s)",
|
|
97
|
+
-2426: "evaporation temperature below the warning threshold",
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
@dataclass
|
|
102
|
+
class Status:
|
|
103
|
+
code: int
|
|
104
|
+
text: str
|
|
105
|
+
kind: str # "state" | "command_error" | "alarm"
|
|
106
|
+
meaning: str
|
|
107
|
+
|
|
108
|
+
@property
|
|
109
|
+
def label(self) -> str:
|
|
110
|
+
"""The code and text as the circulator shows them, e.g. ``03 REMOTE START`` / ``-08 ...``."""
|
|
111
|
+
return f"{self.code:03d} {self.text}" if self.code < 0 else f"{self.code:02d} {self.text}"
|
|
112
|
+
|
|
113
|
+
@property
|
|
114
|
+
def remote(self) -> bool | None:
|
|
115
|
+
return self.code in (2, 3) if self.kind == "state" else None
|
|
116
|
+
|
|
117
|
+
@property
|
|
118
|
+
def running(self) -> bool | None:
|
|
119
|
+
return self.code in (1, 3) if self.kind == "state" else None
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
def parse_status(reply: str) -> Status:
|
|
123
|
+
"""Parse a ``status`` reply such as ``03 REMOTE START`` or ``-01 ...``."""
|
|
124
|
+
m = _STATUS_RE.match(reply)
|
|
125
|
+
if not m:
|
|
126
|
+
raise InstrumentProtocolError(f"Unexpected reply to 'status': {reply!r}")
|
|
127
|
+
code, text = int(m.group(1)), m.group(2)
|
|
128
|
+
if code in STATES:
|
|
129
|
+
return Status(code, text or STATES[code][0], "state", STATES[code][1])
|
|
130
|
+
if code in COMMAND_ERRORS:
|
|
131
|
+
return Status(code, text, "command_error", COMMAND_ERRORS[code])
|
|
132
|
+
if code < 0:
|
|
133
|
+
return Status(code, text, "alarm", ALARMS.get(code, "alarm/warning not listed in the manual"))
|
|
134
|
+
return Status(code, text, "state", "unknown operating state")
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
class JulaboCirculator:
|
|
138
|
+
def __init__(self, transport: Transport, command_delay_s: float = 0.25) -> None:
|
|
139
|
+
self.t = transport
|
|
140
|
+
#: Pause after each OUT command before the next command (see README "Notes").
|
|
141
|
+
self.command_delay_s = command_delay_s
|
|
142
|
+
#: Set by :meth:`stop` so a running wait loop ends early.
|
|
143
|
+
self.abort = threading.Event()
|
|
144
|
+
self._keepalive_stop = threading.Event()
|
|
145
|
+
self._keepalive_thread: threading.Thread | None = None
|
|
146
|
+
#: Optional commands this model did not answer (e.g. in_sp_03 on a CORIO CD).
|
|
147
|
+
self.unsupported: set[str] = set()
|
|
148
|
+
|
|
149
|
+
# ------------------------------------------------------------ low level
|
|
150
|
+
|
|
151
|
+
def query(self, cmd: str, timeout: float | None = None) -> str:
|
|
152
|
+
with self.t.lock:
|
|
153
|
+
self.t.flush_input() # the circulator never sends unsolicited data
|
|
154
|
+
reply = self.t.query(cmd, timeout).strip()
|
|
155
|
+
if not reply:
|
|
156
|
+
raise InstrumentProtocolError(f"Circulator sent an empty reply to {cmd!r}.")
|
|
157
|
+
return reply
|
|
158
|
+
|
|
159
|
+
def query_number(self, cmd: str) -> float:
|
|
160
|
+
reply = self.query(cmd)
|
|
161
|
+
if not _NUMBER_RE.match(reply):
|
|
162
|
+
if _STATUS_RE.match(reply) and any(c.isalpha() for c in reply):
|
|
163
|
+
st = parse_status(reply)
|
|
164
|
+
raise InstrumentProtocolError(f"Circulator replied {reply!r} to {cmd!r}: {st.meaning}.")
|
|
165
|
+
raise InstrumentProtocolError(f"Circulator replied {reply!r} to {cmd!r}; expected a number.")
|
|
166
|
+
return float(reply)
|
|
167
|
+
|
|
168
|
+
def optional_number(self, cmd: str) -> float | None:
|
|
169
|
+
"""Query a command that not every model has; ``None`` if this circulator lacks it."""
|
|
170
|
+
if cmd in self.unsupported:
|
|
171
|
+
return None
|
|
172
|
+
try:
|
|
173
|
+
return self.query_number(cmd)
|
|
174
|
+
except (InstrumentTimeout, InstrumentProtocolError):
|
|
175
|
+
self.unsupported.add(cmd)
|
|
176
|
+
# Read (and so clear) the "-08 INVALID COMMAND" the device may now report.
|
|
177
|
+
with contextlib.suppress(InstrumentError):
|
|
178
|
+
self.status()
|
|
179
|
+
return None
|
|
180
|
+
|
|
181
|
+
def send(self, cmd: str) -> Status:
|
|
182
|
+
"""Send an OUT command, then query ``status`` and raise if the command was rejected."""
|
|
183
|
+
with self.t.lock:
|
|
184
|
+
self.t.write(cmd)
|
|
185
|
+
if self.command_delay_s:
|
|
186
|
+
time.sleep(self.command_delay_s)
|
|
187
|
+
st = self.status()
|
|
188
|
+
if st.kind == "command_error":
|
|
189
|
+
raise InstrumentProtocolError(
|
|
190
|
+
f"Circulator rejected {cmd!r}: '{st.label}' ({st.meaning}). Nothing was changed."
|
|
191
|
+
)
|
|
192
|
+
return st
|
|
193
|
+
|
|
194
|
+
# ------------------------------------------------------------ identity / status
|
|
195
|
+
|
|
196
|
+
def version(self) -> str:
|
|
197
|
+
return self.query("version")
|
|
198
|
+
|
|
199
|
+
def status(self) -> Status:
|
|
200
|
+
return parse_status(self.query("status"))
|
|
201
|
+
|
|
202
|
+
def identify(self) -> dict[str, str]:
|
|
203
|
+
info = {"manufacturer": "JULABO"}
|
|
204
|
+
try:
|
|
205
|
+
info["version"] = self.version()
|
|
206
|
+
except InstrumentError as exc:
|
|
207
|
+
info["version"] = f"unknown ({exc})"
|
|
208
|
+
try:
|
|
209
|
+
st = self.status()
|
|
210
|
+
info["status"] = st.label
|
|
211
|
+
except InstrumentError:
|
|
212
|
+
pass
|
|
213
|
+
return info
|
|
214
|
+
|
|
215
|
+
def require_remote(self) -> Status:
|
|
216
|
+
st = self.status()
|
|
217
|
+
if st.kind == "state" and st.remote is False:
|
|
218
|
+
raise InstrumentProtocolError(
|
|
219
|
+
f"The circulator is in manual mode ('{st.label}'), so it ignores remote "
|
|
220
|
+
"commands. Enable remote control on the circulator first (CORIO: MENU > IntE > rEM > "
|
|
221
|
+
"USb or 232; MAGIO/DYNEO: Main menu > Connect unit > Remote control)."
|
|
222
|
+
)
|
|
223
|
+
return st
|
|
224
|
+
|
|
225
|
+
# ------------------------------------------------------------ readings
|
|
226
|
+
|
|
227
|
+
def bath_temperature_c(self) -> float:
|
|
228
|
+
"""``in_pv_00``: actual (bath/internal) temperature."""
|
|
229
|
+
return self.query_number("in_pv_00")
|
|
230
|
+
|
|
231
|
+
def heating_power_pct(self) -> float:
|
|
232
|
+
"""``in_pv_01``: current actuating variable in % (negative = cooling)."""
|
|
233
|
+
return self.query_number("in_pv_01")
|
|
234
|
+
|
|
235
|
+
def external_temperature_c(self) -> float:
|
|
236
|
+
"""``in_pv_02``: external Pt100 sensor (MAGIO, DYNEO; not CORIO)."""
|
|
237
|
+
return self.query_number("in_pv_02")
|
|
238
|
+
|
|
239
|
+
def safety_sensor_temperature_c(self) -> float:
|
|
240
|
+
"""``in_pv_03``: temperature of the high-temperature safety sensor."""
|
|
241
|
+
return self.query_number("in_pv_03")
|
|
242
|
+
|
|
243
|
+
def excess_temperature_protection_c(self) -> float:
|
|
244
|
+
"""``in_pv_04``: current setting of the high-temperature safety function."""
|
|
245
|
+
return self.query_number("in_pv_04")
|
|
246
|
+
|
|
247
|
+
def setpoint_c(self) -> float:
|
|
248
|
+
return self.query_number("in_sp_00")
|
|
249
|
+
|
|
250
|
+
def warning_limits_c(self) -> tuple[float | None, float | None]:
|
|
251
|
+
"""``in_sp_03`` / ``in_sp_04``: (high, low) temperature warning limits (not on CORIO CD)."""
|
|
252
|
+
return self.optional_number("in_sp_03"), self.optional_number("in_sp_04")
|
|
253
|
+
|
|
254
|
+
def is_running(self) -> bool:
|
|
255
|
+
"""``in_mode_05``: 1 = temperature control started, 0 = stopped."""
|
|
256
|
+
value = self.query_number("in_mode_05")
|
|
257
|
+
if value not in (0, 1):
|
|
258
|
+
raise InstrumentProtocolError(f"Unexpected in_mode_05 value {value:g}; expected 0 or 1.")
|
|
259
|
+
return value == 1
|
|
260
|
+
|
|
261
|
+
# ------------------------------------------------------------ control
|
|
262
|
+
|
|
263
|
+
def set_setpoint(self, celsius: float) -> Status:
|
|
264
|
+
self.require_remote()
|
|
265
|
+
st = self.send(f"out_sp_00 {celsius:.2f}")
|
|
266
|
+
reported = self.setpoint_c()
|
|
267
|
+
if abs(reported - celsius) > 0.051:
|
|
268
|
+
raise InstrumentProtocolError(
|
|
269
|
+
f"Setpoint not accepted: circulator reports {reported:g} ยฐC after 'out_sp_00 {celsius:.2f}'."
|
|
270
|
+
)
|
|
271
|
+
return st
|
|
272
|
+
|
|
273
|
+
def start(self) -> Status:
|
|
274
|
+
self.require_remote()
|
|
275
|
+
self.abort.clear()
|
|
276
|
+
st = self.send("out_mode_05 1")
|
|
277
|
+
if not self.is_running():
|
|
278
|
+
raise InstrumentProtocolError(
|
|
279
|
+
f"The circulator did not start (status '{st.label}': {st.meaning})."
|
|
280
|
+
)
|
|
281
|
+
return st
|
|
282
|
+
|
|
283
|
+
def stop(self) -> bool:
|
|
284
|
+
"""Stop temperature control. Retries once; returns True if in_mode_05 confirms it."""
|
|
285
|
+
self.abort.set()
|
|
286
|
+
for _ in range(2):
|
|
287
|
+
try:
|
|
288
|
+
with self.t.lock:
|
|
289
|
+
self.t.write("out_mode_05 0")
|
|
290
|
+
if self.command_delay_s:
|
|
291
|
+
time.sleep(self.command_delay_s)
|
|
292
|
+
if not self.is_running():
|
|
293
|
+
return True
|
|
294
|
+
except InstrumentError as exc:
|
|
295
|
+
log.warning("Stop attempt failed: %s", exc)
|
|
296
|
+
return False
|
|
297
|
+
|
|
298
|
+
# ------------------------------------------------------------ keep-alive (device watchdog)
|
|
299
|
+
|
|
300
|
+
def start_keepalive(self, interval_s: float) -> None:
|
|
301
|
+
"""Query ``status`` every ``interval_s`` so a watchdog configured on the circulator
|
|
302
|
+
(MAGIO/DYNEO, restart mode "all valid commands") sees continuous traffic."""
|
|
303
|
+
self._keepalive_stop.clear()
|
|
304
|
+
self._keepalive_thread = threading.Thread(
|
|
305
|
+
target=self._keepalive, args=(interval_s,), name="julabo-keepalive", daemon=True
|
|
306
|
+
)
|
|
307
|
+
self._keepalive_thread.start()
|
|
308
|
+
|
|
309
|
+
def _keepalive(self, interval_s: float) -> None:
|
|
310
|
+
while not self._keepalive_stop.wait(interval_s):
|
|
311
|
+
try:
|
|
312
|
+
self.status()
|
|
313
|
+
except InstrumentError as exc:
|
|
314
|
+
log.warning("JULABO keep-alive query failed: %s", exc)
|
|
315
|
+
|
|
316
|
+
def close(self) -> None:
|
|
317
|
+
self._keepalive_stop.set()
|
|
318
|
+
if self._keepalive_thread is not None:
|
|
319
|
+
self._keepalive_thread.join(timeout=5)
|
|
320
|
+
self.t.close()
|