labmcp-ika 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_ika-0.1.0/.gitignore +15 -0
- labmcp_ika-0.1.0/PKG-INFO +121 -0
- labmcp_ika-0.1.0/README.md +109 -0
- labmcp_ika-0.1.0/pyproject.toml +34 -0
- labmcp_ika-0.1.0/server.json +46 -0
- labmcp_ika-0.1.0/src/labmcp_ika/__init__.py +1 -0
- labmcp_ika-0.1.0/src/labmcp_ika/driver.py +310 -0
- labmcp_ika-0.1.0/src/labmcp_ika/server.py +408 -0
- labmcp_ika-0.1.0/src/labmcp_ika/simulator.py +197 -0
- labmcp_ika-0.1.0/tests/test_server.py +269 -0
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: labmcp-ika
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: MCP server for IKA hotplate stirrers and overhead stirrers (NAMUR commands): heat, stir, watchdog, wait for temperature.
|
|
5
|
+
Project-URL: Homepage, https://github.com/K-Dense-AI/lab-instrument-mcps/tree/main/servers/chemistry/ika-stirrer
|
|
6
|
+
Author: K-Dense and LabMCP contributors
|
|
7
|
+
License-Expression: Apache-2.0
|
|
8
|
+
Keywords: hotplate,ika,lab-instrument,magnetic-stirrer,mcp,namur,overhead-stirrer
|
|
9
|
+
Requires-Python: >=3.10
|
|
10
|
+
Requires-Dist: labmcp<0.2,>=0.1
|
|
11
|
+
Description-Content-Type: text/markdown
|
|
12
|
+
|
|
13
|
+
# IKA Hotplate & Overhead Stirrer โ MCP Server
|
|
14
|
+
|
|
15
|
+
<!-- mcp-name: io.github.K-Dense-AI/labmcp-ika -->
|
|
16
|
+
|
|
17
|
+
Let an AI agent heat and stir with **IKA hotplate stirrers** (C-MAG HS control, IKA Plate / RCT digital) and drive **IKA overhead stirrers** (EUROSTAR control) through IKA's documented **NAMUR** interface commands: read plate, probe and medium temperatures, speed and torque; set and start heating and stirring; wait for a temperature; and arm the hotplate's hardware watchdog so it switches itself off if the computer stops talking to it.
|
|
18
|
+
|
|
19
|
+
| | |
|
|
20
|
+
|---|---|
|
|
21
|
+
| **Package** | `labmcp-ika` |
|
|
22
|
+
| **Instruments** | Hotplate stirrers: C-MAG HS 7 control, IKA Plate (RCT digital). Overhead stirrers (`--option device=overhead`): EUROSTAR 60 control, EUROSTAR 100 control. Other IKA devices with the same NAMUR set are likely to work but are unverified |
|
|
23
|
+
| **Interfaces** | RS-232 (9-pin, PC 1.1 cable), USB (virtual COM port) |
|
|
24
|
+
| **Protocol** | IKA NAMUR commands, "Interfaces and outputs" section of the operating instructions: [IKA Plate (RCT digital)](https://shop.textalk.se/shop/ws72/72372/art12/156948912-a58526-20000015643_20014388a_A2_IKA_Plate_112018_web.pdf) (11/2018), [C-MAG HS 7 control](https://shop.textalk.se/shop/ws72/72372/art16/156948916-cc53a2-20000015641_20014381a_A2_C-MAG_HS_7_control_112018_web.pdf) (11/2018), [EUROSTAR 60/100 control](https://www.wolflabs.co.uk/documents/IKA_overhead-stirrers_Eurostar-60-100-control_manual.pdf) (20000003965c). Official downloads: [ika.com](https://www.ika.com/en/Products-LabEq/Magnetic-Stirrers-pg188/C-MAG-HS-7-control-20002694/Downloads-cpdl.html) |
|
|
25
|
+
| **Status** | ๐งช **simulated**: tested against a wire-level NAMUR 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-ika --simulate --check
|
|
31
|
+
uvx labmcp-ika --simulate --option device=overhead --check # EUROSTAR simulator
|
|
32
|
+
```
|
|
33
|
+
|
|
34
|
+
## Connect your stirrer
|
|
35
|
+
|
|
36
|
+
1. **Cable:** USB AโB (the stirrer appears as a virtual COM port; on Windows install IKA's USB driver, linked in the manual) or the 9-pin RS-232 PC 1.1 cable. No setting on the device is needed. The interface is fixed at **9600 baud, 7 data bits, even parity, 1 stop bit, no flow control**, and every command ends with a blank + CR LF.
|
|
37
|
+
2. **Find the port:** `uvx labmcp ports`
|
|
38
|
+
3. **Test the connection:**
|
|
39
|
+
```bash
|
|
40
|
+
uvx labmcp-ika --address /dev/ttyACM0 --check # Linux, hotplate
|
|
41
|
+
uvx labmcp-ika --address /dev/tty.usbmodem14101 --check # macOS
|
|
42
|
+
uvx labmcp-ika --address COM5 --check # Windows
|
|
43
|
+
uvx labmcp-ika --address COM6 --option device=overhead --check # EUROSTAR
|
|
44
|
+
```
|
|
45
|
+
`--check` prints the name the device reports (`IN_NAME`).
|
|
46
|
+
|
|
47
|
+
## Add to your MCP client
|
|
48
|
+
|
|
49
|
+
**Claude Code**
|
|
50
|
+
```bash
|
|
51
|
+
claude mcp add ika -- uvx labmcp-ika --address /dev/ttyACM0 --limit max_temperature_c=120
|
|
52
|
+
```
|
|
53
|
+
|
|
54
|
+
**Claude Desktop / Cursor / Windsurf** (`claude_desktop_config.json`, `.cursor/mcp.json`, โฆ)
|
|
55
|
+
```json
|
|
56
|
+
{
|
|
57
|
+
"mcpServers": {
|
|
58
|
+
"ika": {
|
|
59
|
+
"command": "uvx",
|
|
60
|
+
"args": ["labmcp-ika", "--address", "/dev/ttyACM0", "--limit", "max_temperature_c=120"]
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
```
|
|
65
|
+
|
|
66
|
+
Add `--read-only` to allow monitoring only (the stop tools stay available). For other clients: `uvx labmcp config ika-stirrer --address /dev/ttyACM0 --client vscode`.
|
|
67
|
+
|
|
68
|
+
## Tools
|
|
69
|
+
|
|
70
|
+
<!-- TOOLS:START -->
|
|
71
|
+
| Tool | Kind | Description |
|
|
72
|
+
|---|---|---|
|
|
73
|
+
| `disable_watchdog` | ๐ control | Cancel watchdog mode 2 (OUT_WD2@0) and stop the background refresh. Watchdog mode 1 has no cancel command; this tool reports an error for it. |
|
|
74
|
+
| `enable_watchdog` | ๐ control | Arm the hotplate's communication watchdog (OUT_WD1@m / OUT_WD2@m). The server then re-sends the watchdog command in the background; if the computer, this server or the cable fails, the hotplate switches heating and stirring off (mode 1) or falls back to the given safety values (mode 2) after `timeout_s`. Note: stopping or reconnecting the server also stops the refresh, so the watchdog trips unless you enable it again. Mode 1 cannot be cancelled over the interface. |
|
|
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_status` | ๐ read | Read temperatures, stirring speed and setpoints. On hotplates: plate and external-probe temperature, temperature setpoint and the device's safety-circuit temperature. On overhead stirrers: PT1000 probe temperature, torque and the speed/torque limits set on the device. |
|
|
78
|
+
| `reconnect` | ๐ safety | Close and re-open the connection to the instrument (e.g. after it was power cycled or a cable was re-plugged). |
|
|
79
|
+
| `set_speed` | โ ๏ธ hazard | Set the stirring speed setpoint (OUT_SP_4). Does not start the motor; if it is already running, the speed changes immediately. Refused above `max_speed_rpm` (and, on overhead stirrers, above the speed limit set on the device). |
|
|
80
|
+
| `set_temperature` | โ ๏ธ hazard | Set the hotplate temperature setpoint (OUT_SP_1). With an external probe connected this is the target temperature of the medium. Does not start heating; if heating is already on, the plate starts moving to the new setpoint immediately. Refused above `max_temperature_c` or above the device's own safety-circuit temperature. |
|
|
81
|
+
| `start_heating` | โ ๏ธ hazard | Switch the hotplate heater on (START_1). It heats towards the current setpoint, which is checked against `max_temperature_c` first. Make sure the vessel and its contents can take the setpoint temperature and that nothing flammable is near the plate. |
|
|
82
|
+
| `start_stirring` | โ ๏ธ hazard | Start the stirring motor (START_4) at the current speed setpoint, which is checked against `max_speed_rpm` first. Make sure the stir bar or impeller is in place and the vessel is secured (a decoupled stir bar or an unclamped vessel can splash). |
|
|
83
|
+
| `stop_all` | ๐ safety | Emergency stop: switch the heater off (hotplates) and stop the motor. Every stop command is sent even if one of them fails. Also ends a running `wait_for_temperature`. |
|
|
84
|
+
| `stop_heating` | ๐ safety | Switch the hotplate heater off (STOP_1). Stirring continues. The plate stays hot for a long time after switching off. |
|
|
85
|
+
| `stop_stirring` | ๐ safety | Stop the stirring motor (STOP_4). Heating (if on) continues. |
|
|
86
|
+
| `wait_for_temperature` | ๐ read | Poll a temperature until it is within `tolerance_c` of the target for `stable_for_s` seconds, or until `timeout_s` passes. Does not change anything on the device (start heating first). Returns whether the target was reached plus a short temperature trace. A stop command ends the wait early. |
|
|
87
|
+
<!-- TOOLS:END -->
|
|
88
|
+
|
|
89
|
+
## Safety limits
|
|
90
|
+
|
|
91
|
+
| Limit | Default | Meaning |
|
|
92
|
+
|---|---|---|
|
|
93
|
+
| `max_temperature_c` | 150 ยฐC | Highest hotplate setpoint an agent may set (also checked before `start_heating`, and for the watchdog fallback temperature) |
|
|
94
|
+
| `max_speed_rpm` | 1000 rpm | Highest stirring speed an agent may set (also checked before `start_stirring`) |
|
|
95
|
+
| `max_wait_s` | 3600 s | Longest `wait_for_temperature` |
|
|
96
|
+
|
|
97
|
+
Override at launch, e.g. `--limit max_temperature_c=250 --limit max_speed_rpm=1500`. On top of these, the hotplate refuses setpoints above its own safety-circuit temperature (set with the screwdriver dial on the device, read with `IN_SP_3`), and overhead stirrers refuse speeds above the speed limit set in the device menu (`IN_SP_6`); the server checks both before sending.
|
|
98
|
+
|
|
99
|
+
## Example prompts
|
|
100
|
+
|
|
101
|
+
- "Heat the round-bottom flask to 65 ยฐC using the external probe, stir at 400 rpm, and tell me when it has been stable for a minute."
|
|
102
|
+
- "Arm the watchdog with 60 s, then start heating to 80 ยฐC. I'm leaving it running overnight."
|
|
103
|
+
- "Log the medium temperature every 30 s while it cools down and tell me when it is below 30 ยฐC."
|
|
104
|
+
- "Ramp the EUROSTAR up to 300 rpm and report the torque; the mixture is getting viscous."
|
|
105
|
+
- "Something smells wrong, stop everything now."
|
|
106
|
+
|
|
107
|
+
## Notes
|
|
108
|
+
|
|
109
|
+
- **Two device types.** The default is `--option device=hotplate`. Overhead stirrers need `--option device=overhead`; they have no heater, report their PT1000 probe on `IN_PV_3` and torque on `IN_PV_5`. Tools that don't apply to the configured type return a clear error and send nothing.
|
|
110
|
+
- **No state query for the heater.** The NAMUR set has no "is the heater on?" command, so `get_status` reports the last start/stop *this server* sent (`heating_commanded`, `stirring_commanded`) and `None` when unknown. The front-panel buttons can change it without the server knowing.
|
|
111
|
+
- **Setpoints are read back** after `set_temperature` / `set_speed` (`IN_SP_1` / `IN_SP_4`), and a `warning` is returned if the device reports a different value.
|
|
112
|
+
- **Watchdog** (hotplates, `OUT_WD1@m` / `OUT_WD2@m`, 20โ1500 s): `enable_watchdog` arms it and the server re-sends the command in the background every quarter of the watchdog time. If the server or the computer dies or the cable is pulled, the hotplate switches heating and stirring off (mode 1) or falls back to the safety temperature and speed (mode 2). **Stopping or reconnecting the server also stops the refresh**, so the watchdog trips. Mode 1 has no documented cancel command; mode 2 is cancelled with `OUT_WD2@0`. The manual's watchdog error codes differ between sections (ER 2 vs Err. 1/Err. 2); a trip is visible on the display, not over the interface.
|
|
113
|
+
- **Unverified reply layout.** The IKA manuals document the commands but not the exact reply line. The driver takes the first number on the line (devices are reported to append the channel, e.g. `25.3 2`), so both forms work. The value reported by `IN_PV_1` when no external probe is plugged in is not documented; ignore `external_temperature_c` if you have no probe.
|
|
114
|
+
- The IKA manuals print the terminator as "Blank CR LF (hex 0x20 0x0d 0x20 0x0A)"; the extra 0x20 contradicts the text ("Blank CR LF") and is treated as a typo, so the server sends `0x20 0x0D 0x0A`.
|
|
115
|
+
- Not implemented: `SET_MODE_n` (changes the power-on operating mode A/B/D, a persistent setting) and `RESET`; overhead-stirrer torque/speed-limit writes (`OUT_SP_5/6/8`) and direction change (`OUT_MODE_n`).
|
|
116
|
+
|
|
117
|
+
## Hardware verification
|
|
118
|
+
|
|
119
|
+
| Model | Firmware | Interface | Verified by | Date |
|
|
120
|
+
|---|---|---|---|---|
|
|
121
|
+
| *none yet: [be the first](https://github.com/K-Dense-AI/lab-instrument-mcps/issues/new?template=hardware-verification.yml)* | | | | |
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
# IKA Hotplate & Overhead Stirrer โ MCP Server
|
|
2
|
+
|
|
3
|
+
<!-- mcp-name: io.github.K-Dense-AI/labmcp-ika -->
|
|
4
|
+
|
|
5
|
+
Let an AI agent heat and stir with **IKA hotplate stirrers** (C-MAG HS control, IKA Plate / RCT digital) and drive **IKA overhead stirrers** (EUROSTAR control) through IKA's documented **NAMUR** interface commands: read plate, probe and medium temperatures, speed and torque; set and start heating and stirring; wait for a temperature; and arm the hotplate's hardware watchdog so it switches itself off if the computer stops talking to it.
|
|
6
|
+
|
|
7
|
+
| | |
|
|
8
|
+
|---|---|
|
|
9
|
+
| **Package** | `labmcp-ika` |
|
|
10
|
+
| **Instruments** | Hotplate stirrers: C-MAG HS 7 control, IKA Plate (RCT digital). Overhead stirrers (`--option device=overhead`): EUROSTAR 60 control, EUROSTAR 100 control. Other IKA devices with the same NAMUR set are likely to work but are unverified |
|
|
11
|
+
| **Interfaces** | RS-232 (9-pin, PC 1.1 cable), USB (virtual COM port) |
|
|
12
|
+
| **Protocol** | IKA NAMUR commands, "Interfaces and outputs" section of the operating instructions: [IKA Plate (RCT digital)](https://shop.textalk.se/shop/ws72/72372/art12/156948912-a58526-20000015643_20014388a_A2_IKA_Plate_112018_web.pdf) (11/2018), [C-MAG HS 7 control](https://shop.textalk.se/shop/ws72/72372/art16/156948916-cc53a2-20000015641_20014381a_A2_C-MAG_HS_7_control_112018_web.pdf) (11/2018), [EUROSTAR 60/100 control](https://www.wolflabs.co.uk/documents/IKA_overhead-stirrers_Eurostar-60-100-control_manual.pdf) (20000003965c). Official downloads: [ika.com](https://www.ika.com/en/Products-LabEq/Magnetic-Stirrers-pg188/C-MAG-HS-7-control-20002694/Downloads-cpdl.html) |
|
|
13
|
+
| **Status** | ๐งช **simulated**: tested against a wire-level NAMUR 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-ika --simulate --check
|
|
19
|
+
uvx labmcp-ika --simulate --option device=overhead --check # EUROSTAR simulator
|
|
20
|
+
```
|
|
21
|
+
|
|
22
|
+
## Connect your stirrer
|
|
23
|
+
|
|
24
|
+
1. **Cable:** USB AโB (the stirrer appears as a virtual COM port; on Windows install IKA's USB driver, linked in the manual) or the 9-pin RS-232 PC 1.1 cable. No setting on the device is needed. The interface is fixed at **9600 baud, 7 data bits, even parity, 1 stop bit, no flow control**, and every command ends with a blank + CR LF.
|
|
25
|
+
2. **Find the port:** `uvx labmcp ports`
|
|
26
|
+
3. **Test the connection:**
|
|
27
|
+
```bash
|
|
28
|
+
uvx labmcp-ika --address /dev/ttyACM0 --check # Linux, hotplate
|
|
29
|
+
uvx labmcp-ika --address /dev/tty.usbmodem14101 --check # macOS
|
|
30
|
+
uvx labmcp-ika --address COM5 --check # Windows
|
|
31
|
+
uvx labmcp-ika --address COM6 --option device=overhead --check # EUROSTAR
|
|
32
|
+
```
|
|
33
|
+
`--check` prints the name the device reports (`IN_NAME`).
|
|
34
|
+
|
|
35
|
+
## Add to your MCP client
|
|
36
|
+
|
|
37
|
+
**Claude Code**
|
|
38
|
+
```bash
|
|
39
|
+
claude mcp add ika -- uvx labmcp-ika --address /dev/ttyACM0 --limit max_temperature_c=120
|
|
40
|
+
```
|
|
41
|
+
|
|
42
|
+
**Claude Desktop / Cursor / Windsurf** (`claude_desktop_config.json`, `.cursor/mcp.json`, โฆ)
|
|
43
|
+
```json
|
|
44
|
+
{
|
|
45
|
+
"mcpServers": {
|
|
46
|
+
"ika": {
|
|
47
|
+
"command": "uvx",
|
|
48
|
+
"args": ["labmcp-ika", "--address", "/dev/ttyACM0", "--limit", "max_temperature_c=120"]
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
```
|
|
53
|
+
|
|
54
|
+
Add `--read-only` to allow monitoring only (the stop tools stay available). For other clients: `uvx labmcp config ika-stirrer --address /dev/ttyACM0 --client vscode`.
|
|
55
|
+
|
|
56
|
+
## Tools
|
|
57
|
+
|
|
58
|
+
<!-- TOOLS:START -->
|
|
59
|
+
| Tool | Kind | Description |
|
|
60
|
+
|---|---|---|
|
|
61
|
+
| `disable_watchdog` | ๐ control | Cancel watchdog mode 2 (OUT_WD2@0) and stop the background refresh. Watchdog mode 1 has no cancel command; this tool reports an error for it. |
|
|
62
|
+
| `enable_watchdog` | ๐ control | Arm the hotplate's communication watchdog (OUT_WD1@m / OUT_WD2@m). The server then re-sends the watchdog command in the background; if the computer, this server or the cable fails, the hotplate switches heating and stirring off (mode 1) or falls back to the given safety values (mode 2) after `timeout_s`. Note: stopping or reconnecting the server also stops the refresh, so the watchdog trips unless you enable it again. Mode 1 cannot be cancelled over the interface. |
|
|
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_status` | ๐ read | Read temperatures, stirring speed and setpoints. On hotplates: plate and external-probe temperature, temperature setpoint and the device's safety-circuit temperature. On overhead stirrers: PT1000 probe temperature, torque and the speed/torque limits set on the device. |
|
|
66
|
+
| `reconnect` | ๐ safety | Close and re-open the connection to the instrument (e.g. after it was power cycled or a cable was re-plugged). |
|
|
67
|
+
| `set_speed` | โ ๏ธ hazard | Set the stirring speed setpoint (OUT_SP_4). Does not start the motor; if it is already running, the speed changes immediately. Refused above `max_speed_rpm` (and, on overhead stirrers, above the speed limit set on the device). |
|
|
68
|
+
| `set_temperature` | โ ๏ธ hazard | Set the hotplate temperature setpoint (OUT_SP_1). With an external probe connected this is the target temperature of the medium. Does not start heating; if heating is already on, the plate starts moving to the new setpoint immediately. Refused above `max_temperature_c` or above the device's own safety-circuit temperature. |
|
|
69
|
+
| `start_heating` | โ ๏ธ hazard | Switch the hotplate heater on (START_1). It heats towards the current setpoint, which is checked against `max_temperature_c` first. Make sure the vessel and its contents can take the setpoint temperature and that nothing flammable is near the plate. |
|
|
70
|
+
| `start_stirring` | โ ๏ธ hazard | Start the stirring motor (START_4) at the current speed setpoint, which is checked against `max_speed_rpm` first. Make sure the stir bar or impeller is in place and the vessel is secured (a decoupled stir bar or an unclamped vessel can splash). |
|
|
71
|
+
| `stop_all` | ๐ safety | Emergency stop: switch the heater off (hotplates) and stop the motor. Every stop command is sent even if one of them fails. Also ends a running `wait_for_temperature`. |
|
|
72
|
+
| `stop_heating` | ๐ safety | Switch the hotplate heater off (STOP_1). Stirring continues. The plate stays hot for a long time after switching off. |
|
|
73
|
+
| `stop_stirring` | ๐ safety | Stop the stirring motor (STOP_4). Heating (if on) continues. |
|
|
74
|
+
| `wait_for_temperature` | ๐ read | Poll a temperature until it is within `tolerance_c` of the target for `stable_for_s` seconds, or until `timeout_s` passes. Does not change anything on the device (start heating first). Returns whether the target was reached plus a short temperature trace. A stop command ends the wait early. |
|
|
75
|
+
<!-- TOOLS:END -->
|
|
76
|
+
|
|
77
|
+
## Safety limits
|
|
78
|
+
|
|
79
|
+
| Limit | Default | Meaning |
|
|
80
|
+
|---|---|---|
|
|
81
|
+
| `max_temperature_c` | 150 ยฐC | Highest hotplate setpoint an agent may set (also checked before `start_heating`, and for the watchdog fallback temperature) |
|
|
82
|
+
| `max_speed_rpm` | 1000 rpm | Highest stirring speed an agent may set (also checked before `start_stirring`) |
|
|
83
|
+
| `max_wait_s` | 3600 s | Longest `wait_for_temperature` |
|
|
84
|
+
|
|
85
|
+
Override at launch, e.g. `--limit max_temperature_c=250 --limit max_speed_rpm=1500`. On top of these, the hotplate refuses setpoints above its own safety-circuit temperature (set with the screwdriver dial on the device, read with `IN_SP_3`), and overhead stirrers refuse speeds above the speed limit set in the device menu (`IN_SP_6`); the server checks both before sending.
|
|
86
|
+
|
|
87
|
+
## Example prompts
|
|
88
|
+
|
|
89
|
+
- "Heat the round-bottom flask to 65 ยฐC using the external probe, stir at 400 rpm, and tell me when it has been stable for a minute."
|
|
90
|
+
- "Arm the watchdog with 60 s, then start heating to 80 ยฐC. I'm leaving it running overnight."
|
|
91
|
+
- "Log the medium temperature every 30 s while it cools down and tell me when it is below 30 ยฐC."
|
|
92
|
+
- "Ramp the EUROSTAR up to 300 rpm and report the torque; the mixture is getting viscous."
|
|
93
|
+
- "Something smells wrong, stop everything now."
|
|
94
|
+
|
|
95
|
+
## Notes
|
|
96
|
+
|
|
97
|
+
- **Two device types.** The default is `--option device=hotplate`. Overhead stirrers need `--option device=overhead`; they have no heater, report their PT1000 probe on `IN_PV_3` and torque on `IN_PV_5`. Tools that don't apply to the configured type return a clear error and send nothing.
|
|
98
|
+
- **No state query for the heater.** The NAMUR set has no "is the heater on?" command, so `get_status` reports the last start/stop *this server* sent (`heating_commanded`, `stirring_commanded`) and `None` when unknown. The front-panel buttons can change it without the server knowing.
|
|
99
|
+
- **Setpoints are read back** after `set_temperature` / `set_speed` (`IN_SP_1` / `IN_SP_4`), and a `warning` is returned if the device reports a different value.
|
|
100
|
+
- **Watchdog** (hotplates, `OUT_WD1@m` / `OUT_WD2@m`, 20โ1500 s): `enable_watchdog` arms it and the server re-sends the command in the background every quarter of the watchdog time. If the server or the computer dies or the cable is pulled, the hotplate switches heating and stirring off (mode 1) or falls back to the safety temperature and speed (mode 2). **Stopping or reconnecting the server also stops the refresh**, so the watchdog trips. Mode 1 has no documented cancel command; mode 2 is cancelled with `OUT_WD2@0`. The manual's watchdog error codes differ between sections (ER 2 vs Err. 1/Err. 2); a trip is visible on the display, not over the interface.
|
|
101
|
+
- **Unverified reply layout.** The IKA manuals document the commands but not the exact reply line. The driver takes the first number on the line (devices are reported to append the channel, e.g. `25.3 2`), so both forms work. The value reported by `IN_PV_1` when no external probe is plugged in is not documented; ignore `external_temperature_c` if you have no probe.
|
|
102
|
+
- The IKA manuals print the terminator as "Blank CR LF (hex 0x20 0x0d 0x20 0x0A)"; the extra 0x20 contradicts the text ("Blank CR LF") and is treated as a typo, so the server sends `0x20 0x0D 0x0A`.
|
|
103
|
+
- Not implemented: `SET_MODE_n` (changes the power-on operating mode A/B/D, a persistent setting) and `RESET`; overhead-stirrer torque/speed-limit writes (`OUT_SP_5/6/8`) and direction change (`OUT_MODE_n`).
|
|
104
|
+
|
|
105
|
+
## Hardware verification
|
|
106
|
+
|
|
107
|
+
| Model | Firmware | Interface | Verified by | Date |
|
|
108
|
+
|---|---|---|---|---|
|
|
109
|
+
| *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-ika"
|
|
3
|
+
version = "0.1.0"
|
|
4
|
+
description = "MCP server for IKA hotplate stirrers and overhead stirrers (NAMUR commands): heat, stir, watchdog, wait for temperature."
|
|
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", "ika", "hotplate", "magnetic-stirrer", "overhead-stirrer", "namur"]
|
|
10
|
+
dependencies = ["labmcp>=0.1,<0.2"]
|
|
11
|
+
|
|
12
|
+
[project.scripts]
|
|
13
|
+
labmcp-ika = "labmcp_ika.server:main"
|
|
14
|
+
|
|
15
|
+
[project.urls]
|
|
16
|
+
Homepage = "https://github.com/K-Dense-AI/lab-instrument-mcps/tree/main/servers/chemistry/ika-stirrer"
|
|
17
|
+
|
|
18
|
+
[tool.labmcp]
|
|
19
|
+
name = "IKA Hotplate & Overhead Stirrer"
|
|
20
|
+
domain = "chemistry"
|
|
21
|
+
category = "Heating & stirring"
|
|
22
|
+
vendor = "IKA"
|
|
23
|
+
models = ["C-MAG HS 7 control", "IKA Plate (RCT digital)", "EUROSTAR 60 control", "EUROSTAR 100 control"]
|
|
24
|
+
interfaces = ["RS-232", "USB (virtual COM)"]
|
|
25
|
+
protocol = "IKA NAMUR command set"
|
|
26
|
+
summary = "Read plate/probe temperature and speed, set and start heating and stirring, hardware watchdog, wait for temperature, stop all."
|
|
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_ika"]
|
|
@@ -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-ika",
|
|
4
|
+
"title": "IKA Hotplate & Overhead Stirrer",
|
|
5
|
+
"description": "MCP server for IKA hotplate stirrers and overhead stirrers (NAMUR commands): heat, stir, watchdog, w",
|
|
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/ika-stirrer"
|
|
11
|
+
},
|
|
12
|
+
"websiteUrl": "https://github.com/K-Dense-AI/lab-instrument-mcps/tree/main/servers/chemistry/ika-stirrer",
|
|
13
|
+
"packages": [
|
|
14
|
+
{
|
|
15
|
+
"registryType": "pypi",
|
|
16
|
+
"registryBaseUrl": "https://pypi.org",
|
|
17
|
+
"identifier": "labmcp-ika",
|
|
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 IKA hotplate stirrers and overhead stirrers (NAMUR commands)."""
|
|
@@ -0,0 +1,310 @@
|
|
|
1
|
+
"""IKA NAMUR driver for hotplate stirrers and overhead stirrers.
|
|
2
|
+
|
|
3
|
+
Commands, serial settings and terminators were verified in the "Interfaces and outputs"
|
|
4
|
+
section of these IKA operating instructions:
|
|
5
|
+
|
|
6
|
+
* IKA Plate (RCT digital), 11/2018 (print code 20014388),
|
|
7
|
+
https://shop.textalk.se/shop/ws72/72372/art12/156948912-a58526-20000015643_20014388a_A2_IKA_Plate_112018_web.pdf
|
|
8
|
+
* C-MAG HS 7 control, 11/2018 (print code 20014381a),
|
|
9
|
+
https://shop.textalk.se/shop/ws72/72372/art16/156948916-cc53a2-20000015641_20014381a_A2_C-MAG_HS_7_control_112018_web.pdf
|
|
10
|
+
* EUROSTAR 60 control / EUROSTAR 100 control (20000003965c, 03/2018),
|
|
11
|
+
https://www.wolflabs.co.uk/documents/IKA_overhead-stirrers_Eurostar-60-100-control_manual.pdf
|
|
12
|
+
|
|
13
|
+
Wire format (identical in all three manuals): 9600 baud, 7 data bits, even parity, 1 stop
|
|
14
|
+
bit, no flow control. Commands are upper case, command and parameter are separated by a
|
|
15
|
+
space, and every command and every response ends with "blank CR LF" (0x20 0x0D 0x0A).
|
|
16
|
+
The device only transmits when asked: ``IN_*`` commands return one line, ``START_*``,
|
|
17
|
+
``STOP_*`` and ``OUT_SP_n`` return nothing, and the ``@`` variants (``OUT_SP_12@n``,
|
|
18
|
+
``OUT_SP_42@n``, ``OUT_WDx@m``) echo the value. The manuals do not document the exact
|
|
19
|
+
layout of the reply line, so numeric replies are parsed as "first number on the line"
|
|
20
|
+
(real devices are reported to append the channel number, e.g. ``25.3 2``).
|
|
21
|
+
|
|
22
|
+
Channel numbers: 1 = external temperature sensor, 2 = hotplate, 3 = PT1000 probe
|
|
23
|
+
(overhead stirrers), 4 = stirring speed, 5 = viscosity trend (hotplate) / torque (overhead).
|
|
24
|
+
"""
|
|
25
|
+
|
|
26
|
+
from __future__ import annotations
|
|
27
|
+
|
|
28
|
+
import logging
|
|
29
|
+
import re
|
|
30
|
+
import threading
|
|
31
|
+
|
|
32
|
+
from labmcp import InstrumentError, InstrumentProtocolError, Transport
|
|
33
|
+
|
|
34
|
+
log = logging.getLogger("labmcp.ika")
|
|
35
|
+
|
|
36
|
+
HOTPLATE = "hotplate"
|
|
37
|
+
OVERHEAD = "overhead"
|
|
38
|
+
DEVICE_TYPES = (HOTPLATE, OVERHEAD)
|
|
39
|
+
|
|
40
|
+
#: Watchdog time range documented for OUT_WD1@m / OUT_WD2@m.
|
|
41
|
+
WATCHDOG_MIN_S = 20
|
|
42
|
+
WATCHDOG_MAX_S = 1500
|
|
43
|
+
|
|
44
|
+
_NUMBER_RE = re.compile(r"^\s*([-+]?(?:\d+(?:\.\d*)?|\.\d+))(?:\s+\S+)*\s*$")
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
class IKAStirrer:
|
|
48
|
+
"""An IKA hotplate stirrer (``device_type="hotplate"``) or overhead stirrer (``"overhead"``)."""
|
|
49
|
+
|
|
50
|
+
def __init__(self, transport: Transport, device_type: str = HOTPLATE) -> None:
|
|
51
|
+
if device_type not in DEVICE_TYPES:
|
|
52
|
+
raise ValueError(f"device_type must be one of {DEVICE_TYPES}, got {device_type!r}")
|
|
53
|
+
self.t = transport
|
|
54
|
+
self.device_type = device_type
|
|
55
|
+
# The NAMUR set has no "is the heater on?" query, so remember what we commanded.
|
|
56
|
+
self.heating_commanded: bool | None = None
|
|
57
|
+
self.stirring_commanded: bool | None = None
|
|
58
|
+
#: Set by stop commands so a running wait_for_temperature loop ends early.
|
|
59
|
+
self.abort = threading.Event()
|
|
60
|
+
self._wd_mode: int | None = None
|
|
61
|
+
self._wd_time_s: int | None = None
|
|
62
|
+
self._wd_stop = threading.Event()
|
|
63
|
+
self._wd_thread: threading.Thread | None = None
|
|
64
|
+
self.watchdog_last_error: str | None = None
|
|
65
|
+
|
|
66
|
+
# ------------------------------------------------------------ low level
|
|
67
|
+
|
|
68
|
+
def query(self, cmd: str, timeout: float | None = None) -> str:
|
|
69
|
+
"""Send an ``IN_*`` command and return the reply line (stripped)."""
|
|
70
|
+
with self.t.lock:
|
|
71
|
+
# The device never sends unsolicited data; anything waiting is stale.
|
|
72
|
+
self.t.flush_input()
|
|
73
|
+
reply = self.t.query(cmd, timeout).strip()
|
|
74
|
+
if not reply:
|
|
75
|
+
raise InstrumentProtocolError(f"IKA device sent an empty reply to {cmd!r}.")
|
|
76
|
+
return reply
|
|
77
|
+
|
|
78
|
+
def send(self, cmd: str) -> None:
|
|
79
|
+
"""Send a command that has no reply (``OUT_SP_n x``, ``START_n``, ``STOP_n``)."""
|
|
80
|
+
self.t.write(cmd)
|
|
81
|
+
|
|
82
|
+
def query_number(self, cmd: str) -> float:
|
|
83
|
+
reply = self.query(cmd)
|
|
84
|
+
m = _NUMBER_RE.match(reply)
|
|
85
|
+
if not m:
|
|
86
|
+
raise InstrumentProtocolError(
|
|
87
|
+
f"IKA device replied {reply!r} to {cmd!r}; expected a number. Check that the "
|
|
88
|
+
f"server's device type ({self.device_type}) matches the instrument."
|
|
89
|
+
)
|
|
90
|
+
return float(m.group(1))
|
|
91
|
+
|
|
92
|
+
def _echo(self, cmd: str, expected: float) -> float:
|
|
93
|
+
"""Send an ``@`` command and check the echoed value."""
|
|
94
|
+
value = self.query_number(cmd)
|
|
95
|
+
if abs(value - expected) > 0.5:
|
|
96
|
+
raise InstrumentProtocolError(
|
|
97
|
+
f"IKA device echoed {value:g} to {cmd!r}; expected {expected:g}. The value may be "
|
|
98
|
+
"outside the range the device accepts."
|
|
99
|
+
)
|
|
100
|
+
return value
|
|
101
|
+
|
|
102
|
+
def _require(self, device_type: str, what: str) -> None:
|
|
103
|
+
if self.device_type != device_type:
|
|
104
|
+
raise InstrumentProtocolError(
|
|
105
|
+
f"{what} is only available on IKA {device_type} devices, but this server is "
|
|
106
|
+
f"configured for a {self.device_type} (start it with --option device={device_type} "
|
|
107
|
+
"if that is wrong)."
|
|
108
|
+
)
|
|
109
|
+
|
|
110
|
+
@staticmethod
|
|
111
|
+
def _fmt(value: float) -> str:
|
|
112
|
+
return f"{round(value, 1):g}"
|
|
113
|
+
|
|
114
|
+
# ------------------------------------------------------------ identity
|
|
115
|
+
|
|
116
|
+
def identify(self) -> dict[str, str]:
|
|
117
|
+
info = {"manufacturer": "IKA", "device_type": self.device_type}
|
|
118
|
+
try:
|
|
119
|
+
info["model"] = self.query("IN_NAME")
|
|
120
|
+
except InstrumentError as exc:
|
|
121
|
+
info["model"] = f"unknown ({exc})"
|
|
122
|
+
return info
|
|
123
|
+
|
|
124
|
+
# ------------------------------------------------------------ stirring (both types)
|
|
125
|
+
|
|
126
|
+
def speed_rpm(self) -> float:
|
|
127
|
+
return self.query_number("IN_PV_4")
|
|
128
|
+
|
|
129
|
+
def speed_setpoint_rpm(self) -> float:
|
|
130
|
+
return self.query_number("IN_SP_4")
|
|
131
|
+
|
|
132
|
+
def set_speed(self, rpm: float) -> None:
|
|
133
|
+
self.send(f"OUT_SP_4 {int(round(rpm))}")
|
|
134
|
+
|
|
135
|
+
def start_motor(self) -> None:
|
|
136
|
+
self.abort.clear()
|
|
137
|
+
self.send("START_4")
|
|
138
|
+
self.stirring_commanded = True
|
|
139
|
+
|
|
140
|
+
def stop_motor(self) -> None:
|
|
141
|
+
self.abort.set()
|
|
142
|
+
self.send("STOP_4")
|
|
143
|
+
self.stirring_commanded = False
|
|
144
|
+
|
|
145
|
+
# ------------------------------------------------------------ hotplate
|
|
146
|
+
|
|
147
|
+
def external_temperature_c(self) -> float:
|
|
148
|
+
"""``IN_PV_1``: external temperature sensor (e.g. PT 1000 / ETS-D in the medium)."""
|
|
149
|
+
self._require(HOTPLATE, "The external sensor reading IN_PV_1")
|
|
150
|
+
return self.query_number("IN_PV_1")
|
|
151
|
+
|
|
152
|
+
def hotplate_temperature_c(self) -> float:
|
|
153
|
+
"""``IN_PV_2``: hotplate sensor."""
|
|
154
|
+
self._require(HOTPLATE, "Hotplate temperature")
|
|
155
|
+
return self.query_number("IN_PV_2")
|
|
156
|
+
|
|
157
|
+
def viscosity_trend(self) -> float:
|
|
158
|
+
"""``IN_PV_5``: viscosity trend value (hotplates; relative, no unit)."""
|
|
159
|
+
self._require(HOTPLATE, "The viscosity trend")
|
|
160
|
+
return self.query_number("IN_PV_5")
|
|
161
|
+
|
|
162
|
+
def temperature_setpoint_c(self) -> float:
|
|
163
|
+
self._require(HOTPLATE, "The temperature setpoint")
|
|
164
|
+
return self.query_number("IN_SP_1")
|
|
165
|
+
|
|
166
|
+
def safety_temperature_c(self) -> float:
|
|
167
|
+
"""``IN_SP_3``: the safety-circuit temperature set on the device (screwdriver dial)."""
|
|
168
|
+
self._require(HOTPLATE, "The safety temperature")
|
|
169
|
+
return self.query_number("IN_SP_3")
|
|
170
|
+
|
|
171
|
+
def set_temperature(self, celsius: float) -> None:
|
|
172
|
+
self._require(HOTPLATE, "Setting a temperature")
|
|
173
|
+
self.send(f"OUT_SP_1 {self._fmt(celsius)}")
|
|
174
|
+
|
|
175
|
+
def start_heater(self) -> None:
|
|
176
|
+
self._require(HOTPLATE, "Heating")
|
|
177
|
+
self.abort.clear()
|
|
178
|
+
self.send("START_1")
|
|
179
|
+
self.heating_commanded = True
|
|
180
|
+
|
|
181
|
+
def stop_heater(self) -> None:
|
|
182
|
+
self._require(HOTPLATE, "Heating")
|
|
183
|
+
self.abort.set()
|
|
184
|
+
self.send("STOP_1")
|
|
185
|
+
self.heating_commanded = False
|
|
186
|
+
|
|
187
|
+
# ------------------------------------------------------------ overhead stirrer
|
|
188
|
+
|
|
189
|
+
def probe_temperature_c(self) -> float:
|
|
190
|
+
"""``IN_PV_3``: PT1000 probe of an overhead stirrer."""
|
|
191
|
+
self._require(OVERHEAD, "The PT1000 probe reading IN_PV_3")
|
|
192
|
+
return self.query_number("IN_PV_3")
|
|
193
|
+
|
|
194
|
+
def torque(self) -> float:
|
|
195
|
+
"""``IN_PV_5``: current torque value (overhead stirrers, Ncm as on the display)."""
|
|
196
|
+
self._require(OVERHEAD, "Torque")
|
|
197
|
+
return self.query_number("IN_PV_5")
|
|
198
|
+
|
|
199
|
+
def torque_limit(self) -> float:
|
|
200
|
+
self._require(OVERHEAD, "The torque limit")
|
|
201
|
+
return self.query_number("IN_SP_5")
|
|
202
|
+
|
|
203
|
+
def speed_limit_rpm(self) -> float:
|
|
204
|
+
self._require(OVERHEAD, "The speed limit")
|
|
205
|
+
return self.query_number("IN_SP_6")
|
|
206
|
+
|
|
207
|
+
def safety_speed_rpm(self) -> float:
|
|
208
|
+
self._require(OVERHEAD, "The safety speed")
|
|
209
|
+
return self.query_number("IN_SP_8")
|
|
210
|
+
|
|
211
|
+
# ------------------------------------------------------------ stop
|
|
212
|
+
|
|
213
|
+
def stop_all(self) -> list[str]:
|
|
214
|
+
"""Stop heating (hotplates) and stirring. Sends every stop even if one fails."""
|
|
215
|
+
self.abort.set()
|
|
216
|
+
errors: list[str] = []
|
|
217
|
+
stops = [self.stop_heater, self.stop_motor] if self.device_type == HOTPLATE else [self.stop_motor]
|
|
218
|
+
for stop in stops:
|
|
219
|
+
try:
|
|
220
|
+
stop()
|
|
221
|
+
except InstrumentError as exc:
|
|
222
|
+
errors.append(str(exc))
|
|
223
|
+
return errors
|
|
224
|
+
|
|
225
|
+
# ------------------------------------------------------------ watchdog (hotplates)
|
|
226
|
+
|
|
227
|
+
def set_watchdog_safety_values(self, temperature_c: float, speed_rpm: float) -> None:
|
|
228
|
+
"""``OUT_SP_12@n`` / ``OUT_SP_42@n``: values watchdog mode 2 falls back to."""
|
|
229
|
+
self._require(HOTPLATE, "The watchdog")
|
|
230
|
+
self._echo(f"OUT_SP_12@{self._fmt(temperature_c)}", temperature_c)
|
|
231
|
+
self._echo(f"OUT_SP_42@{int(round(speed_rpm))}", round(speed_rpm))
|
|
232
|
+
|
|
233
|
+
def enable_watchdog(self, mode: int, timeout_s: int, keepalive_interval_s: float | None = None) -> None:
|
|
234
|
+
"""Arm watchdog mode 1 (heater and motor off) or 2 (fall back to the WD safety values)
|
|
235
|
+
and keep it alive from a background thread by re-sending ``OUT_WDx@m``.
|
|
236
|
+
|
|
237
|
+
If this process dies, the cable is pulled or :meth:`close` is called, the device
|
|
238
|
+
is no longer refreshed and trips after ``timeout_s`` seconds.
|
|
239
|
+
"""
|
|
240
|
+
self._require(HOTPLATE, "The watchdog")
|
|
241
|
+
if mode not in (1, 2):
|
|
242
|
+
raise ValueError("watchdog mode must be 1 or 2")
|
|
243
|
+
if not WATCHDOG_MIN_S <= timeout_s <= WATCHDOG_MAX_S:
|
|
244
|
+
raise ValueError(f"watchdog time must be {WATCHDOG_MIN_S}-{WATCHDOG_MAX_S} s")
|
|
245
|
+
if self._wd_mode is not None and self._wd_mode != mode:
|
|
246
|
+
raise InstrumentProtocolError(
|
|
247
|
+
f"Watchdog mode {self._wd_mode} is already active; the manual documents no way to "
|
|
248
|
+
"switch modes without it tripping."
|
|
249
|
+
)
|
|
250
|
+
self._stop_keepalive()
|
|
251
|
+
self._echo(f"OUT_WD{mode}@{timeout_s}", timeout_s)
|
|
252
|
+
self._wd_mode, self._wd_time_s = mode, timeout_s
|
|
253
|
+
self.watchdog_last_error = None
|
|
254
|
+
interval = keepalive_interval_s if keepalive_interval_s is not None else max(1.0, timeout_s / 4)
|
|
255
|
+
self._wd_stop.clear()
|
|
256
|
+
self._wd_thread = threading.Thread(
|
|
257
|
+
target=self._keepalive, args=(interval,), name="ika-watchdog", daemon=True
|
|
258
|
+
)
|
|
259
|
+
self._wd_thread.start()
|
|
260
|
+
|
|
261
|
+
def _keepalive(self, interval: float) -> None:
|
|
262
|
+
while not self._wd_stop.wait(interval):
|
|
263
|
+
self.watchdog_tick()
|
|
264
|
+
|
|
265
|
+
def watchdog_tick(self) -> None:
|
|
266
|
+
"""Re-send the watchdog command once (called by the keep-alive thread)."""
|
|
267
|
+
mode, time_s = self._wd_mode, self._wd_time_s
|
|
268
|
+
if mode is None or time_s is None:
|
|
269
|
+
return
|
|
270
|
+
try:
|
|
271
|
+
self._echo(f"OUT_WD{mode}@{time_s}", time_s)
|
|
272
|
+
self.watchdog_last_error = None
|
|
273
|
+
except InstrumentError as exc: # keep trying; the device trips if we never get through
|
|
274
|
+
self.watchdog_last_error = str(exc)
|
|
275
|
+
log.warning("IKA watchdog refresh failed: %s", exc)
|
|
276
|
+
|
|
277
|
+
def disable_watchdog(self) -> None:
|
|
278
|
+
"""Cancel watchdog mode 2 with ``OUT_WD2@0``. Mode 1 has no documented cancel command."""
|
|
279
|
+
self._require(HOTPLATE, "The watchdog")
|
|
280
|
+
if self._wd_mode is None:
|
|
281
|
+
return
|
|
282
|
+
if self._wd_mode == 1:
|
|
283
|
+
raise InstrumentProtocolError(
|
|
284
|
+
"Watchdog mode 1 cannot be cancelled over the interface (the IKA manual documents "
|
|
285
|
+
"no command for it). It stays armed while this server runs; stopping the server "
|
|
286
|
+
"makes it trip, switching heating and stirring off."
|
|
287
|
+
)
|
|
288
|
+
self._stop_keepalive()
|
|
289
|
+
self.query_number("OUT_WD2@0")
|
|
290
|
+
self._wd_mode = self._wd_time_s = None
|
|
291
|
+
|
|
292
|
+
def watchdog_state(self) -> dict[str, object]:
|
|
293
|
+
return {
|
|
294
|
+
"mode": self._wd_mode,
|
|
295
|
+
"timeout_s": self._wd_time_s,
|
|
296
|
+
"keepalive_running": bool(self._wd_thread and self._wd_thread.is_alive()),
|
|
297
|
+
"last_error": self.watchdog_last_error,
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
def _stop_keepalive(self) -> None:
|
|
301
|
+
self._wd_stop.set()
|
|
302
|
+
thread, self._wd_thread = self._wd_thread, None
|
|
303
|
+
if thread is not None and thread is not threading.current_thread():
|
|
304
|
+
thread.join(timeout=5)
|
|
305
|
+
|
|
306
|
+
def close(self) -> None:
|
|
307
|
+
# Deliberately does NOT cancel the watchdog: if it is armed, the device switches
|
|
308
|
+
# itself off after the watchdog time, which is the fail-safe we want.
|
|
309
|
+
self._stop_keepalive()
|
|
310
|
+
self.t.close()
|